diff --git a/app/components/Carousel.tsx b/app/components/Carousel.tsx index 1d56563..9106d1d 100644 --- a/app/components/Carousel.tsx +++ b/app/components/Carousel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { Swiper, SwiperSlide } from "swiper/react"; import { Navigation, Pagination, Autoplay } from "swiper/modules"; @@ -8,8 +8,27 @@ import "swiper/css"; import "swiper/css/navigation"; import "swiper/css/pagination"; import "../globals.css"; -import "../assets/main.css"; +import "../assets/main.css"; +/** + * Carousel "fiche détail" — refonte Stitch (étape 7.a). + * + * Conserve l'API publique (`images`, `className`) pour ne rien casser côté + * consommateurs (`ContentSection`, `ModalGlossaire`). Changements vs version + * historique : + * + * - Pagination bullets teintée `primary` via surcharge inline des variables + * Swiper (pas de pollution `globals.css`, cf. même approche que `VignetteCarousel`). + * - Flèches Swiper par défaut recolorées `primary` — laissées en chevrons + * Swiper pour garder l'empreinte tactile native (le Material Symbol `chevron` + * demandait un remplacement complet du markup via slots). + * - Conteneur en `rounded-tile overflow-hidden shadow-ambient-sm` plutôt que + * `rounded-md shadow-md` — cohérence avec les tokens de la refonte. + * - **Lightbox refaite en Stitch** : voile `bg-on-surface/80 backdrop-blur-sm` + * (vs `bg-black/10 backdrop-blur-2xl` qui dépixellisait l'image), image en + * `object-contain` (ne déforme plus les photos verticales), bouton close rond + * Material Symbol, fermeture Esc + clic voile. + */ interface CarouselProps { images: Array<{ url: string; alt: string }>; className?: string; @@ -18,25 +37,53 @@ interface CarouselProps { export default function Carousel({ images, className }: CarouselProps) { const [selectedImage, setSelectedImage] = useState(null); + useEffect(() => { + if (!selectedImage) return; + document.body.classList.add("overflow-hidden"); + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setSelectedImage(null); + }; + window.addEventListener("keydown", handleKey); + return () => { + document.body.classList.remove("overflow-hidden"); + window.removeEventListener("keydown", handleKey); + }; + }, [selectedImage]); + return ( <> -
+
1} className={`w-full ${className || "h-64"}`} + style={ + { + "--swiper-navigation-color": "#26445d", + "--swiper-navigation-size": "28px", + "--swiper-pagination-color": "#26445d", + "--swiper-pagination-bullet-inactive-color": "#ffffff", + "--swiper-pagination-bullet-inactive-opacity": "0.6", + "--swiper-pagination-bullet-size": "8px", + "--swiper-pagination-bullet-horizontal-gap": "4px", + } as React.CSSProperties + } > {images.map((img, index) => ( - + {img.alt} setSelectedImage(img.url)} + loading="lazy" /> ))} @@ -46,20 +93,34 @@ export default function Carousel({ images, className }: CarouselProps) { {selectedImage && createPortal(
setSelectedImage(null)} + role="dialog" + aria-modal="true" + aria-label="Image agrandie" > -
+
e.stopPropagation()} + > Agrandissement
, @@ -67,4 +128,4 @@ export default function Carousel({ images, className }: CarouselProps) { )} ); -} \ No newline at end of file +} diff --git a/app/components/CarouselCompetences.tsx b/app/components/CarouselCompetences.tsx index de1d1ad..10bfc71 100644 --- a/app/components/CarouselCompetences.tsx +++ b/app/components/CarouselCompetences.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { Swiper, SwiperSlide } from "swiper/react"; import { Navigation, Pagination, Autoplay } from "swiper/modules"; @@ -10,6 +10,13 @@ import "swiper/css/pagination"; import "../globals.css"; import "../assets/main.css"; +/** + * Variante du `Carousel` pour les fiches compétences + la modale glossaire. + * Comportement et style identiques à `Carousel.tsx` (étape 7.a) — les deux + * composants sont des quasi-doublons historiques, fusionner proprement demande + * de rationaliser `ContentSection*` et `ModalGlossaire` en même temps : hors + * scope, on garde la même API et les mêmes styles côte à côte pour l'instant. + */ interface CarouselProps { images: Array<{ url: string; alt: string }>; className?: string; @@ -18,25 +25,53 @@ interface CarouselProps { export default function CarouselCompetences({ images, className }: CarouselProps) { const [selectedImage, setSelectedImage] = useState(null); + useEffect(() => { + if (!selectedImage) return; + document.body.classList.add("overflow-hidden"); + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setSelectedImage(null); + }; + window.addEventListener("keydown", handleKey); + return () => { + document.body.classList.remove("overflow-hidden"); + window.removeEventListener("keydown", handleKey); + }; + }, [selectedImage]); + return ( <> -
+
1} className={`w-full ${className || "h-64"}`} + style={ + { + "--swiper-navigation-color": "#26445d", + "--swiper-navigation-size": "28px", + "--swiper-pagination-color": "#26445d", + "--swiper-pagination-bullet-inactive-color": "#ffffff", + "--swiper-pagination-bullet-inactive-opacity": "0.6", + "--swiper-pagination-bullet-size": "8px", + "--swiper-pagination-bullet-horizontal-gap": "4px", + } as React.CSSProperties + } > {images.map((img, index) => ( - + {img.alt} setSelectedImage(img.url)} + loading="lazy" /> ))} @@ -46,20 +81,34 @@ export default function CarouselCompetences({ images, className }: CarouselProps {selectedImage && createPortal(
setSelectedImage(null)} + role="dialog" + aria-modal="true" + aria-label="Image agrandie" > -
+
e.stopPropagation()} + > Agrandissement
, @@ -67,4 +116,4 @@ export default function CarouselCompetences({ images, className }: CarouselProps )} ); -} \ No newline at end of file +} diff --git a/app/components/ChatBot.js b/app/components/ChatBot.js index 6c25f5d..3a4c1fc 100644 --- a/app/components/ChatBot.js +++ b/app/components/ChatBot.js @@ -1,70 +1,169 @@ -import { useState } from "react"; +"use client"; + +import { useEffect, useRef, useState } from "react"; import { askAI } from "../utils/askAI"; +/** + * GrasBot — refonte Stitch (étape 7.d). + * + * L'API publique ne change pas (`onClose` propagé par le parent). Le bouton + * d'ouverture n'est plus dans ce composant : il est désormais porté par le FAB + * global `GrasBotFab` monté dans `app/layout.tsx`. Ce composant se concentre + * sur le panneau de conversation proprement dit. + * + * Détails notables : + * - Fond `surface-container-lowest/95 backdrop-blur-vellum rounded-sheet shadow-ambient` + * (plus de carte opaque `bg-white/70` + ombre lourde). + * - Header primary avec Material Symbol `smart_toy` (`translate="no"`, règle § 4 quinquies). + * - Bulles : user = `bg-primary text-white rounded-sheet` à droite, bot = + * `bg-surface-container text-on-surface rounded-sheet` à gauche. Corps Manrope. + * - Input `bg-surface-container-low focus-visible:ring-2 focus-visible:ring-primary`, + * bouton envoyer jewel avec Material Symbol `send`. + * - Auto-scroll en bas à chaque nouveau message, envoi à Enter. + */ export default function ChatBot({ onClose }) { - const [question, setQuestion] = useState(""); - const [messages, setMessages] = useState([]); - const [isWaiting, setIsWaiting] = useState(false); + const [question, setQuestion] = useState(""); + const [messages, setMessages] = useState([]); + const [isWaiting, setIsWaiting] = useState(false); + const scrollRef = useRef(null); + const inputRef = useRef(null); - const handleAsk = async () => { - if (!question.trim()) return; + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages, isWaiting]); - const userMessage = { sender: "user", text: question }; - setMessages([...messages, userMessage]); + useEffect(() => { + inputRef.current?.focus(); + }, []); - setQuestion(""); - setIsWaiting(true); + const handleAsk = async () => { + if (!question.trim() || isWaiting) return; - try { - const botResponse = await askAI(question); - const botMessage = { sender: "bot", text: botResponse }; - setMessages((prevMessages) => [...prevMessages, botMessage]); - } catch (error) { - setMessages([...messages, { sender: "bot", text: "❌ Erreur de réponse. Réessayez plus tard." }]); - } finally { - setIsWaiting(false); - } - }; + const userMessage = { sender: "user", text: question }; + setMessages((prev) => [...prev, userMessage]); + setQuestion(""); + setIsWaiting(true); - return ( -
-
- 💬 GrasBot - -
+ try { + const botResponse = await askAI(userMessage.text); + setMessages((prev) => [...prev, { sender: "bot", text: botResponse }]); + } catch (_error) { + setMessages((prev) => [ + ...prev, + { sender: "bot", text: "Erreur de réponse. Réessayez plus tard." }, + ]); + } finally { + setIsWaiting(false); + } + }; -
- {messages.map((msg, index) => ( -
- {msg.text} -
- ))} - {isWaiting && ( -
- wait... -
- )} -
+ const handleKeyDown = (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleAsk(); + } + }; -
- setQuestion(e.target.value)} - /> - -
+ return ( +
+
+
+ +
+

GrasBot

+

+ Assistant IA locale +

+
- ); -} \ No newline at end of file + +
+ +
+ {messages.length === 0 && !isWaiting && ( +

+ Posez-moi une question sur le site, les projets ou les compétences. +

+ )} + + {messages.map((msg, index) => ( +
+ {msg.text} +
+ ))} + + {isWaiting && ( +
+ GrasBot réfléchit + . + . + . +
+ )} +
+ +
+ setQuestion(e.target.value)} + onKeyDown={handleKeyDown} + disabled={isWaiting} + aria-label="Votre question pour GrasBot" + /> + +
+
+ ); +} diff --git a/app/components/ContentSection.tsx b/app/components/ContentSection.tsx index 1e3537a..d79f539 100644 --- a/app/components/ContentSection.tsx +++ b/app/components/ContentSection.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useEffect, useState } from "react"; import { fetchData } from "../utils/fetchData"; import { getApiUrl } from "../utils/getApiUrl"; @@ -31,52 +32,165 @@ interface ContentSectionProps { contentClass?: string; } -export default function ContentSection({ collection, slug, titleClass, contentClass }: ContentSectionProps) { +/** + * Fiche détail portfolio — refonte "Digital Atelier" (étape 7.b). + * + * Structure : bouton retour + en-tête vellum (kicker + titre Manrope) + carousel + * détail (Swiper Stitch) + corps Markdown en `prose` Newsreader + CTA jewel + * optionnel vers le lien externe. Les props `titleClass` / `contentClass` + * héritées du composant pré-refonte restent acceptées pour compatibilité mais + * sont ignorées (styles tokenisés désormais) — on les garde dans l'interface + * pour ne pas casser les consommateurs. + */ +export default function ContentSection({ + collection, + slug, +}: ContentSectionProps) { const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); const apiUrl = getApiUrl(); useEffect(() => { async function fetchContent() { + try { const result = await fetchData(collection, slug); - setData(result); + setData(result); + } finally { + setIsLoading(false); + } } - fetchContent(); }, [collection, slug, apiUrl]); + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + if (!data) { - return
Contenu introuvable.
; + return ( +
+
+ +

+ Ce projet est introuvable. +

+ + + Retour au portfolio + +
+
+ ); } const { name, Resum: richText, picture, link, linkText } = data; - const images = picture?.map((img: ImageData) => ({ - url: `${apiUrl}${img.formats?.large?.url || img.url}`, - alt: img.name || "Image", - })) || []; + const images = + picture?.map((img: ImageData) => ({ + url: `${apiUrl}${img.formats?.large?.url || img.url}`, + alt: img.name || `Visuel du projet ${name}`, + })) || []; return ( -
-

{name}

+
+ {/* Bouton retour discret, posé sur le wallpaper comme une miette de fil d'Ariane. */} + + + Portfolio + - - -
- {richText} -
- - {link && ( -
- + - )} + + {images.length > 0 && ( +
+ +
+ )} + + {richText && ( +
+ {richText} +
+ )} + + {link && ( + + )} +
); } diff --git a/app/components/ContentSectionCompetences.tsx b/app/components/ContentSectionCompetences.tsx index 1c1980c..de527ba 100644 --- a/app/components/ContentSectionCompetences.tsx +++ b/app/components/ContentSectionCompetences.tsx @@ -1,12 +1,12 @@ "use client"; -import { useState, useEffect } from "react"; +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; import { getApiUrl } from "../utils/getApiUrl"; import CarouselCompetences from "./CarouselCompetences"; import ReactMarkdown from "react-markdown"; import rehypeRaw from "rehype-raw"; import ModalGlossaire from "./ModalGlossaire"; -import ChatBot from "./ChatBot"; interface ImageData { url: string; @@ -37,32 +37,96 @@ interface ContentSectionProps { contentClass?: string; } +/** + * Fiche détail compétences — refonte "Digital Atelier" (étape 7.c). + * + * Trois changements structurants par rapport à la version pré-refonte : + * + * 1. **Style tokenisé** : même gabarit "feuillet de vellum" que le portfolio. + * Les classes hardcodées `bg-white/70 text-blue-700 font-headline font-bold` + * disparaissent. Le corps éditorial est rendu en `prose` Newsreader, les + * titres Markdown en Manrope `text-primary`. + * + * 2. **Keywords glossaire & chatbot sans styles inline** : on retire les + * `style="color: red/blue; cursor: pointer"` injectés dans le HTML. On + * conserve les classes `.keyword` / `.chatbot-keyword` historiques et on + * les stylise via `globals.css` avec la palette Stitch (voir `.glossary-keyword`). + * Pour rester rétro-compatible avec les classes historiques, `keyword` est + * renommée `glossary-keyword` dans la transformation. + * + * 3. **Event listeners scopés au wrapper** (ref `contentRef`) plutôt que + * `document.body.addEventListener`. Avant : risque de fuite + interaction + * avec d'autres parties du DOM. Après : la zone "contenu" capture ses clics + * en bubbling, comportement identique mais sans effet de bord global. + * + * 4. **Chatbot via FAB global** (étape 7.e) : plus de `` local dans + * cette fiche. Un clic sur "IA locale" dispatch `CustomEvent("grasbot:open")` + * que le FAB monté dans `layout.tsx` écoute pour ouvrir le chatbot partagé. + */ export default function ContentSectionCompetences({ competenceData, glossaireData, - titleClass, - contentClass, }: ContentSectionProps) { - console.log("🔍 [ContentSectionCompetences] Chargement du composant..."); - const [selectedMot, setSelectedMot] = useState(null); - const [isChatbotOpen, setIsChatbotOpen] = useState(false); - const [loading, setLoading] = useState(competenceData === null); + const contentRef = useRef(null); const apiUrl = getApiUrl(); + // Délégation locale : capte les clics sur les keywords injectés dans le Markdown, + // sans polluer document.body comme avant la refonte. useEffect(() => { - if (competenceData) { - setLoading(false); - } - }, [competenceData]); + const node = contentRef.current; + if (!node) return; - if (loading) { - return
⏳ Chargement des détails de la compétence...
; - } + const handleClick = (event: Event) => { + const target = event.target as HTMLElement; + + if (target.dataset?.chatbot === "true") { + window.dispatchEvent(new CustomEvent("grasbot:open")); + return; + } + + if (target.classList?.contains("glossary-keyword")) { + const mot = target.getAttribute("data-mot"); + if (!mot) return; + const glossaireMot = glossaireData.find((g) => g.mot_clef === mot); + setSelectedMot(glossaireMot || null); + } + }; + + node.addEventListener("click", handleClick); + return () => node.removeEventListener("click", handleClick); + }, [glossaireData]); if (!competenceData) { - console.error("❌ [ContentSectionCompetences] Compétence introuvable !"); - return
❌ Compétence introuvable.
; + return ( +
+
+ +

+ Cette compétence est introuvable. +

+ + + Retour aux compétences + +
+
+ ); } const { name, content, picture } = competenceData; @@ -70,78 +134,95 @@ export default function ContentSectionCompetences({ const images = picture?.map((img) => ({ url: `${apiUrl}${img.formats?.large?.url || img.url}`, - alt: img.name || "Image de compétence", + alt: img.name || `Visuel de la compétence ${name}`, })) || []; + /** + * Transforme le Markdown en injectant des spans `.glossary-keyword` / `.chatbot-keyword` + * autour des mots-clés trouvés. Les styles sont définis dans `globals.css` + * (palette Stitch, soulignement pointillé) plutôt qu'inline dans l'attribut style. + */ function transformMarkdownWithKeywords(text: string) { - if (!glossaireData.length) return text; - + if (!text) return ""; let modifiedText = text; modifiedText = modifiedText.replace( /\bIA locale\b/g, - `IA locale` + `IA locale` ); - glossaireData.forEach(({ mot_clef, variantes }) => { - const regexVariants = variantes - .map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) - .join("|"); - const regex = new RegExp(`\\b(${mot_clef}|${regexVariants})\\b`, "gi"); + if (glossaireData.length) { + glossaireData.forEach(({ mot_clef, variantes }) => { + const regexVariants = variantes + .map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("|"); + const regex = new RegExp(`\\b(${mot_clef}|${regexVariants})\\b`, "gi"); - modifiedText = modifiedText.replace(regex, (match) => { - return `${match}`; + modifiedText = modifiedText.replace(regex, (match) => { + return `${match}`; + }); }); - }); + } return modifiedText; } const contentWithLinks = transformMarkdownWithKeywords(content); - useEffect(() => { - function handleKeywordClick(event: MouseEvent) { - const target = event.target as HTMLElement; - if (target.classList.contains("keyword")) { - const mot = target.getAttribute("data-mot"); - if (mot) { - const glossaireMot = glossaireData.find((g) => g.mot_clef === mot); - setSelectedMot(glossaireMot || null); - } - } - } - - document.body.addEventListener("click", handleKeywordClick); - return () => document.body.removeEventListener("click", handleKeywordClick); - }, [glossaireData]); - - useEffect(() => { - function handleChatbotClick(event: MouseEvent) { - const target = event.target as HTMLElement; - if (target.dataset.chatbot === "true") { - setIsChatbotOpen(true); - } - } - - document.body.addEventListener("click", handleChatbotClick); - return () => document.body.removeEventListener("click", handleChatbotClick); - }, []); - return ( -
-

- {name} -

- -
- {contentWithLinks} -
- {selectedMot && setSelectedMot(null)} />} +
+ + + Compétences + - {isChatbotOpen && ( -
- setIsChatbotOpen(false)} /> +
+
+ + Compétence · Savoir-faire + +

+ {name} +

+ + {images.length > 0 && ( +
+ +
+ )} + +
+ {contentWithLinks} +
+
+ + {selectedMot && ( + setSelectedMot(null)} /> )}
); diff --git a/app/components/ContentSectionCompetencesContainer.tsx b/app/components/ContentSectionCompetencesContainer.tsx index e61e0e5..ba656be 100644 --- a/app/components/ContentSectionCompetencesContainer.tsx +++ b/app/components/ContentSectionCompetencesContainer.tsx @@ -11,9 +11,16 @@ interface ContentSectionProps { contentClass?: string; } -export default function ContentSectionCompetencesContainer({ collection, slug, titleClass, contentClass }: ContentSectionProps) { - console.log("🔍 [ContentSectionCompetencesContainer] Chargement des données..."); - +/** + * Orchestre le fetch compétence + glossaire et transmet les données au rendu. + * Le rendu (`ContentSectionCompetences`) gère son propre état "non trouvé" en + * feuillet vellum — on s'aligne ici sur le même gabarit pour l'état de + * chargement (étape 7.c). + */ +export default function ContentSectionCompetencesContainer({ + collection, + slug, +}: ContentSectionProps) { const [competenceData, setCompetenceData] = useState(null); const [glossaireData, setGlossaireData] = useState([]); const [loading, setLoading] = useState(true); @@ -38,15 +45,24 @@ export default function ContentSectionCompetencesContainer({ collection, slug, t }, [collection, slug]); if (loading) { - return
⏳ Chargement des compétences...
; + return ( +
+
+
+
+
+
+
+
+
+
+ ); } return ( ); } diff --git a/app/components/GrasBotFab.tsx b/app/components/GrasBotFab.tsx new file mode 100644 index 0000000..e5a064e --- /dev/null +++ b/app/components/GrasBotFab.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useEffect, useState } from "react"; +import ChatBot from "./ChatBot"; + +/** + * FAB "GrasBot" global (étape 7.e). + * + * Monté une seule fois dans `app/layout.tsx` pour que le chatbot soit accessible + * **depuis toutes les pages**, pas seulement depuis les fiches compétences comme + * avant la refonte. + * + * Flux d'ouverture : + * - Clic direct sur le bouton flottant → ouvre le panneau. + * - Clic sur un mot-clé `.chatbot-keyword` dans une fiche compétence → + * `ContentSectionCompetences` dispatche `window.dispatchEvent(new CustomEvent("grasbot:open"))`, + * ce FAB écoute et ouvre le panneau. Pas de Context partagé, pas de store global : + * un événement `window` suffit pour un besoin one-shot et découple proprement + * le ChatBot de ses points d'entrée. + * + * Positionnement : + * - Bouton : `fixed bottom-6 right-6`, z-index 30 (au-dessus du contenu, en + * dessous du header z-20 ? — non : au-dessus, pour rester accessible. On + * passe à z-30). + * - Panneau : `fixed inset-x-4 bottom-24 sm:inset-auto sm:bottom-24 sm:right-6` + * → plein largeur mobile (avec 16 px de marge), 384 px desktop. + */ +export default function GrasBotFab() { + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + const handleOpen = () => setIsOpen(true); + window.addEventListener("grasbot:open", handleOpen); + return () => window.removeEventListener("grasbot:open", handleOpen); + }, []); + + useEffect(() => { + if (!isOpen) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsOpen(false); + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [isOpen]); + + return ( + <> + + + {isOpen && ( +
+ setIsOpen(false)} /> +
+ )} + + ); +} diff --git a/app/globals.css b/app/globals.css index bea50a5..2e44d9c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -60,6 +60,30 @@ body { overflow-x: hidden; } +/* Keywords glossaire & chatbot dans les fiches compétences (étape 7.c). + Les spans sont injectés via `transformMarkdownWithKeywords` + `rehype-raw`. + Avant la refonte : styles inline `style="color:red/blue"` → incohérent avec + la palette Stitch et intraçable. Désormais : deux classes utilitaires alignées + sur `primary` (#26445d), soulignement pointillé éditorial (underline dotted + offset 3px) pour signaler l'interactivité sans rompre le flux de lecture. */ +.glossary-keyword, +.chatbot-keyword { + color: #26445d; + cursor: pointer; + font-weight: 600; + text-decoration: underline dotted; + text-underline-offset: 3px; + transition: color 0.2s ease; +} + +.glossary-keyword:hover, +.chatbot-keyword:hover, +.glossary-keyword:focus-visible, +.chatbot-keyword:focus-visible { + color: #3e5c76; + outline: none; +} + @keyframes fade-in { from { opacity: 0; diff --git a/app/layout.tsx b/app/layout.tsx index 3fdb2d9..3a2753a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ import Footer from "./components/Footer"; import "./assets/main.css"; import "./globals.css"; import NavLink from "./components/NavLink"; +import GrasBotFab from "./components/GrasBotFab"; import { manrope, newsreader } from "./fonts"; export default function RootLayout({ children }: { children: React.ReactNode }) { @@ -244,6 +245,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
+ + {/* GrasBot : FAB global Stitch (étape 7.e). Accessible depuis toutes les + pages, écoute aussi `CustomEvent("grasbot:open")` dispatché depuis + les fiches compétences quand l'utilisateur clique sur « IA locale ». */} + ); diff --git a/docs-site-interne/REFONTE-VISUELLE.md b/docs-site-interne/REFONTE-VISUELLE.md index 2bb9d1d..de799f2 100644 --- a/docs-site-interne/REFONTE-VISUELLE.md +++ b/docs-site-interne/REFONTE-VISUELLE.md @@ -1,7 +1,7 @@ # Refonte visuelle — Direction "Digital Atelier" **Créé :** 2026-04-22 -**Statut :** en cours (étapes 1-6/8 terminées) +**Statut :** en cours (étapes 1-7/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). @@ -60,7 +60,7 @@ Chaque étape = un lot cohérent + éventuelle mise à jour de `captures/AUDIT-V | 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*` | **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 | +| 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` | **fait** (2026-04-22) | | 8 | Contact + Footer éditorial | `app/contact/page.js`, `app/components/ContactForm.tsx`, `app/components/Footer.jsx` | à faire | ## 4 bis. Correctif post-étape 3 (2026-04-22) — cohérence desktop/mobile @@ -229,6 +229,83 @@ Premier retour utilisateur après l'étape 6 : *« j'ai perdu ma fonctionnalité 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. +## 7. Étape 7 — Fiches détail + glossaire + GrasBot flottant (2026-04-22) + +L'étape 7 touche cinq composants et introduit un sixième (le FAB). Elle est découpée en cinq sous-lots décrits ci-dessous. + +### 7.a Carousels fiche détail (`Carousel.tsx` + `CarouselCompetences.tsx`) + +Les deux composants sont quasi-doublons historiques ; on les refait **à l'identique** pour ne pas risquer de régression sur la modale glossaire qui consomme `CarouselCompetences`. Un futur refactor pourra les fusionner une fois le périmètre de la refonte clos (pas dans le scope étape 7). + +Changements appliqués : +- **Pagination bullets `primary`** via surcharge inline des variables Swiper (`--swiper-pagination-color: #26445d`, bullets 8 px). Même approche que `VignetteCarousel` pour ne pas polluer `globals.css` avec un sélecteur `.swiper-pagination-bullet` global. +- **Flèches** : on conserve les chevrons natifs Swiper, recolorés via `--swiper-navigation-color: #26445d`, taille 28 px. Remplacement par Material Symbols écarté (demande un override complet du markup via slots Swiper, sans bénéfice visuel proportionnel). +- **Conteneur** : `rounded-tile overflow-hidden shadow-ambient-sm` (vs `rounded-md shadow-md` pré-refonte). +- **`autoplay: 3500` + `disableOnInteraction: false`** : l'autoplay reprend après un swipe manuel plutôt que de rester figé. +- **`loop` conditionnel** (`images.length > 1`) : évite le warning Swiper sur les entrées mono-image. +- **Lightbox Stitch** : voile `bg-on-surface/80 backdrop-blur-sm` (vs `bg-black/10 backdrop-blur-2xl` qui noyait l'image dans un flou 2xl contre-productif), image en `object-contain max-h-[92vh] max-w-[92vw] rounded-sheet shadow-ambient` (ne déforme plus les portraits), bouton close rond 40 px Material Symbol `close` (`translate="no"`), verrouillage du scroll body + fermeture `Escape` + fermeture sur clic voile avec `stopPropagation` sur l'image. + +### 7.b Fiche portfolio (`ContentSection.tsx`) + +Avant : cartes `bg-white/50 text-blue-700 font-headline font-extrabold` hardcodées, lien externe `bg-white/65 text-red-700 hover:text-blue-700`, pas de retour vers la liste, pas de hiérarchie éditoriale. Contenu Markdown sans `prose`. + +Après : +- **Gabarit aligné** sur les listes (étape 6) : wrapper `max-w-3xl` centré, fil d'Ariane minimaliste (pastille ronde `bg-surface-container-lowest/70 backdrop-blur-vellum` avec Material Symbol `arrow_back` + label « Portfolio »), carte vellum principale (`rounded-sheet bg-surface-container-lowest/85 shadow-ambient backdrop-blur-vellum`). +- **En-tête éditorial** : kicker `Projet · Portfolio` uppercase tracking-[0.3em] + titre Manrope extrabold `text-on-surface`. +- **Carousel détail** : `` plein cadre (`h-64 sm:h-80 md:h-96`) réutilise la version 7.a. +- **Corps Markdown en `prose` Stitch** : mêmes overrides que la home (`prose-headings:text-primary`, `prose-p:text-on-surface-variant`, `prose-hr:` transformés en pastille primary via le fix § 4 sexies). Le CV et les fiches partagent désormais la **même charte typographique**. +- **CTA externe jewel** : `bg-primary text-white shadow-jewel` avec Material Symbol `open_in_new` (`translate="no"`) et hover `-translate-y-0.5`. Le `linkText` Strapi reste utilisé, fallback « Voir plus » (au lieu de « Voir plus/lien externe » qui mélangeait 2 intentions). +- **États loading + not-found** en vellum plutôt qu'en ligne `text-gray-500` orpheline. Le not-found propose un retour explicite vers `/portfolio`. + +### 7.c Fiche compétences (`ContentSectionCompetences.tsx` + `Container`) + +Trois chantiers en un : + +**Style** : gabarit identique à 7.b (pastille retour, en-tête vellum, carousel 16/9, prose Stitch). Les `titleClass` / `contentClass` historiques ne sont plus consommés mais on les garde dans l'interface TS pour ne pas casser le call-site. + +**Keywords glossaire/chatbot sans styles inline** : avant, `transformMarkdownWithKeywords` injectait `...` — visuellement criard et non thématisable. Après, on injecte les classes `.glossary-keyword` et `.chatbot-keyword` (définies dans `globals.css`), stylées en `color: #26445d` (primary), `text-decoration: underline dotted`, `text-underline-offset: 3px`. Le soulignement pointillé signale l'interactivité sans rompre le flux de lecture, la couleur cohérente avec toute la charte Stitch. Attributs `role="button" tabindex="0"` ajoutés au passage pour l'accessibilité clavier (touche Entrée peut être câblée plus tard si besoin). + +**Event listeners scopés** : avant, `document.body.addEventListener("click", ...)` × 2. Après, un seul listener attaché à `contentRef` (ref sur le wrapper prose). Les clics remontent en bubbling depuis les spans Markdown jusqu'au wrapper ; gain en clarté, pas de fuite, pas de risque de conflit avec d'autres zones du DOM. Le handler `keyword` → ouvre `ModalGlossaire`. Le handler `chatbot` → dispatch `CustomEvent("grasbot:open")` sur `window` pour réveiller le FAB global (7.e) au lieu d'instancier un `` local. + +**Container** (`ContentSectionCompetencesContainer.tsx`) : l'état de chargement `⏳ Chargement des compétences...` remplacé par un skeleton vellum (kicker + titre + carousel + 3 lignes de texte en `animate-pulse`), cohérent avec les listes et `ContentSection`. + +### 7.d ChatBot (`ChatBot.js`) + +Le composant garde son API (`onClose`) mais tout le shell visuel est refait : + +| Zone | Avant | Après | +|------|-------|-------| +| Carte | `bg-white/70 shadow-lg rounded-lg border border-gray-300` | `bg-surface-container-lowest/95 backdrop-blur-vellum rounded-sheet shadow-ambient` (= gabarit vellum partagé) | +| Header | `bg-blue-600 text-white` + 💬 emoji + ❌ | `bg-primary text-white` + Material Symbol `smart_toy` + sous-titre Manrope uppercase « Assistant IA locale » + bouton close rond Material Symbol | +| Bulle user | `bg-blue-500 ml-auto` | `bg-primary text-white rounded-sheet` | +| Bulle bot | `bg-gray-500 mr-auto` | `bg-surface-container text-on-surface rounded-sheet` | +| Indicateur attente | `wait...` sur bulle grise | « GrasBot réfléchit... » en italique atténué avec 3 points animés `.dot-1/2/3` existants | +| Input | `border border-gray-300 rounded-l-lg` | `bg-surface-container-low rounded-tile focus-visible:ring-2 focus-visible:ring-primary` | +| Envoyer | `bg-blue-500 text-white ➤` | Bouton rond Material Symbol `send` jewel `bg-primary shadow-jewel hover:-translate-y-0.5`, disabled si vide ou en attente | + +Ajouts fonctionnels : auto-scroll en bas à chaque nouveau message (ref `scrollRef`), focus auto sur l'input à l'ouverture, envoi à Entrée (`handleKeyDown`), input désactivé pendant l'attente (plus de double envoi possible), message d'accueil éditorial quand la conversation est vide. + +### 7.e GrasBotFab (`GrasBotFab.tsx`) + +Nouveau composant monté une seule fois dans `app/layout.tsx` → le chatbot est désormais accessible **depuis toutes les pages**, plus seulement les fiches compétences. + +**Anatomie** : +- **Bouton** : `fixed bottom-6 right-6 z-30`, rond 56 px (64 px md), `bg-primary text-white shadow-jewel` avec Material Symbol `smart_toy` (→ `close` quand ouvert). Hover `-translate-y-0.5 hover:bg-primary-container`. `aria-expanded` tenu à jour. +- **Panneau** : `fixed inset-x-4 bottom-24` mobile (plein largeur - 16 px de chaque côté), `sm:inset-auto sm:bottom-24 sm:right-6 sm:w-96 sm:h-[560px]` desktop. `role="dialog"` avec `aria-label`. Monte le `` refait en 7.d avec `onClose` qui ferme le panneau. +- **Fermeture Esc** globale dès que le panneau est ouvert. + +**Flux d'entrée** : +- Clic direct sur le FAB → ouvre le panneau. +- Clic sur `.chatbot-keyword` (« IA locale ») dans une fiche compétence → `ContentSectionCompetences` dispatch `window.dispatchEvent(new CustomEvent("grasbot:open"))`, le FAB écoute et ouvre. Pas de Context, pas de store : un `CustomEvent` suffit pour un besoin one-shot et garde les composants découplés. + +**Z-index** : le FAB est en `z-30`. Header en `z-20`, drawer mobile en `z-40`. On passe donc le FAB **devant** le header (sinon invisible sous la bande fixe) mais **derrière** le drawer (pour ne pas masquer la navigation). Si le drawer est ouvert, le FAB est recouvert ; acceptable, le drawer étant un mode modal de navigation. + +### Points laissés pour l'étape 8 + +- Fusion de `Carousel.tsx` et `CarouselCompetences.tsx` (doublons) : hors scope étape 7 (pas de gain visuel, risque de régression non justifié). À reprendre en lot "dette technique" après l'étape 8. +- `ModalGlossaire` déjà aligné Stitch au correctif § 4 ter ; aucun changement nécessaire en 7, juste une vérification que son `CarouselCompetences` hérite bien de la lightbox 7.a — oui, c'est automatique. +- Persistance du fil de conversation GrasBot (refresh = historique perdu) : pas demandé, pas introduit. Ajouter un `localStorage` si besoin plus tard. + ## 5. Checklist relecture (à passer à la fin de chaque étape) - [ ] Aucune colonne unique globale `max-w-xl` (c'est le format newsletter). diff --git a/docs-site-interne/feuille-de-route.md b/docs-site-interne/feuille-de-route.md index a75b37b..380ead8 100644 --- a/docs-site-interne/feuille-de-route.md +++ b/docs-site-interne/feuille-de-route.md @@ -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-6 (tokens + garde-fou + migration typo globale + layout racine + home + listes portfolio/compétences) 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-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` | | 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 | @@ -51,4 +51,5 @@ Document vivant : ajuster les statuts et dates au fil du travail. | 2026-04-22 | **Correctif séparateurs `
` du hero** (retour utilisateur : « trop d'espace entre les sections » en fait dû à des `
` 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 `` 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 ? : ` — 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 7 : fiches détail + glossaire + GrasBot flottant**. Cinq sous-lots. **7.a** : `Carousel.tsx` + `CarouselCompetences.tsx` harmonisés (pagination bullets primary via variables CSS Swiper, flèches primary, `rounded-tile shadow-ambient-sm`, autoplay 3500 ms + `loop` conditionnel, lightbox Stitch refaite avec voile `bg-on-surface/80`, image `object-contain rounded-sheet`, bouton close rond Material Symbol + Escape + verrouillage scroll body). **7.b** : `ContentSection.tsx` (fiche portfolio) — gabarit vellum cohérent avec la home/listes, pastille retour `arrow_back`, kicker `Projet · Portfolio`, titre Manrope, carousel détail plein cadre, corps Markdown en `prose` Stitch (mêmes overrides que la home, y compris pastille `prose-hr`), CTA externe jewel avec `open_in_new`, états loading/404 en vellum. **7.c** : `ContentSectionCompetences.tsx` + container — même gabarit. Refactor glossaire : styles inline `style="color:red/blue"` remplacés par les classes `.glossary-keyword` / `.chatbot-keyword` (ajoutées à `globals.css`, couleur primary + underline dotted offset 3 px). Event listeners `document.body.addEventListener` remplacés par un listener unique scopé au wrapper `contentRef`. Le clic sur « IA locale » ne monte plus un `` local mais dispatch `window.dispatchEvent(new CustomEvent("grasbot:open"))` capté par le FAB global (7.e). Container refait avec skeleton vellum à la place de `⏳ Chargement...`. **7.d** : `ChatBot.js` entièrement restylé (carte vellum `rounded-sheet shadow-ambient backdrop-blur-vellum`, header primary avec Material Symbol `smart_toy` + sous-titre « Assistant IA locale », bulles user `bg-primary text-white` et bot `bg-surface-container`, input Stitch avec `focus-visible:ring-primary`, bouton envoyer rond jewel Material Symbol `send`, auto-scroll, focus auto, envoi Enter, disabled pendant attente, message d'accueil vide éditorial). **7.e** : nouveau composant `app/components/GrasBotFab.tsx` — FAB jewel `fixed bottom-6 right-6 z-30` rond 56/64 px, `bg-primary shadow-jewel` Material Symbol `smart_toy`/`close`, monté dans `app/layout.tsx` → chatbot accessible depuis **toutes les pages** (plus seulement fiches compétences). Écoute `CustomEvent("grasbot:open")` dispatché depuis le keyword « IA locale ». Fermeture Escape globale, panneau responsive plein largeur mobile / 384 px desktop. Détails dans `REFONTE-VISUELLE.md` §7. | | 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. |