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 +24 -0
- package/src/components/ContexteEdition.tsx +187 -0
- package/src/components/ImageEditable.tsx +167 -0
- package/src/components/LienEditable.tsx +176 -0
- package/src/components/TexteEditable.tsx +271 -0
- package/src/components/TexteRicheEditable.tsx +280 -0
- package/src/components/index.ts +5 -0
- package/src/index.ts +14 -0
- package/src/lib/index.ts +17 -0
- package/src/lib/supabase.ts +126 -0
- package/src/lib/types.ts +46 -0
- package/src/styles.css +36 -0
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">–</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";
|
package/src/lib/index.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -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
|
+
}
|