cinqcinqdev-seo 0.1.5 → 0.1.6

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.
Files changed (35) hide show
  1. package/dist/module.d.mts +62 -2
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +99 -14
  4. package/dist/runtime/assets/admin-tw.css +1 -0
  5. package/dist/runtime/assets/admin-tw.input.css +1 -0
  6. package/dist/runtime/assets/admin.css +3 -0
  7. package/dist/runtime/components/admin/DynamicRenderer.d.vue.ts +3 -0
  8. package/dist/runtime/components/admin/DynamicRenderer.vue +30 -0
  9. package/dist/runtime/components/admin/DynamicRenderer.vue.d.ts +3 -0
  10. package/dist/runtime/components/admin/Navbar.d.vue.ts +3 -0
  11. package/dist/runtime/components/admin/Navbar.vue +80 -0
  12. package/dist/runtime/components/admin/Navbar.vue.d.ts +3 -0
  13. package/dist/runtime/composables/useAdminSections.d.ts +11 -0
  14. package/dist/runtime/composables/useAdminSections.js +332 -0
  15. package/dist/runtime/middleware/admin-auth.d.ts +2 -0
  16. package/dist/runtime/middleware/admin-auth.js +11 -0
  17. package/dist/runtime/pages/admin/account.d.vue.ts +3 -0
  18. package/dist/runtime/pages/admin/account.vue +154 -0
  19. package/dist/runtime/pages/admin/account.vue.d.ts +3 -0
  20. package/dist/runtime/pages/admin/editor/[id].d.vue.ts +3 -0
  21. package/dist/runtime/pages/admin/editor/[id].vue +521 -0
  22. package/dist/runtime/pages/admin/editor/[id].vue.d.ts +3 -0
  23. package/dist/runtime/pages/admin/index.d.vue.ts +3 -0
  24. package/dist/runtime/pages/admin/index.vue +40 -0
  25. package/dist/runtime/pages/admin/index.vue.d.ts +3 -0
  26. package/dist/runtime/pages/admin/pages/[type].d.vue.ts +3 -0
  27. package/dist/runtime/pages/admin/pages/[type].vue +137 -0
  28. package/dist/runtime/pages/admin/pages/[type].vue.d.ts +3 -0
  29. package/dist/runtime/plugins/admin-font.client.d.ts +7 -0
  30. package/dist/runtime/plugins/admin-font.client.js +6 -0
  31. package/dist/runtime/stores/adminUser.d.ts +1 -0
  32. package/dist/runtime/stores/adminUser.js +55 -0
  33. package/dist/types.d.mts +2 -12
  34. package/package.json +2 -2
  35. package/dist/module.d.cts +0 -2
@@ -0,0 +1,521 @@
1
+ <script setup>
2
+ import { useAdminSections } from "../../../composables/useAdminSections";
3
+ definePageMeta({ layout: false });
4
+ const route = useRoute();
5
+ const supabase = useSupabaseClient();
6
+ const config = useRuntimeConfig().public.adminCms;
7
+ const table = config?.tables?.pages || "pages";
8
+ const bucket = config?.storageBucket || "site";
9
+ const pageId = route.params.id;
10
+ const CMS_COMPONENTS = useAdminSections();
11
+ const { data: page } = await useAsyncData(`admin-edit-${pageId}`, async () => {
12
+ const { data } = await supabase.from(table).select("*").eq("id", pageId).single();
13
+ return data;
14
+ });
15
+ useHead({ title: computed(() => page.value?.title ? `${page.value.title} \u2014 Editor` : "Editor") });
16
+ const isLeftOpen = ref(true);
17
+ const isRightOpen = ref(true);
18
+ const viewMode = ref("structure");
19
+ const selectedBlockIndex = ref(null);
20
+ const selectedFieldGroup = ref("design");
21
+ const isSaving = ref(false);
22
+ const isUploading = ref(false);
23
+ const showAddModal = ref(false);
24
+ const editingLabelIndex = ref(null);
25
+ const editingLang = ref("fr");
26
+ const LANGS = ["fr", "ar", "en"];
27
+ const i18n0 = (fr = "") => ({ fr, ar: "", en: "" });
28
+ const resolveI18nForPreview = (val) => {
29
+ if (!val || typeof val !== "object" || Array.isArray(val)) return val ?? "";
30
+ if ("fr" in val || "en" in val || "ar" in val) {
31
+ return val[editingLang.value] || val.fr || Object.values(val).find((v) => v) || "";
32
+ }
33
+ return val;
34
+ };
35
+ const resolvePropsForPreview = (props) => {
36
+ if (!props) return {};
37
+ return Object.fromEntries(
38
+ Object.entries(props).map(([k, v]) => {
39
+ if (Array.isArray(v)) return [k, v.map(
40
+ (item) => typeof item === "object" && item ? resolvePropsForPreview(item) : item
41
+ )];
42
+ return [k, resolveI18nForPreview(v)];
43
+ })
44
+ );
45
+ };
46
+ const getI18nVal = (val, lang) => {
47
+ if (!val) return "";
48
+ if (typeof val === "string") return lang === "fr" ? val : "";
49
+ return val[lang] || "";
50
+ };
51
+ const setI18nVal = (obj, key, lang, value) => {
52
+ const current = obj[key];
53
+ const out = { fr: "", ar: "", en: "" };
54
+ if (typeof current === "string") out.fr = current;
55
+ else if (current && typeof current === "object") Object.assign(out, current);
56
+ out[lang] = value;
57
+ obj[key] = out;
58
+ };
59
+ const getBlockI18n = (fieldName, lang) => {
60
+ const val = page.value?.content[selectedBlockIndex.value]?.props[fieldName];
61
+ if (!val) return "";
62
+ if (typeof val === "string") return lang === "fr" ? val : "";
63
+ return val[lang] || "";
64
+ };
65
+ const setBlockI18n = (fieldName, lang, value) => {
66
+ const props = page.value?.content[selectedBlockIndex.value]?.props;
67
+ if (!props) return;
68
+ const current = props[fieldName];
69
+ const out = { fr: "", ar: "", en: "" };
70
+ if (typeof current === "string") out.fr = current;
71
+ else if (current && typeof current === "object") Object.assign(out, current);
72
+ out[lang] = value;
73
+ props[fieldName] = out;
74
+ };
75
+ const selectedCMSConfig = computed(
76
+ () => selectedBlockIndex.value !== null ? CMS_COMPONENTS[page.value?.content[selectedBlockIndex.value]?.type] : null
77
+ );
78
+ const visibleFields = computed(() => {
79
+ const config2 = selectedCMSConfig.value;
80
+ if (!config2?.fields) return {};
81
+ if (!config2.groups) return config2.fields;
82
+ return Object.fromEntries(
83
+ Object.entries(config2.fields).filter(([, f]) => f.group === selectedFieldGroup.value)
84
+ );
85
+ });
86
+ watch(selectedBlockIndex, () => {
87
+ selectedFieldGroup.value = selectedCMSConfig.value?.groups?.[0]?.key ?? "design";
88
+ });
89
+ const moveBlock = (index, dir) => {
90
+ const newIdx = dir === "up" ? index - 1 : index + 1;
91
+ if (newIdx < 0 || newIdx >= page.value.content.length) return;
92
+ const content = [...page.value.content];
93
+ const item = content.splice(index, 1)[0];
94
+ content.splice(newIdx, 0, item);
95
+ page.value.content = content;
96
+ selectedBlockIndex.value = newIdx;
97
+ };
98
+ const removeBlock = (index) => {
99
+ if (!confirm("Delete this section?")) return;
100
+ page.value.content.splice(index, 1);
101
+ selectedBlockIndex.value = null;
102
+ };
103
+ const addSection = (type) => {
104
+ const conf = CMS_COMPONENTS[type];
105
+ page.value.content.push({ type, props: JSON.parse(JSON.stringify(conf.defaultProps)) });
106
+ showAddModal.value = false;
107
+ selectedBlockIndex.value = page.value.content.length - 1;
108
+ };
109
+ const fileInput = ref(null);
110
+ let activeUploadTarget = null;
111
+ const triggerUpload = (targetObj, key) => {
112
+ activeUploadTarget = { obj: targetObj, key };
113
+ fileInput.value?.click();
114
+ };
115
+ const handleFileUpload = async (event) => {
116
+ const file = event.target.files?.[0];
117
+ if (!file || !activeUploadTarget) return;
118
+ isUploading.value = true;
119
+ try {
120
+ const ext = file.name.split(".").pop();
121
+ const path = `uploads/${Date.now()}-${Math.random().toString(36).substring(7)}.${ext}`;
122
+ const { error: uploadError } = await supabase.storage.from(bucket).upload(path, file);
123
+ if (uploadError) throw uploadError;
124
+ const { data } = supabase.storage.from(bucket).getPublicUrl(path);
125
+ activeUploadTarget.obj[activeUploadTarget.key] = data.publicUrl;
126
+ } catch (err) {
127
+ console.error(err);
128
+ alert(`Upload error. Make sure the "${bucket}" bucket exists in Supabase.`);
129
+ } finally {
130
+ isUploading.value = false;
131
+ activeUploadTarget = null;
132
+ if (fileInput.value) fileInput.value.value = "";
133
+ }
134
+ };
135
+ const tagOptions = [
136
+ { value: "h1", label: "H1" },
137
+ { value: "h2", label: "H2" },
138
+ { value: "h3", label: "H3" },
139
+ { value: "h4", label: "H4" },
140
+ { value: "div", label: "DIV" }
141
+ ];
142
+ const pageTypeOptions = computed(
143
+ () => config?.pageTypes?.map((t) => ({ value: t.id, label: t.label })) || []
144
+ );
145
+ const typeUrlPrefixes = {
146
+ service_page: "/service/",
147
+ landing_page: "/",
148
+ page: "/"
149
+ };
150
+ const pageViewUrl = computed(() => {
151
+ const prefix = typeUrlPrefixes[page.value?.type] ?? "/";
152
+ return `${prefix}${page.value?.slug ?? ""}`;
153
+ });
154
+ const getSeoI18n = (field, lang) => {
155
+ const val = page.value?.seo_config?.[field];
156
+ if (!val) return "";
157
+ if (typeof val === "string") return lang === "fr" ? val : "";
158
+ return val[lang] || "";
159
+ };
160
+ const setSeoI18n = (field, lang, value) => {
161
+ if (!page.value) return;
162
+ if (!page.value.seo_config) page.value.seo_config = {};
163
+ const current = page.value.seo_config[field];
164
+ const out = { fr: "", ar: "", en: "" };
165
+ if (typeof current === "string") out.fr = current;
166
+ else if (current && typeof current === "object") Object.assign(out, current);
167
+ out[lang] = value;
168
+ page.value.seo_config[field] = out;
169
+ };
170
+ const tagDepth = (tag) => Math.max(0, parseInt(tag.replace("h", "")) - 1) || 0;
171
+ const tagColor = (tag) => ({ h1: "#3d35ff", h2: "#6b63ff", h3: "#aaa", h4: "#ccc" })[tag] ?? "#ddd";
172
+ const seoTree = computed(() => {
173
+ if (!page.value?.content) return [];
174
+ const tree = [];
175
+ page.value.content.forEach((block, bIdx) => {
176
+ const titleText = resolveI18nForPreview(block.props.title);
177
+ if (block.props.titleTag && titleText) tree.push({ tag: block.props.titleTag, text: titleText, blockIndex: bIdx });
178
+ if (block.props.items) {
179
+ block.props.items.forEach((item) => {
180
+ const text = resolveI18nForPreview(item.title || item.question || item.name);
181
+ const tag = item.titleTag || block.props.itemTag;
182
+ if (text && tag) tree.push({ tag, text, blockIndex: bIdx });
183
+ });
184
+ }
185
+ });
186
+ return tree;
187
+ });
188
+ const togglePublished = async () => {
189
+ page.value.status = page.value.status === "published" ? "draft" : "published";
190
+ await supabase.from(table).update({ status: page.value.status }).eq("id", pageId);
191
+ };
192
+ const savePage = async () => {
193
+ isSaving.value = true;
194
+ const { error } = await supabase.from(table).update({
195
+ content: page.value.content,
196
+ seo_config: page.value.seo_config,
197
+ slug: page.value.slug,
198
+ title: page.value.title,
199
+ status: page.value.status,
200
+ type: page.value.type
201
+ }).eq("id", pageId);
202
+ isSaving.value = false;
203
+ if (error) alert("Save error: " + error.message);
204
+ };
205
+ </script>
206
+
207
+ <template>
208
+ <div v-if="page" data-admin-cms class="h-screen flex flex-col bg-[#EBEBEF] overflow-hidden font-sans">
209
+ <input type="file" ref="fileInput" class="hidden" accept="image/*" @change="handleFileUpload" />
210
+
211
+ <!-- Top bar -->
212
+ <header class="h-12 bg-white/80 backdrop-blur-md border-b px-6 flex items-center justify-between z-[60] shrink-0">
213
+ <div class="flex items-center gap-4">
214
+ <NuxtLink to="/admin" class="text-gray-400 hover:text-black transition-colors text-lg">←</NuxtLink>
215
+ <div class="flex gap-1 bg-gray-100 p-1 rounded-md">
216
+ <button @click="isLeftOpen = !isLeftOpen" :class="['p-1.5 rounded transition-all', isLeftOpen ? 'bg-white shadow-sm text-blue-600' : 'text-gray-400']" title="Structure">
217
+ <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>
218
+ </button>
219
+ <button @click="isRightOpen = !isRightOpen" :class="['p-1.5 rounded transition-all', isRightOpen ? 'bg-white shadow-sm text-blue-600' : 'text-gray-400']" title="Properties">
220
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 6h8M12 12h8M12 18h8M4 6h.01M4 12h.01M4 18h.01"/></svg>
221
+ </button>
222
+ </div>
223
+ </div>
224
+
225
+ <div class="flex items-center gap-3">
226
+ <div class="flex bg-gray-100 p-1 rounded-full">
227
+ <button @click="viewMode = 'structure'" :class="['px-3 py-1 rounded-full text-[8px] font-black uppercase', viewMode === 'structure' ? 'bg-white text-black' : 'text-gray-400']">Structure</button>
228
+ <button @click="viewMode = 'seo'" :class="['px-3 py-1 rounded-full text-[8px] font-black uppercase', viewMode === 'seo' ? 'bg-white text-black' : 'text-gray-400']">SEO Audit</button>
229
+ </div>
230
+ <NuxtLink v-if="page?.slug" :to="pageViewUrl" target="_blank" class="flex items-center gap-1.5 px-4 py-2 rounded-full text-[9px] font-black uppercase border border-gray-200 text-gray-500 hover:border-blue-500 hover:text-blue-500 transition-all">
231
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
232
+ View
233
+ </NuxtLink>
234
+ <button @click="togglePublished" :class="[
235
+ 'flex items-center gap-1.5 px-4 py-2 rounded-full text-[9px] font-black uppercase border transition-all',
236
+ page?.status === 'published' ? 'bg-green-50 border-green-200 text-green-600' : 'bg-gray-50 border-gray-200 text-gray-400'
237
+ ]">
238
+ <span :class="['w-1.5 h-1.5 rounded-full', page?.status === 'published' ? 'bg-green-500' : 'bg-gray-300']"></span>
239
+ {{ page?.status === "published" ? "Published" : "Draft" }}
240
+ </button>
241
+ <button @click="savePage" class="bg-blue-600 text-white px-5 py-2 rounded-full text-[9px] font-black uppercase shadow-lg active:scale-95 transition-all">
242
+ {{ isSaving ? "Saving..." : "Save" }}
243
+ </button>
244
+ </div>
245
+ </header>
246
+
247
+ <div class="flex flex-1 overflow-hidden relative">
248
+
249
+ <!-- Left sidebar: structure / SEO tree -->
250
+ <aside :class="['bg-white border-r z-50 transition-all duration-300 overflow-hidden flex flex-col', isLeftOpen ? 'w-64' : 'w-0']">
251
+ <div class="p-6 w-64 h-full flex flex-col">
252
+ <div v-if="viewMode === 'structure'" class="flex-1 space-y-2 overflow-y-auto">
253
+ <h3 class="text-[9px] font-black text-gray-300 uppercase mb-4 tracking-widest">Sections</h3>
254
+ <div v-for="(block, index) in page.content" :key="index" @click="selectedBlockIndex = index"
255
+ :class="[
256
+ 'p-3 rounded-xl border-2 transition-all cursor-pointer group flex items-center justify-between gap-2',
257
+ selectedBlockIndex === index ? 'border-blue-500 bg-blue-50/50 text-blue-600' : 'border-gray-100 bg-gray-50 text-gray-500'
258
+ ]">
259
+ <input v-if="editingLabelIndex === index"
260
+ :value="block.label || ''"
261
+ @input="block.label = $event.target.value"
262
+ @blur="editingLabelIndex = null"
263
+ @keydown.enter="editingLabelIndex = null"
264
+ @click.stop
265
+ class="flex-1 min-w-0 text-[9px] font-black uppercase bg-transparent outline-none border-b border-current"
266
+ :placeholder="block.type" autofocus
267
+ />
268
+ <span v-else class="flex-1 min-w-0 text-[9px] font-black uppercase truncate" @dblclick.stop="editingLabelIndex = index">
269
+ {{ block.label || block.type }}
270
+ </span>
271
+ <div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 text-xs">
272
+ <button @click.stop="editingLabelIndex = index" title="Rename">✎</button>
273
+ <button @click.stop="moveBlock(index, 'up')">↑</button>
274
+ <button @click.stop="moveBlock(index, 'down')">↓</button>
275
+ <button @click.stop="removeBlock(index)" class="text-red-400">✕</button>
276
+ </div>
277
+ </div>
278
+ <button @click="showAddModal = true" class="w-full py-3 border-2 border-dashed border-gray-100 rounded-xl text-[9px] font-black text-gray-400 hover:border-blue-500 hover:text-blue-500 transition-all">
279
+ + ADD SECTION
280
+ </button>
281
+ </div>
282
+
283
+ <div v-else class="flex-1 overflow-y-auto">
284
+ <h3 class="text-[9px] font-black text-blue-600 uppercase mb-6 tracking-widest">Heading Hierarchy</h3>
285
+ <div v-for="(node, nIdx) in seoTree" :key="nIdx"
286
+ class="mb-2 p-2 rounded border-l-2 bg-gray-50/50 cursor-pointer hover:bg-blue-50/50"
287
+ :style="{ marginLeft: `${tagDepth(node.tag) * 14}px`, borderColor: tagColor(node.tag) }"
288
+ @click="selectedBlockIndex = node.blockIndex">
289
+ <span class="text-[7px] font-black uppercase block" :style="{ color: tagColor(node.tag) }">{{ node.tag }}</span>
290
+ <span class="text-[10px] font-bold truncate block">{{ node.text }}</span>
291
+ </div>
292
+ </div>
293
+ </div>
294
+ </aside>
295
+
296
+ <!-- Preview: data-admin-preview opts this area out of the admin font
297
+ so section components inherit the host project's own font stack -->
298
+ <main data-admin-preview class="flex-1 overflow-y-auto p-4 md:p-10 bg-[#F5F5F7]">
299
+ <div class="max-w-4xl mx-auto bg-white shadow-2xl rounded-[32px] overflow-hidden min-h-screen border border-black/5">
300
+ <div v-for="(block, index) in page.content" :key="index" @click="selectedBlockIndex = index"
301
+ class="relative cursor-pointer" :class="{ 'ring-4 ring-blue-500 ring-inset z-10': selectedBlockIndex === index }">
302
+ <component :is="resolveComponent(`Sections${block.type}`)" v-bind="resolvePropsForPreview(block.props)" />
303
+ </div>
304
+ <div v-if="!page.content.length" class="p-20 text-center text-gray-300 italic text-sm">
305
+ Empty page — add a section to get started.
306
+ </div>
307
+ </div>
308
+ </main>
309
+
310
+ <!-- Right sidebar: field editor + SEO -->
311
+ <aside :class="['bg-white border-l z-50 transition-all duration-300 overflow-hidden flex flex-col', isRightOpen ? 'w-80' : 'w-0']">
312
+ <div class="p-6 w-80 h-full overflow-y-auto flex flex-col gap-6">
313
+
314
+ <!-- Block field editor -->
315
+ <div v-if="selectedBlockIndex !== null && selectedCMSConfig">
316
+ <div class="flex items-center justify-between mb-4">
317
+ <h2 class="text-[10px] font-black uppercase text-gray-400 tracking-widest">
318
+ {{ selectedCMSConfig.label }}
319
+ </h2>
320
+ <!-- Lang switcher -->
321
+ <div class="flex gap-1">
322
+ <button v-for="lang in LANGS" :key="lang" @click="editingLang = lang"
323
+ :class="['text-[8px] font-black px-2 py-1 rounded transition-all', editingLang === lang ? 'bg-black text-white' : 'text-gray-400 hover:text-black']">
324
+ {{ lang === "ar" ? "\u0639" : lang.toUpperCase() }}
325
+ </button>
326
+ </div>
327
+ </div>
328
+
329
+ <!-- Group tabs -->
330
+ <div v-if="selectedCMSConfig.groups" class="flex gap-1 mb-6 flex-wrap">
331
+ <button v-for="group in selectedCMSConfig.groups" :key="group.key" @click="selectedFieldGroup = group.key"
332
+ :class="[
333
+ 'px-3 py-1.5 rounded-full text-[8px] font-black uppercase tracking-widest transition-all',
334
+ selectedFieldGroup === group.key ? 'bg-black text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
335
+ ]">
336
+ {{ group.label }}
337
+ </button>
338
+ </div>
339
+
340
+ <!-- Fields -->
341
+ <div v-for="(field, name) in visibleFields" :key="String(name)" class="mb-5">
342
+ <label class="text-[8px] font-black uppercase text-gray-400 block mb-2">{{ field.label }}</label>
343
+
344
+ <!-- i18n text -->
345
+ <template v-if="field.i18n">
346
+ <input v-if="field.type === 'text'"
347
+ :value="getBlockI18n(String(name), editingLang)"
348
+ @input="setBlockI18n(String(name), editingLang, $event.target.value)"
349
+ class="w-full border-b py-2 text-xs font-bold outline-none focus:border-black transition-colors"
350
+ />
351
+ <textarea v-else-if="field.type === 'textarea'"
352
+ :value="getBlockI18n(String(name), editingLang)"
353
+ @input="setBlockI18n(String(name), editingLang, $event.target.value)"
354
+ class="w-full bg-gray-50 p-3 text-xs rounded-xl outline-none focus:ring-1 focus:ring-black resize-none" rows="3"
355
+ />
356
+ </template>
357
+
358
+ <!-- Plain text -->
359
+ <input v-else-if="field.type === 'text'"
360
+ v-model="page.content[selectedBlockIndex].props[String(name)]"
361
+ class="w-full border-b py-2 text-xs font-bold outline-none focus:border-black transition-colors"
362
+ />
363
+
364
+ <!-- Textarea -->
365
+ <textarea v-else-if="field.type === 'textarea'"
366
+ v-model="page.content[selectedBlockIndex].props[String(name)]"
367
+ class="w-full bg-gray-50 p-3 text-xs rounded-xl outline-none focus:ring-1 focus:ring-black resize-none" rows="3"
368
+ />
369
+
370
+ <!-- Color -->
371
+ <div v-else-if="field.type === 'color'" class="space-y-2">
372
+ <input v-model="page.content[selectedBlockIndex].props[String(name)]" type="color" class="h-8 w-16 rounded cursor-pointer border border-gray-200" />
373
+ <input v-model="page.content[selectedBlockIndex].props[String(name)]" class="w-full bg-gray-50 p-2 text-[9px] font-mono rounded-lg outline-none" placeholder="#ffffff" />
374
+ </div>
375
+
376
+ <!-- Image -->
377
+ <div v-else-if="field.type === 'image'"
378
+ @click="triggerUpload(page.content[selectedBlockIndex].props, String(name))"
379
+ class="relative h-24 border-2 border-dashed rounded-xl flex items-center justify-center overflow-hidden cursor-pointer hover:border-blue-400 transition-colors">
380
+ <img v-if="page.content[selectedBlockIndex].props[String(name)]" :src="page.content[selectedBlockIndex].props[String(name)]" class="w-full h-full object-cover" />
381
+ <div v-else class="text-center">
382
+ <div class="text-[8px] font-black text-gray-300 uppercase">Click to upload</div>
383
+ <div v-if="isUploading" class="text-[8px] text-blue-500 mt-1">Uploading...</div>
384
+ </div>
385
+ </div>
386
+
387
+ <!-- Boolean select -->
388
+ <select v-else-if="field.type === 'boolean' || field.type === 'select' && field.options?.length"
389
+ v-model="page.content[selectedBlockIndex].props[String(name)]"
390
+ class="w-full bg-gray-50 p-2 text-xs rounded-lg outline-none focus:ring-1 focus:ring-black">
391
+ <option v-for="opt in field.options" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</option>
392
+ </select>
393
+
394
+ <!-- Cards (button group) -->
395
+ <div v-else-if="field.type === 'cards'" class="flex flex-wrap gap-1">
396
+ <button v-for="opt in field.options" :key="String(opt.value)"
397
+ @click="page.content[selectedBlockIndex].props[String(name)] = opt.value"
398
+ :class="[
399
+ 'flex flex-col items-center gap-1 p-2 rounded-lg border-2 transition-all text-[7px] font-black uppercase',
400
+ page.content[selectedBlockIndex].props[String(name)] === opt.value ? 'border-black bg-black text-white' : 'border-gray-100 hover:border-gray-300'
401
+ ]">
402
+ <span class="text-xs">{{ opt.icon }}</span>
403
+ <span>{{ opt.label }}</span>
404
+ </button>
405
+ </div>
406
+
407
+ <!-- List of items -->
408
+ <div v-else-if="field.type === 'list'" class="space-y-3">
409
+ <div v-for="(item, itemIdx) in page.content[selectedBlockIndex].props[String(name)]" :key="itemIdx"
410
+ class="border border-gray-100 rounded-xl p-3 space-y-2">
411
+ <div class="flex justify-between items-center mb-2">
412
+ <span class="text-[7px] font-black uppercase text-gray-400">Item {{ itemIdx + 1 }}</span>
413
+ <button @click="page.content[selectedBlockIndex].props[String(name)].splice(itemIdx, 1)" class="text-red-400 text-xs">✕</button>
414
+ </div>
415
+ <template v-for="(subField, subKey) in field.itemFields" :key="String(subKey)">
416
+ <label class="text-[7px] font-black uppercase text-gray-400 block">{{ subField.label }}</label>
417
+ <template v-if="subField.i18n">
418
+ <input v-if="subField.type === 'text'"
419
+ :value="getI18nVal(item[String(subKey)], editingLang)"
420
+ @input="setI18nVal(item, String(subKey), editingLang, $event.target.value)"
421
+ class="w-full border-b py-1 text-xs outline-none focus:border-black"
422
+ />
423
+ <textarea v-else-if="subField.type === 'textarea'"
424
+ :value="getI18nVal(item[String(subKey)], editingLang)"
425
+ @input="setI18nVal(item, String(subKey), editingLang, $event.target.value)"
426
+ class="w-full bg-gray-50 p-2 text-xs rounded-lg outline-none resize-none" rows="2"
427
+ />
428
+ </template>
429
+ <input v-else-if="subField.type === 'text'" v-model="item[String(subKey)]" class="w-full border-b py-1 text-xs outline-none focus:border-black" />
430
+ <textarea v-else-if="subField.type === 'textarea'" v-model="item[String(subKey)]" class="w-full bg-gray-50 p-2 text-xs rounded-lg outline-none resize-none" rows="2" />
431
+ <div v-else-if="subField.type === 'image'"
432
+ @click="triggerUpload(item, String(subKey))"
433
+ class="h-16 border-2 border-dashed rounded-lg flex items-center justify-center overflow-hidden cursor-pointer hover:border-blue-400 transition-colors">
434
+ <img v-if="item[String(subKey)]" :src="item[String(subKey)]" class="w-full h-full object-cover" />
435
+ <span v-else class="text-[7px] font-black text-gray-300 uppercase">Upload</span>
436
+ </div>
437
+ </template>
438
+ </div>
439
+ <button
440
+ @click="page.content[selectedBlockIndex].props[String(name)].push(
441
+ Object.fromEntries(Object.keys(field.itemFields || {}).map((k) => [k, field.itemFields?.[k]?.i18n ? { fr: '', ar: '', en: '' } : '']))
442
+ )"
443
+ class="w-full py-2 border-2 border-dashed border-gray-100 rounded-xl text-[8px] font-black text-gray-400 hover:border-blue-500 transition-all">
444
+ + Add item
445
+ </button>
446
+ </div>
447
+ </div>
448
+ </div>
449
+
450
+ <!-- SEO panel -->
451
+ <div class="border-t pt-6">
452
+ <h2 class="text-[10px] font-black uppercase text-gray-400 tracking-widest mb-4">SEO</h2>
453
+ <div class="space-y-4">
454
+ <!-- Page meta -->
455
+ <div>
456
+ <label class="text-[8px] font-black uppercase text-gray-400 block mb-1">Page Title</label>
457
+ <input v-model="page.title" class="w-full border-b py-2 text-xs font-bold outline-none focus:border-black" />
458
+ </div>
459
+ <div>
460
+ <label class="text-[8px] font-black uppercase text-gray-400 block mb-1">Slug</label>
461
+ <input v-model="page.slug" class="w-full border-b py-2 text-xs font-mono outline-none focus:border-black" />
462
+ </div>
463
+ <div>
464
+ <label class="text-[8px] font-black uppercase text-gray-400 block mb-1">Page Type</label>
465
+ <select v-model="page.type" class="w-full bg-gray-50 p-2 text-xs rounded-lg outline-none">
466
+ <option v-for="opt in pageTypeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
467
+ </select>
468
+ </div>
469
+
470
+ <!-- i18n SEO fields -->
471
+ <div class="flex gap-1 mt-4 mb-2">
472
+ <button v-for="lang in LANGS" :key="lang" @click="editingLang = lang"
473
+ :class="['text-[8px] font-black px-2 py-1 rounded transition-all', editingLang === lang ? 'bg-black text-white' : 'text-gray-400 hover:text-black']">
474
+ {{ lang === "ar" ? "\u0639" : lang.toUpperCase() }}
475
+ </button>
476
+ </div>
477
+ <div>
478
+ <label class="text-[8px] font-black uppercase text-gray-400 block mb-1">Meta Title</label>
479
+ <input
480
+ :value="getSeoI18n('meta_title', editingLang)"
481
+ @input="setSeoI18n('meta_title', editingLang, $event.target.value)"
482
+ class="w-full border-b py-2 text-xs outline-none focus:border-black"
483
+ />
484
+ </div>
485
+ <div>
486
+ <label class="text-[8px] font-black uppercase text-gray-400 block mb-1">Meta Description</label>
487
+ <textarea
488
+ :value="getSeoI18n('meta_description', editingLang)"
489
+ @input="setSeoI18n('meta_description', editingLang, $event.target.value)"
490
+ class="w-full bg-gray-50 p-2 text-xs rounded-lg outline-none resize-none" rows="3"
491
+ />
492
+ </div>
493
+ <div>
494
+ <label class="text-[8px] font-black uppercase text-gray-400 block mb-1">OG Image URL</label>
495
+ <input v-model="page.seo_config.og_image" class="w-full border-b py-2 text-xs font-mono outline-none focus:border-black" />
496
+ </div>
497
+ </div>
498
+ </div>
499
+ </div>
500
+ </aside>
501
+ </div>
502
+
503
+ <!-- Add section modal -->
504
+ <div v-if="showAddModal" class="fixed inset-0 bg-black/90 backdrop-blur-xl z-[100] flex items-center justify-center p-6" @click.self="showAddModal = false">
505
+ <div class="bg-white max-w-xl w-full rounded-[32px] p-10 shadow-2xl">
506
+ <h3 class="text-[10px] font-black uppercase tracking-widest mb-6 text-gray-400">Add Section</h3>
507
+ <div class="grid grid-cols-3 gap-4">
508
+ <button v-for="(conf, type) in CMS_COMPONENTS" :key="String(type)" @click="addSection(String(type))"
509
+ class="flex flex-col items-center gap-2 p-5 border-2 border-gray-100 rounded-[24px] hover:border-black transition-all group">
510
+ <span class="text-2xl">{{ conf.icon || "\u{1F4E6}" }}</span>
511
+ <span class="text-[7px] font-black uppercase tracking-widest text-gray-600 group-hover:text-black text-center">{{ conf.label }}</span>
512
+ </button>
513
+ </div>
514
+ </div>
515
+ </div>
516
+ </div>
517
+
518
+ <div v-else class="h-screen flex items-center justify-center text-gray-400">
519
+ Loading...
520
+ </div>
521
+ </template>
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: any;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: any;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;
@@ -0,0 +1,40 @@
1
+ <script setup>
2
+ const config = useRuntimeConfig().public.adminCms;
3
+ const supabase = useSupabaseClient();
4
+ const pageTypes = config?.pageTypes || [];
5
+ const { data: pageStats } = await useAsyncData("admin-page-stats", async () => {
6
+ const { data } = await supabase.from("pages").select("type");
7
+ return data?.reduce((acc, curr) => {
8
+ acc[curr.type] = (acc[curr.type] || 0) + 1;
9
+ return acc;
10
+ }, {}) ?? {};
11
+ });
12
+ useHead({ title: "Dashboard \u2014 Admin" });
13
+ </script>
14
+
15
+ <template>
16
+ <div data-admin-cms>
17
+ <AdminNavbar />
18
+ <div class="p-8">
19
+ <h1 class="text-2xl font-bold mb-2">Page Management</h1>
20
+ <p class="text-gray-500 mb-8">Create and manage your pages by type.</p>
21
+
22
+ <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
23
+ <NuxtLink
24
+ v-for="type in pageTypes"
25
+ :key="type.id"
26
+ :to="`/admin/pages/${type.id}`"
27
+ class="bg-white p-10 rounded-xl shadow-sm hover:shadow-md transition-shadow flex flex-col items-center justify-center border border-gray-100 group"
28
+ >
29
+ <span class="text-3xl mb-4">{{ type.icon || "\u{1F4C4}" }}</span>
30
+ <h3 class="font-semibold text-gray-800 group-hover:text-blue-600 transition-colors text-center">
31
+ {{ type.label }}
32
+ </h3>
33
+ <span class="text-xs text-gray-400 mt-2">
34
+ {{ pageStats?.[type.id] || 0 }} page(s)
35
+ </span>
36
+ </NuxtLink>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ </template>
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: any;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: any;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;