cinqcinqdev-seo 0.1.10 → 0.1.12

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.
@@ -10,24 +10,69 @@ const bucket = config?.storageBucket || "site";
10
10
  const pageId = route.params.id;
11
11
  const CMS_COMPONENTS = useAdminSections();
12
12
  const { vueApp } = useNuxtApp();
13
- const resolveSection = (type) => vueApp.component(`Sections${type}`) || vueApp.component(type) || type;
13
+ const resolveSection = (type) => vueApp.component(`Sections${type}`) || vueApp.component(type) || null;
14
14
  const { data: page } = await useAsyncData(`admin-edit-${pageId}`, async () => {
15
15
  const { data } = await supabase.from(table).select("*").eq("id", pageId).maybeSingle();
16
16
  return data;
17
17
  });
18
- useHead({ title: computed(() => page.value?.title ? `${page.value.title} \u2014 Editor` : "Editor") });
19
- const isLeftOpen = ref(true);
20
- const isRightOpen = ref(true);
18
+ useHead({ title: computed(() => page.value?.title ? `${page.value.title} \u2014 \xC9diteur` : "\xC9diteur \u2014 Admin") });
19
+ const isLeftSidebarOpen = ref(true);
20
+ const isRightSidebarOpen = ref(true);
21
21
  const viewMode = ref("structure");
22
22
  const selectedBlockIndex = ref(null);
23
- const selectedFieldGroup = ref("design");
23
+ const editingLabelIndex = ref(null);
24
24
  const isSaving = ref(false);
25
25
  const isUploading = ref(false);
26
26
  const showAddModal = ref(false);
27
- const editingLabelIndex = ref(null);
27
+ const selectedFieldGroup = ref("design");
28
+ const rightPanelMode = ref("seo");
29
+ const savedSnapshot = ref(JSON.stringify(page.value?.content ?? []));
30
+ const readableVal = (v) => {
31
+ if (v === void 0 || v === null || v === "") return "(vide)";
32
+ if (typeof v === "object" && !Array.isArray(v) && ("fr" in v || "en" in v)) return v.fr || v.en || "(vide)";
33
+ if (Array.isArray(v)) return `${v.length} \xE9l\xE9ment(s)`;
34
+ return String(v);
35
+ };
36
+ const blockDiffs = computed(() => {
37
+ const _track = JSON.stringify(page.value?.content);
38
+ if (!page.value?.content) return /* @__PURE__ */ new Map();
39
+ const original = JSON.parse(savedSnapshot.value);
40
+ const result = /* @__PURE__ */ new Map();
41
+ page.value.content.forEach((block, i) => {
42
+ if (i >= original.length) {
43
+ result.set(i, [{ field: "section", old: "(nouveau)", new: block.type }]);
44
+ return;
45
+ }
46
+ const origBlock = original[i];
47
+ if (JSON.stringify(block) === JSON.stringify(origBlock)) return;
48
+ const changes = [];
49
+ const allFields = /* @__PURE__ */ new Set([...Object.keys(block.props || {}), ...Object.keys(origBlock.props || {})]);
50
+ for (const field of allFields) {
51
+ const curr = block.props?.[field];
52
+ const orig = origBlock.props?.[field];
53
+ if (JSON.stringify(curr) === JSON.stringify(orig)) continue;
54
+ if (Array.isArray(curr) || Array.isArray(orig)) {
55
+ const c = curr || [];
56
+ const o = orig || [];
57
+ if (c.length !== o.length) {
58
+ changes.push({ field, old: `${o.length} \xE9l\xE9ment(s)`, new: `${c.length} \xE9l\xE9ment(s)` });
59
+ } else {
60
+ const changed = c.filter((item, idx) => JSON.stringify(item) !== JSON.stringify(o[idx])).length;
61
+ if (changed > 0) changes.push({ field, old: `${o.length} \xE9l\xE9ment(s)`, new: `${changed} modifi\xE9(s)` });
62
+ }
63
+ continue;
64
+ }
65
+ changes.push({ field, old: readableVal(orig), new: readableVal(curr) });
66
+ }
67
+ if (changes.length) result.set(i, changes);
68
+ });
69
+ return result;
70
+ });
71
+ const dirtyBlocks = computed(() => new Set(blockDiffs.value.keys()));
72
+ const showDiff = ref(false);
28
73
  const editingLang = ref("fr");
29
74
  const LANGS = ["fr", "ar", "en"];
30
- const i18n0 = (fr = "") => ({ fr, ar: "", en: "" });
75
+ const langLabel = (l) => l === "ar" ? "\u0639" : l.toUpperCase();
31
76
  const resolveI18nForPreview = (val) => {
32
77
  if (!val || typeof val !== "object" || Array.isArray(val)) return val ?? "";
33
78
  if ("fr" in val || "en" in val || "ar" in val) {
@@ -39,75 +84,172 @@ const resolvePropsForPreview = (props) => {
39
84
  if (!props) return {};
40
85
  return Object.fromEntries(
41
86
  Object.entries(props).map(([k, v]) => {
42
- if (Array.isArray(v)) return [k, v.map(
43
- (item) => typeof item === "object" && item ? resolvePropsForPreview(item) : item
44
- )];
87
+ if (Array.isArray(v)) return [k, v.map((item) => typeof item === "object" && item ? resolvePropsForPreview(item) : item)];
45
88
  return [k, resolveI18nForPreview(v)];
46
89
  })
47
90
  );
48
91
  };
49
- const getI18nVal = (val, lang) => {
92
+ const getI18nProp = (fieldName, lang) => {
93
+ const val = page.value?.content[selectedBlockIndex.value]?.props[fieldName];
50
94
  if (!val) return "";
51
95
  if (typeof val === "string") return lang === "fr" ? val : "";
52
96
  return val[lang] || "";
53
97
  };
54
- const setI18nVal = (obj, key, lang, value) => {
55
- const current = obj[key];
56
- const out = { fr: "", ar: "", en: "" };
57
- if (typeof current === "string") out.fr = current;
58
- else if (current && typeof current === "object") Object.assign(out, current);
59
- out[lang] = value;
60
- obj[key] = out;
98
+ const setI18nProp = (fieldName, lang, newValue) => {
99
+ const props = page.value?.content[selectedBlockIndex.value]?.props;
100
+ if (!props) return;
101
+ const current = props[fieldName];
102
+ const obj = { fr: "", ar: "", en: "" };
103
+ if (typeof current === "string") {
104
+ obj.fr = current;
105
+ } else if (current && typeof current === "object") {
106
+ Object.assign(obj, current);
107
+ }
108
+ obj[lang] = newValue;
109
+ props[fieldName] = obj;
61
110
  };
62
- const getBlockI18n = (fieldName, lang) => {
63
- const val = page.value?.content[selectedBlockIndex.value]?.props[fieldName];
111
+ const getI18nVal = (val, lang) => {
64
112
  if (!val) return "";
65
113
  if (typeof val === "string") return lang === "fr" ? val : "";
66
114
  return val[lang] || "";
67
115
  };
68
- const setBlockI18n = (fieldName, lang, value) => {
69
- const props = page.value?.content[selectedBlockIndex.value]?.props;
70
- if (!props) return;
71
- const current = props[fieldName];
72
- const out = { fr: "", ar: "", en: "" };
73
- if (typeof current === "string") out.fr = current;
74
- else if (current && typeof current === "object") Object.assign(out, current);
75
- out[lang] = value;
76
- props[fieldName] = out;
116
+ const setI18nVal = (obj, key, lang, newValue) => {
117
+ const current = obj[key];
118
+ const i18nObj = { fr: "", ar: "", en: "" };
119
+ if (typeof current === "string") {
120
+ i18nObj.fr = current;
121
+ } else if (current && typeof current === "object") {
122
+ Object.assign(i18nObj, current);
123
+ }
124
+ i18nObj[lang] = newValue;
125
+ obj[key] = i18nObj;
77
126
  };
127
+ const tagDepth = (tag) => {
128
+ const n = parseInt(tag.replace("h", ""));
129
+ return isNaN(n) ? 0 : n - 1;
130
+ };
131
+ const tagColor = (tag) => ({ h1: "#3d35ff", h2: "#6b63ff", h3: "#aaa", h4: "#ccc" })[tag] ?? "#ddd";
132
+ const seoTree = computed(() => {
133
+ if (!page.value?.content) return [];
134
+ const tree = [];
135
+ page.value.content.forEach((block, bIdx) => {
136
+ const titleText = resolveI18nForPreview(block.props.title);
137
+ if (block.props.titleTag && titleText) tree.push({ tag: block.props.titleTag, text: titleText, blockIndex: bIdx });
138
+ if (block.props.items) {
139
+ block.props.items.forEach((item) => {
140
+ const text = resolveI18nForPreview(item.title || item.question || item.name);
141
+ const tag = item.titleTag || block.props.itemTag;
142
+ if (text && tag) tree.push({ tag, text, blockIndex: bIdx });
143
+ });
144
+ }
145
+ });
146
+ return tree;
147
+ });
78
148
  const selectedCMSConfig = computed(
79
149
  () => selectedBlockIndex.value !== null ? CMS_COMPONENTS[page.value?.content[selectedBlockIndex.value]?.type] : null
80
150
  );
81
151
  const visibleFields = computed(() => {
82
- const config2 = selectedCMSConfig.value;
83
- if (!config2?.fields) return {};
84
- if (!config2.groups) return config2.fields;
152
+ const cfg = selectedCMSConfig.value;
153
+ if (!cfg?.fields) return {};
154
+ if (!cfg.groups) return cfg.fields;
85
155
  return Object.fromEntries(
86
- Object.entries(config2.fields).filter(([, f]) => f.group === selectedFieldGroup.value)
156
+ Object.entries(cfg.fields).filter(([, f]) => f.group === selectedFieldGroup.value)
87
157
  );
88
158
  });
89
- watch(selectedBlockIndex, () => {
159
+ watch(selectedBlockIndex, (newVal) => {
90
160
  selectedFieldGroup.value = selectedCMSConfig.value?.groups?.[0]?.key ?? "design";
161
+ rightPanelMode.value = newVal !== null && !page.value?.content_locked ? "block" : "seo";
162
+ showDiff.value = newVal !== null && blockDiffs.value.has(newVal);
91
163
  });
92
- const moveBlock = (index, dir) => {
93
- const newIdx = dir === "up" ? index - 1 : index + 1;
94
- if (newIdx < 0 || newIdx >= page.value.content.length) return;
95
- const content = [...page.value.content];
96
- const item = content.splice(index, 1)[0];
97
- content.splice(newIdx, 0, item);
98
- page.value.content = content;
99
- selectedBlockIndex.value = newIdx;
164
+ const isGenerating = ref(false);
165
+ const showAiPrompt = ref(false);
166
+ const aiPromptText = ref("");
167
+ const generateWithAI = async () => {
168
+ if (!selectedCMSConfig.value || selectedBlockIndex.value === null || !aiPromptText.value.trim()) return;
169
+ isGenerating.value = true;
170
+ showAiPrompt.value = false;
171
+ const fields = Object.entries(selectedCMSConfig.value.fields).filter(([, f]) => (f.type === "text" || f.type === "textarea") && f.i18n).map(([name, f]) => ({ name, label: f.label }));
172
+ const currentProps = page.value.content[selectedBlockIndex.value].props;
173
+ const itemFieldsConfig = selectedCMSConfig.value.fields.items?.itemFields ? Object.entries(selectedCMSConfig.value.fields.items.itemFields).filter(([, f]) => f.type === "text" || f.type === "textarea" || f.type === "stringlist").map(([name, f]) => ({ name, label: f.label, i18n: !!f.i18n, type: f.type })) : [];
174
+ try {
175
+ const result = await $fetch("/api/admin-cms/ai/generate", {
176
+ method: "POST",
177
+ body: {
178
+ sectionType: page.value.content[selectedBlockIndex.value].type,
179
+ sectionLabel: selectedCMSConfig.value.label,
180
+ fields,
181
+ currentProps,
182
+ prompt: aiPromptText.value,
183
+ itemFieldsConfig
184
+ }
185
+ });
186
+ const props = page.value.content[selectedBlockIndex.value].props;
187
+ for (const [fieldName, value] of Object.entries(result)) {
188
+ if (fieldName === "items" && Array.isArray(value)) {
189
+ if (!Array.isArray(props.items)) props.items = [];
190
+ value.forEach((aiItem, idx) => {
191
+ if (!props.items[idx]) props.items[idx] = {};
192
+ for (const [itemField, val] of Object.entries(aiItem)) {
193
+ if (Array.isArray(val)) {
194
+ props.items[idx][itemField] = val.map((v) => {
195
+ if (typeof v === "object" && v !== null) return { fr: "", ar: "", en: "", ...v };
196
+ return { fr: String(v), ar: "", en: "" };
197
+ });
198
+ } else if (typeof val === "object" && val !== null && ("fr" in val || "ar" in val || "en" in val)) {
199
+ const base = { fr: "", ar: "", en: "" };
200
+ const current = props.items[idx][itemField];
201
+ if (typeof current === "object" && current) Object.assign(base, current);
202
+ props.items[idx][itemField] = { ...base, ...val };
203
+ } else if (typeof val === "string" && val !== "...") {
204
+ props.items[idx][itemField] = val;
205
+ }
206
+ }
207
+ });
208
+ } else if (props[fieldName] !== void 0 && typeof value === "object" && !Array.isArray(value)) {
209
+ const base = { fr: "", ar: "", en: "" };
210
+ const current = props[fieldName];
211
+ if (typeof current === "object" && current) Object.assign(base, current);
212
+ props[fieldName] = { ...base, ...value };
213
+ }
214
+ }
215
+ } catch (err) {
216
+ console.error(err);
217
+ alert("Erreur IA. V\xE9rifiez la cl\xE9 OPENROUTER_API_KEY dans .env");
218
+ } finally {
219
+ isGenerating.value = false;
220
+ aiPromptText.value = "";
221
+ }
100
222
  };
101
- const removeBlock = (index) => {
102
- if (!confirm("Delete this section?")) return;
103
- page.value.content.splice(index, 1);
104
- selectedBlockIndex.value = null;
223
+ const seoAudit = ref(null);
224
+ const isAuditing = ref(false);
225
+ const runSeoAudit = async () => {
226
+ if (!page.value) return;
227
+ isAuditing.value = true;
228
+ seoAudit.value = null;
229
+ try {
230
+ const result = await $fetch("/api/admin-cms/ai/seo-audit", {
231
+ method: "POST",
232
+ body: { pageTitle: page.value.title, pageType: page.value.type, content: page.value.content, seoConfig: page.value.seo_config }
233
+ });
234
+ seoAudit.value = result;
235
+ } catch (err) {
236
+ console.error(err);
237
+ } finally {
238
+ isAuditing.value = false;
239
+ }
105
240
  };
106
- const addSection = (type) => {
107
- const conf = CMS_COMPONENTS[type];
108
- page.value.content.push({ type, props: JSON.parse(JSON.stringify(conf.defaultProps)) });
109
- showAddModal.value = false;
110
- selectedBlockIndex.value = page.value.content.length - 1;
241
+ const applySeoAudit = () => {
242
+ if (!seoAudit.value || !page.value) return;
243
+ const mt = seoAudit.value.meta_title || {};
244
+ const md = seoAudit.value.meta_description || {};
245
+ page.value = {
246
+ ...page.value,
247
+ seo_config: {
248
+ ...page.value.seo_config || {},
249
+ meta_title: { fr: mt.fr || "", ar: mt.ar || "", en: mt.en || "" },
250
+ meta_description: { fr: md.fr || "", ar: md.ar || "", en: md.en || "" }
251
+ }
252
+ };
111
253
  };
112
254
  const fileInput = ref(null);
113
255
  let activeUploadTarget = null;
@@ -120,40 +262,72 @@ const handleFileUpload = async (event) => {
120
262
  if (!file || !activeUploadTarget) return;
121
263
  isUploading.value = true;
122
264
  try {
123
- const ext = file.name.split(".").pop();
124
- const path = `uploads/${Date.now()}-${Math.random().toString(36).substring(7)}.${ext}`;
125
- const { error: uploadError } = await supabase.storage.from(bucket).upload(path, file);
265
+ const fileExt = file.name.split(".").pop();
266
+ const fileName = `${Date.now()}-${Math.random().toString(36).substring(7)}.${fileExt}`;
267
+ const filePath = `uploads/${fileName}`;
268
+ const { error: uploadError } = await supabase.storage.from(bucket).upload(filePath, file);
126
269
  if (uploadError) throw uploadError;
127
- const { data } = supabase.storage.from(bucket).getPublicUrl(path);
270
+ const { data } = supabase.storage.from(bucket).getPublicUrl(filePath);
128
271
  activeUploadTarget.obj[activeUploadTarget.key] = data.publicUrl;
129
272
  } catch (err) {
273
+ alert(`Erreur upload : V\xE9rifiez le bucket '${bucket}' sur Supabase.`);
130
274
  console.error(err);
131
- alert(`Upload error. Make sure the "${bucket}" bucket exists in Supabase.`);
132
275
  } finally {
133
276
  isUploading.value = false;
134
277
  activeUploadTarget = null;
135
278
  if (fileInput.value) fileInput.value.value = "";
136
279
  }
137
280
  };
138
- const tagOptions = [
139
- { value: "h1", label: "H1" },
140
- { value: "h2", label: "H2" },
141
- { value: "h3", label: "H3" },
142
- { value: "h4", label: "H4" },
143
- { value: "div", label: "DIV" }
144
- ];
145
- const pageTypeOptions = computed(
146
- () => config?.pageTypes?.map((t) => ({ value: t.id, label: t.label })) || []
147
- );
148
- const typeUrlPrefixes = {
149
- service_page: "/service/",
150
- landing_page: "/",
151
- page: "/"
152
- };
153
- const pageViewUrl = computed(() => {
154
- const prefix = typeUrlPrefixes[page.value?.type] ?? "/";
155
- return `${prefix}${page.value?.slug ?? ""}`;
281
+ const showGallery = ref(false);
282
+ const galleryImages = ref([]);
283
+ const isLoadingGallery = ref(false);
284
+ const gallerySearch = ref("");
285
+ const filteredGallery = computed(() => {
286
+ const q = gallerySearch.value.trim().toLowerCase();
287
+ if (!q) return galleryImages.value;
288
+ return galleryImages.value.filter((img) => img.name.toLowerCase().includes(q));
156
289
  });
290
+ const listBucketRecursive = async (bkt, prefix = "") => {
291
+ const { data, error } = await supabase.storage.from(bkt).list(prefix || void 0, { limit: 500 });
292
+ if (error || !data) return [];
293
+ const files = [];
294
+ const folderPromises = [];
295
+ for (const f of data) {
296
+ if (f.name === ".emptyFolderPlaceholder") continue;
297
+ const fullPath = prefix ? `${prefix}/${f.name}` : f.name;
298
+ if (/\.\w{2,5}$/.test(f.name)) {
299
+ files.push({ name: f.name, url: supabase.storage.from(bkt).getPublicUrl(fullPath).data.publicUrl });
300
+ } else {
301
+ folderPromises.push(listBucketRecursive(bkt, fullPath));
302
+ }
303
+ }
304
+ const nested = (await Promise.all(folderPromises)).flat();
305
+ return [...files, ...nested];
306
+ };
307
+ const openGallery = async (targetObj, key) => {
308
+ activeUploadTarget = { obj: targetObj, key };
309
+ showGallery.value = true;
310
+ gallerySearch.value = "";
311
+ isLoadingGallery.value = true;
312
+ galleryImages.value = await listBucketRecursive(bucket);
313
+ isLoadingGallery.value = false;
314
+ };
315
+ const selectGalleryImage = (url) => {
316
+ if (activeUploadTarget) {
317
+ activeUploadTarget.obj[activeUploadTarget.key] = url;
318
+ activeUploadTarget = null;
319
+ }
320
+ showGallery.value = false;
321
+ };
322
+ const moveBlock = (index, dir) => {
323
+ const newIdx = dir === "up" ? index - 1 : index + 1;
324
+ if (newIdx < 0 || newIdx >= page.value.content.length) return;
325
+ const content = [...page.value.content];
326
+ const item = content.splice(index, 1)[0];
327
+ content.splice(newIdx, 0, item);
328
+ page.value.content = content;
329
+ selectedBlockIndex.value = newIdx;
330
+ };
157
331
  const getSeoI18n = (field, lang) => {
158
332
  const val = page.value?.seo_config?.[field];
159
333
  if (!val) return "";
@@ -164,29 +338,36 @@ const setSeoI18n = (field, lang, value) => {
164
338
  if (!page.value) return;
165
339
  if (!page.value.seo_config) page.value.seo_config = {};
166
340
  const current = page.value.seo_config[field];
167
- const out = { fr: "", ar: "", en: "" };
168
- if (typeof current === "string") out.fr = current;
169
- else if (current && typeof current === "object") Object.assign(out, current);
170
- out[lang] = value;
171
- page.value.seo_config[field] = out;
341
+ const obj = { fr: "", ar: "", en: "" };
342
+ if (typeof current === "string") {
343
+ obj.fr = current;
344
+ } else if (current && typeof current === "object") {
345
+ Object.assign(obj, current);
346
+ }
347
+ obj[lang] = value;
348
+ page.value.seo_config[field] = obj;
172
349
  };
173
- const tagDepth = (tag) => Math.max(0, parseInt(tag.replace("h", "")) - 1) || 0;
174
- const tagColor = (tag) => ({ h1: "#3d35ff", h2: "#6b63ff", h3: "#aaa", h4: "#ccc" })[tag] ?? "#ddd";
175
- const seoTree = computed(() => {
176
- if (!page.value?.content) return [];
177
- const tree = [];
178
- page.value.content.forEach((block, bIdx) => {
179
- const titleText = resolveI18nForPreview(block.props.title);
180
- if (block.props.titleTag && titleText) tree.push({ tag: block.props.titleTag, text: titleText, blockIndex: bIdx });
181
- if (block.props.items) {
182
- block.props.items.forEach((item) => {
183
- const text = resolveI18nForPreview(item.title || item.question || item.name);
184
- const tag = item.titleTag || block.props.itemTag;
185
- if (text && tag) tree.push({ tag, text, blockIndex: bIdx });
186
- });
187
- }
188
- });
189
- return tree;
350
+ const updateSeoField = (field, value) => {
351
+ if (!page.value) return;
352
+ if (!page.value.seo_config) page.value.seo_config = {};
353
+ page.value.seo_config[field] = value;
354
+ };
355
+ const pageTypeOptions = computed(() => {
356
+ const types = config?.pageTypes || [];
357
+ if (types.length) return types.map((t) => ({ value: t.id, label: t.label }));
358
+ return [
359
+ { value: "landing_page", label: "Landing Page" },
360
+ { value: "service_page", label: "Page de Service" },
361
+ { value: "about_page", label: "Page \xC0 Propos" },
362
+ { value: "product_page", label: "Page Produit" },
363
+ { value: "blog_article", label: "Article de Blog" },
364
+ { value: "portfolio_item", label: "Projet Portfolio" }
365
+ ];
366
+ });
367
+ const pageViewUrl = computed(() => {
368
+ const type = page.value?.type;
369
+ const prefix = type === "service_page" ? "/service/" : "/";
370
+ return `${prefix}${page.value?.slug ?? ""}`;
190
371
  });
191
372
  const togglePublished = async () => {
192
373
  page.value.status = page.value.status === "published" ? "draft" : "published";
@@ -203,88 +384,103 @@ const savePage = async () => {
203
384
  type: page.value.type
204
385
  }).eq("id", pageId);
205
386
  isSaving.value = false;
206
- if (error) alert("Save error: " + error.message);
387
+ if (error) alert("Erreur lors de la sauvegarde : " + error.message);
388
+ else savedSnapshot.value = JSON.stringify(page.value?.content ?? []);
207
389
  };
208
390
  </script>
209
391
 
210
392
  <template>
211
- <div v-if="page" data-admin-cms class="h-screen flex flex-col bg-[#EBEBEF] overflow-hidden font-sans">
393
+ <div v-if="page" class="h-screen flex flex-col bg-[#EBEBEF] overflow-hidden font-sans">
394
+
212
395
  <input type="file" ref="fileInput" class="hidden" accept="image/*" @change="handleFileUpload" />
213
396
 
214
- <!-- Top bar -->
397
+ <!-- ── Top Bar ── -->
215
398
  <header class="h-12 bg-white/80 backdrop-blur-md border-b px-6 flex items-center justify-between z-[60] shrink-0">
216
399
  <div class="flex items-center gap-4">
217
- <NuxtLink to="/admin" class="text-gray-400 hover:text-black transition-colors text-lg">←</NuxtLink>
400
+ <NuxtLink to="/admin" class="text-gray-400 hover:text-black transition-colors">←</NuxtLink>
218
401
  <div class="flex gap-1 bg-gray-100 p-1 rounded-md">
219
- <button @click="isLeftOpen = !isLeftOpen" :class="['p-1.5 rounded transition-all', isLeftOpen ? 'bg-white shadow-sm text-blue-600' : 'text-gray-400']" title="Structure">
402
+ <button @click="isLeftSidebarOpen = !isLeftSidebarOpen" :class="['p-1.5 rounded transition-all', isLeftSidebarOpen ? 'bg-white shadow-sm text-[#3d35ff]' : 'text-gray-400']" title="Structure">
220
403
  <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 6h16M4 12h10M4 18h16"/></svg>
221
404
  </button>
222
- <button @click="isRightOpen = !isRightOpen" :class="['p-1.5 rounded transition-all', isRightOpen ? 'bg-white shadow-sm text-blue-600' : 'text-gray-400']" title="Properties">
405
+ <button @click="isRightSidebarOpen = !isRightSidebarOpen" :class="['p-1.5 rounded transition-all', isRightSidebarOpen ? 'bg-white shadow-sm text-[#3d35ff]' : 'text-gray-400']" title="Propriétés">
223
406
  <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 6h8M12 12h8M12 18h8M4 6h.01M4 12h.01M4 18h.01"/></svg>
224
407
  </button>
225
408
  </div>
409
+ <div v-if="page?.content_locked" class="px-2 py-1 bg-amber-50 border border-amber-200 rounded-full text-[8px] font-black uppercase tracking-widest text-amber-600">SEO Only</div>
226
410
  </div>
227
411
 
228
- <div class="flex items-center gap-3">
229
- <div class="flex bg-gray-100 p-1 rounded-full">
230
- <button @click="viewMode = 'structure'" :class="['px-3 py-1 rounded-full text-[8px] font-black uppercase', viewMode === 'structure' ? 'bg-white text-black' : 'text-gray-400']">Structure</button>
231
- <button @click="viewMode = 'seo'" :class="['px-3 py-1 rounded-full text-[8px] font-black uppercase', viewMode === 'seo' ? 'bg-white text-black' : 'text-gray-400']">SEO Audit</button>
412
+ <div class="flex items-center gap-4">
413
+ <div class="flex bg-gray-100 p-1 rounded-full scale-90">
414
+ <button @click="viewMode = 'structure'" :class="['px-3 py-1 rounded-full text-[8px] font-black uppercase tracking-widest', viewMode === 'structure' ? 'bg-white text-black' : 'text-gray-400']">Structure</button>
415
+ <button @click="viewMode = 'seo'" :class="['px-3 py-1 rounded-full text-[8px] font-black uppercase tracking-widest', viewMode === 'seo' ? 'bg-white text-black' : 'text-gray-400']">Audit SEO</button>
232
416
  </div>
233
- <NuxtLink v-if="page?.slug" :to="pageViewUrl" target="_blank" class="flex items-center gap-1.5 px-4 py-2 rounded-full text-[9px] font-black uppercase border border-gray-200 text-gray-500 hover:border-blue-500 hover:text-blue-500 transition-all">
417
+ <NuxtLink v-if="page?.slug" :to="pageViewUrl" target="_blank" class="flex items-center gap-1.5 px-4 py-2 rounded-full text-[9px] font-black uppercase tracking-widest border border-gray-200 text-gray-500 hover:border-[#3d35ff] hover:text-[#3d35ff] transition-all">
234
418
  <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
235
- View
419
+ Voir la page
236
420
  </NuxtLink>
237
- <button @click="togglePublished" :class="[
238
- 'flex items-center gap-1.5 px-4 py-2 rounded-full text-[9px] font-black uppercase border transition-all',
239
- page?.status === 'published' ? 'bg-green-50 border-green-200 text-green-600' : 'bg-gray-50 border-gray-200 text-gray-400'
421
+ <button @click="togglePublished"
422
+ :class="[
423
+ 'flex items-center gap-1.5 px-4 py-2 rounded-full text-[9px] font-black uppercase tracking-widest border transition-all active:scale-95',
424
+ page?.status === 'published' ? 'bg-green-50 border-green-200 text-green-600 hover:bg-green-100' : 'bg-gray-50 border-gray-200 text-gray-400 hover:border-gray-300'
240
425
  ]">
241
426
  <span :class="['w-1.5 h-1.5 rounded-full', page?.status === 'published' ? 'bg-green-500' : 'bg-gray-300']"></span>
242
- {{ page?.status === "published" ? "Published" : "Draft" }}
427
+ {{ page?.status === "published" ? "Publi\xE9" : "Brouillon" }}
243
428
  </button>
244
- <button @click="savePage" class="bg-blue-600 text-white px-5 py-2 rounded-full text-[9px] font-black uppercase shadow-lg active:scale-95 transition-all">
245
- {{ isSaving ? "Saving..." : "Save" }}
429
+ <button @click="savePage" class="relative bg-[#3d35ff] text-white px-5 py-2 rounded-full text-[9px] font-black uppercase tracking-widest shadow-lg active:scale-95 transition-all">
430
+ {{ isSaving ? "Enregistrement..." : "Mettre \xE0 jour" }}
431
+ <span v-if="!isSaving && dirtyBlocks.size > 0"
432
+ class="absolute -top-1.5 -right-1.5 w-4 h-4 bg-amber-400 text-white rounded-full text-[7px] font-black flex items-center justify-center">
433
+ {{ dirtyBlocks.size }}
434
+ </span>
246
435
  </button>
247
436
  </div>
248
437
  </header>
249
438
 
250
439
  <div class="flex flex-1 overflow-hidden relative">
251
440
 
252
- <!-- Left sidebar: structure / SEO tree -->
253
- <aside :class="['bg-white border-r z-50 transition-all duration-300 overflow-hidden flex flex-col', isLeftOpen ? 'w-64' : 'w-0']">
441
+ <!-- ── Left Sidebar: Structure / SEO Tree ── -->
442
+ <aside :class="['bg-white border-r z-50 transition-all duration-300 overflow-hidden flex flex-col', isLeftSidebarOpen ? 'w-64' : 'w-0']">
254
443
  <div class="p-6 w-64 h-full flex flex-col">
255
444
  <div v-if="viewMode === 'structure'" class="flex-1 space-y-2 overflow-y-auto">
256
- <h3 class="text-[9px] font-black text-gray-300 uppercase mb-4 tracking-widest">Sections</h3>
257
- <div v-for="(block, index) in page.content" :key="index" @click="selectedBlockIndex = index"
258
- :class="[
445
+ <h3 class="text-[9px] font-black text-gray-300 uppercase mb-4 italic tracking-widest">Arborescence</h3>
446
+ <div v-if="page?.content_locked" class="p-4 rounded-xl bg-amber-50 border border-amber-100 text-amber-600 text-[8px] font-black uppercase tracking-widest text-center">
447
+ Page verrouillée<br/><span class="font-normal normal-case opacity-60 text-[7px]">Seul le SEO est éditable</span>
448
+ </div>
449
+ <template v-else>
450
+ <div v-for="(block, index) in page.content" :key="index" @click="selectedBlockIndex = index"
451
+ :class="[
259
452
  'p-3 rounded-xl border-2 transition-all cursor-pointer group flex items-center justify-between gap-2',
260
- selectedBlockIndex === index ? 'border-blue-500 bg-blue-50/50 text-blue-600' : 'border-gray-100 bg-gray-50 text-gray-500'
453
+ selectedBlockIndex === index ? 'border-[#3d35ff] bg-blue-50/50 text-[#3d35ff]' : dirtyBlocks.has(index) ? 'border-amber-300 bg-amber-50 text-amber-700' : 'border-gray-50 bg-gray-50 text-gray-500'
261
454
  ]">
262
- <input v-if="editingLabelIndex === index"
263
- :value="block.label || ''"
264
- @input="block.label = $event.target.value"
265
- @blur="editingLabelIndex = null"
266
- @keydown.enter="editingLabelIndex = null"
267
- @click.stop
268
- class="flex-1 min-w-0 text-[9px] font-black uppercase bg-transparent outline-none border-b border-current"
269
- :placeholder="block.type" autofocus
270
- />
271
- <span v-else class="flex-1 min-w-0 text-[9px] font-black uppercase truncate" @dblclick.stop="editingLabelIndex = index">
272
- {{ block.label || block.type }}
273
- </span>
274
- <div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 text-xs">
275
- <button @click.stop="editingLabelIndex = index" title="Rename">✎</button>
276
- <button @click.stop="moveBlock(index, 'up')">↑</button>
277
- <button @click.stop="moveBlock(index, 'down')">↓</button>
278
- <button @click.stop="removeBlock(index)" class="text-red-400">✕</button>
455
+ <input
456
+ v-if="editingLabelIndex === index"
457
+ :value="block.label || ''"
458
+ @input="block.label = $event.target.value"
459
+ @blur="editingLabelIndex = null"
460
+ @keydown.enter="editingLabelIndex = null"
461
+ @click.stop
462
+ class="flex-1 min-w-0 text-[9px] font-black uppercase bg-transparent outline-none border-b border-current"
463
+ :placeholder="block.type"
464
+ autofocus
465
+ />
466
+ <span v-else class="flex-1 min-w-0 text-[9px] font-black uppercase truncate" @dblclick.stop="editingLabelIndex = index">
467
+ {{ block.label || block.type }}
468
+ </span>
469
+ <div class="flex items-center gap-1 shrink-0">
470
+ <span v-if="dirtyBlocks.has(index) && selectedBlockIndex !== index" class="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0"></span>
471
+ <div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
472
+ <button @click.stop="editingLabelIndex = index" title="Renommer">✎</button>
473
+ <button @click.stop="moveBlock(index, 'up')">↑</button>
474
+ <button @click.stop="page.content.splice(index, 1)" class="text-red-400">✕</button>
475
+ </div>
476
+ </div>
279
477
  </div>
280
- </div>
281
- <button @click="showAddModal = true" class="w-full py-3 border-2 border-dashed border-gray-100 rounded-xl text-[9px] font-black text-gray-400 hover:border-blue-500 hover:text-blue-500 transition-all">
282
- + ADD SECTION
283
- </button>
478
+ <button @click="showAddModal = true" class="w-full py-3 border-2 border-dashed border-gray-100 rounded-xl text-[9px] font-black text-gray-400 hover:border-[#3d35ff] transition-all">+ SECTION</button>
479
+ </template>
284
480
  </div>
285
481
 
286
482
  <div v-else class="flex-1 overflow-y-auto">
287
- <h3 class="text-[9px] font-black text-blue-600 uppercase mb-6 tracking-widest">Heading Hierarchy</h3>
483
+ <h3 class="text-[9px] font-black text-[#3d35ff] uppercase mb-6 italic tracking-widest">Hiérarchie Hn</h3>
288
484
  <div v-for="(node, nIdx) in seoTree" :key="nIdx"
289
485
  class="mb-2 p-2 rounded border-l-2 bg-gray-50/50 cursor-pointer hover:bg-blue-50/50"
290
486
  :style="{ marginLeft: `${tagDepth(node.tag) * 14}px`, borderColor: tagColor(node.tag) }"
@@ -296,229 +492,405 @@ const savePage = async () => {
296
492
  </div>
297
493
  </aside>
298
494
 
299
- <!-- Preview: data-admin-preview opts this area out of the admin font
300
- so section components inherit the host project's own font stack -->
301
- <main data-admin-preview class="flex-1 overflow-y-auto p-4 md:p-10 bg-[#F5F5F7]">
302
- <div class="max-w-4xl mx-auto bg-white shadow-2xl rounded-[32px] overflow-hidden min-h-screen border border-black/5">
495
+ <!-- ── Preview ── -->
496
+ <main class="flex-1 overflow-y-auto p-4 md:p-10 bg-[#F5F5F7]">
497
+ <div class="max-w-4xl mx-auto bg-white shadow-2xl rounded-[40px] overflow-hidden min-h-screen border border-black/5">
303
498
  <div v-for="(block, index) in page.content" :key="index" @click="selectedBlockIndex = index"
304
- class="relative cursor-pointer" :class="{ 'ring-4 ring-blue-500 ring-inset z-10': selectedBlockIndex === index }">
499
+ class="relative cursor-pointer" :class="{ 'ring-4 ring-[#3d35ff] ring-inset z-10': selectedBlockIndex === index }">
305
500
  <component :is="resolveSection(block.type)" v-bind="resolvePropsForPreview(block.props)" />
306
501
  </div>
307
- <div v-if="!page.content.length" class="p-20 text-center text-gray-300 italic text-sm">
308
- Empty page — add a section to get started.
309
- </div>
310
502
  </div>
311
503
  </main>
312
504
 
313
- <!-- Right sidebar: field editor + SEO -->
314
- <aside :class="['bg-white border-l z-50 transition-all duration-300 overflow-hidden flex flex-col', isRightOpen ? 'w-80' : 'w-0']">
315
- <div class="p-6 w-80 h-full overflow-y-auto flex flex-col gap-6">
316
-
317
- <!-- Block field editor -->
318
- <div v-if="selectedBlockIndex !== null && selectedCMSConfig">
319
- <div class="flex items-center justify-between mb-4">
320
- <h2 class="text-[10px] font-black uppercase text-gray-400 tracking-widest">
321
- {{ selectedCMSConfig.label }}
322
- </h2>
323
- <!-- Lang switcher -->
324
- <div class="flex gap-1">
325
- <button v-for="lang in LANGS" :key="lang" @click="editingLang = lang"
326
- :class="['text-[8px] font-black px-2 py-1 rounded transition-all', editingLang === lang ? 'bg-black text-white' : 'text-gray-400 hover:text-black']">
327
- {{ lang === "ar" ? "\u0639" : lang.toUpperCase() }}
505
+ <!-- ── Right Panel: Props / SEO ── -->
506
+ <aside :class="['bg-white border-l z-50 transition-all duration-300 overflow-y-auto', isRightSidebarOpen ? 'w-80' : 'w-0']">
507
+ <div class="p-6 w-80">
508
+
509
+ <!-- Mode tabs + lang switcher -->
510
+ <div class="flex items-center justify-between mb-5 border-b pb-4">
511
+ <div class="flex gap-0.5 bg-gray-100 p-0.5 rounded-lg">
512
+ <button @click="rightPanelMode = 'block'"
513
+ :disabled="selectedBlockIndex === null || page?.content_locked"
514
+ :class="[
515
+ 'px-2.5 py-1 text-[7px] font-black uppercase rounded-md transition-all',
516
+ rightPanelMode === 'block' ? 'bg-white shadow text-[#3d35ff]' : 'text-gray-400',
517
+ selectedBlockIndex === null || page?.content_locked ? 'opacity-30 cursor-not-allowed' : ''
518
+ ]">
519
+ Bloc
520
+ </button>
521
+ <button @click="rightPanelMode = 'seo'"
522
+ :class="[
523
+ 'px-2.5 py-1 text-[7px] font-black uppercase rounded-md transition-all',
524
+ rightPanelMode === 'seo' ? 'bg-white shadow text-[#3d35ff]' : 'text-gray-400'
525
+ ]">
526
+ SEO
527
+ </button>
528
+ </div>
529
+ <div class="flex gap-0.5 bg-gray-100 p-0.5 rounded-lg">
530
+ <button v-for="lang in LANGS" :key="lang" @click="editingLang = lang"
531
+ :class="[
532
+ 'px-2 py-1 text-[7px] font-black uppercase rounded-md transition-all',
533
+ editingLang === lang ? 'bg-white shadow text-[#3d35ff]' : 'text-gray-400'
534
+ ]">
535
+ {{ langLabel(lang) }}
536
+ </button>
537
+ </div>
538
+ </div>
539
+
540
+ <!-- ── BLOCK CONFIG PANEL ── -->
541
+ <div v-if="rightPanelMode === 'block' && selectedBlockIndex !== null">
542
+
543
+ <!-- Diff -->
544
+ <div v-if="blockDiffs.has(selectedBlockIndex)" class="mb-5 rounded-2xl overflow-hidden border border-amber-200 bg-white">
545
+ <button @click="showDiff = !showDiff"
546
+ class="w-full flex items-center justify-between px-4 py-3 bg-amber-50 hover:bg-amber-100 transition-colors">
547
+ <span class="flex items-center gap-2 text-[10px] font-black text-amber-700 uppercase tracking-wider">
548
+ <span class="w-2 h-2 rounded-full bg-amber-400 shrink-0"></span>
549
+ {{ blockDiffs.get(selectedBlockIndex).length }} champ{{ blockDiffs.get(selectedBlockIndex).length > 1 ? "s" : "" }} modifié{{ blockDiffs.get(selectedBlockIndex).length > 1 ? "s" : "" }}
550
+ </span>
551
+ <svg :class="['w-3.5 h-3.5 text-amber-500 transition-transform', showDiff ? 'rotate-180' : '']" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7"/></svg>
552
+ </button>
553
+ <div v-if="showDiff" class="divide-y divide-gray-50">
554
+ <div v-for="change in blockDiffs.get(selectedBlockIndex)" :key="change.field" class="px-4 py-3">
555
+ <p class="text-[9px] font-black uppercase tracking-widest text-gray-400 mb-2">{{ change.field }}</p>
556
+ <div class="flex items-start gap-2 mb-1.5">
557
+ <span class="w-1 self-stretch rounded-full bg-red-300 shrink-0"></span>
558
+ <p class="text-[11px] text-red-500 line-through leading-snug break-words min-w-0">{{ change.old }}</p>
559
+ </div>
560
+ <div class="flex items-start gap-2">
561
+ <span class="w-1 self-stretch rounded-full bg-green-400 shrink-0"></span>
562
+ <p class="text-[11px] text-green-700 font-semibold leading-snug break-words min-w-0">{{ change.new }}</p>
563
+ </div>
564
+ </div>
565
+ </div>
566
+ </div>
567
+
568
+ <!-- AI Generate -->
569
+ <div class="mb-5">
570
+ <div v-if="!showAiPrompt && !isGenerating">
571
+ <button @click="showAiPrompt = true" class="w-full py-2.5 rounded-xl border-2 border-dashed border-[#3d35ff]/30 text-[#3d35ff] text-[8px] font-black uppercase tracking-widest hover:border-[#3d35ff] hover:bg-[#3d35ff]/5 transition-all">
572
+ ✦ Générer avec l'IA
328
573
  </button>
329
574
  </div>
575
+ <div v-else-if="isGenerating" class="py-2.5 rounded-xl bg-[#3d35ff]/5 border border-[#3d35ff]/20 text-center text-[8px] font-black uppercase tracking-widest text-[#3d35ff] animate-pulse">
576
+ Génération en cours...
577
+ </div>
578
+ <div v-else class="p-3 bg-[#3d35ff]/5 rounded-2xl border border-[#3d35ff]/20 space-y-2">
579
+ <label class="text-[8px] font-black uppercase text-[#3d35ff]/60 tracking-widest block">Décrivez votre contenu</label>
580
+ <textarea v-model="aiPromptText" rows="2" placeholder="Ex: service de création de sites web pour PME..." class="w-full bg-white rounded-xl p-2.5 text-[10px] outline-none ring-1 ring-[#3d35ff]/20 focus:ring-[#3d35ff] resize-none"></textarea>
581
+ <div class="flex gap-2">
582
+ <button @click="showAiPrompt = false;
583
+ aiPromptText = ''" class="flex-1 py-2 rounded-xl border border-gray-200 text-[8px] font-black uppercase text-gray-400 hover:border-gray-300 transition-all">Annuler</button>
584
+ <button @click="generateWithAI" :disabled="!aiPromptText.trim()" class="flex-1 py-2 rounded-xl bg-[#3d35ff] text-white text-[8px] font-black uppercase tracking-widest disabled:opacity-40 transition-all active:scale-95">✦ Générer</button>
585
+ </div>
586
+ </div>
587
+ </div>
588
+
589
+ <!-- Anchor ID -->
590
+ <div class="mb-6 pb-6 border-b border-gray-50">
591
+ <label class="text-[8px] font-black uppercase text-gray-400 mb-2 block tracking-widest">ID / Ancre</label>
592
+ <div class="flex items-center bg-gray-50 rounded-xl ring-1 ring-gray-100 focus-within:ring-[#3d35ff] overflow-hidden">
593
+ <span class="text-[9px] font-mono text-gray-300 pl-3 pr-1 shrink-0">#</span>
594
+ <input v-model="page.content[selectedBlockIndex].id" class="flex-1 bg-transparent p-3 text-[10px] font-mono outline-none" placeholder="mon-ancre" />
595
+ </div>
330
596
  </div>
331
597
 
332
598
  <!-- Group tabs -->
333
- <div v-if="selectedCMSConfig.groups" class="flex gap-1 mb-6 flex-wrap">
334
- <button v-for="group in selectedCMSConfig.groups" :key="group.key" @click="selectedFieldGroup = group.key"
599
+ <div v-if="selectedCMSConfig?.groups" class="flex gap-1 bg-gray-100 p-1 rounded-xl mb-6">
600
+ <button v-for="group in selectedCMSConfig.groups" :key="group.key"
601
+ @click="selectedFieldGroup = group.key"
335
602
  :class="[
336
- 'px-3 py-1.5 rounded-full text-[8px] font-black uppercase tracking-widest transition-all',
337
- selectedFieldGroup === group.key ? 'bg-black text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
603
+ 'flex-1 py-2 text-[8px] font-black uppercase tracking-widest rounded-lg transition-all',
604
+ selectedFieldGroup === group.key ? 'bg-white shadow text-[#3d35ff]' : 'text-gray-400 hover:text-gray-600'
338
605
  ]">
339
606
  {{ group.label }}
340
607
  </button>
341
608
  </div>
342
609
 
343
610
  <!-- Fields -->
344
- <div v-for="(field, name) in visibleFields" :key="String(name)" class="mb-5">
345
- <label class="text-[8px] font-black uppercase text-gray-400 block mb-2">{{ field.label }}</label>
346
-
347
- <!-- i18n text -->
348
- <template v-if="field.i18n">
349
- <input v-if="field.type === 'text'"
350
- :value="getBlockI18n(String(name), editingLang)"
351
- @input="setBlockI18n(String(name), editingLang, $event.target.value)"
352
- class="w-full border-b py-2 text-xs font-bold outline-none focus:border-black transition-colors"
353
- />
354
- <textarea v-else-if="field.type === 'textarea'"
355
- :value="getBlockI18n(String(name), editingLang)"
356
- @input="setBlockI18n(String(name), editingLang, $event.target.value)"
357
- class="w-full bg-gray-50 p-3 text-xs rounded-xl outline-none focus:ring-1 focus:ring-black resize-none" rows="3"
358
- />
359
- </template>
360
-
361
- <!-- Plain text -->
362
- <input v-else-if="field.type === 'text'"
363
- v-model="page.content[selectedBlockIndex].props[String(name)]"
364
- class="w-full border-b py-2 text-xs font-bold outline-none focus:border-black transition-colors"
365
- />
366
-
367
- <!-- Textarea -->
368
- <textarea v-else-if="field.type === 'textarea'"
369
- v-model="page.content[selectedBlockIndex].props[String(name)]"
370
- class="w-full bg-gray-50 p-3 text-xs rounded-xl outline-none focus:ring-1 focus:ring-black resize-none" rows="3"
371
- />
372
-
373
- <!-- Color -->
374
- <div v-else-if="field.type === 'color'" class="space-y-2">
375
- <input v-model="page.content[selectedBlockIndex].props[String(name)]" type="color" class="h-8 w-16 rounded cursor-pointer border border-gray-200" />
376
- <input v-model="page.content[selectedBlockIndex].props[String(name)]" class="w-full bg-gray-50 p-2 text-[9px] font-mono rounded-lg outline-none" placeholder="#ffffff" />
377
- </div>
611
+ <div v-for="(field, name) in visibleFields" :key="name" class="mb-6">
612
+ <label class="text-[8px] font-black uppercase text-gray-400 mb-2 block tracking-widest">{{ field.label }}</label>
378
613
 
379
614
  <!-- Image -->
380
- <div v-else-if="field.type === 'image'"
381
- @click="triggerUpload(page.content[selectedBlockIndex].props, String(name))"
382
- class="relative h-24 border-2 border-dashed rounded-xl flex items-center justify-center overflow-hidden cursor-pointer hover:border-blue-400 transition-colors">
383
- <img v-if="page.content[selectedBlockIndex].props[String(name)]" :src="page.content[selectedBlockIndex].props[String(name)]" class="w-full h-full object-cover" />
384
- <div v-else class="text-center">
385
- <div class="text-[8px] font-black text-gray-300 uppercase">Click to upload</div>
386
- <div v-if="isUploading" class="text-[8px] text-blue-500 mt-1">Uploading...</div>
615
+ <div v-if="field.type === 'image'" class="space-y-2">
616
+ <div v-if="page.content[selectedBlockIndex].props[name]" class="group relative h-28 rounded-xl overflow-hidden border">
617
+ <img :src="page.content[selectedBlockIndex].props[name]" class="w-full h-full object-cover" />
618
+ <button @click="page.content[selectedBlockIndex].props[name] = ''" class="absolute inset-0 bg-black/60 text-white text-[8px] font-black opacity-0 group-hover:opacity-100 transition-opacity">SUPPRIMER</button>
619
+ </div>
620
+ <div class="flex gap-2">
621
+ <button @click="triggerUpload(page.content[selectedBlockIndex].props, name)" class="flex-1 py-2.5 bg-gray-50 border-2 border-dashed rounded-xl text-[9px] font-black hover:bg-gray-100 transition-all" :disabled="isUploading">
622
+ {{ isUploading ? "ENVOI..." : "\u2191 UPLOAD" }}
623
+ </button>
624
+ <button @click="openGallery(page.content[selectedBlockIndex].props, name)" class="flex-1 py-2.5 bg-gray-50 border-2 border-dashed rounded-xl text-[9px] font-black hover:bg-gray-100 transition-all">
625
+ ⊞ GALERIE
626
+ </button>
387
627
  </div>
388
628
  </div>
389
629
 
390
- <!-- Boolean select -->
391
- <select v-else-if="field.type === 'boolean' || field.type === 'select' && field.options?.length"
392
- v-model="page.content[selectedBlockIndex].props[String(name)]"
393
- class="w-full bg-gray-50 p-2 text-xs rounded-lg outline-none focus:ring-1 focus:ring-black">
394
- <option v-for="opt in field.options" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</option>
630
+ <!-- Text i18n -->
631
+ <template v-else-if="field.type === 'text'">
632
+ <div v-if="field.i18n" class="relative">
633
+ <span class="absolute -top-1 right-0 text-[6px] font-black uppercase text-[#3d35ff]/40">{{ langLabel(editingLang) }}</span>
634
+ <input :value="getI18nProp(name, editingLang)" @input="setI18nProp(name, editingLang, $event.target.value)" :dir="editingLang === 'ar' ? 'rtl' : 'ltr'" class="w-full bg-gray-50 rounded-xl p-3 text-[10px] font-bold outline-none ring-1 ring-gray-100 focus:ring-[#3d35ff]" />
635
+ </div>
636
+ <input v-else v-model="page.content[selectedBlockIndex].props[name]" class="w-full bg-gray-50 rounded-xl p-3 text-[10px] font-bold outline-none ring-1 ring-gray-100 focus:ring-[#3d35ff]" />
637
+ </template>
638
+
639
+ <!-- Textarea i18n -->
640
+ <template v-else-if="field.type === 'textarea'">
641
+ <div v-if="field.i18n" class="relative">
642
+ <span class="absolute -top-1 right-0 text-[6px] font-black uppercase text-[#3d35ff]/40">{{ langLabel(editingLang) }}</span>
643
+ <textarea :value="getI18nProp(name, editingLang)" @input="setI18nProp(name, editingLang, $event.target.value)" :dir="editingLang === 'ar' ? 'rtl' : 'ltr'" rows="3" class="w-full bg-gray-50 rounded-xl p-3 text-[10px] outline-none ring-1 ring-gray-100 focus:ring-[#3d35ff]"></textarea>
644
+ </div>
645
+ <textarea v-else v-model="page.content[selectedBlockIndex].props[name]" rows="3" class="w-full bg-gray-50 rounded-xl p-3 text-[10px] outline-none ring-1 ring-gray-100"></textarea>
646
+ </template>
647
+
648
+ <!-- Select -->
649
+ <select v-else-if="field.type === 'select'" v-model="page.content[selectedBlockIndex].props[name]" class="w-full bg-gray-50 rounded-xl p-3 text-[10px] font-bold ring-1 ring-gray-100 outline-none">
650
+ <option v-for="opt in field.options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
395
651
  </select>
396
652
 
397
- <!-- Cards (button group) -->
398
- <div v-else-if="field.type === 'cards'" class="flex flex-wrap gap-1">
399
- <button v-for="opt in field.options" :key="String(opt.value)"
400
- @click="page.content[selectedBlockIndex].props[String(name)] = opt.value"
653
+ <!-- Cards -->
654
+ <div v-else-if="field.type === 'cards'" class="grid grid-cols-3 gap-2">
655
+ <button v-for="opt in field.options" :key="opt.value"
656
+ @click="page.content[selectedBlockIndex].props[name] = opt.value"
401
657
  :class="[
402
- 'flex flex-col items-center gap-1 p-2 rounded-lg border-2 transition-all text-[7px] font-black uppercase',
403
- page.content[selectedBlockIndex].props[String(name)] === opt.value ? 'border-black bg-black text-white' : 'border-gray-100 hover:border-gray-300'
658
+ 'p-2.5 rounded-xl border-2 text-center transition-all',
659
+ page.content[selectedBlockIndex].props[name] === opt.value ? 'border-[#3d35ff] bg-blue-50/50 text-[#3d35ff]' : 'border-gray-100 text-gray-400 hover:border-gray-200'
404
660
  ]">
405
- <span class="text-xs">{{ opt.icon }}</span>
406
- <span>{{ opt.label }}</span>
661
+ <span class="text-base block mb-1">{{ opt.icon }}</span>
662
+ <span class="text-[7px] font-black uppercase tracking-wide block leading-tight">{{ opt.label }}</span>
407
663
  </button>
408
664
  </div>
409
665
 
410
- <!-- List of items -->
411
- <div v-else-if="field.type === 'list'" class="space-y-3">
412
- <div v-for="(item, itemIdx) in page.content[selectedBlockIndex].props[String(name)]" :key="itemIdx"
413
- class="border border-gray-100 rounded-xl p-3 space-y-2">
414
- <div class="flex justify-between items-center mb-2">
415
- <span class="text-[7px] font-black uppercase text-gray-400">Item {{ itemIdx + 1 }}</span>
416
- <button @click="page.content[selectedBlockIndex].props[String(name)].splice(itemIdx, 1)" class="text-red-400 text-xs">✕</button>
417
- </div>
418
- <template v-for="(subField, subKey) in field.itemFields" :key="String(subKey)">
419
- <label class="text-[7px] font-black uppercase text-gray-400 block">{{ subField.label }}</label>
420
- <template v-if="subField.i18n">
421
- <input v-if="subField.type === 'text'"
422
- :value="getI18nVal(item[String(subKey)], editingLang)"
423
- @input="setI18nVal(item, String(subKey), editingLang, $event.target.value)"
424
- class="w-full border-b py-1 text-xs outline-none focus:border-black"
425
- />
426
- <textarea v-else-if="subField.type === 'textarea'"
427
- :value="getI18nVal(item[String(subKey)], editingLang)"
428
- @input="setI18nVal(item, String(subKey), editingLang, $event.target.value)"
429
- class="w-full bg-gray-50 p-2 text-xs rounded-lg outline-none resize-none" rows="2"
430
- />
666
+ <!-- Color -->
667
+ <div v-else-if="field.type === 'color'" class="flex gap-2 bg-gray-50 p-2 rounded-xl ring-1 ring-gray-100">
668
+ <button v-for="c in ['#3d35ff', '#ffffff', '#000000', '#F9F9FB']" :key="c" @click="page.content[selectedBlockIndex].props[name] = c" class="w-5 h-5 rounded-full border border-black/5" :style="{ background: c }"></button>
669
+ <input v-model="page.content[selectedBlockIndex].props[name]" class="flex-1 bg-transparent text-[9px] font-mono outline-none" />
670
+ </div>
671
+
672
+ <!-- List -->
673
+ <div v-else-if="field.type === 'list'" class="space-y-4">
674
+ <div v-for="(item, idx) in page.content[selectedBlockIndex].props[name] || []" :key="idx" class="p-4 bg-gray-50 rounded-2xl relative border">
675
+ <button @click="page.content[selectedBlockIndex].props[name].splice(idx, 1)" class="absolute top-2 right-2 text-red-400 text-[8px]">✕</button>
676
+ <div v-for="(subField, subName) in field.itemFields" :key="subName" class="mb-2">
677
+ <label class="text-[7px] font-black uppercase opacity-30">{{ subField.label }}</label>
678
+ <div v-if="subField.type === 'image'" class="mt-1 space-y-1.5">
679
+ <img v-if="item[subName]" :src="item[subName]" class="w-full h-16 rounded-lg border object-cover" />
680
+ <div class="flex gap-1.5">
681
+ <button @click="triggerUpload(item, subName)" class="flex-1 text-[8px] font-black uppercase bg-white px-2 py-1.5 rounded-lg shadow-sm border">↑ Upload</button>
682
+ <button @click="openGallery(item, subName)" class="flex-1 text-[8px] font-black uppercase bg-white px-2 py-1.5 rounded-lg shadow-sm border">⊞ Galerie</button>
683
+ </div>
684
+ </div>
685
+ <select v-else-if="subField.type === 'select'" v-model="item[subName]" class="w-full bg-white rounded-lg p-2 text-[10px] font-bold outline-none shadow-sm border">
686
+ <option v-for="opt in subField.options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
687
+ </select>
688
+ <template v-else-if="subField.i18n">
689
+ <input :value="getI18nVal(item[subName], editingLang)" @input="setI18nVal(item, subName, editingLang, $event.target.value)" :dir="editingLang === 'ar' ? 'rtl' : 'ltr'" :placeholder="langLabel(editingLang)" class="w-full bg-white rounded-lg p-2 text-[10px] font-bold outline-none shadow-sm" />
431
690
  </template>
432
- <input v-else-if="subField.type === 'text'" v-model="item[String(subKey)]" class="w-full border-b py-1 text-xs outline-none focus:border-black" />
433
- <textarea v-else-if="subField.type === 'textarea'" v-model="item[String(subKey)]" class="w-full bg-gray-50 p-2 text-xs rounded-lg outline-none resize-none" rows="2" />
434
- <div v-else-if="subField.type === 'image'"
435
- @click="triggerUpload(item, String(subKey))"
436
- class="h-16 border-2 border-dashed rounded-lg flex items-center justify-center overflow-hidden cursor-pointer hover:border-blue-400 transition-colors">
437
- <img v-if="item[String(subKey)]" :src="item[String(subKey)]" class="w-full h-full object-cover" />
438
- <span v-else class="text-[7px] font-black text-gray-300 uppercase">Upload</span>
691
+ <div v-else-if="subField.type === 'stringlist'" class="space-y-1 mt-1">
692
+ <div v-for="(feat, fi) in item[subName] || []" :key="fi" class="flex gap-1 items-center">
693
+ <input
694
+ :value="getI18nVal(feat, editingLang)"
695
+ @input="setI18nVal(item[subName], fi, editingLang, $event.target.value)"
696
+ :dir="editingLang === 'ar' ? 'rtl' : 'ltr'"
697
+ :placeholder="langLabel(editingLang)"
698
+ class="flex-1 bg-white rounded-lg p-2 text-[10px] font-bold outline-none shadow-sm" />
699
+ <button @click="item[subName].splice(fi, 1)" class="text-red-400 text-[8px] px-1 shrink-0">✕</button>
700
+ </div>
701
+ <button @click="if (!item[subName]) item[subName] = [];
702
+ item[subName].push({ fr: '', ar: '', en: '' })" class="w-full py-1.5 border border-dashed rounded-lg text-[8px] font-black text-gray-400 hover:border-gray-400 transition">+ Fonctionnalité</button>
439
703
  </div>
440
- </template>
704
+ <input v-else v-model="item[subName]" class="w-full bg-white rounded-lg p-2 text-[10px] font-bold outline-none shadow-sm" />
705
+ </div>
441
706
  </div>
442
- <button
443
- @click="page.content[selectedBlockIndex].props[String(name)].push(
444
- Object.fromEntries(Object.keys(field.itemFields || {}).map((k) => [k, field.itemFields?.[k]?.i18n ? { fr: '', ar: '', en: '' } : '']))
445
- )"
446
- class="w-full py-2 border-2 border-dashed border-gray-100 rounded-xl text-[8px] font-black text-gray-400 hover:border-blue-500 transition-all">
447
- + Add item
448
- </button>
707
+ <button @click="if (!page.content[selectedBlockIndex].props[name]) page.content[selectedBlockIndex].props[name] = [];
708
+ page.content[selectedBlockIndex].props[name].push({})" class="w-full py-2 border-2 border-dashed rounded-xl text-[8px] font-black text-gray-400">+ AJOUTER</button>
449
709
  </div>
450
710
  </div>
451
711
  </div>
452
712
 
453
- <!-- SEO panel -->
454
- <div class="border-t pt-6">
455
- <h2 class="text-[10px] font-black uppercase text-gray-400 tracking-widest mb-4">SEO</h2>
456
- <div class="space-y-4">
457
- <!-- Page meta -->
458
- <div>
459
- <label class="text-[8px] font-black uppercase text-gray-400 block mb-1">Page Title</label>
460
- <input v-model="page.title" class="w-full border-b py-2 text-xs font-bold outline-none focus:border-black" />
713
+ <!-- ── SEO CONFIG PANEL ── -->
714
+ <div v-else-if="rightPanelMode === 'seo'">
715
+ <h3 class="text-[9px] font-black text-gray-300 uppercase mb-4 italic tracking-widest">Config SEO</h3>
716
+
717
+ <!-- AI Audit -->
718
+ <div class="mb-6">
719
+ <button @click="runSeoAudit" :disabled="isAuditing"
720
+ class="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest bg-[#3d35ff]/10 text-[#3d35ff] hover:bg-[#3d35ff] hover:text-white transition-all disabled:opacity-50">
721
+ <svg v-if="isAuditing" class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/></svg>
722
+ <svg v-else class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
723
+ {{ isAuditing ? "Analyse..." : "Audit IA" }}
724
+ </button>
725
+ </div>
726
+
727
+ <!-- Audit results -->
728
+ <div v-if="seoAudit" class="mb-6 space-y-3">
729
+ <div class="flex items-center gap-3 p-3 bg-gray-50 rounded-xl">
730
+ <div :class="[
731
+ 'w-11 h-11 rounded-xl flex items-center justify-center text-sm font-black shrink-0',
732
+ seoAudit.score >= 75 ? 'bg-green-100 text-green-700' : seoAudit.score >= 50 ? 'bg-amber-100 text-amber-700' : 'bg-red-100 text-red-600'
733
+ ]">
734
+ {{ seoAudit.score }}
735
+ </div>
736
+ <div>
737
+ <p class="text-[9px] font-black uppercase tracking-widest text-gray-400">Score SEO</p>
738
+ <p :class="['text-[11px] font-black', seoAudit.score >= 75 ? 'text-green-600' : seoAudit.score >= 50 ? 'text-amber-600' : 'text-red-500']">
739
+ {{ seoAudit.score >= 75 ? "Bon" : seoAudit.score >= 50 ? "\xC0 am\xE9liorer" : "Insuffisant" }}
740
+ </p>
741
+ </div>
742
+ <button @click="applySeoAudit" class="ml-auto px-3 py-1.5 bg-[#3d35ff] text-white rounded-lg text-[9px] font-black uppercase tracking-wide hover:bg-[#2f28cc] transition shrink-0">Appliquer</button>
461
743
  </div>
462
- <div>
463
- <label class="text-[8px] font-black uppercase text-gray-400 block mb-1">Slug</label>
464
- <input v-model="page.slug" class="w-full border-b py-2 text-xs font-mono outline-none focus:border-black" />
744
+ <div v-if="seoAudit.issues?.length" class="space-y-1.5">
745
+ <p class="text-[8px] font-black uppercase tracking-widest text-gray-400">Problèmes</p>
746
+ <div v-for="(issue, i) in seoAudit.issues" :key="i"
747
+ :class="[
748
+ 'flex items-start gap-2 p-2.5 rounded-lg text-[10px]',
749
+ issue.level === 'error' ? 'bg-red-50 text-red-700' : issue.level === 'warning' ? 'bg-amber-50 text-amber-700' : 'bg-blue-50 text-blue-700'
750
+ ]">
751
+ <span class="shrink-0 mt-0.5 font-black">{{ issue.level === "error" ? "\u2715" : issue.level === "warning" ? "!" : "i" }}</span>
752
+ {{ issue.message }}
753
+ </div>
465
754
  </div>
466
- <div>
467
- <label class="text-[8px] font-black uppercase text-gray-400 block mb-1">Page Type</label>
468
- <select v-model="page.type" class="w-full bg-gray-50 p-2 text-xs rounded-lg outline-none">
469
- <option v-for="opt in pageTypeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
470
- </select>
755
+ <div v-if="seoAudit.suggestions?.length" class="space-y-1.5">
756
+ <p class="text-[8px] font-black uppercase tracking-widest text-gray-400">Recommandations</p>
757
+ <div v-for="(tip, i) in seoAudit.suggestions" :key="i" class="flex items-start gap-2 p-2.5 bg-[#3d35ff]/5 rounded-lg text-[10px] text-[#3d35ff]">
758
+ <span class="shrink-0 font-black mt-0.5">→</span>{{ tip }}
759
+ </div>
471
760
  </div>
472
-
473
- <!-- i18n SEO fields -->
474
- <div class="flex gap-1 mt-4 mb-2">
475
- <button v-for="lang in LANGS" :key="lang" @click="editingLang = lang"
476
- :class="['text-[8px] font-black px-2 py-1 rounded transition-all', editingLang === lang ? 'bg-black text-white' : 'text-gray-400 hover:text-black']">
477
- {{ lang === "ar" ? "\u0639" : lang.toUpperCase() }}
478
- </button>
761
+ <div v-if="seoAudit.keywords?.length">
762
+ <p class="text-[8px] font-black uppercase tracking-widest text-gray-400 mb-1.5">Mots-clés suggérés</p>
763
+ <div class="flex flex-wrap gap-1">
764
+ <span v-for="kw in seoAudit.keywords" :key="kw" class="px-2 py-1 bg-gray-100 text-gray-600 rounded-lg text-[9px] font-semibold">{{ kw }}</span>
765
+ </div>
479
766
  </div>
480
- <div>
481
- <label class="text-[8px] font-black uppercase text-gray-400 block mb-1">Meta Title</label>
482
- <input
483
- :value="getSeoI18n('meta_title', editingLang)"
484
- @input="setSeoI18n('meta_title', editingLang, $event.target.value)"
485
- class="w-full border-b py-2 text-xs outline-none focus:border-black"
486
- />
767
+ <div class="border-t border-gray-100 pt-3">
768
+ <p class="text-[8px] font-black uppercase tracking-widest text-gray-400 mb-1">Meta title suggéré</p>
769
+ <p class="text-[10px] text-gray-700 bg-gray-50 rounded-lg p-2">{{ seoAudit.meta_title?.fr }}</p>
770
+ <p class="text-[8px] font-black uppercase tracking-widest text-gray-400 mb-1 mt-2">Meta description suggérée</p>
771
+ <p class="text-[10px] text-gray-500 bg-gray-50 rounded-lg p-2">{{ seoAudit.meta_description?.fr }}</p>
487
772
  </div>
488
- <div>
489
- <label class="text-[8px] font-black uppercase text-gray-400 block mb-1">Meta Description</label>
490
- <textarea
491
- :value="getSeoI18n('meta_description', editingLang)"
492
- @input="setSeoI18n('meta_description', editingLang, $event.target.value)"
493
- class="w-full bg-gray-50 p-2 text-xs rounded-lg outline-none resize-none" rows="3"
494
- />
773
+ </div>
774
+
775
+ <div class="border-t border-gray-100 pt-4 mb-5"></div>
776
+
777
+ <div class="mb-5">
778
+ <label class="text-[8px] font-black uppercase text-gray-400 mb-2 block tracking-widest">Type de page</label>
779
+ <select v-model="page.type" class="w-full bg-gray-50 rounded-xl p-3 text-[10px] font-bold outline-none ring-1 ring-gray-100 focus:ring-[#3d35ff]">
780
+ <option v-for="opt in pageTypeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
781
+ </select>
782
+ </div>
783
+
784
+ <div class="mb-5">
785
+ <label class="text-[8px] font-black uppercase text-gray-400 mb-2 block tracking-widest">Titre de la page</label>
786
+ <input v-model="page.title" class="w-full bg-gray-50 rounded-xl p-3 text-[10px] font-bold outline-none ring-1 ring-gray-100 focus:ring-[#3d35ff]" placeholder="Nom interne de la page" />
787
+ </div>
788
+
789
+ <div class="mb-5">
790
+ <label class="text-[8px] font-black uppercase text-gray-400 mb-2 block tracking-widest">URL (Slug)</label>
791
+ <div class="flex items-center bg-gray-50 rounded-xl ring-1 ring-gray-100 focus-within:ring-[#3d35ff] overflow-hidden">
792
+ <span class="text-[9px] font-mono text-gray-300 pl-3 pr-1 shrink-0">/</span>
793
+ <input v-model="page.slug" class="flex-1 bg-transparent p-3 text-[10px] font-mono outline-none" placeholder="mon-url" />
495
794
  </div>
496
- <div>
497
- <label class="text-[8px] font-black uppercase text-gray-400 block mb-1">OG Image URL</label>
498
- <input v-model="page.seo_config.og_image" class="w-full border-b py-2 text-xs font-mono outline-none focus:border-black" />
795
+ </div>
796
+
797
+ <div class="mb-5">
798
+ <label class="text-[8px] font-black uppercase text-gray-400 mb-2 block tracking-widest">Meta Title</label>
799
+ <div class="relative">
800
+ <span class="absolute -top-1 right-0 text-[6px] font-black uppercase text-[#3d35ff]/40">{{ langLabel(editingLang) }}</span>
801
+ <input :value="getSeoI18n('meta_title', editingLang)" @input="setSeoI18n('meta_title', editingLang, $event.target.value)" :dir="editingLang === 'ar' ? 'rtl' : 'ltr'" placeholder="Title de la page..." class="w-full bg-gray-50 rounded-xl p-3 text-[10px] font-bold outline-none ring-1 ring-gray-100 focus:ring-[#3d35ff]" />
802
+ </div>
803
+ </div>
804
+
805
+ <div class="mb-5">
806
+ <label class="text-[8px] font-black uppercase text-gray-400 mb-2 block tracking-widest">Meta Description</label>
807
+ <div class="relative">
808
+ <span class="absolute -top-1 right-0 text-[6px] font-black uppercase text-[#3d35ff]/40">{{ langLabel(editingLang) }}</span>
809
+ <textarea :value="getSeoI18n('meta_description', editingLang)" @input="setSeoI18n('meta_description', editingLang, $event.target.value)" :dir="editingLang === 'ar' ? 'rtl' : 'ltr'" rows="4" placeholder="Description courte de la page..." class="w-full bg-gray-50 rounded-xl p-3 text-[10px] outline-none ring-1 ring-gray-100 focus:ring-[#3d35ff]"></textarea>
499
810
  </div>
500
811
  </div>
812
+
813
+ <div class="mb-5">
814
+ <label class="text-[8px] font-black uppercase text-gray-400 mb-2 block tracking-widest">OG Title <span class="text-gray-300 normal-case font-medium">(défaut: meta title)</span></label>
815
+ <input :value="page?.seo_config?.og_title ?? ''" @input="updateSeoField('og_title', $event.target.value)" class="w-full bg-gray-50 rounded-xl p-3 text-[10px] font-bold outline-none ring-1 ring-gray-100 focus:ring-[#3d35ff]" />
816
+ </div>
817
+
818
+ <div class="mb-5">
819
+ <label class="text-[8px] font-black uppercase text-gray-400 mb-2 block tracking-widest">OG Description</label>
820
+ <textarea :value="page?.seo_config?.og_description ?? ''" @input="updateSeoField('og_description', $event.target.value)" rows="3" class="w-full bg-gray-50 rounded-xl p-3 text-[10px] outline-none ring-1 ring-gray-100 focus:ring-[#3d35ff]"></textarea>
821
+ </div>
822
+
823
+ <div class="mb-5">
824
+ <label class="text-[8px] font-black uppercase text-gray-400 mb-2 block tracking-widest">OG Image (URL)</label>
825
+ <input :value="page?.seo_config?.og_image ?? ''" @input="updateSeoField('og_image', $event.target.value)" placeholder="https://..." class="w-full bg-gray-50 rounded-xl p-3 text-[10px] font-bold outline-none ring-1 ring-gray-100 focus:ring-[#3d35ff]" />
826
+ </div>
827
+
828
+ <div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl ring-1 ring-gray-100">
829
+ <label class="text-[8px] font-black uppercase text-gray-400 tracking-widest">No Index</label>
830
+ <button @click="updateSeoField('no_index', !page?.seo_config?.no_index)"
831
+ :class="['w-9 h-5 rounded-full transition-colors relative', page?.seo_config?.no_index ? 'bg-[#3d35ff]' : 'bg-gray-200']">
832
+ <span :class="['absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform', page?.seo_config?.no_index ? 'translate-x-4' : 'translate-x-0.5']"></span>
833
+ </button>
834
+ </div>
501
835
  </div>
836
+
837
+ <!-- Empty state -->
838
+ <div v-else class="text-center py-12 text-gray-300">
839
+ <p class="text-[9px] font-black uppercase tracking-widest">Sélectionnez un bloc</p>
840
+ <p class="text-[8px] mt-2 opacity-60">ou passez en mode SEO →</p>
841
+ </div>
842
+
502
843
  </div>
503
844
  </aside>
504
845
  </div>
505
846
 
506
- <!-- Add section modal -->
507
- <div v-if="showAddModal" class="fixed inset-0 bg-black/90 backdrop-blur-xl z-[100] flex items-center justify-center p-6" @click.self="showAddModal = false">
508
- <div class="bg-white max-w-xl w-full rounded-[32px] p-10 shadow-2xl">
509
- <h3 class="text-[10px] font-black uppercase tracking-widest mb-6 text-gray-400">Add Section</h3>
510
- <div class="grid grid-cols-3 gap-4">
511
- <button v-for="(conf, type) in CMS_COMPONENTS" :key="String(type)" @click="addSection(String(type))"
512
- class="flex flex-col items-center gap-2 p-5 border-2 border-gray-100 rounded-[24px] hover:border-black transition-all group">
513
- <span class="text-2xl">{{ conf.icon || "\u{1F4E6}" }}</span>
514
- <span class="text-[7px] font-black uppercase tracking-widest text-gray-600 group-hover:text-black text-center">{{ conf.label }}</span>
847
+ <!-- ── Gallery Modal ── -->
848
+ <div v-if="showGallery" class="fixed inset-0 bg-black/90 backdrop-blur-xl z-[110] flex items-center justify-center p-6" @click.self="showGallery = false">
849
+ <div class="bg-white max-w-4xl w-full rounded-[32px] flex flex-col overflow-hidden shadow-2xl" style="max-height:85vh" @click.stop>
850
+ <div class="flex items-center gap-4 px-8 py-5 border-b shrink-0">
851
+ <h2 class="text-[11px] font-black uppercase tracking-[0.4em] text-[#3d35ff] shrink-0">Galerie</h2>
852
+ <div class="flex-1 relative">
853
+ <svg class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-300 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M21 21l-4.35-4.35M17 11A6 6 0 115 11a6 6 0 0112 0z"/></svg>
854
+ <input v-model="gallerySearch" type="text" placeholder="Rechercher une image..." class="w-full bg-gray-50 rounded-xl pl-9 pr-4 py-2 text-[10px] font-bold outline-none ring-1 ring-gray-100 focus:ring-[#3d35ff]" />
855
+ </div>
856
+ <button @click="showGallery = false" class="text-gray-400 hover:text-black transition-colors text-lg leading-none shrink-0">✕</button>
857
+ </div>
858
+ <div class="flex-1 overflow-y-auto p-6">
859
+ <div v-if="isLoadingGallery" class="flex items-center justify-center py-20 text-gray-300 text-[9px] font-black uppercase tracking-widest">Chargement...</div>
860
+ <div v-else-if="filteredGallery.length === 0" class="flex items-center justify-center py-20 text-gray-300 text-[9px] font-black uppercase tracking-widest">Aucune image trouvée</div>
861
+ <div v-else class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
862
+ <button v-for="img in filteredGallery" :key="img.url" @click="selectGalleryImage(img.url)"
863
+ class="group relative aspect-square rounded-xl overflow-hidden border-2 border-transparent hover:border-[#3d35ff] transition-all focus:outline-none">
864
+ <img :src="img.url" :alt="img.name" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" />
865
+ <div class="absolute bottom-0 inset-x-0 bg-black/60 px-2 py-1 translate-y-full group-hover:translate-y-0 transition-transform duration-200">
866
+ <p class="text-white text-[7px] font-bold truncate">{{ img.name }}</p>
867
+ </div>
868
+ </button>
869
+ </div>
870
+ </div>
871
+ </div>
872
+ </div>
873
+
874
+ <!-- ── Add Section Modal ── -->
875
+ <div v-if="showAddModal" class="fixed inset-0 bg-black/95 backdrop-blur-xl z-[100] flex items-center justify-center p-6" @click.self="showAddModal = false">
876
+ <div class="bg-white max-w-4xl w-full rounded-[40px] p-12 shadow-2xl overflow-y-auto max-h-[85vh]">
877
+ <h2 class="text-[12px] font-black uppercase text-center mb-12 tracking-[0.4em] italic text-[#3d35ff]">Nouvelle Section</h2>
878
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-8">
879
+ <button v-for="(conf, type) in CMS_COMPONENTS" :key="type"
880
+ @click="page.content.push({ type, props: JSON.parse(JSON.stringify(conf.defaultProps)) });
881
+ showAddModal = false;
882
+ selectedBlockIndex = page.content.length - 1"
883
+ class="flex flex-col items-center gap-6 p-8 border-2 border-gray-50 rounded-[40px] hover:border-[#3d35ff] transition-all group active:scale-95">
884
+ <span class="text-6xl group-hover:scale-110 transition-transform">{{ conf.icon }}</span>
885
+ <span class="text-[10px] font-black uppercase tracking-widest text-gray-900">{{ conf.label }}</span>
515
886
  </button>
516
887
  </div>
517
888
  </div>
518
889
  </div>
519
- </div>
520
890
 
521
- <div v-else class="h-screen flex items-center justify-center text-gray-400">
522
- Loading...
523
891
  </div>
524
892
  </template>
893
+
894
+ <style scoped>
895
+ ::-webkit-scrollbar{width:3px}::-webkit-scrollbar-thumb{background:#3d35ff;border-radius:10px}
896
+ </style>