cinqcinqdev-seo 0.1.15 → 0.1.17
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/README.md +554 -113
- package/dist/module.d.mts +6 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +14 -6
- package/dist/runtime/assets/admin-tw.css +1 -1
- package/dist/runtime/components/admin/Navbar.vue +6 -5
- package/dist/runtime/components/admin/Sidebar.vue +21 -8
- package/dist/runtime/components/sections/ContactForm.vue +14 -10
- package/dist/runtime/components/sections/FAQ.vue +10 -6
- package/dist/runtime/components/sections/Features.vue +10 -5
- package/dist/runtime/components/sections/HeroSection.vue +12 -8
- package/dist/runtime/components/sections/Pricing.vue +8 -5
- package/dist/runtime/components/sections/Process.vue +9 -4
- package/dist/runtime/components/sections/ServiceGrid.vue +10 -5
- package/dist/runtime/components/sections/Testimonials.vue +12 -7
- package/dist/runtime/components/sections/TextVisual.vue +8 -3
- package/dist/runtime/composables/useAdminBasePath.d.ts +1 -0
- package/dist/runtime/composables/useAdminBasePath.js +5 -0
- package/dist/runtime/composables/useAdminSections.d.ts +0 -7
- package/dist/runtime/composables/useAdminSections.js +68 -17
- package/dist/runtime/composables/useAdminSettings.d.ts +31 -0
- package/dist/runtime/composables/useAdminSettings.js +31 -0
- package/dist/runtime/middleware/admin-auth.js +3 -2
- package/dist/runtime/pages/admin/editor/[id].vue +10 -5
- package/dist/runtime/pages/admin/index.vue +4 -2
- package/dist/runtime/pages/admin/pages/[type].vue +5 -3
- package/dist/runtime/pages/admin/personas/[id].vue +3 -2
- package/dist/runtime/pages/admin/personas/index.vue +3 -2
- package/dist/runtime/pages/admin/setup.d.vue.ts +3 -0
- package/dist/runtime/pages/admin/setup.vue +220 -0
- package/dist/runtime/pages/admin/setup.vue.d.ts +3 -0
- package/dist/runtime/pages/admin/strategies/[id].vue +3 -2
- package/dist/runtime/pages/admin/strategies/index.vue +3 -2
- package/dist/runtime/server/api/ai/generate.post.js +12 -6
- package/dist/runtime/server/api/ai/seo-audit.post.js +12 -6
- package/package.json +4 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, computed, watch, definePageMeta, useRoute, useRuntimeConfig, useAsyncData, useHead, useSupabaseClient, useNuxtApp } from "#imports";
|
|
2
|
+
import { ref, computed, watch, onMounted, definePageMeta, useRoute, useRuntimeConfig, useAsyncData, useHead, useSupabaseClient, useNuxtApp } from "#imports";
|
|
3
3
|
import { useAdminSections } from "../../../composables/useAdminSections";
|
|
4
|
+
import { useAdminSettings, useAdminBasePath } from "#imports";
|
|
4
5
|
definePageMeta({ layout: false });
|
|
5
6
|
const route = useRoute();
|
|
6
7
|
const supabase = useSupabaseClient();
|
|
@@ -10,6 +11,9 @@ const bucket = config?.storageBucket || "site";
|
|
|
10
11
|
const pageId = route.params.id;
|
|
11
12
|
const CMS_COMPONENTS = useAdminSections();
|
|
12
13
|
const { vueApp } = useNuxtApp();
|
|
14
|
+
const adminSettings = useAdminSettings();
|
|
15
|
+
const basePath = useAdminBasePath();
|
|
16
|
+
onMounted(() => adminSettings.load());
|
|
13
17
|
const resolveSection = (type) => vueApp.component(`Sections${type}`) || vueApp.component(type) || null;
|
|
14
18
|
const { data: page } = await useAsyncData(`admin-edit-${pageId}`, async () => {
|
|
15
19
|
const { data } = await supabase.from(table).select("*").eq("id", pageId).maybeSingle();
|
|
@@ -180,7 +184,8 @@ const generateWithAI = async () => {
|
|
|
180
184
|
fields,
|
|
181
185
|
currentProps,
|
|
182
186
|
prompt: aiPromptText.value,
|
|
183
|
-
itemFieldsConfig
|
|
187
|
+
itemFieldsConfig,
|
|
188
|
+
aiContext: adminSettings.settings.value?.ai_context || ""
|
|
184
189
|
}
|
|
185
190
|
});
|
|
186
191
|
const props = page.value.content[selectedBlockIndex.value].props;
|
|
@@ -229,7 +234,7 @@ const runSeoAudit = async () => {
|
|
|
229
234
|
try {
|
|
230
235
|
const result = await $fetch("/api/admin-cms/ai/seo-audit", {
|
|
231
236
|
method: "POST",
|
|
232
|
-
body: { pageTitle: page.value.title, pageType: page.value.type, content: page.value.content, seoConfig: page.value.seo_config }
|
|
237
|
+
body: { pageTitle: page.value.title, pageType: page.value.type, content: page.value.content, seoConfig: page.value.seo_config, aiContext: adminSettings.settings.value?.ai_context || "" }
|
|
233
238
|
});
|
|
234
239
|
seoAudit.value = result;
|
|
235
240
|
} catch (err) {
|
|
@@ -397,7 +402,7 @@ const savePage = async () => {
|
|
|
397
402
|
<!-- ── Top Bar ── -->
|
|
398
403
|
<header class="h-12 bg-white/80 backdrop-blur-md border-b px-6 flex items-center justify-between z-[60] shrink-0">
|
|
399
404
|
<div class="flex items-center gap-4">
|
|
400
|
-
<NuxtLink to="
|
|
405
|
+
<NuxtLink :to="basePath" class="text-gray-400 hover:text-black transition-colors">←</NuxtLink>
|
|
401
406
|
<div class="flex gap-1 bg-gray-100 p-1 rounded-md">
|
|
402
407
|
<button @click="isLeftSidebarOpen = !isLeftSidebarOpen" :class="['p-1.5 rounded transition-all', isLeftSidebarOpen ? 'bg-white shadow-sm text-[#3d35ff]' : 'text-gray-400']" title="Structure">
|
|
403
408
|
<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>
|
|
@@ -877,7 +882,7 @@ const savePage = async () => {
|
|
|
877
882
|
<h2 class="text-[12px] font-black uppercase text-center mb-12 tracking-[0.4em] italic text-[#3d35ff]">Nouvelle Section</h2>
|
|
878
883
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
|
|
879
884
|
<button v-for="(conf, type) in CMS_COMPONENTS" :key="type"
|
|
880
|
-
@click="page.content.push({ type, props: JSON.parse(JSON.stringify(conf.defaultProps)) });
|
|
885
|
+
@click="page.content.push({ type, props: JSON.parse(JSON.stringify(adminSettings.applyPresets(type, conf.defaultProps))) });
|
|
881
886
|
showAddModal = false;
|
|
882
887
|
selectedBlockIndex = page.content.length - 1"
|
|
883
888
|
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">
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, useHead, useRuntimeConfig, useSupabaseClient } from "#imports";
|
|
2
|
+
import { computed, useHead, useRuntimeConfig, useSupabaseClient, useAsyncData } from "#imports";
|
|
3
|
+
import { useAdminBasePath } from "#imports";
|
|
3
4
|
useHead({ title: "Tableau de bord \u2014 Admin" });
|
|
4
5
|
const config = useRuntimeConfig().public.adminCms;
|
|
5
6
|
const supabase = useSupabaseClient();
|
|
6
7
|
const tables = computed(() => config?.tables || { pages: "pages" });
|
|
8
|
+
const basePath = useAdminBasePath();
|
|
7
9
|
const pageTypes = computed(() => {
|
|
8
10
|
const types = config?.pageTypes || [];
|
|
9
11
|
return types.map((pt) => ({
|
|
@@ -74,7 +76,7 @@ const { data: stats } = await useAsyncData("admin-dashboard-stats", async () =>
|
|
|
74
76
|
<NuxtLink
|
|
75
77
|
v-for="type in pageTypes"
|
|
76
78
|
:key="type.id"
|
|
77
|
-
:to="
|
|
79
|
+
:to="`${basePath}/pages/${type.id}`"
|
|
78
80
|
class="group bg-white rounded-2xl p-6 border border-gray-100 shadow-sm hover:border-[#3d35ff]/30 hover:shadow-md transition-all"
|
|
79
81
|
>
|
|
80
82
|
<div class="flex items-start justify-between mb-4">
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { ref, computed, watch, useRoute, useRuntimeConfig, useAsyncData, useHead, navigateTo, useSupabaseClient } from "#imports";
|
|
3
|
+
import { useAdminBasePath } from "#imports";
|
|
3
4
|
const route = useRoute();
|
|
4
5
|
const supabase = useSupabaseClient();
|
|
5
6
|
const config = useRuntimeConfig().public.adminCms;
|
|
7
|
+
const basePath = useAdminBasePath();
|
|
6
8
|
const pageType = route.params.type;
|
|
7
9
|
const table = config?.tables?.pages || "pages";
|
|
8
10
|
const typeLabel = computed(
|
|
@@ -46,7 +48,7 @@ const handleCreate = async () => {
|
|
|
46
48
|
}
|
|
47
49
|
const { data, error } = await supabase.from(table).insert([{ title: newPageTitle.value, slug, type: pageType, status: "draft", content }]).select();
|
|
48
50
|
if (!error && data) {
|
|
49
|
-
navigateTo(
|
|
51
|
+
navigateTo(`${basePath}/editor/${data[0].id}`);
|
|
50
52
|
} else {
|
|
51
53
|
isCreating.value = false;
|
|
52
54
|
showToast("Ce slug est d\xE9j\xE0 utilis\xE9. Changez le titre.", "error");
|
|
@@ -106,7 +108,7 @@ useHead({ title: computed(() => `${typeLabel.value} \u2014 Admin`) });
|
|
|
106
108
|
<div class="mb-8 flex items-center justify-between">
|
|
107
109
|
<div>
|
|
108
110
|
<div class="flex items-center gap-2 text-[11px] text-gray-400 mb-2">
|
|
109
|
-
<NuxtLink to="
|
|
111
|
+
<NuxtLink :to="basePath" class="hover:text-gray-600 transition-colors">Tableau de bord</NuxtLink>
|
|
110
112
|
<span>/</span>
|
|
111
113
|
<span class="text-gray-600 font-medium">{{ typeLabel }}</span>
|
|
112
114
|
</div>
|
|
@@ -164,7 +166,7 @@ useHead({ title: computed(() => `${typeLabel.value} \u2014 Admin`) });
|
|
|
164
166
|
</td>
|
|
165
167
|
<td class="px-6 py-4">
|
|
166
168
|
<div class="flex items-center justify-end gap-1">
|
|
167
|
-
<NuxtLink :to="
|
|
169
|
+
<NuxtLink :to="`${basePath}/editor/${page.id}`"
|
|
168
170
|
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
171
|
Éditer
|
|
170
172
|
</NuxtLink>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { ref, onMounted, useHead, useRoute, navigateTo } from "#imports";
|
|
3
|
-
import { useAdminPersonas, useAdminContent } from "#imports";
|
|
3
|
+
import { useAdminPersonas, useAdminContent, useAdminBasePath } from "#imports";
|
|
4
4
|
const route = useRoute();
|
|
5
5
|
const id = route.params.id;
|
|
6
|
+
const basePath = useAdminBasePath();
|
|
6
7
|
useHead({ title: "Persona - Admin" });
|
|
7
8
|
const { fetchPersonaById, updatePersona } = useAdminPersonas();
|
|
8
9
|
const { getContentByPersona } = useAdminContent();
|
|
@@ -47,7 +48,7 @@ const statusClass = {
|
|
|
47
48
|
<template>
|
|
48
49
|
<AdminDashboardLayout>
|
|
49
50
|
<div class="max-w-5xl mx-auto px-6 md:px-10 py-10">
|
|
50
|
-
<button @click="navigateTo(
|
|
51
|
+
<button @click="navigateTo(`${basePath}/personas`)" class="flex items-center gap-2 text-slate-500 hover:text-blue-600 font-semibold transition-all mb-8 group">
|
|
51
52
|
<Icon name="mdi:arrow-left" class="text-xl group-hover:-translate-x-1 transition-transform" />
|
|
52
53
|
Retour aux Personas
|
|
53
54
|
</button>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { ref, computed, onMounted, useHead, navigateTo } from "#imports";
|
|
3
|
-
import { useAdminPersonas } from "#imports";
|
|
3
|
+
import { useAdminPersonas, useAdminBasePath } from "#imports";
|
|
4
4
|
useHead({ title: "Personas - Admin" });
|
|
5
|
+
const basePath = useAdminBasePath();
|
|
5
6
|
const { fetchPersonas, createPersona, updatePersona, deletePersona } = useAdminPersonas();
|
|
6
7
|
const personas = ref([]);
|
|
7
8
|
const loading = ref(true);
|
|
@@ -88,7 +89,7 @@ const handleDelete = async (id) => {
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
};
|
|
91
|
-
const viewPersona = (persona) => navigateTo(
|
|
92
|
+
const viewPersona = (persona) => navigateTo(`${basePath}/personas/${persona.id}`);
|
|
92
93
|
</script>
|
|
93
94
|
|
|
94
95
|
<template>
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, onMounted, computed, useHead } from "#imports";
|
|
3
|
+
import { useAdminSettings } from "#imports";
|
|
4
|
+
import { useAdminSections } from "#imports";
|
|
5
|
+
useHead({ title: "Configuration \u2014 Admin" });
|
|
6
|
+
const { settings, loading, saving, load, save } = useAdminSettings();
|
|
7
|
+
const sections = useAdminSections();
|
|
8
|
+
const aiContext = ref("");
|
|
9
|
+
const presets = ref({});
|
|
10
|
+
const saved = ref(false);
|
|
11
|
+
onMounted(async () => {
|
|
12
|
+
await load();
|
|
13
|
+
aiContext.value = settings.value?.ai_context || "";
|
|
14
|
+
presets.value = JSON.parse(JSON.stringify(settings.value?.section_presets || {}));
|
|
15
|
+
});
|
|
16
|
+
const setPreset = (sectionType, field, value) => {
|
|
17
|
+
if (!presets.value[sectionType]) presets.value[sectionType] = {};
|
|
18
|
+
presets.value[sectionType][field] = value;
|
|
19
|
+
};
|
|
20
|
+
const getPreset = (sectionType, field, fallback) => {
|
|
21
|
+
return presets.value[sectionType]?.[field] ?? fallback ?? "";
|
|
22
|
+
};
|
|
23
|
+
const saveAll = async () => {
|
|
24
|
+
await save({ ai_context: aiContext.value, section_presets: presets.value });
|
|
25
|
+
saved.value = true;
|
|
26
|
+
setTimeout(() => {
|
|
27
|
+
saved.value = false;
|
|
28
|
+
}, 2e3);
|
|
29
|
+
};
|
|
30
|
+
const styleFields = ["layout", "spacing", "radius", "animation"];
|
|
31
|
+
const hasStyleOptions = (conf) => styleFields.some((f) => conf.fields?.[f]?.options?.length);
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<AdminDashboardLayout>
|
|
36
|
+
<div data-admin-cms class="max-w-3xl mx-auto px-6 py-10">
|
|
37
|
+
|
|
38
|
+
<!-- Header -->
|
|
39
|
+
<div class="flex items-start justify-between mb-10">
|
|
40
|
+
<div>
|
|
41
|
+
<h1 class="text-2xl font-bold text-slate-900">Configuration du Projet</h1>
|
|
42
|
+
<p class="text-sm text-slate-500 mt-1">Contexte IA et préférences des composants</p>
|
|
43
|
+
</div>
|
|
44
|
+
<button
|
|
45
|
+
@click="saveAll"
|
|
46
|
+
:disabled="saving"
|
|
47
|
+
class="px-5 py-2.5 bg-slate-900 text-white text-sm font-semibold rounded-xl hover:bg-slate-700 disabled:opacity-50 transition-all flex items-center gap-2"
|
|
48
|
+
>
|
|
49
|
+
<svg v-if="!saving && !saved" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
50
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
|
51
|
+
</svg>
|
|
52
|
+
<svg v-if="saving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
53
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
|
54
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
|
|
55
|
+
</svg>
|
|
56
|
+
{{ saved ? "Enregistr\xE9 \u2713" : saving ? "Enregistrement..." : "Enregistrer" }}
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div v-if="loading" class="text-center py-20 text-slate-400 text-sm">Chargement...</div>
|
|
61
|
+
|
|
62
|
+
<div v-else class="space-y-6">
|
|
63
|
+
|
|
64
|
+
<!-- ── AI Context ───────────────────────────────────────────────── -->
|
|
65
|
+
<div class="bg-white border border-slate-200 rounded-2xl p-8">
|
|
66
|
+
<div class="flex items-center gap-3 mb-2">
|
|
67
|
+
<span class="text-xl">✨</span>
|
|
68
|
+
<h2 class="text-base font-bold text-slate-900">Contexte IA</h2>
|
|
69
|
+
</div>
|
|
70
|
+
<p class="text-xs text-slate-500 mb-5 leading-relaxed">
|
|
71
|
+
Décrivez votre activité, cible et ton. L'IA s'en souviendra pour chaque génération de contenu et audit SEO.
|
|
72
|
+
</p>
|
|
73
|
+
<textarea
|
|
74
|
+
v-model="aiContext"
|
|
75
|
+
rows="7"
|
|
76
|
+
placeholder="Ex: Agence web basée à Alger spécialisée dans la création de sites vitrines et e-commerce pour les PME algériennes. Ton professionnel mais accessible, orienté résultats. Nos clients sont des entrepreneurs et dirigeants algériens entre 30 et 50 ans..."
|
|
77
|
+
class="w-full px-4 py-3 text-sm border border-slate-200 rounded-xl focus:outline-none focus:border-slate-400 resize-none leading-relaxed text-slate-700 placeholder:text-slate-300"
|
|
78
|
+
/>
|
|
79
|
+
<p class="text-xs text-slate-400 mt-2 text-right">{{ aiContext.length }} caractères</p>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<!-- ── Component Defaults ──────────────────────────────────────── -->
|
|
83
|
+
<div class="bg-white border border-slate-200 rounded-2xl p-8">
|
|
84
|
+
<div class="flex items-center gap-3 mb-2">
|
|
85
|
+
<span class="text-xl">🎨</span>
|
|
86
|
+
<h2 class="text-base font-bold text-slate-900">Préférences des Composants</h2>
|
|
87
|
+
</div>
|
|
88
|
+
<p class="text-xs text-slate-500 mb-8 leading-relaxed">
|
|
89
|
+
Définissez le style par défaut de chaque section. Ces préférences s'appliquent automatiquement quand vous ajoutez une section dans l'éditeur.
|
|
90
|
+
</p>
|
|
91
|
+
|
|
92
|
+
<div class="space-y-8">
|
|
93
|
+
<div
|
|
94
|
+
v-for="(conf, type) in sections"
|
|
95
|
+
:key="type"
|
|
96
|
+
v-show="hasStyleOptions(conf)"
|
|
97
|
+
class="border border-slate-100 rounded-xl p-5"
|
|
98
|
+
>
|
|
99
|
+
<!-- Component label -->
|
|
100
|
+
<div class="flex items-center gap-2 mb-5">
|
|
101
|
+
<span class="text-lg leading-none">{{ conf.icon }}</span>
|
|
102
|
+
<span class="text-sm font-bold text-slate-800">{{ conf.label }}</span>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div class="space-y-4">
|
|
106
|
+
|
|
107
|
+
<!-- Layout -->
|
|
108
|
+
<div v-if="conf.fields?.layout?.options">
|
|
109
|
+
<p class="text-[10px] font-semibold uppercase tracking-widest text-slate-400 mb-2">Layout</p>
|
|
110
|
+
<div class="flex flex-wrap gap-1.5">
|
|
111
|
+
<button
|
|
112
|
+
v-for="opt in conf.fields.layout.options"
|
|
113
|
+
:key="opt.value"
|
|
114
|
+
@click="setPreset(String(type), 'layout', opt.value)"
|
|
115
|
+
:class="[
|
|
116
|
+
'px-3 py-1.5 rounded-lg text-xs font-semibold border transition-all',
|
|
117
|
+
getPreset(String(type), 'layout', conf.defaultProps?.layout) === opt.value ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-200 hover:border-slate-400'
|
|
118
|
+
]"
|
|
119
|
+
>
|
|
120
|
+
<span v-if="opt.icon" class="mr-1">{{ opt.icon }}</span>{{ opt.label }}
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<!-- Animation -->
|
|
126
|
+
<div v-if="conf.fields?.animation?.options">
|
|
127
|
+
<p class="text-[10px] font-semibold uppercase tracking-widest text-slate-400 mb-2">Animation</p>
|
|
128
|
+
<div class="flex flex-wrap gap-1.5">
|
|
129
|
+
<button
|
|
130
|
+
v-for="opt in conf.fields.animation.options"
|
|
131
|
+
:key="opt.value"
|
|
132
|
+
@click="setPreset(String(type), 'animation', opt.value)"
|
|
133
|
+
:class="[
|
|
134
|
+
'px-3 py-1.5 rounded-lg text-xs font-semibold border transition-all',
|
|
135
|
+
getPreset(String(type), 'animation', conf.defaultProps?.animation) === opt.value ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-200 hover:border-slate-400'
|
|
136
|
+
]"
|
|
137
|
+
>
|
|
138
|
+
<span v-if="opt.icon" class="mr-1">{{ opt.icon }}</span>{{ opt.label }}
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<!-- Radius + Spacing inline -->
|
|
144
|
+
<div class="flex gap-8">
|
|
145
|
+
<div v-if="conf.fields?.radius?.options">
|
|
146
|
+
<p class="text-[10px] font-semibold uppercase tracking-widest text-slate-400 mb-2">Arrondis</p>
|
|
147
|
+
<div class="flex gap-1.5">
|
|
148
|
+
<button
|
|
149
|
+
v-for="opt in conf.fields.radius.options"
|
|
150
|
+
:key="opt.value"
|
|
151
|
+
@click="setPreset(String(type), 'radius', opt.value)"
|
|
152
|
+
:class="[
|
|
153
|
+
'px-3 py-1.5 rounded-lg text-xs font-semibold border transition-all',
|
|
154
|
+
getPreset(String(type), 'radius', conf.defaultProps?.radius) === opt.value ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-200 hover:border-slate-400'
|
|
155
|
+
]"
|
|
156
|
+
>
|
|
157
|
+
<span v-if="opt.icon" class="mr-1">{{ opt.icon }}</span>{{ opt.label }}
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div v-if="conf.fields?.spacing?.options">
|
|
163
|
+
<p class="text-[10px] font-semibold uppercase tracking-widest text-slate-400 mb-2">Espacement</p>
|
|
164
|
+
<div class="flex gap-1.5">
|
|
165
|
+
<button
|
|
166
|
+
v-for="opt in conf.fields.spacing.options"
|
|
167
|
+
:key="opt.value"
|
|
168
|
+
@click="setPreset(String(type), 'spacing', opt.value)"
|
|
169
|
+
:class="[
|
|
170
|
+
'px-3 py-1.5 rounded-lg text-xs font-semibold border transition-all',
|
|
171
|
+
getPreset(String(type), 'spacing', conf.defaultProps?.spacing) === opt.value ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-200 hover:border-slate-400'
|
|
172
|
+
]"
|
|
173
|
+
>
|
|
174
|
+
<span v-if="opt.icon" class="mr-1">{{ opt.icon }}</span>{{ opt.label }}
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<!-- Colors -->
|
|
181
|
+
<div v-if="conf.fields?.bgColor || conf.fields?.textColor" class="flex gap-6">
|
|
182
|
+
<div v-if="conf.fields?.bgColor">
|
|
183
|
+
<p class="text-[10px] font-semibold uppercase tracking-widest text-slate-400 mb-2">Fond</p>
|
|
184
|
+
<div class="flex items-center gap-2">
|
|
185
|
+
<input
|
|
186
|
+
type="color"
|
|
187
|
+
:value="getPreset(String(type), 'bgColor', conf.defaultProps?.bgColor || '#ffffff')"
|
|
188
|
+
@input="setPreset(String(type), 'bgColor', $event.target.value)"
|
|
189
|
+
class="w-8 h-8 rounded-lg border border-slate-200 cursor-pointer p-0.5"
|
|
190
|
+
/>
|
|
191
|
+
<span class="text-xs font-mono text-slate-500">
|
|
192
|
+
{{ getPreset(String(type), "bgColor", conf.defaultProps?.bgColor || "#ffffff") }}
|
|
193
|
+
</span>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
<div v-if="conf.fields?.textColor">
|
|
197
|
+
<p class="text-[10px] font-semibold uppercase tracking-widest text-slate-400 mb-2">Texte</p>
|
|
198
|
+
<div class="flex items-center gap-2">
|
|
199
|
+
<input
|
|
200
|
+
type="color"
|
|
201
|
+
:value="getPreset(String(type), 'textColor', conf.defaultProps?.textColor || '#000000')"
|
|
202
|
+
@input="setPreset(String(type), 'textColor', $event.target.value)"
|
|
203
|
+
class="w-8 h-8 rounded-lg border border-slate-200 cursor-pointer p-0.5"
|
|
204
|
+
/>
|
|
205
|
+
<span class="text-xs font-mono text-slate-500">
|
|
206
|
+
{{ getPreset(String(type), "textColor", conf.defaultProps?.textColor || "#000000") }}
|
|
207
|
+
</span>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</AdminDashboardLayout>
|
|
220
|
+
</template>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { ref, onMounted, useHead, useRoute, navigateTo } from "#imports";
|
|
3
|
-
import { useAdminStrategies, useAdminContent, useAdminPersonas } from "#imports";
|
|
3
|
+
import { useAdminStrategies, useAdminContent, useAdminPersonas, useAdminBasePath } from "#imports";
|
|
4
4
|
const route = useRoute();
|
|
5
5
|
const id = route.params.id;
|
|
6
|
+
const basePath = useAdminBasePath();
|
|
6
7
|
useHead({ title: "Strategie - Admin" });
|
|
7
8
|
const { fetchStrategyById, updateStrategy, fetchStrategyContents, fetchStrategyPersonas } = useAdminStrategies();
|
|
8
9
|
const { fetchContent, createContent } = useAdminContent();
|
|
@@ -74,7 +75,7 @@ const contentStatusClass = {
|
|
|
74
75
|
<template>
|
|
75
76
|
<AdminDashboardLayout>
|
|
76
77
|
<div class="max-w-6xl mx-auto px-6 md:px-10 py-10">
|
|
77
|
-
<NuxtLink to="/
|
|
78
|
+
<NuxtLink :to="`${basePath}/strategies`" class="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 mb-8 font-medium">
|
|
78
79
|
<Icon name="mdi:arrow-left" />
|
|
79
80
|
Retour aux strategies
|
|
80
81
|
</NuxtLink>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { ref, computed, onMounted, useHead, navigateTo } from "#imports";
|
|
3
|
-
import { useAdminStrategies } from "#imports";
|
|
3
|
+
import { useAdminStrategies, useAdminBasePath } from "#imports";
|
|
4
4
|
useHead({ title: "Strategies - Admin" });
|
|
5
|
+
const basePath = useAdminBasePath();
|
|
5
6
|
const { fetchStrategies, createStrategy, updateStrategy, deleteStrategy, fetchStrategyContents, fetchStrategyPersonas } = useAdminStrategies();
|
|
6
7
|
const strategies = ref([]);
|
|
7
8
|
const strategyContents = ref({});
|
|
@@ -125,7 +126,7 @@ const handleDelete = async () => {
|
|
|
125
126
|
<p class="text-sm text-slate-600">{{ strategy.description }}</p>
|
|
126
127
|
</div>
|
|
127
128
|
<div class="flex gap-2 ml-4">
|
|
128
|
-
<button @click="navigateTo(
|
|
129
|
+
<button @click="navigateTo(`${basePath}/strategies/${strategy.id}`)" class="px-5 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 transition-all">Gerer</button>
|
|
129
130
|
<button @click="openEdit(strategy)" class="p-2.5 bg-slate-100 text-slate-600 rounded-xl hover:bg-slate-200 transition-all"><Icon name="mdi:pencil" /></button>
|
|
130
131
|
</div>
|
|
131
132
|
</div>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { defineEventHandler, readBody, createError } from "h3";
|
|
1
2
|
const isI18nField = (val) => val !== null && typeof val === "object" && !Array.isArray(val) && ("fr" in val || "ar" in val || "en" in val);
|
|
2
3
|
export default defineEventHandler(async (event) => {
|
|
3
4
|
const body = await readBody(event);
|
|
4
|
-
const { sectionType, sectionLabel, fields, currentProps, prompt, itemFieldsConfig } = body;
|
|
5
|
-
const
|
|
6
|
-
if (!
|
|
5
|
+
const { sectionType, sectionLabel, fields, currentProps, prompt, itemFieldsConfig, aiContext } = body;
|
|
6
|
+
const openrouterApiKey = process.env.OPENROUTER_API_KEY || "";
|
|
7
|
+
if (!openrouterApiKey) {
|
|
7
8
|
throw createError({ statusCode: 500, message: "OPENROUTER_API_KEY not configured" });
|
|
8
9
|
}
|
|
9
10
|
const skeleton = {};
|
|
@@ -52,8 +53,13 @@ export default defineEventHandler(async (event) => {
|
|
|
52
53
|
return `items[${i}]: "${typeof val === "object" ? val?.fr : val || ""}"`;
|
|
53
54
|
}) : []
|
|
54
55
|
].join("\n");
|
|
55
|
-
const systemPrompt =
|
|
56
|
-
|
|
56
|
+
const systemPrompt = [
|
|
57
|
+
"You are a professional multilingual copywriter for a web agency CMS.",
|
|
58
|
+
aiContext ? `
|
|
59
|
+
Website context (always keep this in mind):
|
|
60
|
+
${aiContext}` : "",
|
|
61
|
+
"\nAlways respond with a valid JSON object only \u2014 no markdown, no code blocks, no explanation."
|
|
62
|
+
].filter(Boolean).join("");
|
|
57
63
|
const userPrompt = `Section: ${sectionLabel} (${sectionType})
|
|
58
64
|
User instruction: ${prompt}
|
|
59
65
|
|
|
@@ -77,7 +83,7 @@ Rules:
|
|
|
77
83
|
const response = await $fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
78
84
|
method: "POST",
|
|
79
85
|
headers: {
|
|
80
|
-
"Authorization": `Bearer ${
|
|
86
|
+
"Authorization": `Bearer ${openrouterApiKey}`,
|
|
81
87
|
"Content-Type": "application/json",
|
|
82
88
|
"HTTP-Referer": "https://admin-cms.dev",
|
|
83
89
|
"X-Title": "Admin CMS"
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { defineEventHandler, readBody, createError } from "h3";
|
|
1
2
|
export default defineEventHandler(async (event) => {
|
|
2
3
|
const body = await readBody(event);
|
|
3
|
-
const { pageTitle, pageType, content, seoConfig } = body;
|
|
4
|
-
const
|
|
5
|
-
if (!
|
|
4
|
+
const { pageTitle, pageType, content, seoConfig, aiContext } = body;
|
|
5
|
+
const openrouterApiKey = process.env.OPENROUTER_API_KEY || "";
|
|
6
|
+
if (!openrouterApiKey) {
|
|
6
7
|
throw createError({ statusCode: 500, message: "OPENROUTER_API_KEY not configured" });
|
|
7
8
|
}
|
|
8
9
|
const contentSummary = content.map((block) => {
|
|
@@ -25,8 +26,13 @@ export default defineEventHandler(async (event) => {
|
|
|
25
26
|
title: typeof seoConfig?.meta_title === "object" ? seoConfig.meta_title?.fr : seoConfig?.meta_title || "",
|
|
26
27
|
description: typeof seoConfig?.meta_description === "object" ? seoConfig.meta_description?.fr : seoConfig?.meta_description || ""
|
|
27
28
|
};
|
|
28
|
-
const systemPrompt =
|
|
29
|
-
|
|
29
|
+
const systemPrompt = [
|
|
30
|
+
"You are an expert SEO consultant.",
|
|
31
|
+
aiContext ? `
|
|
32
|
+
Website context:
|
|
33
|
+
${aiContext}` : "",
|
|
34
|
+
"\nRespond with a valid JSON object only \u2014 no markdown, no code blocks, no extra text."
|
|
35
|
+
].filter(Boolean).join("");
|
|
30
36
|
const userPrompt = `Audit this web page for SEO and provide actionable recommendations.
|
|
31
37
|
|
|
32
38
|
Page title: "${pageTitle}"
|
|
@@ -61,7 +67,7 @@ Rules:
|
|
|
61
67
|
const response = await $fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
62
68
|
method: "POST",
|
|
63
69
|
headers: {
|
|
64
|
-
"Authorization": `Bearer ${
|
|
70
|
+
"Authorization": `Bearer ${openrouterApiKey}`,
|
|
65
71
|
"Content-Type": "application/json",
|
|
66
72
|
"HTTP-Referer": "https://admin-cms.dev",
|
|
67
73
|
"X-Title": "Admin CMS"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cinqcinqdev-seo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
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",
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
"@pinia/nuxt": ">=0.5.0 || >=0.11.0",
|
|
28
28
|
"nuxt": ">=3.0.0"
|
|
29
29
|
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"h3": ">=1.0.0"
|
|
32
|
+
},
|
|
30
33
|
"devDependencies": {
|
|
31
34
|
"@nuxt/icon": "^1.15.0",
|
|
32
35
|
"@nuxt/kit": "^3.13.0",
|