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.
@@ -1,108 +1,180 @@
1
1
  <script setup>
2
- import { ref, computed, useRoute, useRuntimeConfig, useAsyncData, useHead, navigateTo, useSupabaseClient } from "#imports";
2
+ import { ref, computed, watch, useRoute, useRuntimeConfig, useAsyncData, useHead, navigateTo, useSupabaseClient } from "#imports";
3
3
  const route = useRoute();
4
4
  const supabase = useSupabaseClient();
5
5
  const config = useRuntimeConfig().public.adminCms;
6
6
  const pageType = route.params.type;
7
7
  const table = config?.tables?.pages || "pages";
8
- const typeLabel = computed(() => {
9
- return config?.pageTypes?.find((t) => t.id === pageType)?.label || pageType.replace(/_/g, " ");
10
- });
8
+ const typeLabel = computed(
9
+ () => config?.pageTypes?.find((t) => t.id === pageType)?.label || pageType.replace(/_/g, " ")
10
+ );
11
11
  const { data: pages, refresh } = await useAsyncData(`admin-pages-${pageType}`, async () => {
12
12
  const { data } = await supabase.from(table).select("id, title, slug, status, updated_at, seo_config").eq("type", pageType).order("updated_at", { ascending: false });
13
13
  return data;
14
14
  });
15
+ const toast = ref(null);
16
+ let toastTimer;
17
+ const showToast = (msg, type = "success") => {
18
+ clearTimeout(toastTimer);
19
+ toast.value = { msg, type };
20
+ toastTimer = setTimeout(() => {
21
+ toast.value = null;
22
+ }, 3500);
23
+ };
15
24
  const isCreating = ref(false);
16
25
  const newPageTitle = ref("");
26
+ const createMode = ref("blank");
27
+ const templates = ref([]);
28
+ const selectedTemplateId = ref("");
29
+ watch(isCreating, async (val) => {
30
+ if (val) {
31
+ const { data } = await supabase.from("pages_templates").select("id, name").order("created_at", { ascending: false });
32
+ templates.value = data || [];
33
+ } else {
34
+ createMode.value = "blank";
35
+ selectedTemplateId.value = "";
36
+ newPageTitle.value = "";
37
+ }
38
+ });
17
39
  const handleCreate = async () => {
18
- if (!newPageTitle.value.trim()) return;
40
+ if (!newPageTitle.value) return;
19
41
  const slug = newPageTitle.value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
20
- const { data, error } = await supabase.from(table).insert([{ title: newPageTitle.value, slug, type: pageType, status: "draft", content: [] }]).select();
42
+ let content = [];
43
+ if (createMode.value === "template" && selectedTemplateId.value) {
44
+ const { data: tpl } = await supabase.from("pages_templates").select("content").eq("id", selectedTemplateId.value).single();
45
+ if (tpl) content = JSON.parse(JSON.stringify(tpl.content));
46
+ }
47
+ const { data, error } = await supabase.from(table).insert([{ title: newPageTitle.value, slug, type: pageType, status: "draft", content }]).select();
21
48
  if (!error && data) {
22
49
  navigateTo(`/admin/editor/${data[0].id}`);
23
50
  } else {
24
- alert(error?.message || "Error: slug may already be in use.");
51
+ isCreating.value = false;
52
+ showToast("Ce slug est d\xE9j\xE0 utilis\xE9. Changez le titre.", "error");
25
53
  }
26
54
  };
27
- const handleDelete = async (id, title) => {
28
- if (!confirm(`Delete "${title}"? This cannot be undone.`)) return;
29
- const { error } = await supabase.from(table).delete().eq("id", id);
30
- if (error) alert("Error: " + error.message);
31
- else await refresh();
55
+ const deleteModal = ref(null);
56
+ const isDeleting = ref(false);
57
+ const confirmDelete = (id, title) => {
58
+ deleteModal.value = { id, title };
59
+ };
60
+ const handleDelete = async () => {
61
+ if (!deleteModal.value) return;
62
+ isDeleting.value = true;
63
+ const { error } = await supabase.from(table).delete().eq("id", deleteModal.value.id);
64
+ isDeleting.value = false;
65
+ if (error) {
66
+ showToast("Erreur lors de la suppression.", "error");
67
+ } else {
68
+ showToast("Page supprim\xE9e.");
69
+ deleteModal.value = null;
70
+ await refresh();
71
+ }
72
+ };
73
+ const templateModal = ref(null);
74
+ const templateName = ref("");
75
+ const isSavingTemplate = ref(false);
76
+ const openTemplateModal = (page) => {
77
+ templateModal.value = page;
78
+ templateName.value = page.title;
79
+ };
80
+ const handleTurnIntoTemplate = async () => {
81
+ if (!templateModal.value || !templateName.value.trim()) return;
82
+ isSavingTemplate.value = true;
83
+ const { data: fullPage } = await supabase.from(table).select("content").eq("id", templateModal.value.id).single();
84
+ const { error } = await supabase.from("pages_templates").insert({
85
+ name: templateName.value,
86
+ content: fullPage?.content ?? [],
87
+ source_page_id: templateModal.value.id
88
+ });
89
+ isSavingTemplate.value = false;
90
+ if (error) {
91
+ showToast("Erreur : " + error.message, "error");
92
+ } else {
93
+ showToast(`Template "${templateName.value}" enregistr\xE9.`);
94
+ templateModal.value = null;
95
+ templateName.value = "";
96
+ }
32
97
  };
33
98
  useHead({ title: computed(() => `${typeLabel.value} \u2014 Admin`) });
34
99
  </script>
35
100
 
36
101
  <template>
37
- <div data-admin-cms>
38
- <AdminNavbar />
39
- <div class="p-8">
40
- <div class="mb-8">
41
- <NuxtLink to="/admin" class="text-sm text-gray-500 hover:text-blue-600">← Back to dashboard</NuxtLink>
42
- <div class="flex justify-between items-end mt-4">
43
- <div>
44
- <h1 class="text-3xl font-bold text-gray-900">{{ typeLabel }}</h1>
45
- <p class="text-gray-500 mt-1">Manage your content and internal linking.</p>
102
+ <AdminDashboardLayout>
103
+ <div class="p-8 max-w-5xl mx-auto">
104
+
105
+ <!-- Header -->
106
+ <div class="mb-8 flex items-center justify-between">
107
+ <div>
108
+ <div class="flex items-center gap-2 text-[11px] text-gray-400 mb-2">
109
+ <NuxtLink to="/admin" class="hover:text-gray-600 transition-colors">Tableau de bord</NuxtLink>
110
+ <span>/</span>
111
+ <span class="text-gray-600 font-medium">{{ typeLabel }}</span>
46
112
  </div>
47
- <button
48
- @click="isCreating = true"
49
- class="bg-black text-white px-6 py-2.5 rounded-lg font-medium hover:bg-gray-800 transition shadow-lg"
50
- >
51
- + New page
52
- </button>
113
+ <h1 class="text-2xl font-black text-gray-900">{{ typeLabel }}</h1>
114
+ <p class="text-sm text-gray-400 mt-1">{{ pages?.length ?? 0 }} page{{ (pages?.length ?? 0) > 1 ? "s" : "" }}</p>
53
115
  </div>
116
+ <button @click="isCreating = true"
117
+ class="bg-[#3d35ff] text-white px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-[#2f28cc] transition shadow-lg shadow-[#3d35ff]/20 active:scale-95">
118
+ + Nouvelle page
119
+ </button>
54
120
  </div>
55
121
 
56
- <div class="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
57
- <div v-if="!pages?.length" class="p-20 text-center">
58
- <p class="text-gray-400">No pages created yet in this category.</p>
122
+ <!-- Table -->
123
+ <div class="bg-white border border-gray-100 rounded-2xl shadow-sm overflow-hidden">
124
+ <div v-if="!pages?.length" class="py-24 text-center">
125
+ <svg class="w-10 h-10 mx-auto text-gray-200 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
126
+ <p class="text-sm font-semibold text-gray-500">Aucune page dans cette catégorie</p>
127
+ <p class="text-xs text-gray-400 mt-1">Créez votre première page pour commencer</p>
128
+ <button @click="isCreating = true" class="mt-5 px-5 py-2 bg-[#3d35ff] text-white rounded-xl text-xs font-bold hover:bg-[#2f28cc] transition">
129
+ + Créer
130
+ </button>
59
131
  </div>
60
132
 
61
133
  <table v-else class="w-full text-left">
62
- <thead class="bg-gray-50 border-b border-gray-200">
63
- <tr>
64
- <th class="px-6 py-4 text-xs font-bold text-gray-400 uppercase">Title & URL</th>
65
- <th class="px-6 py-4 text-xs font-bold text-gray-400 uppercase">SEO</th>
66
- <th class="px-6 py-4 text-xs font-bold text-gray-400 uppercase">Status</th>
67
- <th class="px-6 py-4 text-xs font-bold text-gray-400 uppercase text-right">Actions</th>
134
+ <thead>
135
+ <tr class="border-b border-gray-100">
136
+ <th class="px-6 py-3.5 text-[10px] font-black text-gray-400 uppercase tracking-widest">Page</th>
137
+ <th class="px-6 py-3.5 text-[10px] font-black text-gray-400 uppercase tracking-widest">SEO</th>
138
+ <th class="px-6 py-3.5 text-[10px] font-black text-gray-400 uppercase tracking-widest">Statut</th>
139
+ <th class="px-6 py-3.5 text-[10px] font-black text-gray-400 uppercase tracking-widest text-right">Actions</th>
68
140
  </tr>
69
141
  </thead>
70
- <tbody class="divide-y divide-gray-100">
71
- <tr v-for="page in pages" :key="page.id" class="hover:bg-gray-50 group transition-colors">
142
+ <tbody class="divide-y divide-gray-50">
143
+ <tr v-for="page in pages" :key="page.id" class="group hover:bg-gray-50/60 transition-colors">
72
144
  <td class="px-6 py-4">
73
- <div class="font-semibold text-gray-800">{{ page.title }}</div>
74
- <div class="text-xs text-blue-500 font-mono italic">/{{ page.slug }}</div>
145
+ <div class="font-bold text-gray-900 text-sm">{{ page.title }}</div>
146
+ <div class="text-[11px] text-[#3d35ff] font-mono mt-0.5 opacity-70">/{{ page.slug }}</div>
75
147
  </td>
76
148
  <td class="px-6 py-4">
77
- <span v-if="page.seo_config?.meta_title" class="text-green-600 text-xs flex items-center gap-1.5">
78
- <span class="w-2 h-2 bg-green-500 rounded-full"></span> Optimized
149
+ <span v-if="page.seo_config?.meta_title" class="inline-flex items-center gap-1.5 text-[11px] font-semibold text-green-600">
150
+ <span class="w-1.5 h-1.5 bg-green-500 rounded-full"></span> Optimisé
79
151
  </span>
80
- <span v-else class="text-amber-500 text-xs flex items-center gap-1.5">
81
- <span class="w-2 h-2 bg-amber-500 rounded-full"></span> Missing meta title
152
+ <span v-else class="inline-flex items-center gap-1.5 text-[11px] font-semibold text-amber-500">
153
+ <span class="w-1.5 h-1.5 bg-amber-400 rounded-full"></span> Incomplet
82
154
  </span>
83
155
  </td>
84
156
  <td class="px-6 py-4">
85
157
  <span :class="[
86
- 'px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wider',
87
- page.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
88
- ]">{{ page.status }}</span>
158
+ 'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider',
159
+ page.status === 'published' ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'
160
+ ]">
161
+ <span :class="['w-1.5 h-1.5 rounded-full', page.status === 'published' ? 'bg-green-500' : 'bg-gray-400']"></span>
162
+ {{ page.status === "published" ? "Publi\xE9" : "Brouillon" }}
163
+ </span>
89
164
  </td>
90
- <td class="px-6 py-4 text-right">
91
- <div class="flex justify-end items-center gap-2">
92
- <NuxtLink
93
- :to="`/admin/editor/${page.id}`"
94
- class="bg-gray-100 text-gray-700 px-4 py-2 rounded-md text-sm font-medium hover:bg-black hover:text-white transition"
95
- >
96
- Edit
165
+ <td class="px-6 py-4">
166
+ <div class="flex items-center justify-end gap-1">
167
+ <NuxtLink :to="`/admin/editor/${page.id}`"
168
+ class="px-3 py-1.5 rounded-lg text-[11px] font-bold bg-gray-100 text-gray-600 hover:bg-[#3d35ff] hover:text-white transition-all">
169
+ Éditer
97
170
  </NuxtLink>
98
- <button
99
- @click="handleDelete(page.id, page.title)"
100
- class="p-2 text-gray-400 hover:text-red-600 transition-colors"
101
- title="Delete page"
102
- >
103
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
104
- <path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
105
- </svg>
171
+ <button @click="openTemplateModal(page)"
172
+ class="p-1.5 rounded-lg text-gray-300 hover:text-[#3d35ff] hover:bg-[#3d35ff]/5 transition-all" title="Sauvegarder comme template">
173
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
174
+ </button>
175
+ <button @click="confirmDelete(page.id, page.title)"
176
+ class="p-1.5 rounded-lg text-gray-300 hover:text-red-500 hover:bg-red-50 transition-all" title="Supprimer">
177
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
106
178
  </button>
107
179
  </div>
108
180
  </td>
@@ -111,28 +183,140 @@ useHead({ title: computed(() => `${typeLabel.value} \u2014 Admin`) });
111
183
  </table>
112
184
  </div>
113
185
 
114
- <!-- Create modal -->
115
- <div v-if="isCreating" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
116
- <div class="bg-white p-8 rounded-2xl max-w-md w-full shadow-2xl">
117
- <h3 class="text-xl font-bold mb-4">New {{ typeLabel }}</h3>
118
- <input
119
- v-model="newPageTitle"
120
- type="text"
121
- placeholder="e.g. My Premium SEO Service"
122
- class="w-full border border-gray-300 p-3 rounded-lg mb-6 focus:ring-2 focus:ring-black outline-none transition"
123
- @keyup.enter="handleCreate"
124
- />
125
- <div class="flex gap-3">
126
- <button @click="isCreating = false;
127
- newPageTitle = ''" class="flex-1 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition">
128
- Cancel
129
- </button>
130
- <button @click="handleCreate" class="flex-1 px-4 py-2 bg-black text-white rounded-lg hover:bg-gray-800 transition">
131
- Create
132
- </button>
186
+ <!-- ── Create modal ───────────────────────────────────── -->
187
+ <Teleport to="body">
188
+ <div v-if="isCreating" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
189
+ <div class="bg-white rounded-2xl max-w-md w-full shadow-2xl overflow-hidden">
190
+ <div class="px-7 pt-7 pb-5 border-b border-gray-100 flex items-start justify-between">
191
+ <div>
192
+ <h3 class="text-lg font-black text-gray-900">Nouvelle page</h3>
193
+ <p class="text-xs text-gray-400 mt-0.5">{{ typeLabel }}</p>
194
+ </div>
195
+ <button @click="isCreating = false" class="text-gray-300 hover:text-gray-500 transition-colors mt-0.5">
196
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
197
+ </button>
198
+ </div>
199
+
200
+ <div class="px-7 py-6 space-y-5">
201
+ <div>
202
+ <label class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-2 block">Titre</label>
203
+ <input v-model="newPageTitle" type="text" placeholder="Ex: Création site web"
204
+ class="w-full border border-gray-200 p-3 rounded-xl text-sm focus:ring-2 focus:ring-[#3d35ff] outline-none transition" autofocus />
205
+ </div>
206
+ <div>
207
+ <label class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-2 block">Départ</label>
208
+ <div class="grid grid-cols-2 gap-2">
209
+ <button @click="createMode = 'blank'"
210
+ :class="[
211
+ 'py-3 rounded-xl border-2 text-xs font-black uppercase tracking-wide transition',
212
+ createMode === 'blank' ? 'border-[#3d35ff] bg-[#3d35ff]/5 text-[#3d35ff]' : 'border-gray-100 text-gray-400 hover:border-gray-300'
213
+ ]">
214
+ Page vierge
215
+ </button>
216
+ <button @click="createMode = 'template'"
217
+ :class="[
218
+ 'py-3 rounded-xl border-2 text-xs font-black uppercase tracking-wide transition',
219
+ createMode === 'template' ? 'border-[#3d35ff] bg-[#3d35ff]/5 text-[#3d35ff]' : 'border-gray-100 text-gray-400 hover:border-gray-300'
220
+ ]">
221
+ Template
222
+ </button>
223
+ </div>
224
+ </div>
225
+ <div v-if="createMode === 'template'">
226
+ <label class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-2 block">Template</label>
227
+ <select v-model="selectedTemplateId" class="w-full border border-gray-200 p-3 rounded-xl text-sm outline-none focus:ring-2 focus:ring-[#3d35ff] transition bg-white">
228
+ <option value="">— Choisir —</option>
229
+ <option v-for="tpl in templates" :key="tpl.id" :value="tpl.id">{{ tpl.name }}</option>
230
+ </select>
231
+ <p v-if="!templates.length" class="text-[11px] text-gray-400 mt-1.5">Aucun template disponible.</p>
232
+ </div>
233
+ </div>
234
+
235
+ <div class="px-7 pb-7 flex gap-3">
236
+ <button @click="isCreating = false" class="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl text-sm font-semibold hover:bg-gray-50 transition">
237
+ Annuler
238
+ </button>
239
+ <button @click="handleCreate"
240
+ :disabled="!newPageTitle || createMode === 'template' && !selectedTemplateId"
241
+ class="flex-1 px-4 py-2.5 bg-[#3d35ff] text-white rounded-xl text-sm font-bold hover:bg-[#2f28cc] transition disabled:opacity-40 disabled:cursor-not-allowed shadow-lg shadow-[#3d35ff]/20">
242
+ Créer →
243
+ </button>
244
+ </div>
133
245
  </div>
134
246
  </div>
135
- </div>
247
+ </Teleport>
248
+
249
+ <!-- ── Delete confirmation modal ─────────────────────── -->
250
+ <Teleport to="body">
251
+ <div v-if="deleteModal" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
252
+ <div class="bg-white rounded-2xl max-w-sm w-full shadow-2xl overflow-hidden">
253
+ <div class="p-7">
254
+ <div class="w-11 h-11 bg-red-50 rounded-xl flex items-center justify-center mb-4">
255
+ <svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
256
+ </div>
257
+ <h3 class="text-base font-black text-gray-900 mb-1">Supprimer cette page ?</h3>
258
+ <p class="text-sm text-gray-500">
259
+ <span class="font-semibold text-gray-700">« {{ deleteModal.title }} »</span> sera définitivement supprimée. Cette action est irréversible.
260
+ </p>
261
+ </div>
262
+ <div class="px-7 pb-7 flex gap-3">
263
+ <button @click="deleteModal = null" class="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl text-sm font-semibold hover:bg-gray-50 transition">
264
+ Annuler
265
+ </button>
266
+ <button @click="handleDelete" :disabled="isDeleting"
267
+ class="flex-1 px-4 py-2.5 bg-red-500 text-white rounded-xl text-sm font-bold hover:bg-red-600 transition disabled:opacity-50">
268
+ {{ isDeleting ? "Suppression..." : "Supprimer" }}
269
+ </button>
270
+ </div>
271
+ </div>
272
+ </div>
273
+ </Teleport>
274
+
275
+ <!-- ── Save as template modal ─────────────────────────── -->
276
+ <Teleport to="body">
277
+ <div v-if="templateModal" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
278
+ <div class="bg-white rounded-2xl max-w-sm w-full shadow-2xl overflow-hidden">
279
+ <div class="px-7 pt-7 pb-5 border-b border-gray-100 flex items-start justify-between">
280
+ <div>
281
+ <h3 class="text-base font-black text-gray-900">Sauvegarder comme template</h3>
282
+ <p class="text-xs text-gray-400 mt-0.5">Ce template sera disponible lors de la création de pages.</p>
283
+ </div>
284
+ <button @click="templateModal = null" class="text-gray-300 hover:text-gray-500 transition-colors mt-0.5">
285
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
286
+ </button>
287
+ </div>
288
+ <div class="px-7 py-6">
289
+ <label class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-2 block">Nom du template</label>
290
+ <input v-model="templateName" type="text" placeholder="Ex: Landing page service"
291
+ class="w-full border border-gray-200 p-3 rounded-xl text-sm focus:ring-2 focus:ring-[#3d35ff] outline-none transition" autofocus />
292
+ </div>
293
+ <div class="px-7 pb-7 flex gap-3">
294
+ <button @click="templateModal = null" class="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl text-sm font-semibold hover:bg-gray-50 transition">
295
+ Annuler
296
+ </button>
297
+ <button @click="handleTurnIntoTemplate" :disabled="!templateName.trim() || isSavingTemplate"
298
+ class="flex-1 px-4 py-2.5 bg-[#3d35ff] text-white rounded-xl text-sm font-bold hover:bg-[#2f28cc] transition disabled:opacity-40 shadow-lg shadow-[#3d35ff]/20">
299
+ {{ isSavingTemplate ? "Enregistrement..." : "Enregistrer" }}
300
+ </button>
301
+ </div>
302
+ </div>
303
+ </div>
304
+ </Teleport>
305
+
306
+ <!-- ── Toast ──────────────────────────────────────────── -->
307
+ <Teleport to="body">
308
+ <Transition enter-from-class="opacity-0 translate-y-2" leave-to-class="opacity-0 translate-y-2" enter-active-class="transition duration-200" leave-active-class="transition duration-200">
309
+ <div v-if="toast" :class="[
310
+ 'fixed bottom-6 right-6 z-[100] flex items-center gap-3 px-4 py-3 rounded-xl shadow-xl text-sm font-semibold',
311
+ toast.type === 'success' ? 'bg-gray-900 text-white' : 'bg-red-500 text-white'
312
+ ]">
313
+ <svg v-if="toast.type === 'success'" class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/></svg>
314
+ <svg v-else class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
315
+ {{ toast.msg }}
316
+ </div>
317
+ </Transition>
318
+ </Teleport>
319
+
136
320
  </div>
137
- </div>
321
+ </AdminDashboardLayout>
138
322
  </template>
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,102 @@
1
+ const isI18nField = (val) => val !== null && typeof val === "object" && !Array.isArray(val) && ("fr" in val || "ar" in val || "en" in val);
2
+ export default defineEventHandler(async (event) => {
3
+ const body = await readBody(event);
4
+ const { sectionType, sectionLabel, fields, currentProps, prompt, itemFieldsConfig } = body;
5
+ const config = useRuntimeConfig();
6
+ if (!config.openrouterApiKey) {
7
+ throw createError({ statusCode: 500, message: "OPENROUTER_API_KEY not configured" });
8
+ }
9
+ const skeleton = {};
10
+ for (const f of fields) {
11
+ skeleton[f.name] = { fr: "...", ar: "...", en: "..." };
12
+ }
13
+ const currentItems = currentProps?.items || [];
14
+ const configuredItemFields = itemFieldsConfig || [];
15
+ let hasItems = false;
16
+ if (configuredItemFields.length > 0) {
17
+ hasItems = true;
18
+ const defaultCount = currentItems.length > 0 ? currentItems.length : 3;
19
+ skeleton.items = Array.from({ length: defaultCount }, (_, idx) => {
20
+ const existing = currentItems[idx] || {};
21
+ const obj = {};
22
+ for (const f of configuredItemFields) {
23
+ if (f.type === "stringlist") {
24
+ const existingFeatures = existing[f.name] || [];
25
+ const featureCount = existingFeatures.length > 0 ? existingFeatures.length : 3;
26
+ obj[f.name] = Array.from({ length: featureCount }, () => ({ fr: "...", ar: "...", en: "..." }));
27
+ } else if (f.i18n) {
28
+ obj[f.name] = { fr: "...", ar: "...", en: "..." };
29
+ } else {
30
+ obj[f.name] = existing[f.name] || "...";
31
+ }
32
+ }
33
+ return obj;
34
+ });
35
+ } else if (currentItems.length > 0) {
36
+ const firstItem = currentItems[0];
37
+ const i18nFields = Object.keys(firstItem).filter((k) => isI18nField(firstItem[k]));
38
+ if (i18nFields.length > 0) {
39
+ hasItems = true;
40
+ skeleton.items = currentItems.map((item) => {
41
+ const obj = {};
42
+ for (const f of i18nFields) obj[f] = { fr: "...", ar: "...", en: "..." };
43
+ return obj;
44
+ });
45
+ }
46
+ }
47
+ const contextSummary = [
48
+ ...fields.map((f) => `${f.name}: "${currentProps?.[f.name]?.fr || ""}"`),
49
+ ...currentItems.length > 0 ? currentItems.map((it, i) => {
50
+ const firstField = configuredItemFields[0]?.name || Object.keys(it)[0];
51
+ const val = it[firstField];
52
+ return `items[${i}]: "${typeof val === "object" ? val?.fr : val || ""}"`;
53
+ }) : []
54
+ ].join("\n");
55
+ const systemPrompt = `You are a professional multilingual copywriter for a web agency CMS.
56
+ Always respond with a valid JSON object only \u2014 no markdown, no code blocks, no explanation.`;
57
+ const userPrompt = `Section: ${sectionLabel} (${sectionType})
58
+ User instruction: ${prompt}
59
+
60
+ Current content:
61
+ ${contextSummary}
62
+
63
+ Fill this JSON skeleton (replace every "..." with real content):
64
+ ${JSON.stringify(skeleton, null, 2)}
65
+
66
+ Rules:
67
+ - Replace ALL "..." \u2014 never leave "..." in the response
68
+ - Arabic must be real Arabic script (right-to-left)
69
+ - Titles: max 8 words
70
+ - Subtitles/descriptions: max 2 sentences
71
+ - Badges: max 3 words
72
+ - items array: return exactly ${skeleton.items?.length ?? 0} items
73
+ - i18n fields in items use {"fr":"...","ar":"...","en":"..."} format
74
+ - plain text fields in items use a plain string value
75
+ - stringlist fields are arrays of {"fr":"...","ar":"...","en":"..."} objects
76
+ - Tone: professional, modern, confident`;
77
+ const response = await $fetch("https://openrouter.ai/api/v1/chat/completions", {
78
+ method: "POST",
79
+ headers: {
80
+ "Authorization": `Bearer ${config.openrouterApiKey}`,
81
+ "Content-Type": "application/json",
82
+ "HTTP-Referer": "https://admin-cms.dev",
83
+ "X-Title": "Admin CMS"
84
+ },
85
+ body: {
86
+ model: "google/gemini-2.0-flash-lite-001",
87
+ messages: [
88
+ { role: "system", content: systemPrompt },
89
+ { role: "user", content: userPrompt }
90
+ ],
91
+ temperature: 0.7
92
+ }
93
+ });
94
+ const content = response.choices?.[0]?.message?.content;
95
+ if (!content) throw createError({ statusCode: 500, message: "Empty AI response" });
96
+ const cleaned = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim();
97
+ try {
98
+ return JSON.parse(cleaned);
99
+ } catch {
100
+ throw createError({ statusCode: 500, message: "Invalid JSON from AI: " + cleaned.slice(0, 200) });
101
+ }
102
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,86 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const body = await readBody(event);
3
+ const { pageTitle, pageType, content, seoConfig } = body;
4
+ const config = useRuntimeConfig();
5
+ if (!config.openrouterApiKey) {
6
+ throw createError({ statusCode: 500, message: "OPENROUTER_API_KEY not configured" });
7
+ }
8
+ const contentSummary = content.map((block) => {
9
+ const parts = [`[${block.type}]`];
10
+ const p = block.props || {};
11
+ const t = (v) => typeof v === "object" ? v?.fr || "" : v || "";
12
+ if (p.title) parts.push(`title: "${t(p.title)}"`);
13
+ if (p.subtitle) parts.push(`subtitle: "${t(p.subtitle)}"`);
14
+ if (p.description) parts.push(`description: "${t(p.description)}"`);
15
+ if (p.badge) parts.push(`badge: "${t(p.badge)}"`);
16
+ if (Array.isArray(p.items)) {
17
+ p.items.forEach((it, j) => {
18
+ const itText = t(it.title || it.question || it.name);
19
+ if (itText) parts.push(`item${j}: "${itText}"`);
20
+ });
21
+ }
22
+ return parts.join(" | ");
23
+ }).join("\n");
24
+ const currentMeta = {
25
+ title: typeof seoConfig?.meta_title === "object" ? seoConfig.meta_title?.fr : seoConfig?.meta_title || "",
26
+ description: typeof seoConfig?.meta_description === "object" ? seoConfig.meta_description?.fr : seoConfig?.meta_description || ""
27
+ };
28
+ const systemPrompt = `You are an expert SEO consultant.
29
+ Respond with a valid JSON object only \u2014 no markdown, no code blocks, no extra text.`;
30
+ const userPrompt = `Audit this web page for SEO and provide actionable recommendations.
31
+
32
+ Page title: "${pageTitle}"
33
+ Page type: ${pageType}
34
+ Current meta title: "${currentMeta.title}"
35
+ Current meta description: "${currentMeta.description}"
36
+
37
+ Page content:
38
+ ${contentSummary}
39
+
40
+ Return this exact JSON structure:
41
+ {
42
+ "score": <number 0-100>,
43
+ "meta_title": { "fr": "...", "ar": "...", "en": "..." },
44
+ "meta_description": { "fr": "...", "ar": "...", "en": "..." },
45
+ "keywords": ["keyword1", "keyword2", "keyword3", "keyword4", "keyword5"],
46
+ "issues": [
47
+ { "level": "error"|"warning"|"info", "message": "..." }
48
+ ],
49
+ "suggestions": ["actionable tip 1", "actionable tip 2", "actionable tip 3"]
50
+ }
51
+
52
+ Rules:
53
+ - score: honest SEO score based on content quality, meta tags, keyword usage, structure
54
+ - meta_title: optimized title 50-60 chars, include main keyword
55
+ - meta_description: compelling 150-160 chars description with CTA
56
+ - keywords: most relevant keywords for this page
57
+ - issues: identify real problems (missing meta, duplicate H1, thin content, etc.)
58
+ - suggestions: max 4 specific, actionable tips
59
+ - Arabic meta must be real Arabic script
60
+ - All text must reflect the actual page content`;
61
+ const response = await $fetch("https://openrouter.ai/api/v1/chat/completions", {
62
+ method: "POST",
63
+ headers: {
64
+ "Authorization": `Bearer ${config.openrouterApiKey}`,
65
+ "Content-Type": "application/json",
66
+ "HTTP-Referer": "https://admin-cms.dev",
67
+ "X-Title": "Admin CMS"
68
+ },
69
+ body: {
70
+ model: "google/gemini-2.0-flash-lite-001",
71
+ messages: [
72
+ { role: "system", content: systemPrompt },
73
+ { role: "user", content: userPrompt }
74
+ ],
75
+ temperature: 0.4
76
+ }
77
+ });
78
+ const aiContent = response.choices?.[0]?.message?.content;
79
+ if (!aiContent) throw createError({ statusCode: 500, message: "Empty AI response" });
80
+ const cleaned = aiContent.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim();
81
+ try {
82
+ return JSON.parse(cleaned);
83
+ } catch {
84
+ throw createError({ statusCode: 500, message: "Invalid JSON from AI" });
85
+ }
86
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cinqcinqdev-seo",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "A reusable Nuxt 3 admin CMS module with visual page editor powered by Supabase",
5
5
  "license": "MIT",
6
6
  "module": "./dist/module.mjs",