pagery-edition 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "pagery-edition",
3
+ "version": "1.0.0",
4
+ "description": "Composants d'édition en ligne pour les sites clients Pagery",
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./styles": "./src/styles.css"
10
+ },
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "peerDependencies": {
15
+ "react": ">=18",
16
+ "react-dom": ">=18",
17
+ "next": ">=14",
18
+ "@supabase/supabase-js": ">=2"
19
+ },
20
+ "devDependencies": {
21
+ "typescript": "^5.0.0",
22
+ "@types/react": "^19.0.0"
23
+ }
24
+ }
@@ -0,0 +1,187 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useState,
9
+ type ReactNode,
10
+ } from "react";
11
+ import {
12
+ recupererContenuPage,
13
+ recupererImagesPage,
14
+ recupererBrouillonsPage,
15
+ sauvegarderBrouillonTexte,
16
+ sauvegarderBrouillonImage,
17
+ compterBrouillons,
18
+ } from "../lib/supabase";
19
+ import type { ClientSupabase, ImageEditableData, MessageEdition } from "../lib/types";
20
+
21
+ type ContexteEditionType = {
22
+ modeEdition: boolean;
23
+ contenus: Map<string, string>;
24
+ images: Map<string, ImageEditableData>;
25
+ modifierTexte: (cle: string, valeur: string) => Promise<void>;
26
+ modifierImage: (cle: string, fichier: File, alt?: string) => Promise<string>;
27
+ enChargement: boolean;
28
+ sauvegardeEnCours: boolean;
29
+ };
30
+
31
+ const ContexteEdition = createContext<ContexteEditionType | null>(null);
32
+
33
+ export function useEdition() {
34
+ const contexte = useContext(ContexteEdition);
35
+ if (!contexte) {
36
+ throw new Error("useEdition doit être utilisé dans un FournisseurEdition");
37
+ }
38
+ return contexte;
39
+ }
40
+
41
+ type PropsFournisseur = {
42
+ supabase: ClientSupabase;
43
+ siteId: string;
44
+ page: string;
45
+ children: ReactNode;
46
+ };
47
+
48
+ export function FournisseurEdition({ supabase, siteId, page, children }: PropsFournisseur) {
49
+ const [modeEdition, setModeEdition] = useState(false);
50
+ const [contenus, setContenus] = useState<Map<string, string>>(new Map());
51
+ const [images, setImages] = useState<Map<string, ImageEditableData>>(new Map());
52
+ const [enChargement, setEnChargement] = useState(true);
53
+ const [sauvegardeEnCours, setSauvegardeEnCours] = useState(false);
54
+
55
+ useEffect(() => {
56
+ const charger = async () => {
57
+ setEnChargement(true);
58
+ try {
59
+ const estDansIframe = typeof window !== "undefined" && window.self !== window.top;
60
+
61
+ const [contenuPage, imagesPage] = await Promise.all([
62
+ recupererContenuPage(supabase, siteId, page),
63
+ recupererImagesPage(supabase, siteId, page),
64
+ ]);
65
+
66
+ // Charger les brouillons uniquement dans l'iframe de l'éditeur
67
+ if (estDansIframe) {
68
+ const brouillons = await recupererBrouillonsPage(supabase, siteId, page);
69
+
70
+ brouillons.textes.forEach((valeur, cle) => {
71
+ contenuPage.set(cle, valeur);
72
+ });
73
+
74
+ brouillons.images.forEach((imgData, cle) => {
75
+ const existant = imagesPage.get(cle);
76
+ if (existant) {
77
+ imagesPage.set(cle, { ...existant, url: imgData.url, alt: imgData.alt });
78
+ }
79
+ });
80
+ }
81
+
82
+ setContenus(contenuPage);
83
+ setImages(imagesPage);
84
+ } catch (erreur) {
85
+ console.error("Erreur chargement contenu :", erreur);
86
+ } finally {
87
+ setEnChargement(false);
88
+ }
89
+ };
90
+
91
+ charger();
92
+ }, [supabase, siteId, page]);
93
+
94
+ useEffect(() => {
95
+ const estDansIframe = window.self !== window.top;
96
+ if (!estDansIframe) return;
97
+
98
+ const gererMessage = (event: MessageEvent) => {
99
+ const msg = event.data as MessageEdition;
100
+ if (!msg || !msg.type) return;
101
+
102
+ switch (msg.type) {
103
+ case "ACTIVER_EDITION":
104
+ setModeEdition(true);
105
+ break;
106
+ case "DESACTIVER_EDITION":
107
+ setModeEdition(false);
108
+ break;
109
+ }
110
+ };
111
+
112
+ window.addEventListener("message", gererMessage);
113
+ window.parent.postMessage({ type: "EDITION_PRETE" } as MessageEdition, "*");
114
+
115
+ return () => window.removeEventListener("message", gererMessage);
116
+ }, []);
117
+
118
+ const envoyerCompteur = useCallback(async () => {
119
+ try {
120
+ const compteur = await compterBrouillons(supabase, siteId);
121
+ if (window.self !== window.top) {
122
+ window.parent.postMessage(
123
+ { type: "BROUILLON_SAUVEGARDE", compteur } as MessageEdition,
124
+ "*"
125
+ );
126
+ }
127
+ } catch {
128
+ // Silencieux
129
+ }
130
+ }, [supabase, siteId]);
131
+
132
+ const modifierTexte = useCallback(
133
+ async (cle: string, valeur: string) => {
134
+ setSauvegardeEnCours(true);
135
+ try {
136
+ await sauvegarderBrouillonTexte(supabase, siteId, cle, valeur);
137
+ setContenus((prev) => {
138
+ const nouveau = new Map(prev);
139
+ nouveau.set(cle, valeur);
140
+ return nouveau;
141
+ });
142
+ await envoyerCompteur();
143
+ } finally {
144
+ setSauvegardeEnCours(false);
145
+ }
146
+ },
147
+ [supabase, siteId, envoyerCompteur]
148
+ );
149
+
150
+ const modifierImage = useCallback(
151
+ async (cle: string, fichier: File, alt?: string) => {
152
+ setSauvegardeEnCours(true);
153
+ try {
154
+ const nouvelleUrl = await sauvegarderBrouillonImage(supabase, siteId, cle, fichier, alt);
155
+ setImages((prev) => {
156
+ const nouveau = new Map(prev);
157
+ const existant = nouveau.get(cle);
158
+ if (existant) {
159
+ nouveau.set(cle, { ...existant, url: nouvelleUrl, alt: alt || existant.alt });
160
+ }
161
+ return nouveau;
162
+ });
163
+ await envoyerCompteur();
164
+ return nouvelleUrl;
165
+ } finally {
166
+ setSauvegardeEnCours(false);
167
+ }
168
+ },
169
+ [supabase, siteId, envoyerCompteur]
170
+ );
171
+
172
+ return (
173
+ <ContexteEdition.Provider
174
+ value={{
175
+ modeEdition,
176
+ contenus,
177
+ images,
178
+ modifierTexte,
179
+ modifierImage,
180
+ enChargement,
181
+ sauvegardeEnCours,
182
+ }}
183
+ >
184
+ {children}
185
+ </ContexteEdition.Provider>
186
+ );
187
+ }
@@ -0,0 +1,167 @@
1
+ // components/edition/ImageEditable.tsx
2
+ // Composant image qui permet le remplacement en mode édition
3
+
4
+ "use client";
5
+
6
+ import { useCallback, useRef, useState } from "react";
7
+ import { useEdition } from "./ContexteEdition";
8
+ import Image from "next/image";
9
+
10
+ type PropsImageEditable = {
11
+ cle: string;
12
+ fallbackUrl?: string;
13
+ fallbackAlt?: string;
14
+ width?: number;
15
+ height?: number;
16
+ fill?: boolean;
17
+ className?: string;
18
+ classNameWrapper?: string;
19
+ sizes?: string;
20
+ priority?: boolean;
21
+ };
22
+
23
+ export function ImageEditable({
24
+ cle,
25
+ fallbackUrl = "",
26
+ fallbackAlt = "",
27
+ width,
28
+ height,
29
+ fill = false,
30
+ className = "",
31
+ classNameWrapper = "",
32
+ sizes,
33
+ priority = false,
34
+ }: PropsImageEditable) {
35
+ const { modeEdition, images, modifierImage } = useEdition();
36
+ const [enUpload, setEnUpload] = useState(false);
37
+ const [apercu, setApercu] = useState<string | null>(null);
38
+ const refInput = useRef<HTMLInputElement>(null);
39
+
40
+ const imageData = images.get(cle);
41
+ const urlImage = apercu || imageData?.url || fallbackUrl;
42
+ const altImage = imageData?.alt || fallbackAlt;
43
+
44
+ const gererClic = useCallback(() => {
45
+ if (modeEdition && refInput.current) {
46
+ refInput.current.click();
47
+ }
48
+ }, [modeEdition]);
49
+
50
+ const gererChangement = useCallback(
51
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
52
+ const fichier = e.target.files?.[0];
53
+ if (!fichier) return;
54
+
55
+ // Vérifier le type
56
+ if (!fichier.type.startsWith("image/")) {
57
+ alert("Veuillez sélectionner une image.");
58
+ return;
59
+ }
60
+
61
+ // Vérifier la taille (5 Mo max)
62
+ if (fichier.size > 5 * 1024 * 1024) {
63
+ alert("L'image ne doit pas dépasser 5 Mo.");
64
+ return;
65
+ }
66
+
67
+ // Aperçu immédiat
68
+ const lecteur = new FileReader();
69
+ lecteur.onload = () => setApercu(lecteur.result as string);
70
+ lecteur.readAsDataURL(fichier);
71
+
72
+ // Upload
73
+ setEnUpload(true);
74
+ try {
75
+ await modifierImage(cle, fichier, altImage);
76
+ setApercu(null);
77
+ } catch (erreur) {
78
+ console.error("Erreur upload :", erreur);
79
+ setApercu(null);
80
+ alert("Erreur lors de l'upload. Veuillez réessayer.");
81
+ } finally {
82
+ setEnUpload(false);
83
+ if (refInput.current) refInput.current.value = "";
84
+ }
85
+ },
86
+ [cle, altImage, modifierImage]
87
+ );
88
+
89
+ return (
90
+ <div
91
+ className={`
92
+ relative group
93
+ ${classNameWrapper}
94
+ ${modeEdition ? "cursor-pointer" : ""}
95
+ `}
96
+ onClick={gererClic}
97
+ >
98
+ {fill ? (
99
+ <Image
100
+ src={urlImage}
101
+ alt={altImage}
102
+ fill
103
+ className={`${className} ${enUpload ? "opacity-50" : ""}`}
104
+ sizes={sizes}
105
+ priority={priority}
106
+ />
107
+ ) : (
108
+ <Image
109
+ src={urlImage}
110
+ alt={altImage}
111
+ width={width || 800}
112
+ height={height || 600}
113
+ className={`${className} ${enUpload ? "opacity-50" : ""}`}
114
+ sizes={sizes}
115
+ priority={priority}
116
+ />
117
+ )}
118
+
119
+ {/* Overlay en mode édition */}
120
+ {modeEdition && (
121
+ <div
122
+ className={`
123
+ absolute inset-0 flex flex-col items-center justify-center
124
+ bg-black/0 group-hover:bg-black/40
125
+ transition-all duration-300
126
+ rounded-inherit
127
+ `}
128
+ >
129
+ <div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300 text-center">
130
+ <svg
131
+ className="h-8 w-8 mx-auto mb-2 text-white"
132
+ fill="none"
133
+ viewBox="0 0 24 24"
134
+ stroke="currentColor"
135
+ strokeWidth={1.5}
136
+ >
137
+ <path
138
+ strokeLinecap="round"
139
+ strokeLinejoin="round"
140
+ d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
141
+ />
142
+ </svg>
143
+ <span className="text-white text-sm font-medium">
144
+ Changer l'image
145
+ </span>
146
+ </div>
147
+ </div>
148
+ )}
149
+
150
+ {/* Spinner upload */}
151
+ {enUpload && (
152
+ <div className="absolute inset-0 flex items-center justify-center bg-black/30">
153
+ <span className="h-8 w-8 animate-spin rounded-full border-3 border-white border-t-transparent" />
154
+ </div>
155
+ )}
156
+
157
+ {/* Input fichier caché */}
158
+ <input
159
+ ref={refInput}
160
+ type="file"
161
+ accept="image/*"
162
+ onChange={gererChangement}
163
+ className="hidden"
164
+ />
165
+ </div>
166
+ );
167
+ }
@@ -0,0 +1,176 @@
1
+ // components/edition/LienEditable.tsx
2
+ // Composant lien/bouton éditable : texte + URL
3
+
4
+ "use client";
5
+
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+ import { useEdition } from "./ContexteEdition";
8
+
9
+ type PropsLienEditable = {
10
+ cleTexte: string;
11
+ cleUrl: string;
12
+ fallbackTexte?: string;
13
+ fallbackUrl?: string;
14
+ className?: string;
15
+ classNameLien?: string;
16
+ balise?: "a" | "button";
17
+ target?: "_blank" | "_self";
18
+ };
19
+
20
+ export function LienEditable({
21
+ cleTexte,
22
+ cleUrl,
23
+ fallbackTexte = "En savoir plus",
24
+ fallbackUrl = "#",
25
+ className = "",
26
+ classNameLien = "",
27
+ balise = "a",
28
+ target = "_self",
29
+ }: PropsLienEditable) {
30
+ const { modeEdition, contenus, modifierTexte } = useEdition();
31
+ const [enEdition, setEnEdition] = useState(false);
32
+ const [texteLocal, setTexteLocal] = useState("");
33
+ const [urlLocale, setUrlLocale] = useState("");
34
+ const refTexte = useRef<HTMLInputElement>(null);
35
+
36
+ const texte = contenus.get(cleTexte) || fallbackTexte;
37
+ const url = contenus.get(cleUrl) || fallbackUrl;
38
+
39
+ useEffect(() => {
40
+ setTexteLocal(texte);
41
+ setUrlLocale(url);
42
+ }, [texte, url]);
43
+
44
+ const gererClic = useCallback(
45
+ (e: React.MouseEvent) => {
46
+ if (modeEdition) {
47
+ e.preventDefault();
48
+ e.stopPropagation();
49
+ setEnEdition(true);
50
+ setTexteLocal(texte);
51
+ setUrlLocale(url);
52
+ setTimeout(() => refTexte.current?.focus(), 0);
53
+ }
54
+ },
55
+ [modeEdition, texte, url]
56
+ );
57
+
58
+ const gererSauvegarde = useCallback(async () => {
59
+ const promesses: Promise<void>[] = [];
60
+ if (texteLocal !== texte) {
61
+ promesses.push(modifierTexte(cleTexte, texteLocal));
62
+ }
63
+ if (urlLocale !== url) {
64
+ promesses.push(modifierTexte(cleUrl, urlLocale));
65
+ }
66
+ if (promesses.length > 0) {
67
+ await Promise.all(promesses);
68
+ }
69
+ setEnEdition(false);
70
+ }, [cleTexte, cleUrl, texteLocal, urlLocale, texte, url, modifierTexte]);
71
+
72
+ const gererTouche = useCallback(
73
+ (e: React.KeyboardEvent) => {
74
+ if (e.key === "Enter") {
75
+ e.preventDefault();
76
+ gererSauvegarde();
77
+ }
78
+ if (e.key === "Escape") {
79
+ setTexteLocal(texte);
80
+ setUrlLocale(url);
81
+ setEnEdition(false);
82
+ }
83
+ },
84
+ [gererSauvegarde, texte, url]
85
+ );
86
+
87
+ // Mode édition avec formulaire
88
+ if (enEdition) {
89
+ return (
90
+ <div className={`${className} relative`}>
91
+ <div className="flex flex-col gap-2 p-3 bg-white rounded-lg ring-2 ring-[#0051ff] ring-offset-2 shadow-lg">
92
+ <div>
93
+ <label className="block text-[11px] font-semibold text-[#0051ff] mb-1 tracking-wide">
94
+ Texte du bouton
95
+ </label>
96
+ <input
97
+ ref={refTexte}
98
+ type="text"
99
+ value={texteLocal}
100
+ onChange={(e) => setTexteLocal(e.target.value)}
101
+ onKeyDown={gererTouche}
102
+ className="w-full px-3 py-2 text-sm text-zinc-900 bg-zinc-50 border border-zinc-200 rounded-md focus:outline-none focus:ring-1 focus:ring-[#0051ff]"
103
+ />
104
+ </div>
105
+ <div>
106
+ <label className="block text-[11px] font-semibold text-[#0051ff] mb-1 tracking-wide">
107
+ Lien (URL)
108
+ </label>
109
+ <input
110
+ type="url"
111
+ value={urlLocale}
112
+ onChange={(e) => setUrlLocale(e.target.value)}
113
+ onKeyDown={gererTouche}
114
+ placeholder="https://..."
115
+ className="w-full px-3 py-2 text-sm text-zinc-900 bg-zinc-50 border border-zinc-200 rounded-md focus:outline-none focus:ring-1 focus:ring-[#0051ff]"
116
+ />
117
+ </div>
118
+ <div className="flex gap-2 justify-end pt-1">
119
+ <button
120
+ type="button"
121
+ onClick={() => {
122
+ setTexteLocal(texte);
123
+ setUrlLocale(url);
124
+ setEnEdition(false);
125
+ }}
126
+ className="px-3 py-1.5 text-xs font-medium text-zinc-500 hover:text-zinc-700 transition-colors"
127
+ >
128
+ Annuler
129
+ </button>
130
+ <button
131
+ type="button"
132
+ onClick={gererSauvegarde}
133
+ className="px-4 py-1.5 text-xs font-semibold text-white bg-[#0051ff] rounded-md hover:bg-[#003dcc] transition-colors"
134
+ >
135
+ Sauvegarder
136
+ </button>
137
+ </div>
138
+ </div>
139
+ </div>
140
+ );
141
+ }
142
+
143
+ // Mode normal
144
+ const propsCommuns = {
145
+ className: `${classNameLien} ${
146
+ modeEdition
147
+ ? "cursor-pointer hover:ring-2 hover:ring-[#0051ff]/50 hover:ring-offset-2 rounded transition-all"
148
+ : ""
149
+ }`,
150
+ onClick: gererClic,
151
+ ...(modeEdition ? { title: "Cliquer pour modifier le lien" } : {}),
152
+ };
153
+
154
+ if (balise === "button") {
155
+ return (
156
+ <div className={className}>
157
+ <button type="button" {...propsCommuns}>
158
+ {texte}
159
+ </button>
160
+ </div>
161
+ );
162
+ }
163
+
164
+ return (
165
+ <div className={className}>
166
+ <a
167
+ href={modeEdition ? undefined : url}
168
+ target={target}
169
+ rel={target === "_blank" ? "noopener noreferrer" : undefined}
170
+ {...propsCommuns}
171
+ >
172
+ {texte}
173
+ </a>
174
+ </div>
175
+ );
176
+ }
@@ -0,0 +1,271 @@
1
+ // components/edition/TexteEditable.tsx
2
+ // Édition en place avec barre d'outils flottante (comme demo.html)
3
+
4
+ "use client";
5
+
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+ // @ts-expect-error react-dom types manquants
8
+ import { createPortal } from "react-dom";
9
+ import { useEdition } from "./ContexteEdition";
10
+
11
+ type PropsTexteEditable = {
12
+ cle: string;
13
+ fallback?: string;
14
+ balise?: "h1" | "h2" | "h3" | "h4" | "p" | "span" | "a";
15
+ className?: string;
16
+ href?: string;
17
+ classeAccent?: string;
18
+ };
19
+
20
+ // Convertit *mot* en <span class="classeAccent">mot</span>
21
+ function rendreAccent(texte: string, classeAccent: string): string {
22
+ return texte.replace(/\*([^*]+)\*/g, `<span class="${classeAccent}">$1</span>`);
23
+ }
24
+
25
+ // Barre d'outils flottante
26
+ function BarreOutils({
27
+ elementRef,
28
+ onSauvegarder,
29
+ onAnnuler,
30
+ }: {
31
+ elementRef: React.RefObject<HTMLElement | null>;
32
+ onSauvegarder: () => void;
33
+ onAnnuler: () => void;
34
+ }) {
35
+ const refBarre = useRef<HTMLDivElement>(null);
36
+ const [position, setPosition] = useState({ top: 0, left: 0 });
37
+ const [taille, setTaille] = useState("16");
38
+
39
+ const positionner = useCallback(() => {
40
+ if (!elementRef.current || !refBarre.current) return;
41
+ const rect = elementRef.current.getBoundingClientRect();
42
+ const barreH = refBarre.current.offsetHeight;
43
+ const barreW = refBarre.current.offsetWidth;
44
+
45
+ let top = rect.top - barreH - 8;
46
+ if (top < 8) top = rect.bottom + 8;
47
+
48
+ let left = rect.left + rect.width / 2 - barreW / 2;
49
+ if (left + barreW > window.innerWidth - 16) left = window.innerWidth - barreW - 16;
50
+ if (left < 8) left = 8;
51
+
52
+ setPosition({ top, left });
53
+ }, [elementRef]);
54
+
55
+ useEffect(() => {
56
+ positionner();
57
+ if (elementRef.current) {
58
+ setTaille(Math.round(parseFloat(window.getComputedStyle(elementRef.current).fontSize)).toString());
59
+ }
60
+ let rafId = 0;
61
+ const gererScroll = () => {
62
+ cancelAnimationFrame(rafId);
63
+ rafId = requestAnimationFrame(positionner);
64
+ };
65
+ window.addEventListener("scroll", gererScroll, true);
66
+ window.addEventListener("resize", positionner);
67
+ return () => {
68
+ window.removeEventListener("scroll", gererScroll, true);
69
+ window.removeEventListener("resize", positionner);
70
+ cancelAnimationFrame(rafId);
71
+ };
72
+ }, [positionner, elementRef]);
73
+
74
+ const exec = useCallback((cmd: string, val?: string) => {
75
+ document.execCommand(cmd, false, val);
76
+ elementRef.current?.focus();
77
+ }, [elementRef]);
78
+
79
+ const changerTaille = useCallback((delta: number) => {
80
+ if (!elementRef.current) return;
81
+ const actuelle = parseFloat(window.getComputedStyle(elementRef.current).fontSize);
82
+ let pas = 1;
83
+ if (actuelle >= 48) pas = 4;
84
+ else if (actuelle >= 32) pas = 2;
85
+ const nouvelle = Math.max(10, Math.min(120, actuelle + pas * delta));
86
+ elementRef.current.style.fontSize = `${nouvelle}px`;
87
+ setTaille(Math.round(nouvelle).toString());
88
+ }, [elementRef]);
89
+
90
+ const btnClass = "p-1 px-1.5 rounded text-[#6b6560] hover:bg-[#eef4ff] hover:text-[#0051ff] transition-colors cursor-pointer";
91
+ const separateur = <div className="w-px h-4 bg-[#e8e4df] mx-0.5" />;
92
+
93
+ return createPortal(
94
+ <div
95
+ ref={refBarre}
96
+ data-barre-outils
97
+ className="fixed z-[10000] flex items-center gap-0.5 bg-white border border-[#e8e4df] rounded-lg shadow-[0_4px_16px_rgba(0,0,0,0.12)] py-1 px-1.5"
98
+ style={{ top: position.top, left: position.left }}
99
+ onMouseDown={(e) => e.preventDefault()}
100
+ >
101
+ <button type="button" onClick={() => exec("bold")} className={btnClass} title="Gras">
102
+ <span className="text-xs font-bold">B</span>
103
+ </button>
104
+ <button type="button" onClick={() => exec("italic")} className={btnClass} title="Italique">
105
+ <span className="text-xs italic">I</span>
106
+ </button>
107
+ {separateur}
108
+ <button type="button" onClick={() => exec("underline")} className={btnClass} title="Souligner">
109
+ <span className="text-xs underline">U</span>
110
+ </button>
111
+ {separateur}
112
+ <button type="button" onClick={() => changerTaille(-1)} className={btnClass} title="Réduire la taille">
113
+ <span className="text-xs font-medium">&ndash;</span>
114
+ </button>
115
+ <span className="text-[11px] text-[#6b6560] min-w-[20px] text-center tabular-nums select-none">{taille}</span>
116
+ <button type="button" onClick={() => changerTaille(1)} className={btnClass} title="Augmenter la taille">
117
+ <span className="text-xs font-medium">+</span>
118
+ </button>
119
+ {separateur}
120
+ <button type="button" onClick={onAnnuler} className="px-2 py-0.5 text-[11px] font-medium text-[#6b6560] hover:text-[#1a1a1a] transition-colors cursor-pointer">
121
+ Annuler
122
+ </button>
123
+ <button
124
+ type="button"
125
+ onClick={onSauvegarder}
126
+ className="px-2.5 py-1 text-[11px] font-semibold text-white bg-[#0051ff] rounded hover:bg-[#0045dd] transition-colors cursor-pointer"
127
+ >
128
+ Sauvegarder
129
+ </button>
130
+ </div>,
131
+ document.body
132
+ );
133
+ }
134
+
135
+ export function TexteEditable({
136
+ cle,
137
+ fallback = "",
138
+ balise: Balise = "p",
139
+ className = "",
140
+ href,
141
+ classeAccent = "",
142
+ }: PropsTexteEditable) {
143
+ const { modeEdition, contenus, modifierTexte } = useEdition();
144
+ const refElement = useRef<HTMLElement>(null);
145
+ const texteOriginal = useRef<string>("");
146
+ const [enEdition, setEnEdition] = useState(false);
147
+ const [monte, setMonte] = useState(false);
148
+
149
+ const texte = contenus.get(cle) || fallback;
150
+
151
+ useEffect(() => { setMonte(true); }, []);
152
+
153
+ const estMonoLigne = ["h1", "h2", "h3", "h4", "span"].includes(Balise);
154
+
155
+ // Clic en dehors = annuler (seul le bouton Sauvegarder sauvegarde)
156
+ useEffect(() => {
157
+ if (!enEdition) return;
158
+ const gererClicExterieur = (e: MouseEvent) => {
159
+ const cible = e.target as Node;
160
+ if (refElement.current?.contains(cible)) return;
161
+ const barre = document.querySelector("[data-barre-outils]");
162
+ if (barre?.contains(cible)) return;
163
+ // Clic extérieur : annuler sans sauvegarder
164
+ if (refElement.current) {
165
+ refElement.current.textContent = texteOriginal.current;
166
+ refElement.current.contentEditable = "false";
167
+ setEnEdition(false);
168
+ }
169
+ };
170
+ document.addEventListener("mousedown", gererClicExterieur);
171
+ return () => document.removeEventListener("mousedown", gererClicExterieur);
172
+ }, [enEdition]);
173
+
174
+ const activerEdition = useCallback(() => {
175
+ if (!modeEdition || enEdition || !refElement.current) return;
176
+ const el = refElement.current;
177
+ // Stocker le texte brut avec * pour l'accent
178
+ texteOriginal.current = texte;
179
+ // Afficher le texte sans astérisques pour l'édition
180
+ el.textContent = texte.replace(/\*/g, "");
181
+ el.contentEditable = "true";
182
+ el.focus();
183
+ setEnEdition(true);
184
+
185
+ const range = document.createRange();
186
+ const selection = window.getSelection();
187
+ range.selectNodeContents(el);
188
+ range.collapse(false);
189
+ selection?.removeAllRanges();
190
+ selection?.addRange(range);
191
+ }, [modeEdition, enEdition, texte]);
192
+
193
+ const sauvegarder = useCallback(async () => {
194
+ if (!refElement.current) return;
195
+ const el = refElement.current;
196
+ const nouveauTexte = el.textContent || "";
197
+ el.contentEditable = "false";
198
+ setEnEdition(false);
199
+ if (nouveauTexte !== texteOriginal.current) {
200
+ await modifierTexte(cle, nouveauTexte);
201
+ }
202
+ }, [cle, modifierTexte]);
203
+
204
+ const annuler = useCallback(() => {
205
+ if (!refElement.current) return;
206
+ const el = refElement.current;
207
+ el.contentEditable = "false";
208
+ setEnEdition(false);
209
+ }, []);
210
+
211
+ const gererTouche = useCallback(
212
+ (e: React.KeyboardEvent) => {
213
+ if (e.key === "Escape") { e.preventDefault(); annuler(); }
214
+ if (e.key === "Enter" && !e.shiftKey && estMonoLigne) { e.preventDefault(); sauvegarder(); }
215
+ },
216
+ [sauvegarder, annuler, estMonoLigne]
217
+ );
218
+
219
+ // Rendu HTML : accent *mot* et/ou balises HTML
220
+ const htmlRendu = classeAccent
221
+ ? rendreAccent(texte, classeAccent)
222
+ : texte;
223
+ const doitRendreHtml = classeAccent ? texte.includes("*") : texte.includes("<");
224
+
225
+ // Mode normal
226
+ if (!modeEdition) {
227
+ if (Balise === "a" && href) {
228
+ return doitRendreHtml
229
+ ? <a href={href} className={className} dangerouslySetInnerHTML={{ __html: htmlRendu }} />
230
+ : <a href={href} className={className}>{texte}</a>;
231
+ }
232
+ return doitRendreHtml
233
+ ? <Balise className={className} dangerouslySetInnerHTML={{ __html: htmlRendu }} />
234
+ : <Balise className={className}>{texte}</Balise>;
235
+ }
236
+
237
+ // Styles d'édition directement sur l'élément (pas de wrapper div)
238
+ const classesEdition = enEdition
239
+ ? `${className} editable-actif outline-none cursor-text`
240
+ : `${className} editable-hover cursor-pointer`;
241
+
242
+ const styleEdition = enEdition
243
+ ? { boxShadow: "0 0 0 6px rgba(0, 81, 255, 0.08)", caretColor: "#0051ff", outline: "2px solid #0051ff", outlineOffset: "4px" } as React.CSSProperties
244
+ : undefined;
245
+
246
+ return (
247
+ <>
248
+ <Balise
249
+ ref={refElement as React.RefObject<never>}
250
+ className={classesEdition}
251
+ style={styleEdition}
252
+ onClick={(e: React.MouseEvent) => {
253
+ if (Balise === "a") e.preventDefault();
254
+ activerEdition();
255
+ }}
256
+ onKeyDown={gererTouche}
257
+ suppressContentEditableWarning
258
+ {...(!enEdition && doitRendreHtml ? { dangerouslySetInnerHTML: { __html: htmlRendu } } : {})}
259
+ >
260
+ {!enEdition && doitRendreHtml ? undefined : texte}
261
+ </Balise>
262
+ {enEdition && monte && (
263
+ <BarreOutils
264
+ elementRef={refElement}
265
+ onSauvegarder={sauvegarder}
266
+ onAnnuler={annuler}
267
+ />
268
+ )}
269
+ </>
270
+ );
271
+ }
@@ -0,0 +1,280 @@
1
+ // components/edition/TexteRicheEditable.tsx
2
+ // Composant paragraphe avec édition rich text léger (gras, italique, liens)
3
+ // Utilise contentEditable pour rester léger (pas de dépendance externe)
4
+
5
+ "use client";
6
+
7
+ import { useCallback, useEffect, useRef, useState } from "react";
8
+ import { useEdition } from "./ContexteEdition";
9
+
10
+ type PropsTexteRicheEditable = {
11
+ cle: string;
12
+ fallback?: string;
13
+ className?: string;
14
+ };
15
+
16
+ export function TexteRicheEditable({
17
+ cle,
18
+ fallback = "",
19
+ className = "",
20
+ }: PropsTexteRicheEditable) {
21
+ const { modeEdition, contenus, modifierTexte } = useEdition();
22
+ const [enEdition, setEnEdition] = useState(false);
23
+ const refEditeur = useRef<HTMLDivElement>(null);
24
+ const [contenuOriginal, setContenuOriginal] = useState("");
25
+
26
+ const html = contenus.get(cle) || fallback;
27
+
28
+ const gererClic = useCallback(() => {
29
+ if (modeEdition && !enEdition) {
30
+ setEnEdition(true);
31
+ setContenuOriginal(html);
32
+ }
33
+ }, [modeEdition, enEdition, html]);
34
+
35
+ useEffect(() => {
36
+ if (enEdition && refEditeur.current) {
37
+ refEditeur.current.innerHTML = html;
38
+ refEditeur.current.focus();
39
+ // Placer le curseur à la fin
40
+ const selection = window.getSelection();
41
+ if (selection) {
42
+ const plage = document.createRange();
43
+ plage.selectNodeContents(refEditeur.current);
44
+ plage.collapse(false);
45
+ selection.removeAllRanges();
46
+ selection.addRange(plage);
47
+ }
48
+ }
49
+ }, [enEdition, html]);
50
+
51
+ const executerCommande = useCallback((commande: string, valeur?: string) => {
52
+ document.execCommand(commande, false, valeur);
53
+ refEditeur.current?.focus();
54
+ }, []);
55
+
56
+ const [popoverLien, setPopoverLien] = useState(false);
57
+ const [urlLien, setUrlLien] = useState("");
58
+ const refSelectionSauvegardee = useRef<Range | null>(null);
59
+
60
+ const ajouterLien = useCallback(() => {
61
+ const selection = window.getSelection();
62
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
63
+ return;
64
+ }
65
+ // Sauvegarder la sélection
66
+ refSelectionSauvegardee.current = selection.getRangeAt(0).cloneRange();
67
+ setUrlLien("");
68
+ setPopoverLien(true);
69
+ }, []);
70
+
71
+ const appliquerLien = useCallback(() => {
72
+ if (!urlLien.trim()) return;
73
+ // Restaurer la sélection
74
+ if (refSelectionSauvegardee.current) {
75
+ const sel = window.getSelection();
76
+ if (sel) {
77
+ sel.removeAllRanges();
78
+ sel.addRange(refSelectionSauvegardee.current);
79
+ }
80
+ }
81
+ executerCommande("createLink", urlLien.trim());
82
+ setPopoverLien(false);
83
+ refSelectionSauvegardee.current = null;
84
+ }, [urlLien, executerCommande]);
85
+
86
+ const fermerPopoverLien = useCallback(() => {
87
+ setPopoverLien(false);
88
+ // Restaurer la sélection sans appliquer de lien
89
+ if (refSelectionSauvegardee.current) {
90
+ const sel = window.getSelection();
91
+ if (sel) {
92
+ sel.removeAllRanges();
93
+ sel.addRange(refSelectionSauvegardee.current);
94
+ }
95
+ }
96
+ refSelectionSauvegardee.current = null;
97
+ refEditeur.current?.focus();
98
+ }, []);
99
+
100
+ const gererSauvegarde = useCallback(async () => {
101
+ if (refEditeur.current) {
102
+ const nouveauHtml = refEditeur.current.innerHTML;
103
+ if (nouveauHtml !== contenuOriginal) {
104
+ await modifierTexte(cle, nouveauHtml);
105
+ }
106
+ }
107
+ setEnEdition(false);
108
+ }, [cle, contenuOriginal, modifierTexte]);
109
+
110
+ const gererAnnulation = useCallback(() => {
111
+ setEnEdition(false);
112
+ }, []);
113
+
114
+ const gererTouche = useCallback(
115
+ (e: React.KeyboardEvent) => {
116
+ if (e.key === "Escape") {
117
+ e.preventDefault();
118
+ gererAnnulation();
119
+ }
120
+ // Raccourcis clavier
121
+ if (e.ctrlKey || e.metaKey) {
122
+ if (e.key === "b") {
123
+ e.preventDefault();
124
+ executerCommande("bold");
125
+ }
126
+ if (e.key === "i") {
127
+ e.preventDefault();
128
+ executerCommande("italic");
129
+ }
130
+ if (e.key === "k") {
131
+ e.preventDefault();
132
+ ajouterLien();
133
+ }
134
+ }
135
+ },
136
+ [executerCommande, ajouterLien, gererAnnulation]
137
+ );
138
+
139
+ // Mode édition avec barre d'outils
140
+ if (enEdition) {
141
+ return (
142
+ <div className="relative">
143
+ {/* Barre d'outils */}
144
+ <div className="flex items-center gap-1 mb-2 p-1.5 bg-white border border-zinc-200 rounded-lg shadow-sm">
145
+ <button
146
+ type="button"
147
+ onClick={() => executerCommande("bold")}
148
+ className="p-1.5 rounded hover:bg-zinc-100 transition-colors"
149
+ title="Gras (Ctrl+B)"
150
+ >
151
+ <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
152
+ <path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
153
+ <path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
154
+ </svg>
155
+ </button>
156
+ <button
157
+ type="button"
158
+ onClick={() => executerCommande("italic")}
159
+ className="p-1.5 rounded hover:bg-zinc-100 transition-colors"
160
+ title="Italique (Ctrl+I)"
161
+ >
162
+ <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
163
+ <line x1="19" y1="4" x2="10" y2="4" />
164
+ <line x1="14" y1="20" x2="5" y2="20" />
165
+ <line x1="15" y1="4" x2="9" y2="20" />
166
+ </svg>
167
+ </button>
168
+ <div className="w-px h-5 bg-zinc-200 mx-1" />
169
+ <button
170
+ type="button"
171
+ onClick={ajouterLien}
172
+ className="p-1.5 rounded hover:bg-zinc-100 transition-colors"
173
+ title="Ajouter un lien (Ctrl+K)"
174
+ >
175
+ <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
176
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
177
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
178
+ </svg>
179
+ </button>
180
+ <button
181
+ type="button"
182
+ onClick={() => executerCommande("unlink")}
183
+ className="p-1.5 rounded hover:bg-zinc-100 transition-colors"
184
+ title="Retirer le lien"
185
+ >
186
+ <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
187
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
188
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
189
+ <line x1="2" y1="2" x2="22" y2="22" strokeWidth={2} />
190
+ </svg>
191
+ </button>
192
+
193
+ <div className="flex-1" />
194
+
195
+ <button
196
+ type="button"
197
+ onClick={gererAnnulation}
198
+ className="px-2.5 py-1 text-xs font-medium text-zinc-500 hover:text-zinc-700 transition-colors"
199
+ >
200
+ Annuler
201
+ </button>
202
+ <button
203
+ type="button"
204
+ onClick={gererSauvegarde}
205
+ className="px-3 py-1 text-xs font-semibold text-white bg-[#0051ff] rounded-md hover:bg-[#003dcc] transition-colors"
206
+ >
207
+ Sauvegarder
208
+ </button>
209
+
210
+ {/* Popover lien inline */}
211
+ {popoverLien && (
212
+ <div className="absolute top-full left-0 right-0 mt-1 p-2.5 bg-white border border-zinc-200 rounded-lg shadow-lg flex items-center gap-2 z-50">
213
+ <input
214
+ type="url"
215
+ value={urlLien}
216
+ onChange={(e) => setUrlLien(e.target.value)}
217
+ onKeyDown={(e) => {
218
+ if (e.key === "Enter") { e.preventDefault(); appliquerLien(); }
219
+ if (e.key === "Escape") { e.preventDefault(); fermerPopoverLien(); }
220
+ }}
221
+ placeholder="https://exemple.com"
222
+ className="flex-1 px-3 py-1.5 text-sm bg-zinc-50 border border-zinc-200 rounded-md focus:outline-none focus:ring-1 focus:ring-[#0051ff] focus:border-[#0051ff]"
223
+ autoFocus
224
+ />
225
+ <button
226
+ type="button"
227
+ onClick={appliquerLien}
228
+ className="px-3 py-1.5 text-xs font-semibold text-white bg-[#0051ff] rounded-md hover:bg-[#003dcc] transition-colors whitespace-nowrap"
229
+ >
230
+ Appliquer
231
+ </button>
232
+ <button
233
+ type="button"
234
+ onClick={fermerPopoverLien}
235
+ className="p-1.5 text-zinc-400 hover:text-zinc-600 transition-colors"
236
+ >
237
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
238
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
239
+ </svg>
240
+ </button>
241
+ </div>
242
+ )}
243
+ </div>
244
+
245
+ {/* Zone d'édition */}
246
+ <div
247
+ ref={refEditeur}
248
+ contentEditable
249
+ onKeyDown={gererTouche}
250
+ className={`
251
+ ${className}
252
+ outline-none ring-2 ring-[#0051ff] ring-offset-2 rounded-md
253
+ p-2 min-h-[60px]
254
+ [&_a]:text-[#0051ff] [&_a]:underline
255
+ [&_b]:font-bold [&_strong]:font-bold
256
+ [&_i]:italic [&_em]:italic
257
+ `}
258
+ suppressContentEditableWarning
259
+ />
260
+ </div>
261
+ );
262
+ }
263
+
264
+ // Mode normal
265
+ return (
266
+ <div
267
+ onClick={gererClic}
268
+ className={`
269
+ ${className}
270
+ ${
271
+ modeEdition
272
+ ? "cursor-pointer hover:ring-2 hover:ring-[#0051ff]/50 hover:ring-offset-2 rounded-md transition-all"
273
+ : ""
274
+ }
275
+ `}
276
+ dangerouslySetInnerHTML={{ __html: html }}
277
+ {...(modeEdition ? { title: "Cliquer pour modifier" } : {})}
278
+ />
279
+ );
280
+ }
@@ -0,0 +1,5 @@
1
+ export { FournisseurEdition, useEdition } from "./ContexteEdition";
2
+ export { TexteEditable } from "./TexteEditable";
3
+ export { ImageEditable } from "./ImageEditable";
4
+ export { LienEditable } from "./LienEditable";
5
+ export { TexteRicheEditable } from "./TexteRicheEditable";
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export { FournisseurEdition, useEdition } from "./components/ContexteEdition";
2
+ export { TexteEditable } from "./components/TexteEditable";
3
+ export { ImageEditable } from "./components/ImageEditable";
4
+ export { LienEditable } from "./components/LienEditable";
5
+ export { TexteRicheEditable } from "./components/TexteRicheEditable";
6
+
7
+ export type {
8
+ ClientSupabase,
9
+ ContenuEditable,
10
+ ImageEditableData,
11
+ CompteurBrouillons,
12
+ ResultatPublication,
13
+ MessageEdition,
14
+ } from "./lib/types";
@@ -0,0 +1,17 @@
1
+ export {
2
+ recupererContenuPage,
3
+ recupererImagesPage,
4
+ recupererBrouillonsPage,
5
+ sauvegarderBrouillonTexte,
6
+ sauvegarderBrouillonImage,
7
+ compterBrouillons,
8
+ } from "./supabase";
9
+
10
+ export type {
11
+ ClientSupabase,
12
+ ContenuEditable,
13
+ ImageEditableData,
14
+ CompteurBrouillons,
15
+ ResultatPublication,
16
+ MessageEdition,
17
+ } from "./types";
@@ -0,0 +1,126 @@
1
+ import type {
2
+ ClientSupabase,
3
+ ContenuEditable,
4
+ ImageEditableData,
5
+ CompteurBrouillons,
6
+ } from "./types";
7
+
8
+ export async function recupererContenuPage(client: ClientSupabase, siteId: string, page: string) {
9
+ const { data, error } = await client
10
+ .from("contenu")
11
+ .select("*")
12
+ .eq("site_id", siteId)
13
+ .eq("page", page);
14
+
15
+ if (error) throw error;
16
+
17
+ const carte = new Map<string, string>();
18
+ (data as ContenuEditable[])?.forEach((item) => {
19
+ carte.set(item.cle, item.valeur);
20
+ });
21
+ return carte;
22
+ }
23
+
24
+ export async function recupererImagesPage(client: ClientSupabase, siteId: string, page: string) {
25
+ const { data, error } = await client
26
+ .from("images_editables")
27
+ .select("*")
28
+ .eq("site_id", siteId)
29
+ .eq("page", page);
30
+
31
+ if (error) throw error;
32
+
33
+ const carte = new Map<string, ImageEditableData>();
34
+ (data as ImageEditableData[])?.forEach((item) => {
35
+ carte.set(item.cle, item);
36
+ });
37
+ return carte;
38
+ }
39
+
40
+ export async function recupererBrouillonsPage(client: ClientSupabase, siteId: string, page: string) {
41
+ const [textes, images] = await Promise.all([
42
+ client
43
+ .from("brouillons_contenu")
44
+ .select("cle, valeur")
45
+ .eq("site_id", siteId)
46
+ .like("cle", `${page}.%`),
47
+ client
48
+ .from("brouillons_images")
49
+ .select("cle, url, alt")
50
+ .eq("site_id", siteId)
51
+ .like("cle", `${page}.%`),
52
+ ]);
53
+
54
+ const carteTextes = new Map<string, string>();
55
+ textes.data?.forEach((item: { cle: string; valeur: string }) => {
56
+ carteTextes.set(item.cle, item.valeur);
57
+ });
58
+
59
+ const carteImages = new Map<string, { url: string; alt: string }>();
60
+ images.data?.forEach((item: { cle: string; url: string; alt: string }) => {
61
+ carteImages.set(item.cle, { url: item.url, alt: item.alt });
62
+ });
63
+
64
+ return { textes: carteTextes, images: carteImages };
65
+ }
66
+
67
+ export async function sauvegarderBrouillonTexte(
68
+ client: ClientSupabase,
69
+ siteId: string,
70
+ cle: string,
71
+ valeur: string
72
+ ) {
73
+ const { error } = await client
74
+ .from("brouillons_contenu")
75
+ .upsert(
76
+ { site_id: siteId, cle, valeur },
77
+ { onConflict: "site_id,cle" }
78
+ );
79
+
80
+ if (error) throw error;
81
+ }
82
+
83
+ export async function sauvegarderBrouillonImage(
84
+ client: ClientSupabase,
85
+ siteId: string,
86
+ cle: string,
87
+ fichier: File,
88
+ alt?: string
89
+ ) {
90
+ const extension = fichier.name.split(".").pop();
91
+ const chemin = `${siteId}/${cle.replace(/\./g, "/")}/${Date.now()}.${extension}`;
92
+
93
+ const { error: erreurUpload } = await client.storage
94
+ .from("images-site")
95
+ .upload(chemin, fichier, {
96
+ upsert: true,
97
+ contentType: fichier.type,
98
+ });
99
+
100
+ if (erreurUpload) throw erreurUpload;
101
+
102
+ const { data } = client.storage.from("images-site").getPublicUrl(chemin);
103
+
104
+ const { error } = await client
105
+ .from("brouillons_images")
106
+ .upsert(
107
+ { site_id: siteId, cle, url: data.publicUrl, alt: alt || "" },
108
+ { onConflict: "site_id,cle" }
109
+ );
110
+
111
+ if (error) throw error;
112
+
113
+ return data.publicUrl;
114
+ }
115
+
116
+ export async function compterBrouillons(
117
+ client: ClientSupabase,
118
+ siteId: string
119
+ ): Promise<CompteurBrouillons> {
120
+ const { data, error } = await client.rpc("compter_brouillons", {
121
+ id_site: siteId,
122
+ });
123
+
124
+ if (error) throw error;
125
+ return data as CompteurBrouillons;
126
+ }
@@ -0,0 +1,46 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+
3
+ export type ClientSupabase = SupabaseClient;
4
+
5
+ export type ContenuEditable = {
6
+ id: string;
7
+ site_id: string;
8
+ cle: string;
9
+ valeur: string;
10
+ type: "texte" | "titre" | "paragraphe" | "lien" | "riche";
11
+ page: string;
12
+ mis_a_jour_le: string;
13
+ mis_a_jour_par: string | null;
14
+ };
15
+
16
+ export type ImageEditableData = {
17
+ id: string;
18
+ site_id: string;
19
+ cle: string;
20
+ url: string;
21
+ alt: string;
22
+ page: string;
23
+ mis_a_jour_le: string;
24
+ mis_a_jour_par: string | null;
25
+ };
26
+
27
+ export type CompteurBrouillons = {
28
+ textes: number;
29
+ images: number;
30
+ };
31
+
32
+ export type ResultatPublication = {
33
+ textes_publies: number;
34
+ images_publiees: number;
35
+ };
36
+
37
+ export type MessageEdition =
38
+ | { type: "ACTIVER_EDITION" }
39
+ | { type: "DESACTIVER_EDITION" }
40
+ | { type: "PUBLIER_BROUILLONS" }
41
+ | { type: "ANNULER_BROUILLONS" }
42
+ | { type: "CHANGER_PAGE"; page: string }
43
+ | { type: "EDITION_PRETE" }
44
+ | { type: "BROUILLON_SAUVEGARDE"; compteur: CompteurBrouillons }
45
+ | { type: "PUBLICATION_TERMINEE"; resultat: ResultatPublication }
46
+ | { type: "BROUILLONS_ANNULES" };
package/src/styles.css ADDED
@@ -0,0 +1,36 @@
1
+ /* Édition en ligne - hover et tooltip */
2
+ .editable-hover {
3
+ position: relative;
4
+ transition: outline 0.2s, outline-offset 0.2s;
5
+ }
6
+
7
+ .editable-hover:hover {
8
+ outline: 2px solid #0051ff;
9
+ outline-offset: 6px;
10
+ border-radius: 2px;
11
+ }
12
+
13
+ .editable-hover::after {
14
+ content: "Cliquer pour modifier";
15
+ position: absolute;
16
+ bottom: 100%;
17
+ left: 50%;
18
+ transform: translateX(-50%);
19
+ margin-bottom: 8px;
20
+ padding: 2px 8px;
21
+ font-size: 11px;
22
+ font-weight: 500;
23
+ font-family: system-ui, sans-serif;
24
+ color: white;
25
+ background: #0051ff;
26
+ border-radius: 4px;
27
+ white-space: nowrap;
28
+ opacity: 0;
29
+ pointer-events: none;
30
+ transition: opacity 0.15s;
31
+ z-index: 9999;
32
+ }
33
+
34
+ .editable-hover:hover::after {
35
+ opacity: 1;
36
+ }