cinqcinqdev-seo 0.1.9 → 0.1.11
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.d.mts +17 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +19 -11
- package/dist/runtime/assets/admin-tw.css +1 -1
- package/dist/runtime/components/admin/ContentCalendar.d.vue.ts +3 -0
- package/dist/runtime/components/admin/ContentCalendar.vue +196 -0
- package/dist/runtime/components/admin/ContentCalendar.vue.d.ts +3 -0
- package/dist/runtime/components/admin/DashboardLayout.d.vue.ts +13 -0
- package/dist/runtime/components/admin/DashboardLayout.vue +12 -0
- package/dist/runtime/components/admin/DashboardLayout.vue.d.ts +13 -0
- package/dist/runtime/components/admin/NavItem.d.vue.ts +3 -0
- package/dist/runtime/components/admin/NavItem.vue +46 -0
- package/dist/runtime/components/admin/NavItem.vue.d.ts +3 -0
- package/dist/runtime/components/admin/Navbar.vue +1 -2
- package/dist/runtime/components/admin/Sidebar.d.vue.ts +3 -0
- package/dist/runtime/components/admin/Sidebar.vue +115 -0
- package/dist/runtime/components/admin/Sidebar.vue.d.ts +3 -0
- package/dist/runtime/composables/useAdminBranding.d.ts +4 -0
- package/dist/runtime/composables/useAdminBranding.js +22 -0
- package/dist/runtime/composables/useAdminContent.d.ts +9 -0
- package/dist/runtime/composables/useAdminContent.js +46 -0
- package/dist/runtime/composables/useAdminPersonas.d.ts +8 -0
- package/dist/runtime/composables/useAdminPersonas.js +31 -0
- package/dist/runtime/composables/useAdminProducts.d.ts +6 -0
- package/dist/runtime/composables/useAdminProducts.js +24 -0
- package/dist/runtime/composables/useAdminStrategies.d.ts +10 -0
- package/dist/runtime/composables/useAdminStrategies.js +57 -0
- package/dist/runtime/composables/useAdminTodos.d.ts +10 -0
- package/dist/runtime/composables/useAdminTodos.js +55 -0
- package/dist/runtime/pages/admin/account.vue +59 -51
- package/dist/runtime/pages/admin/branding.d.vue.ts +3 -0
- package/dist/runtime/pages/admin/branding.vue +276 -0
- package/dist/runtime/pages/admin/branding.vue.d.ts +3 -0
- package/dist/runtime/pages/admin/calendar.d.vue.ts +3 -0
- package/dist/runtime/pages/admin/calendar.vue +169 -0
- package/dist/runtime/pages/admin/calendar.vue.d.ts +3 -0
- package/dist/runtime/pages/admin/content-library.d.vue.ts +3 -0
- package/dist/runtime/pages/admin/content-library.vue +359 -0
- package/dist/runtime/pages/admin/content-library.vue.d.ts +3 -0
- package/dist/runtime/pages/admin/editor/[id].vue +5 -3
- package/dist/runtime/pages/admin/index.vue +81 -23
- package/dist/runtime/pages/admin/pages/[type].vue +267 -83
- package/dist/runtime/pages/admin/personas/[id].d.vue.ts +3 -0
- package/dist/runtime/pages/admin/personas/[id].vue +170 -0
- package/dist/runtime/pages/admin/personas/[id].vue.d.ts +3 -0
- package/dist/runtime/pages/admin/personas/index.d.vue.ts +3 -0
- package/dist/runtime/pages/admin/personas/index.vue +266 -0
- package/dist/runtime/pages/admin/personas/index.vue.d.ts +3 -0
- package/dist/runtime/pages/admin/products.d.vue.ts +3 -0
- package/dist/runtime/pages/admin/products.vue +300 -0
- package/dist/runtime/pages/admin/products.vue.d.ts +3 -0
- package/dist/runtime/pages/admin/strategies/[id].d.vue.ts +3 -0
- package/dist/runtime/pages/admin/strategies/[id].vue +237 -0
- package/dist/runtime/pages/admin/strategies/[id].vue.d.ts +3 -0
- package/dist/runtime/pages/admin/strategies/index.d.vue.ts +3 -0
- package/dist/runtime/pages/admin/strategies/index.vue +232 -0
- package/dist/runtime/pages/admin/strategies/index.vue.d.ts +3 -0
- package/dist/runtime/pages/admin/todos.d.vue.ts +3 -0
- package/dist/runtime/pages/admin/todos.vue +251 -0
- package/dist/runtime/pages/admin/todos.vue.d.ts +3 -0
- package/dist/runtime/stores/adminUser.d.ts +29 -1
- package/dist/runtime/stores/adminUser.js +2 -2
- package/dist/types.d.mts +1 -1
- package/package.json +6 -3
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
isCreating.value = false;
|
|
52
|
+
showToast("Ce slug est d\xE9j\xE0 utilis\xE9. Changez le titre.", "error");
|
|
25
53
|
}
|
|
26
54
|
};
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
<
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
<div
|
|
43
|
-
<div>
|
|
44
|
-
<
|
|
45
|
-
<
|
|
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
|
-
<
|
|
48
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
63
|
-
<tr>
|
|
64
|
-
<th class="px-6 py-
|
|
65
|
-
<th class="px-6 py-
|
|
66
|
-
<th class="px-6 py-
|
|
67
|
-
<th class="px-6 py-
|
|
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-
|
|
71
|
-
<tr v-for="page in pages" :key="page.id" class="hover:bg-gray-50
|
|
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-
|
|
74
|
-
<div class="text-
|
|
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="
|
|
78
|
-
<span class="w-
|
|
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="
|
|
81
|
-
<span class="w-
|
|
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-
|
|
87
|
-
page.status === 'published' ? 'bg-green-
|
|
88
|
-
]">
|
|
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
|
|
91
|
-
<div class="flex justify-end
|
|
92
|
-
<NuxtLink
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
<
|
|
116
|
-
<div class="bg-
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
</
|
|
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
|
-
</
|
|
321
|
+
</AdminDashboardLayout>
|
|
138
322
|
</template>
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, onMounted, useHead, useRoute, navigateTo } from "#imports";
|
|
3
|
+
import { useAdminPersonas, useAdminContent } from "#imports";
|
|
4
|
+
const route = useRoute();
|
|
5
|
+
const id = route.params.id;
|
|
6
|
+
useHead({ title: "Persona - Admin" });
|
|
7
|
+
const { fetchPersonaById, updatePersona } = useAdminPersonas();
|
|
8
|
+
const { getContentByPersona } = useAdminContent();
|
|
9
|
+
const persona = ref(null);
|
|
10
|
+
const contents = ref([]);
|
|
11
|
+
const loading = ref(true);
|
|
12
|
+
const editMode = ref(false);
|
|
13
|
+
const saving = ref(false);
|
|
14
|
+
const formData = ref({});
|
|
15
|
+
onMounted(async () => {
|
|
16
|
+
loading.value = true;
|
|
17
|
+
try {
|
|
18
|
+
const [p, c] = await Promise.all([fetchPersonaById(id), getContentByPersona(id)]);
|
|
19
|
+
persona.value = p;
|
|
20
|
+
contents.value = c || [];
|
|
21
|
+
formData.value = { ...p };
|
|
22
|
+
} catch {
|
|
23
|
+
} finally {
|
|
24
|
+
loading.value = false;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
const saveChanges = async () => {
|
|
28
|
+
saving.value = true;
|
|
29
|
+
try {
|
|
30
|
+
const updated = await updatePersona(id, formData.value);
|
|
31
|
+
persona.value = updated;
|
|
32
|
+
editMode.value = false;
|
|
33
|
+
} catch {
|
|
34
|
+
} finally {
|
|
35
|
+
saving.value = false;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const getInitials = (name) => name ? name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) : "?";
|
|
39
|
+
const statusClass = {
|
|
40
|
+
draft: "bg-slate-100 text-slate-600",
|
|
41
|
+
approved: "bg-blue-100 text-blue-600",
|
|
42
|
+
published: "bg-emerald-100 text-emerald-600",
|
|
43
|
+
archived: "bg-amber-100 text-amber-600"
|
|
44
|
+
};
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<template>
|
|
48
|
+
<AdminDashboardLayout>
|
|
49
|
+
<div class="max-w-5xl mx-auto px-6 md:px-10 py-10">
|
|
50
|
+
<button @click="navigateTo('/admin/personas')" class="flex items-center gap-2 text-slate-500 hover:text-blue-600 font-semibold transition-all mb-8 group">
|
|
51
|
+
<Icon name="mdi:arrow-left" class="text-xl group-hover:-translate-x-1 transition-transform" />
|
|
52
|
+
Retour aux Personas
|
|
53
|
+
</button>
|
|
54
|
+
|
|
55
|
+
<div v-if="loading" class="h-64 bg-slate-100 animate-pulse rounded-3xl"></div>
|
|
56
|
+
|
|
57
|
+
<div v-else-if="persona">
|
|
58
|
+
<!-- Hero -->
|
|
59
|
+
<div class="bg-gradient-to-br from-blue-600 to-blue-800 rounded-3xl p-8 text-white mb-8 relative overflow-hidden">
|
|
60
|
+
<div class="absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full blur-3xl -mr-16 -mt-16"></div>
|
|
61
|
+
<div class="relative z-10 flex flex-col md:flex-row items-start gap-8">
|
|
62
|
+
<div class="w-24 h-24 bg-white/20 backdrop-blur-xl rounded-2xl flex items-center justify-center border-2 border-white/40 overflow-hidden flex-shrink-0 text-2xl font-black">
|
|
63
|
+
<img v-if="persona.avatar_url" :src="persona.avatar_url" :alt="persona.name" class="w-full h-full object-cover" />
|
|
64
|
+
<span v-else>{{ getInitials(persona.name) }}</span>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="flex-1">
|
|
67
|
+
<h1 class="text-4xl font-black mb-1">{{ persona.name }}</h1>
|
|
68
|
+
<p class="text-blue-100 text-lg mb-4">{{ persona.job_title }}</p>
|
|
69
|
+
<div class="flex flex-wrap gap-4 text-sm text-blue-100">
|
|
70
|
+
<span v-if="persona.age"><Icon name="mdi:cake-variant" class="inline mr-1" />{{ persona.age }} ans</span>
|
|
71
|
+
<span v-if="persona.location"><Icon name="mdi:map-marker" class="inline mr-1" />{{ persona.location }}</span>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="flex gap-3">
|
|
75
|
+
<button @click="editMode = !editMode" class="px-5 py-2.5 bg-white/20 hover:bg-white/30 text-white rounded-xl font-bold text-sm transition-all flex items-center gap-2">
|
|
76
|
+
<Icon name="mdi:pencil" /> Modifier
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<!-- Edit Form -->
|
|
83
|
+
<div v-if="editMode" class="bg-white rounded-3xl p-8 border border-slate-200 shadow-sm mb-8">
|
|
84
|
+
<h2 class="text-xl font-bold text-slate-900 mb-6">Modifier le Profil</h2>
|
|
85
|
+
<div class="grid grid-cols-2 gap-6 mb-6">
|
|
86
|
+
<div>
|
|
87
|
+
<label class="block text-xs font-black uppercase text-slate-500 mb-2">Nom</label>
|
|
88
|
+
<input v-model="formData.name" type="text" class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl" />
|
|
89
|
+
</div>
|
|
90
|
+
<div>
|
|
91
|
+
<label class="block text-xs font-black uppercase text-slate-500 mb-2">Profession</label>
|
|
92
|
+
<input v-model="formData.job_title" type="text" class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl" />
|
|
93
|
+
</div>
|
|
94
|
+
<div>
|
|
95
|
+
<label class="block text-xs font-black uppercase text-slate-500 mb-2">Age</label>
|
|
96
|
+
<input v-model.number="formData.age" type="number" class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl" />
|
|
97
|
+
</div>
|
|
98
|
+
<div>
|
|
99
|
+
<label class="block text-xs font-black uppercase text-slate-500 mb-2">Ville</label>
|
|
100
|
+
<input v-model="formData.location" type="text" class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl" />
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
<div class="mb-6">
|
|
104
|
+
<label class="block text-xs font-black uppercase text-slate-500 mb-2">A propos</label>
|
|
105
|
+
<textarea v-model="formData.about" rows="3" class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl resize-none"></textarea>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="flex gap-3">
|
|
108
|
+
<button @click="saveChanges" :disabled="saving" class="px-8 py-3 bg-blue-600 text-white font-bold rounded-xl hover:bg-blue-700 disabled:opacity-50">{{ saving ? "Enregistrement..." : "Enregistrer" }}</button>
|
|
109
|
+
<button @click="editMode = false" class="px-8 py-3 bg-slate-100 text-slate-700 font-bold rounded-xl hover:bg-slate-200">Annuler</button>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<!-- Info Cards -->
|
|
114
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
|
115
|
+
<div class="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
|
|
116
|
+
<h3 class="font-black text-slate-900 mb-4 flex items-center gap-2"><Icon name="mdi:account-outline" class="text-blue-600" /> A propos</h3>
|
|
117
|
+
<p class="text-slate-600 leading-relaxed">{{ persona.about || "Aucune description." }}</p>
|
|
118
|
+
</div>
|
|
119
|
+
<div v-if="(persona.core_needs || []).length > 0" class="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
|
|
120
|
+
<h3 class="font-black text-slate-900 mb-4 flex items-center gap-2"><Icon name="mdi:star-outline" class="text-amber-500" /> Besoins Principaux</h3>
|
|
121
|
+
<div class="flex flex-wrap gap-2">
|
|
122
|
+
<span v-for="need in persona.core_needs" :key="need" class="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-xl text-sm font-semibold">{{ need }}</span>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
<div v-if="(persona.goals || []).length > 0" class="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
|
|
126
|
+
<h3 class="font-black text-slate-900 mb-4 flex items-center gap-2"><Icon name="mdi:target" class="text-emerald-500" /> Objectifs</h3>
|
|
127
|
+
<ul class="space-y-2">
|
|
128
|
+
<li v-for="goal in persona.goals" :key="goal" class="flex items-start gap-2 text-sm text-slate-600">
|
|
129
|
+
<Icon name="mdi:check-circle" class="text-emerald-500 mt-0.5 flex-shrink-0" />{{ goal }}
|
|
130
|
+
</li>
|
|
131
|
+
</ul>
|
|
132
|
+
</div>
|
|
133
|
+
<div v-if="(persona.pain_points || []).length > 0" class="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
|
|
134
|
+
<h3 class="font-black text-slate-900 mb-4 flex items-center gap-2"><Icon name="mdi:lightning-bolt" class="text-red-500" /> Points de Douleur</h3>
|
|
135
|
+
<ul class="space-y-2">
|
|
136
|
+
<li v-for="pain in persona.pain_points" :key="pain" class="flex items-start gap-2 text-sm text-slate-600">
|
|
137
|
+
<Icon name="mdi:close-circle" class="text-red-400 mt-0.5 flex-shrink-0" />{{ pain }}
|
|
138
|
+
</li>
|
|
139
|
+
</ul>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<!-- Recent Content -->
|
|
144
|
+
<div class="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
|
|
145
|
+
<h3 class="font-black text-slate-900 mb-4 flex items-center gap-2">
|
|
146
|
+
<Icon name="mdi:file-document-multiple-outline" class="text-purple-500" /> Contenus associes ({{ contents.length }})
|
|
147
|
+
</h3>
|
|
148
|
+
<div v-if="contents.length === 0" class="text-center py-8 text-slate-300">
|
|
149
|
+
<Icon name="mdi:file-document-outline" class="text-4xl mb-2" />
|
|
150
|
+
<p class="text-sm">Aucun contenu pour cette persona</p>
|
|
151
|
+
</div>
|
|
152
|
+
<div v-else class="space-y-3">
|
|
153
|
+
<div v-for="content in contents.slice(0, 5)" :key="content.id" class="flex items-center justify-between p-4 bg-slate-50 rounded-xl">
|
|
154
|
+
<div class="flex-1 min-w-0">
|
|
155
|
+
<p class="font-bold text-slate-900 truncate">{{ content.title }}</p>
|
|
156
|
+
<p class="text-xs text-slate-500">{{ content.platform }} • {{ content.created_at ? new Date(content.created_at).toLocaleDateString("fr-FR") : "" }}</p>
|
|
157
|
+
</div>
|
|
158
|
+
<span :class="['px-2 py-1 rounded-lg text-[10px] font-black uppercase', statusClass[content.status] || statusClass.draft]">{{ content.status }}</span>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div v-else class="text-center py-20">
|
|
165
|
+
<Icon name="mdi:account-off" class="text-6xl text-slate-200 mb-4" />
|
|
166
|
+
<p class="text-slate-400">Persona introuvable.</p>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</AdminDashboardLayout>
|
|
170
|
+
</template>
|