mirror of
https://github.com/Ladebeze66/devsite.git
synced 2026-05-11 16:56:26 +02:00
etape7ok
This commit is contained in:
parent
f824053a31
commit
ab47a41a37
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { 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";
|
||||||
import { Navigation, Pagination, Autoplay } from "swiper/modules";
|
import { Navigation, Pagination, Autoplay } from "swiper/modules";
|
||||||
@ -10,6 +10,25 @@ import "swiper/css/pagination";
|
|||||||
import "../globals.css";
|
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 {
|
interface CarouselProps {
|
||||||
images: Array<{ url: string; alt: string }>;
|
images: Array<{ url: string; alt: string }>;
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -18,25 +37,53 @@ interface CarouselProps {
|
|||||||
export default function Carousel({ images, className }: CarouselProps) {
|
export default function Carousel({ images, className }: CarouselProps) {
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
const [selectedImage, setSelectedImage] = useState<string | null>(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`relative w-full ${className || "h-64"} rounded-md shadow-md`}>
|
<div
|
||||||
|
className={`relative w-full ${className || "h-64"} overflow-hidden rounded-tile shadow-ambient-sm`}
|
||||||
|
>
|
||||||
<Swiper
|
<Swiper
|
||||||
modules={[Navigation, Pagination, Autoplay]}
|
modules={[Navigation, Pagination, Autoplay]}
|
||||||
spaceBetween={10}
|
spaceBetween={10}
|
||||||
slidesPerView={1}
|
slidesPerView={1}
|
||||||
navigation
|
navigation
|
||||||
pagination={{ clickable: true }}
|
pagination={{ clickable: true }}
|
||||||
autoplay={{ delay: 3000 }}
|
autoplay={{ delay: 3500, disableOnInteraction: false }}
|
||||||
|
loop={images.length > 1}
|
||||||
className={`w-full ${className || "h-64"}`}
|
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) => (
|
{images.map((img, index) => (
|
||||||
<SwiperSlide key={index} className="flex items-center justify-center h-full">
|
<SwiperSlide key={index} className="flex h-full items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src={img.url}
|
src={img.url}
|
||||||
alt={img.alt}
|
alt={img.alt}
|
||||||
className="w-full h-full object-cover rounded-md cursor-pointer transition-transform duration-300 hover:scale-105"
|
className="h-full w-full cursor-zoom-in object-cover transition-transform duration-300 hover:scale-[1.02]"
|
||||||
onClick={() => setSelectedImage(img.url)}
|
onClick={() => setSelectedImage(img.url)}
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
))}
|
))}
|
||||||
@ -46,20 +93,34 @@ export default function Carousel({ images, className }: CarouselProps) {
|
|||||||
{selectedImage &&
|
{selectedImage &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 flex items-center justify-center w-screen h-screen bg-black bg-opacity-10 backdrop-blur-2xl transition-opacity duration-300 z-[1000]"
|
className="fixed inset-0 z-[1000] flex items-center justify-center bg-on-surface/80 p-4 backdrop-blur-sm transition-opacity duration-300"
|
||||||
onClick={() => setSelectedImage(null)}
|
onClick={() => setSelectedImage(null)}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Image agrandie"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative flex max-h-[92vh] max-w-[92vw] items-center justify-center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="relative w-full max-w-6xl p-6 bg-transparent">
|
|
||||||
<button
|
<button
|
||||||
className="absolute top-6 right-6 text-white text-l bg-gray-900/70 p-2 rounded-full"
|
type="button"
|
||||||
|
className="absolute -right-1 -top-1 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-surface-container-lowest/95 text-primary shadow-ambient-sm transition-colors hover:bg-primary hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
onClick={() => setSelectedImage(null)}
|
onClick={() => setSelectedImage(null)}
|
||||||
|
aria-label="Fermer l'aperçu"
|
||||||
>
|
>
|
||||||
✖
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
aria-hidden="true"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
close
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<img
|
<img
|
||||||
src={selectedImage}
|
src={selectedImage}
|
||||||
alt="Agrandissement"
|
alt="Aperçu en taille réelle"
|
||||||
className="w-full h-full object-cover rounded-md"
|
className="max-h-[92vh] max-w-[92vw] rounded-sheet object-contain shadow-ambient"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { 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";
|
||||||
import { Navigation, Pagination, Autoplay } from "swiper/modules";
|
import { Navigation, Pagination, Autoplay } from "swiper/modules";
|
||||||
@ -10,6 +10,13 @@ import "swiper/css/pagination";
|
|||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
import "../assets/main.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 {
|
interface CarouselProps {
|
||||||
images: Array<{ url: string; alt: string }>;
|
images: Array<{ url: string; alt: string }>;
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -18,25 +25,53 @@ interface CarouselProps {
|
|||||||
export default function CarouselCompetences({ images, className }: CarouselProps) {
|
export default function CarouselCompetences({ images, className }: CarouselProps) {
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
const [selectedImage, setSelectedImage] = useState<string | null>(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`relative w-full ${className || "h-64"} rounded-md shadow-md`}>
|
<div
|
||||||
|
className={`relative w-full ${className || "h-64"} overflow-hidden rounded-tile shadow-ambient-sm`}
|
||||||
|
>
|
||||||
<Swiper
|
<Swiper
|
||||||
modules={[Navigation, Pagination, Autoplay]}
|
modules={[Navigation, Pagination, Autoplay]}
|
||||||
spaceBetween={10}
|
spaceBetween={10}
|
||||||
slidesPerView={1}
|
slidesPerView={1}
|
||||||
navigation
|
navigation
|
||||||
pagination={{ clickable: true }}
|
pagination={{ clickable: true }}
|
||||||
autoplay={{ delay: 3000 }}
|
autoplay={{ delay: 3500, disableOnInteraction: false }}
|
||||||
|
loop={images.length > 1}
|
||||||
className={`w-full ${className || "h-64"}`}
|
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) => (
|
{images.map((img, index) => (
|
||||||
<SwiperSlide key={index} className="flex items-center justify-center h-full">
|
<SwiperSlide key={index} className="flex h-full items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src={img.url}
|
src={img.url}
|
||||||
alt={img.alt}
|
alt={img.alt}
|
||||||
className="w-full h-full object-cover rounded-md cursor-pointer transition-transform duration-300 hover:scale-105"
|
className="h-full w-full cursor-zoom-in object-cover transition-transform duration-300 hover:scale-[1.02]"
|
||||||
onClick={() => setSelectedImage(img.url)}
|
onClick={() => setSelectedImage(img.url)}
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
))}
|
))}
|
||||||
@ -46,20 +81,34 @@ export default function CarouselCompetences({ images, className }: CarouselProps
|
|||||||
{selectedImage &&
|
{selectedImage &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 flex items-center justify-center w-screen h-screen bg-black bg-opacity-10 backdrop-blur-2xl transition-opacity duration-300 z-[1000]"
|
className="fixed inset-0 z-[1000] flex items-center justify-center bg-on-surface/80 p-4 backdrop-blur-sm transition-opacity duration-300"
|
||||||
onClick={() => setSelectedImage(null)}
|
onClick={() => setSelectedImage(null)}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Image agrandie"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative flex max-h-[92vh] max-w-[92vw] items-center justify-center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="relative w-full max-w-6xl p-6 bg-transparent">
|
|
||||||
<button
|
<button
|
||||||
className="absolute top-6 right-6 text-white text-l bg-gray-900/70 p-2 rounded-full"
|
type="button"
|
||||||
|
className="absolute -right-1 -top-1 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-surface-container-lowest/95 text-primary shadow-ambient-sm transition-colors hover:bg-primary hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
onClick={() => setSelectedImage(null)}
|
onClick={() => setSelectedImage(null)}
|
||||||
|
aria-label="Fermer l'aperçu"
|
||||||
>
|
>
|
||||||
✖
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
aria-hidden="true"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
close
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<img
|
<img
|
||||||
src={selectedImage}
|
src={selectedImage}
|
||||||
alt="Agrandissement"
|
alt="Aperçu en taille réelle"
|
||||||
className="w-full h-full object-cover rounded-md"
|
className="max-h-[92vh] max-w-[92vw] rounded-sheet object-contain shadow-ambient"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@ -1,68 +1,167 @@
|
|||||||
import { useState } from "react";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { askAI } from "../utils/askAI";
|
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 }) {
|
export default function ChatBot({ onClose }) {
|
||||||
const [question, setQuestion] = useState("");
|
const [question, setQuestion] = useState("");
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
const [isWaiting, setIsWaiting] = useState(false);
|
const [isWaiting, setIsWaiting] = useState(false);
|
||||||
|
const scrollRef = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages, isWaiting]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleAsk = async () => {
|
const handleAsk = async () => {
|
||||||
if (!question.trim()) return;
|
if (!question.trim() || isWaiting) return;
|
||||||
|
|
||||||
const userMessage = { sender: "user", text: question };
|
const userMessage = { sender: "user", text: question };
|
||||||
setMessages([...messages, userMessage]);
|
setMessages((prev) => [...prev, userMessage]);
|
||||||
|
|
||||||
setQuestion("");
|
setQuestion("");
|
||||||
setIsWaiting(true);
|
setIsWaiting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const botResponse = await askAI(question);
|
const botResponse = await askAI(userMessage.text);
|
||||||
const botMessage = { sender: "bot", text: botResponse };
|
setMessages((prev) => [...prev, { sender: "bot", text: botResponse }]);
|
||||||
setMessages((prevMessages) => [...prevMessages, botMessage]);
|
} catch (_error) {
|
||||||
} catch (error) {
|
setMessages((prev) => [
|
||||||
setMessages([...messages, { sender: "bot", text: "❌ Erreur de réponse. Réessayez plus tard." }]);
|
...prev,
|
||||||
|
{ sender: "bot", text: "Erreur de réponse. Réessayez plus tard." },
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsWaiting(false);
|
setIsWaiting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAsk();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-96 bg-white/70 shadow-lg rounded-lg border border-gray-300">
|
<div className="flex h-full w-full flex-col overflow-hidden rounded-sheet bg-surface-container-lowest/95 shadow-ambient backdrop-blur-vellum">
|
||||||
<div className="bg-blue-600 text-white p-3 rounded-t-lg flex justify-between items-center">
|
<div className="flex items-center justify-between gap-3 bg-primary px-4 py-3 text-white">
|
||||||
<span className="font-headline font-bold">💬 GrasBot</span>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<button className="text-white hover:text-red-400 text-xl" onClick={onClose}>❌</button>
|
<span
|
||||||
|
className="material-symbols-outlined text-2xl"
|
||||||
|
aria-hidden="true"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
smart_toy
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-headline text-sm font-bold tracking-tight">GrasBot</p>
|
||||||
|
<p className="truncate font-headline text-[10px] uppercase tracking-[0.25em] text-primary-fixed">
|
||||||
|
Assistant IA locale
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Fermer le chat"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full text-white transition-colors hover:bg-primary-container focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-fixed"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
aria-hidden="true"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
close
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-64 overflow-y-auto p-4 space-y-2">
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex-1 space-y-2 overflow-y-auto bg-surface-container-lowest/60 p-4"
|
||||||
|
>
|
||||||
|
{messages.length === 0 && !isWaiting && (
|
||||||
|
<p className="mt-6 text-center font-body italic text-sm text-on-surface-variant">
|
||||||
|
Posez-moi une question sur le site, les projets ou les compétences.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{messages.map((msg, index) => (
|
{messages.map((msg, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`p-2 rounded-lg text-white font-headline text-xs ${msg.sender === "user" ? "bg-blue-500 ml-auto" : "bg-gray-500 mr-auto"}`}
|
className={`max-w-[80%] rounded-sheet px-3 py-2 font-headline text-xs leading-relaxed ${
|
||||||
style={{ maxWidth: "80%" }}
|
msg.sender === "user"
|
||||||
|
? "ml-auto bg-primary text-white"
|
||||||
|
: "mr-auto bg-surface-container text-on-surface"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{msg.text}
|
{msg.text}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{isWaiting && (
|
{isWaiting && (
|
||||||
<div className="p-2 rounded-lg text-white bg-gray-500 mr-auto wait-animation" style={{ maxWidth: "80%" }}>
|
<div
|
||||||
wait<span className="dot-1">.</span><span className="dot-2">.</span><span className="dot-3">.</span>
|
className="wait-animation mr-auto inline-flex max-w-[80%] items-end gap-0.5 rounded-sheet bg-surface-container px-3 py-2 font-headline text-xs text-on-surface-variant"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
GrasBot réfléchit
|
||||||
|
<span className="dot-1">.</span>
|
||||||
|
<span className="dot-2">.</span>
|
||||||
|
<span className="dot-3">.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex p-3 border-t border-gray-300">
|
<div className="flex items-center gap-2 border-t border-outline-variant/30 bg-surface-container-lowest/80 p-3">
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
className="flex-1 p-2 border border-gray-300 font-headline text-xs rounded-l-lg focus:outline-none"
|
className="min-w-0 flex-1 rounded-tile bg-surface-container-low px-3 py-2 font-headline text-xs text-on-surface placeholder:text-on-surface-variant focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
placeholder="Posez votre question..."
|
placeholder="Votre question…"
|
||||||
value={question}
|
value={question}
|
||||||
onChange={(e) => setQuestion(e.target.value)}
|
onChange={(e) => setQuestion(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isWaiting}
|
||||||
|
aria-label="Votre question pour GrasBot"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="bg-blue-500 text-white px-4 py-2 rounded-r-lg hover:bg-blue-700"
|
type="button"
|
||||||
onClick={handleAsk}
|
onClick={handleAsk}
|
||||||
|
disabled={isWaiting || !question.trim()}
|
||||||
|
aria-label="Envoyer la question"
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-white shadow-jewel transition-transform duration-200 hover:-translate-y-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none disabled:hover:translate-y-0"
|
||||||
>
|
>
|
||||||
➤
|
<span
|
||||||
|
className="material-symbols-outlined text-lg"
|
||||||
|
aria-hidden="true"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
send
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
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";
|
||||||
@ -31,39 +32,144 @@ interface ContentSectionProps {
|
|||||||
contentClass?: string;
|
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<ContentData | null>(null);
|
const [data, setData] = useState<ContentData | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const apiUrl = getApiUrl();
|
const apiUrl = getApiUrl();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchContent() {
|
async function fetchContent() {
|
||||||
|
try {
|
||||||
const result = await fetchData(collection, slug);
|
const result = await fetchData(collection, slug);
|
||||||
setData(result);
|
setData(result);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchContent();
|
fetchContent();
|
||||||
}, [collection, slug, apiUrl]);
|
}, [collection, slug, apiUrl]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pb-10 sm:px-6">
|
||||||
|
<div className="rounded-sheet bg-surface-container-lowest/65 p-6 shadow-ambient-sm backdrop-blur-vellum">
|
||||||
|
<div className="h-4 w-24 animate-pulse rounded-full bg-surface-container-low/80" />
|
||||||
|
<div className="mt-3 h-8 w-3/4 animate-pulse rounded-full bg-surface-container-low/80" />
|
||||||
|
<div className="mt-5 aspect-[16/9] w-full animate-pulse rounded-tile bg-surface-container-low/80" />
|
||||||
|
<div className="mt-5 h-4 w-full animate-pulse rounded-full bg-surface-container-low/60" />
|
||||||
|
<div className="mt-2 h-4 w-5/6 animate-pulse rounded-full bg-surface-container-low/60" />
|
||||||
|
<div className="mt-2 h-4 w-4/6 animate-pulse rounded-full bg-surface-container-low/60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <div className="text-center text-gray-500">Contenu introuvable.</div>;
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pb-10 sm:px-6">
|
||||||
|
<section className="rounded-sheet bg-surface-container-lowest/85 p-8 text-center shadow-ambient backdrop-blur-vellum">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined mb-3 text-4xl text-primary"
|
||||||
|
aria-hidden="true"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
search_off
|
||||||
|
</span>
|
||||||
|
<p className="font-body italic text-on-surface-variant">
|
||||||
|
Ce projet est introuvable.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/portfolio"
|
||||||
|
className="mt-5 inline-flex items-center gap-1.5 font-headline text-sm font-bold uppercase tracking-[0.2em] text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-lg"
|
||||||
|
aria-hidden="true"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
arrow_back
|
||||||
|
</span>
|
||||||
|
Retour au portfolio
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, Resum: richText, picture, link, linkText } = data;
|
const { name, Resum: richText, picture, link, linkText } = data;
|
||||||
|
|
||||||
const images = picture?.map((img: ImageData) => ({
|
const images =
|
||||||
|
picture?.map((img: ImageData) => ({
|
||||||
url: `${apiUrl}${img.formats?.large?.url || img.url}`,
|
url: `${apiUrl}${img.formats?.large?.url || img.url}`,
|
||||||
alt: img.name || "Image",
|
alt: img.name || `Visuel du projet ${name}`,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto p-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">
|
||||||
<h1 className={titleClass || "bg-white/50 rounded-md text-3xl mb-6 font-headline font-extrabold tracking-tight p-2 text-blue-700"}>{name}</h1>
|
{/* Bouton retour discret, posé sur le wallpaper comme une miette de fil d'Ariane. */}
|
||||||
|
<Link
|
||||||
|
href="/portfolio"
|
||||||
|
className="inline-flex w-fit items-center gap-1.5 rounded-full bg-surface-container-lowest/70 px-3 py-1.5 font-headline text-xs font-bold uppercase tracking-[0.2em] text-primary backdrop-blur-vellum transition-colors hover:bg-surface-container-lowest/95 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-base"
|
||||||
|
aria-hidden="true"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
arrow_back
|
||||||
|
</span>
|
||||||
|
Portfolio
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Carousel images={images} className="w-full h-64" />
|
{/* En-tête "feuillet de vellum" aligné sur la home et les listes. */}
|
||||||
|
<section
|
||||||
|
className="rounded-sheet bg-surface-container-lowest/85 p-5 shadow-ambient backdrop-blur-vellum sm:p-7 md:p-8"
|
||||||
|
aria-labelledby="project-title"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
|
||||||
|
Projet · Portfolio
|
||||||
|
</span>
|
||||||
|
<h1
|
||||||
|
id="project-title"
|
||||||
|
className="font-headline text-3xl font-extrabold tracking-tight text-on-surface md:text-4xl"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={contentClass || "bg-white/80 rounded-md p-4 font-headline font-bold shadow-md mt-6"}>
|
{images.length > 0 && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<Carousel images={images} className="h-64 sm:h-80 md:h-96" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{richText && (
|
||||||
|
<div
|
||||||
|
className="prose prose-sm mt-5 max-w-none font-body text-on-surface-variant sm:prose-base
|
||||||
|
prose-headings:font-headline prose-headings:text-primary
|
||||||
|
prose-p:font-body prose-p:text-on-surface-variant
|
||||||
|
prose-strong:text-on-surface
|
||||||
|
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
|
||||||
|
prose-li:marker:text-primary
|
||||||
|
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"
|
||||||
|
>
|
||||||
<ReactMarkdown>{richText}</ReactMarkdown>
|
<ReactMarkdown>{richText}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{link && (
|
{link && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
@ -71,12 +177,20 @@ export default function ContentSection({ collection, slug, titleClass, contentCl
|
|||||||
href={link}
|
href={link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="bg-white/65 rounded-md p-1 text-red-700 hover:underline transition duration-300 ease-in-out transform hover:scale-105 font-headline font-bold hover:text-blue-700"
|
className="inline-flex items-center gap-2 rounded-tile bg-primary px-5 py-2.5 font-headline text-sm font-bold uppercase tracking-[0.2em] text-white shadow-jewel transition-transform duration-200 hover:-translate-y-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
>
|
>
|
||||||
{linkText || "Voir plus/lien externe"}
|
{linkText || "Voir plus"}
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-lg"
|
||||||
|
aria-hidden="true"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
open_in_new
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import Link from "next/link";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { getApiUrl } from "../utils/getApiUrl";
|
import { getApiUrl } from "../utils/getApiUrl";
|
||||||
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";
|
||||||
import ChatBot from "./ChatBot";
|
|
||||||
|
|
||||||
interface ImageData {
|
interface ImageData {
|
||||||
url: string;
|
url: string;
|
||||||
@ -37,32 +37,96 @@ interface ContentSectionProps {
|
|||||||
contentClass?: string;
|
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 `<ChatBot />` 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({
|
export default function ContentSectionCompetences({
|
||||||
competenceData,
|
competenceData,
|
||||||
glossaireData,
|
glossaireData,
|
||||||
titleClass,
|
|
||||||
contentClass,
|
|
||||||
}: ContentSectionProps) {
|
}: ContentSectionProps) {
|
||||||
console.log("🔍 [ContentSectionCompetences] Chargement du composant...");
|
|
||||||
|
|
||||||
const [selectedMot, setSelectedMot] = useState<GlossaireItem | null>(null);
|
const [selectedMot, setSelectedMot] = useState<GlossaireItem | null>(null);
|
||||||
const [isChatbotOpen, setIsChatbotOpen] = useState(false);
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [loading, setLoading] = useState(competenceData === null);
|
|
||||||
const apiUrl = getApiUrl();
|
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(() => {
|
useEffect(() => {
|
||||||
if (competenceData) {
|
const node = contentRef.current;
|
||||||
setLoading(false);
|
if (!node) return;
|
||||||
}
|
|
||||||
}, [competenceData]);
|
|
||||||
|
|
||||||
if (loading) {
|
const handleClick = (event: Event) => {
|
||||||
return <div className="text-center text-gray-500">⏳ Chargement des détails de la compétence...</div>;
|
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) {
|
if (!competenceData) {
|
||||||
console.error("❌ [ContentSectionCompetences] Compétence introuvable !");
|
return (
|
||||||
return <div className="text-red-500 text-center">❌ Compétence introuvable.</div>;
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pb-10 sm:px-6">
|
||||||
|
<section className="rounded-sheet bg-surface-container-lowest/85 p-8 text-center shadow-ambient backdrop-blur-vellum">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined mb-3 text-4xl text-primary"
|
||||||
|
aria-hidden="true"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
search_off
|
||||||
|
</span>
|
||||||
|
<p className="font-body italic text-on-surface-variant">
|
||||||
|
Cette compétence est introuvable.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/competences"
|
||||||
|
className="mt-5 inline-flex items-center gap-1.5 font-headline text-sm font-bold uppercase tracking-[0.2em] text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-lg"
|
||||||
|
aria-hidden="true"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
arrow_back
|
||||||
|
</span>
|
||||||
|
Retour aux compétences
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, content, picture } = competenceData;
|
const { name, content, picture } = competenceData;
|
||||||
@ -70,19 +134,24 @@ export default function ContentSectionCompetences({
|
|||||||
const images =
|
const images =
|
||||||
picture?.map((img) => ({
|
picture?.map((img) => ({
|
||||||
url: `${apiUrl}${img.formats?.large?.url || img.url}`,
|
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) {
|
function transformMarkdownWithKeywords(text: string) {
|
||||||
if (!glossaireData.length) return text;
|
if (!text) return "";
|
||||||
|
|
||||||
let modifiedText = text;
|
let modifiedText = text;
|
||||||
|
|
||||||
modifiedText = modifiedText.replace(
|
modifiedText = modifiedText.replace(
|
||||||
/\bIA locale\b/g,
|
/\bIA locale\b/g,
|
||||||
`<span class="chatbot-keyword" data-chatbot="true" style="color: red; cursor: pointer;">IA locale</span>`
|
`<span class="chatbot-keyword" data-chatbot="true" role="button" tabindex="0">IA locale</span>`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (glossaireData.length) {
|
||||||
glossaireData.forEach(({ mot_clef, variantes }) => {
|
glossaireData.forEach(({ mot_clef, variantes }) => {
|
||||||
const regexVariants = variantes
|
const regexVariants = variantes
|
||||||
.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||||
@ -90,58 +159,70 @@ export default function ContentSectionCompetences({
|
|||||||
const regex = new RegExp(`\\b(${mot_clef}|${regexVariants})\\b`, "gi");
|
const regex = new RegExp(`\\b(${mot_clef}|${regexVariants})\\b`, "gi");
|
||||||
|
|
||||||
modifiedText = modifiedText.replace(regex, (match) => {
|
modifiedText = modifiedText.replace(regex, (match) => {
|
||||||
return `<span class="keyword" data-mot="${mot_clef}" style="color: blue; cursor: pointer;">${match}</span>`;
|
return `<span class="glossary-keyword" data-mot="${mot_clef}" role="button" tabindex="0">${match}</span>`;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return modifiedText;
|
return modifiedText;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentWithLinks = transformMarkdownWithKeywords(content);
|
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 (
|
return (
|
||||||
<div className="max-w-3xl mx-auto p-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">
|
||||||
<h1 className={titleClass || "bg-white/60 rounded-md p-1 text-2xl mb-6 font-headline font-bold text-blue-700"}>
|
<Link
|
||||||
|
href="/competences"
|
||||||
|
className="inline-flex w-fit items-center gap-1.5 rounded-full bg-surface-container-lowest/70 px-3 py-1.5 font-headline text-xs font-bold uppercase tracking-[0.2em] text-primary backdrop-blur-vellum transition-colors hover:bg-surface-container-lowest/95 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-base"
|
||||||
|
aria-hidden="true"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
arrow_back
|
||||||
|
</span>
|
||||||
|
Compétences
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="rounded-sheet bg-surface-container-lowest/85 p-5 shadow-ambient backdrop-blur-vellum sm:p-7 md:p-8"
|
||||||
|
aria-labelledby="competence-title"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
|
||||||
|
Compétence · Savoir-faire
|
||||||
|
</span>
|
||||||
|
<h1
|
||||||
|
id="competence-title"
|
||||||
|
className="font-headline text-3xl font-extrabold tracking-tight text-on-surface md:text-4xl"
|
||||||
|
>
|
||||||
{name}
|
{name}
|
||||||
</h1>
|
</h1>
|
||||||
<CarouselCompetences images={images} className="w-full h-64" />
|
</div>
|
||||||
<div className={contentClass || "bg-white/70 rounded-md p-4 mt-6 text-lg font-headline font-bold text-gray-700"}>
|
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<CarouselCompetences images={images} className="h-64 sm:h-80 md:h-96" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="prose prose-sm mt-5 max-w-none font-body text-on-surface-variant sm:prose-base
|
||||||
|
prose-headings:font-headline prose-headings:text-primary
|
||||||
|
prose-p:font-body prose-p:text-on-surface-variant
|
||||||
|
prose-strong:text-on-surface
|
||||||
|
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
|
||||||
|
prose-li:marker:text-primary
|
||||||
|
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"
|
||||||
|
>
|
||||||
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{contentWithLinks}</ReactMarkdown>
|
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{contentWithLinks}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
{selectedMot && <ModalGlossaire mot={selectedMot} onClose={() => setSelectedMot(null)} />}
|
</section>
|
||||||
|
|
||||||
{isChatbotOpen && (
|
{selectedMot && (
|
||||||
<div className="fixed bottom-10 right-10 p-4 w-96">
|
<ModalGlossaire mot={selectedMot} onClose={() => setSelectedMot(null)} />
|
||||||
<ChatBot onClose={() => setIsChatbotOpen(false)} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,9 +11,16 @@ interface ContentSectionProps {
|
|||||||
contentClass?: string;
|
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 [competenceData, setCompetenceData] = useState(null);
|
||||||
const [glossaireData, setGlossaireData] = useState([]);
|
const [glossaireData, setGlossaireData] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -38,15 +45,24 @@ export default function ContentSectionCompetencesContainer({ collection, slug, t
|
|||||||
}, [collection, slug]);
|
}, [collection, slug]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="text-center text-gray-500">⏳ Chargement des compétences...</div>;
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pb-10 sm:px-6">
|
||||||
|
<div className="rounded-sheet bg-surface-container-lowest/65 p-6 shadow-ambient-sm backdrop-blur-vellum">
|
||||||
|
<div className="h-4 w-24 animate-pulse rounded-full bg-surface-container-low/80" />
|
||||||
|
<div className="mt-3 h-8 w-3/4 animate-pulse rounded-full bg-surface-container-low/80" />
|
||||||
|
<div className="mt-5 aspect-[16/9] w-full animate-pulse rounded-tile bg-surface-container-low/80" />
|
||||||
|
<div className="mt-5 h-4 w-full animate-pulse rounded-full bg-surface-container-low/60" />
|
||||||
|
<div className="mt-2 h-4 w-5/6 animate-pulse rounded-full bg-surface-container-low/60" />
|
||||||
|
<div className="mt-2 h-4 w-4/6 animate-pulse rounded-full bg-surface-container-low/60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentSectionCompetences
|
<ContentSectionCompetences
|
||||||
competenceData={competenceData}
|
competenceData={competenceData}
|
||||||
glossaireData={glossaireData}
|
glossaireData={glossaireData}
|
||||||
titleClass={titleClass}
|
|
||||||
contentClass={contentClass}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
76
app/components/GrasBotFab.tsx
Normal file
76
app/components/GrasBotFab.tsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
aria-label={isOpen ? "Fermer GrasBot" : "Ouvrir GrasBot"}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
className="fixed bottom-6 right-6 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-white shadow-jewel transition-transform duration-200 hover:-translate-y-0.5 hover:bg-primary-container focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-fixed md:h-16 md:w-16"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-3xl"
|
||||||
|
aria-hidden="true"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
{isOpen ? "close" : "smart_toy"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-x-4 bottom-24 z-30 h-[70vh] max-h-[560px] sm:inset-auto sm:bottom-24 sm:right-6 sm:h-[560px] sm:w-96"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="false"
|
||||||
|
aria-label="Conversation avec GrasBot"
|
||||||
|
>
|
||||||
|
<ChatBot onClose={() => setIsOpen(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -60,6 +60,30 @@ body {
|
|||||||
overflow-x: hidden;
|
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 {
|
@keyframes fade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import Footer from "./components/Footer";
|
|||||||
import "./assets/main.css";
|
import "./assets/main.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import NavLink from "./components/NavLink";
|
import NavLink from "./components/NavLink";
|
||||||
|
import GrasBotFab from "./components/GrasBotFab";
|
||||||
import { manrope, newsreader } from "./fonts";
|
import { manrope, newsreader } from "./fonts";
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
@ -244,6 +245,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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 ». */}
|
||||||
|
<GrasBotFab />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# Refonte visuelle — Direction "Digital Atelier"
|
# Refonte visuelle — Direction "Digital Atelier"
|
||||||
|
|
||||||
**Créé :** 2026-04-22
|
**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`).
|
**Source d'inspiration :** `stitch_V1/` (design newsletter Stitch — `DESIGN.md` et `code.html`).
|
||||||
**Audit préalable :** [`captures/AUDIT-VISUEL.md`](./captures/AUDIT-VISUEL.md).
|
**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) |
|
| 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) |
|
| 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) |
|
| 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 |
|
| 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
|
## 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.
|
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** : `<Carousel>` 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 `<span style="color: blue">...</span>` — 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 `<ChatBot />` 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 `<ChatBot>` 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)
|
## 5. Checklist relecture (à passer à la fin de chaque étape)
|
||||||
|
|
||||||
- [ ] Aucune colonne unique globale `max-w-xl` (c'est le format newsletter).
|
- [ ] Aucune colonne unique globale `max-w-xl` (c'est le format newsletter).
|
||||||
|
|||||||
@ -8,7 +8,7 @@ Document vivant : ajuster les statuts et dates au fil du travail.
|
|||||||
|
|
||||||
| ID | Sujet | Statut | Notes |
|
| 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` |
|
| 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 | É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 `<hr>` du hero** (retour utilisateur : « trop d'espace entre les sections » en fait dû à des `<hr>` quasi invisibles générés depuis les `---` Markdown du CV Strapi). Option B retenue : surcharge `prose-hr` sur le wrapper `ReactMarkdown` (`app/page.tsx`) pour transformer la règle en pastille Stitch centrée (`prose-hr:border-0 prose-hr:w-16 prose-hr:mx-auto prose-hr:bg-primary/30 prose-hr:h-0.5 prose-hr:rounded-full prose-hr:my-6`). Le séparateur redevient un signal visuel intentionnel et cohérent avec la palette, marges verticales réduites de 32 px à 24 px. Détail dans `REFONTE-VISUELLE.md` §4 sexies. |
|
| 2026-04-22 | **Correctif séparateurs `<hr>` du hero** (retour utilisateur : « trop d'espace entre les sections » en fait dû à des `<hr>` quasi invisibles générés depuis les `---` Markdown du CV Strapi). Option B retenue : surcharge `prose-hr` sur le wrapper `ReactMarkdown` (`app/page.tsx`) pour transformer la règle en pastille Stitch centrée (`prose-hr:border-0 prose-hr:w-16 prose-hr:mx-auto prose-hr:bg-primary/30 prose-hr:h-0.5 prose-hr:rounded-full prose-hr:my-6`). Le séparateur redevient un signal visuel intentionnel et cohérent avec la palette, marges verticales réduites de 32 px à 24 px. Détail dans `REFONTE-VISUELLE.md` §4 sexies. |
|
||||||
| 2026-04-22 | **Correctif post-étape 6 : réintroduction du défilement auto en vignette**. L'arbitrage initial "carousel retiré des listes" retiré après retour utilisateur (*« j'ai perdu ma fonctionnalité précédente »*). Nouveau composant `app/components/VignetteCarousel.tsx` : Swiper allégé (modules `Autoplay` + `Pagination` uniquement), **sans flèches** (conflit de clic avec le `<Link>` englobant) et **sans lightbox** (réservée à la fiche détail). Pagination bullets teintée `primary` via surcharge inline des variables CSS Swiper (`--swiper-pagination-color: #26445d`) pour ne pas polluer `globals.css`. Autoplay 3500 ms, `loop` conditionnel (`length > 1` pour éviter le warning Swiper sur les entrées mono-image). Intégré dans `app/portfolio/page.jsx` et `app/competences/page.jsx` via `images.length > 1 ? <VignetteCarousel /> : <img statique />` — comportement identique à l'ancien pour les mono-image, défilement retrouvé pour les multi-image. Les composants `Carousel.tsx` et `CarouselCompetences.tsx` existants restent intacts pour la fiche détail (étape 7). Détail dans `REFONTE-VISUELLE.md` §6 *"Correctif post-étape 6 — réintroduction du défilement automatique en vignette"*. |
|
| 2026-04-22 | **Correctif post-étape 6 : réintroduction du défilement auto en vignette**. L'arbitrage initial "carousel retiré des listes" retiré après retour utilisateur (*« j'ai perdu ma fonctionnalité précédente »*). Nouveau composant `app/components/VignetteCarousel.tsx` : Swiper allégé (modules `Autoplay` + `Pagination` uniquement), **sans flèches** (conflit de clic avec le `<Link>` englobant) et **sans lightbox** (réservée à la fiche détail). Pagination bullets teintée `primary` via surcharge inline des variables CSS Swiper (`--swiper-pagination-color: #26445d`) pour ne pas polluer `globals.css`. Autoplay 3500 ms, `loop` conditionnel (`length > 1` pour éviter le warning Swiper sur les entrées mono-image). Intégré dans `app/portfolio/page.jsx` et `app/competences/page.jsx` via `images.length > 1 ? <VignetteCarousel /> : <img statique />` — comportement identique à l'ancien pour les mono-image, défilement retrouvé pour les multi-image. Les composants `Carousel.tsx` et `CarouselCompetences.tsx` existants restent intacts pour la fiche détail (étape 7). Détail dans `REFONTE-VISUELLE.md` §6 *"Correctif post-étape 6 — réintroduction du défilement automatique en vignette"*. |
|
||||||
| 2026-04-22 | **Correctif wallpaper sur-zoomé sur pages longues** (retour utilisateur post-étape 6 : incohérence visuelle entre la home et `/portfolio`). Cause : `.bg-wallpaper` en `absolute inset-0` à l'intérieur du conteneur grid `min-h-[100dvh]` — le conteneur s'étirant à la hauteur du contenu (≈ 2-3 viewports sur les listes), `background-size: cover` zoomait l'image pour couvrir toute cette hauteur. Fix : wallpaper sorti du grid, passé en `fixed inset-0 z-0 pointer-events-none` (calé sur le viewport, plus la page entière). Les cercles animés restent en `absolute` dans le grid. Impact transversal sur toutes les pages longues (fiches détail à venir, etc.). Détail dans `REFONTE-VISUELLE.md` §6. |
|
| 2026-04-22 | **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 `<ChatBot>` 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. |
|
| 2026-04-22 | Refonte visuelle — **étape 6 : listes portfolio + compétences**. `app/portfolio/page.jsx` et `app/competences/page.jsx` entièrement réécrits. En-tête éditorial (kicker + titre Manrope extrabold + pitch Newsreader) cohérent avec le hero de la home. Grille **asymétrique 2/3 + 1/3** alternée (`md:grid-cols-6` + pattern de `col-span-4`/`col-span-2` sur modulo 4, `sm:grid-cols-2`, `grid-cols-1` mobile) — conforme DESIGN.md §6 "No-Grid-Lock". Cartes « feuillet vellum » alignées home : `rounded-sheet bg-surface-container-lowest/85 backdrop-blur-vellum shadow-ambient`, image `aspect-[4/3]` fixe avec `group-hover:scale-[1.03]`, titre `text-primary`, description `line-clamp-3` en Newsreader, CTA tertiaire « Découvrir → » / « Explorer → » avec Material Symbol `arrow_forward` qui se décale au hover (`translate="no"` appliqué). Hover : `hover:-translate-y-0.5 hover:shadow-jewel` (remplace le `scale-105` qui débordait). **`Swiper` retiré des vignettes de liste** (arbitrage acté § 2 : carousel réservé aux galeries intra-fiche) — une seule image par carte, `loading="lazy"`. États ajoutés : skeletons animés respectant la grille + état vide avec Material Symbol. Régressions corrigées au passage : largeur fixe `w-80` qui débordait sur S25 Ultra, `hover:scale-105` qui tapait sous le header, classes `bg-white/80 rounded-lg` remplacées par les tokens Stitch. Les composants `Carousel.tsx` et `CarouselCompetences.tsx` restent en place pour les fiches détail (étape 7). Détail dans `REFONTE-VISUELLE.md` §6. |
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user