cinqcinqdev-seo 0.1.11 → 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.
- package/dist/module.json +1 -1
- package/dist/module.mjs +4 -1
- package/dist/runtime/assets/admin-tw.css +1 -1
- package/dist/runtime/pages/admin/editor/[id].vue +683 -311
- package/dist/runtime/server/api/ai/generate.post.d.ts +2 -0
- package/dist/runtime/server/api/ai/generate.post.js +102 -0
- package/dist/runtime/server/api/ai/seo-audit.post.d.ts +2 -0
- package/dist/runtime/server/api/ai/seo-audit.post.js +86 -0
- package/package.json +1 -1
|
@@ -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) ||
|
|
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
|
|
19
|
-
const
|
|
20
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (typeof current === "
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
83
|
-
if (!
|
|
84
|
-
if (!
|
|
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(
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
124
|
-
const
|
|
125
|
-
const
|
|
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(
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
168
|
-
if (typeof current === "string")
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
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("
|
|
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"
|
|
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
|
|
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
|
|
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="
|
|
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="
|
|
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-
|
|
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
|
|
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-
|
|
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
|
-
|
|
419
|
+
Voir la page
|
|
236
420
|
</NuxtLink>
|
|
237
|
-
<button @click="togglePublished"
|
|
238
|
-
|
|
239
|
-
|
|
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" ? "
|
|
427
|
+
{{ page?.status === "published" ? "Publi\xE9" : "Brouillon" }}
|
|
243
428
|
</button>
|
|
244
|
-
<button @click="savePage" class="bg-
|
|
245
|
-
{{ isSaving ? "
|
|
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
|
|
253
|
-
<aside :class="['bg-white border-r z-50 transition-all duration-300 overflow-hidden flex flex-col',
|
|
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">
|
|
257
|
-
<div v-
|
|
258
|
-
|
|
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-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
<
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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-
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
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-
|
|
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
|
|
314
|
-
<aside :class="['bg-white border-l z-50 transition-all duration-300 overflow-
|
|
315
|
-
<div class="p-6 w-80
|
|
316
|
-
|
|
317
|
-
<!--
|
|
318
|
-
<div
|
|
319
|
-
<div class="flex
|
|
320
|
-
<
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
334
|
-
<button v-for="group in selectedCMSConfig.groups" :key="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
|
-
'
|
|
337
|
-
selectedFieldGroup === group.key ? 'bg-
|
|
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="
|
|
345
|
-
<label class="text-[8px] font-black uppercase text-gray-400 block
|
|
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-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
<
|
|
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
|
-
<!--
|
|
391
|
-
<
|
|
392
|
-
v-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
398
|
-
<div v-else-if="field.type === 'cards'" class="
|
|
399
|
-
<button v-for="opt in field.options" :key="
|
|
400
|
-
@click="page.content[selectedBlockIndex].props[
|
|
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
|
-
'
|
|
403
|
-
page.content[selectedBlockIndex].props[
|
|
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-
|
|
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
|
-
<!--
|
|
411
|
-
<div v-else-if="field.type === '
|
|
412
|
-
<
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
<
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
454
|
-
<div
|
|
455
|
-
<
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
<
|
|
464
|
-
<
|
|
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
|
-
<
|
|
468
|
-
<
|
|
469
|
-
<
|
|
470
|
-
</
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
<
|
|
482
|
-
<
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
<!--
|
|
507
|
-
<div v-if="
|
|
508
|
-
<div class="bg-white max-w-
|
|
509
|
-
<
|
|
510
|
-
|
|
511
|
-
<
|
|
512
|
-
class="
|
|
513
|
-
<
|
|
514
|
-
|
|
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>
|