feat: form tabanlı admin editörü — JSON yerine kullanıcı dostu alanlar
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3420d32271
commit
9ee4c94278
Vendored
+1
@@ -11,6 +11,7 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
AdminField: typeof import('./src/components/Admin/Field.vue')['default']
|
||||||
Button: typeof import('./src/components/Button.vue')['default']
|
Button: typeof import('./src/components/Button.vue')['default']
|
||||||
Footer: typeof import('./src/components/Footer.vue')['default']
|
Footer: typeof import('./src/components/Footer.vue')['default']
|
||||||
Header: typeof import('./src/components/Header.vue')['default']
|
Header: typeof import('./src/components/Header.vue')['default']
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Object inside an array item: render fields directly (card already has a header) -->
|
||||||
|
<div v-if="kind === 'object' && bare" class="space-y-3">
|
||||||
|
<AdminField
|
||||||
|
v-for="k in objectKeys"
|
||||||
|
:key="k"
|
||||||
|
:parent="value"
|
||||||
|
:field="k"
|
||||||
|
:depth="(depth ?? 0) + 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Object: collapsible section -->
|
||||||
|
<details
|
||||||
|
v-else-if="kind === 'object'"
|
||||||
|
class="border border-gray-200 rounded-xl bg-white"
|
||||||
|
:open="(depth ?? 0) < 1"
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
class="cursor-pointer select-none px-4 py-2.5 font-semibold text-sm text-[#3C393D] flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>{{ displayLabel }}</span>
|
||||||
|
<span
|
||||||
|
v-if="value.__component"
|
||||||
|
class="text-[10px] font-normal bg-gray-100 text-gray-400 rounded-full px-2 py-0.5"
|
||||||
|
>{{ value.__component }}</span
|
||||||
|
>
|
||||||
|
</summary>
|
||||||
|
<div class="px-4 pb-4 space-y-3 border-t border-gray-100 pt-3">
|
||||||
|
<AdminField
|
||||||
|
v-for="k in objectKeys"
|
||||||
|
:key="k"
|
||||||
|
:parent="value"
|
||||||
|
:field="k"
|
||||||
|
:depth="(depth ?? 0) + 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Array: item list with add/remove/reorder -->
|
||||||
|
<div v-else-if="kind === 'array'" class="space-y-2">
|
||||||
|
<p class="text-sm font-semibold text-[#3C393D]">
|
||||||
|
{{ displayLabel }}
|
||||||
|
<span class="text-xs font-normal text-gray-400">({{ value.length }})</span>
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="(item, i) in value"
|
||||||
|
:key="i"
|
||||||
|
class="relative border border-gray-200 rounded-xl bg-[#fafbfc]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
<span class="font-semibold truncate">{{ itemTitle(item, i) }}</span>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<button
|
||||||
|
class="px-1.5 py-0.5 rounded hover:bg-gray-200 cursor-pointer disabled:opacity-30"
|
||||||
|
:disabled="i === 0"
|
||||||
|
title="Yukarı taşı"
|
||||||
|
@click="move(i, -1)"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-1.5 py-0.5 rounded hover:bg-gray-200 cursor-pointer disabled:opacity-30"
|
||||||
|
:disabled="i === value.length - 1"
|
||||||
|
title="Aşağı taşı"
|
||||||
|
@click="move(i, 1)"
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-1.5 py-0.5 rounded text-red-500 hover:bg-red-50 cursor-pointer"
|
||||||
|
title="Sil"
|
||||||
|
@click="removeAt(i)"
|
||||||
|
>
|
||||||
|
Sil
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<AdminField
|
||||||
|
:parent="value"
|
||||||
|
:field="i"
|
||||||
|
:depth="(depth ?? 0) + 1"
|
||||||
|
bare
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="w-full border border-dashed border-gray-300 rounded-xl py-2 text-sm text-gray-500 hover:border-[#3FA0C7] hover:text-[#3FA0C7] cursor-pointer"
|
||||||
|
@click="addItem"
|
||||||
|
>
|
||||||
|
+ Ekle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Primitive inside an array item (no label of its own) -->
|
||||||
|
<div v-else-if="bare">
|
||||||
|
<component :is="renderPrimitive" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Primitive field -->
|
||||||
|
<div v-else class="space-y-1">
|
||||||
|
<label class="block text-xs font-medium text-gray-500">
|
||||||
|
{{ displayLabel }}
|
||||||
|
</label>
|
||||||
|
<component :is="renderPrimitive" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h } from "vue";
|
||||||
|
|
||||||
|
defineOptions({ name: "AdminField" });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
parent: any;
|
||||||
|
field: string | number;
|
||||||
|
depth?: number;
|
||||||
|
bare?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const READONLY_KEYS = new Set(["__component", "id", "slug", "locale", "type"]);
|
||||||
|
|
||||||
|
const LABELS: Record<string, string> = {
|
||||||
|
title: "Başlık",
|
||||||
|
subtitle: "Alt Başlık",
|
||||||
|
description: "Açıklama",
|
||||||
|
excerpt: "Özet",
|
||||||
|
text: "Metin",
|
||||||
|
content: "İçerik",
|
||||||
|
image: "Görsel",
|
||||||
|
images: "Görseller",
|
||||||
|
src: "Görsel Yolu",
|
||||||
|
alt: "Görsel Açıklaması (alt)",
|
||||||
|
price: "Fiyat",
|
||||||
|
name: "Ad",
|
||||||
|
fullName: "Ad Soyad",
|
||||||
|
items: "Öğeler",
|
||||||
|
categories: "Kategoriler",
|
||||||
|
components: "Sayfa Bölümleri",
|
||||||
|
meta: "SEO Ayarları (Gelişmiş)",
|
||||||
|
keywords: "Anahtar Kelimeler",
|
||||||
|
canonical: "Canonical URL",
|
||||||
|
author: "Yazar",
|
||||||
|
robots: "Robots",
|
||||||
|
enabled: "Aktif",
|
||||||
|
startDate: "Başlangıç Tarihi",
|
||||||
|
endDate: "Bitiş Tarihi",
|
||||||
|
discount: "İndirim",
|
||||||
|
rules: "Kurallar",
|
||||||
|
tags: "Etiketler",
|
||||||
|
buttons: "Butonlar",
|
||||||
|
button: "Buton",
|
||||||
|
link: "Bağlantı",
|
||||||
|
url: "URL",
|
||||||
|
href: "Bağlantı (href)",
|
||||||
|
label: "Etiket",
|
||||||
|
plates: "Tabak Görselleri",
|
||||||
|
parent: "Üst Kategori",
|
||||||
|
phone: "Telefon",
|
||||||
|
email: "E-posta",
|
||||||
|
address: "Adres",
|
||||||
|
testimonials: "Yorumlar",
|
||||||
|
comment: "Yorum",
|
||||||
|
rating: "Puan",
|
||||||
|
locale: "Dil",
|
||||||
|
gallery: "Galeri",
|
||||||
|
video: "Video",
|
||||||
|
poster: "Kapak Görseli",
|
||||||
|
order: "Sıra",
|
||||||
|
badge: "Rozet",
|
||||||
|
icon: "İkon",
|
||||||
|
list: "Liste",
|
||||||
|
features: "Özellikler",
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = computed(() => props.parent[props.field]);
|
||||||
|
|
||||||
|
const kind = computed(() => {
|
||||||
|
const v = value.value;
|
||||||
|
if (Array.isArray(v)) return "array";
|
||||||
|
if (v !== null && typeof v === "object") return "object";
|
||||||
|
return "primitive";
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayLabel = computed(() => {
|
||||||
|
const f = props.field;
|
||||||
|
if (typeof f === "number") return `Öğe ${f + 1}`;
|
||||||
|
if (LABELS[f]) return LABELS[f];
|
||||||
|
// camelCase / kebab-case -> Title Case
|
||||||
|
return String(f)
|
||||||
|
.replace(/[-_]/g, " ")
|
||||||
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||||
|
.replace(/^./, (c) => c.toUpperCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
const objectKeys = computed(() => {
|
||||||
|
const keys = Object.keys(value.value).filter((k) => k !== "__component");
|
||||||
|
// Meta/SEO en sona
|
||||||
|
return keys.sort((a, b) => (a === "meta" ? 1 : b === "meta" ? -1 : 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
function stripHtml(s: string) {
|
||||||
|
return s.replace(/<[^>]*>/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTION_LABELS: Record<string, string> = {
|
||||||
|
"sections.hero": "Hero (Giriş)",
|
||||||
|
"sections.highlight": "Öne Çıkanlar",
|
||||||
|
"sections.banner": "Banner",
|
||||||
|
"sections.parallax": "Parallax Görsel",
|
||||||
|
"sections.overview": "Genel Bakış",
|
||||||
|
"sections.cta": "Sipariş Çağrısı (CTA)",
|
||||||
|
"sections.gallery": "Galeri",
|
||||||
|
"sections.testimonials": "Müşteri Yorumları",
|
||||||
|
"sections.breakfast": "Kahvaltı Bölümü",
|
||||||
|
"sections.contact": "İletişim Formu",
|
||||||
|
};
|
||||||
|
|
||||||
|
function itemTitle(item: any, i: number) {
|
||||||
|
if (item && typeof item === "object" && item.__component) {
|
||||||
|
return SECTION_LABELS[item.__component] ?? item.__component;
|
||||||
|
}
|
||||||
|
if (item && typeof item === "object") {
|
||||||
|
const t = item.title || item.name || item.label || item.alt || item.fullName;
|
||||||
|
if (typeof t === "string" && t.trim()) return stripHtml(t).slice(0, 60);
|
||||||
|
}
|
||||||
|
if (typeof item === "string" && item.trim()) return stripHtml(item).slice(0, 60);
|
||||||
|
return `Öğe ${i + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(i: number, dir: number) {
|
||||||
|
const arr = value.value;
|
||||||
|
const j = i + dir;
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(i: number) {
|
||||||
|
if (!confirm(`"${itemTitle(value.value[i], i)}" silinsin mi?`)) return;
|
||||||
|
value.value.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptied(v: any): any {
|
||||||
|
if (Array.isArray(v)) return v.length ? [emptied(v[0])] : [];
|
||||||
|
if (v !== null && typeof v === "object") {
|
||||||
|
const out: any = {};
|
||||||
|
for (const k of Object.keys(v)) {
|
||||||
|
// Yapıyı koru: __component gibi anahtarlar aynen kalsın
|
||||||
|
out[k] = k.startsWith("__") || READONLY_KEYS.has(k) ? v[k] : emptied(v[k]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (typeof v === "string") return "";
|
||||||
|
if (typeof v === "number") return 0;
|
||||||
|
if (typeof v === "boolean") return v;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
const arr = value.value;
|
||||||
|
if (arr.length) {
|
||||||
|
arr.push(emptied(arr[arr.length - 1]));
|
||||||
|
} else {
|
||||||
|
arr.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- primitive rendering ----
|
||||||
|
|
||||||
|
const isReadonly = computed(
|
||||||
|
() => typeof props.field === "string" && READONLY_KEYS.has(props.field),
|
||||||
|
);
|
||||||
|
|
||||||
|
function stringKind(s: string): "date" | "image" | "long" | "bool" | "text" {
|
||||||
|
if (/^(true|false)$/i.test(s)) return "bool";
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return "date";
|
||||||
|
if (/^\/[^\s]+\.(webp|jpe?g|png|svg|gif|avif)$/i.test(s)) return "image";
|
||||||
|
if (s.length > 90 || s.includes("\n")) return "long";
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full border border-gray-300 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#3FA0C7] disabled:bg-gray-100 disabled:text-gray-400";
|
||||||
|
|
||||||
|
const renderPrimitive = () => {
|
||||||
|
const v = value.value;
|
||||||
|
const set = (nv: any) => (props.parent[props.field] = nv);
|
||||||
|
|
||||||
|
if (typeof v === "boolean") {
|
||||||
|
return h("label", { class: "flex items-center gap-2 cursor-pointer" }, [
|
||||||
|
h("input", {
|
||||||
|
type: "checkbox",
|
||||||
|
checked: v,
|
||||||
|
class: "w-4 h-4 accent-[#3FA0C7]",
|
||||||
|
onChange: (e: Event) => set((e.target as HTMLInputElement).checked),
|
||||||
|
}),
|
||||||
|
h("span", { class: "text-sm" }, v ? "Açık" : "Kapalı"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof v === "number") {
|
||||||
|
return h("input", {
|
||||||
|
type: "number",
|
||||||
|
value: v,
|
||||||
|
disabled: isReadonly.value,
|
||||||
|
class: inputCls,
|
||||||
|
onInput: (e: Event) =>
|
||||||
|
set(Number((e.target as HTMLInputElement).value) || 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = String(v ?? "");
|
||||||
|
const sk = typeof v === "string" ? stringKind(s) : "text";
|
||||||
|
|
||||||
|
if (sk === "bool" && !isReadonly.value) {
|
||||||
|
// "True"/"False" gibi string boolean'lar — orijinal yazımı koru
|
||||||
|
const isTrue = /^true$/i.test(s);
|
||||||
|
const cap = s[0] === s[0]?.toUpperCase();
|
||||||
|
return h(
|
||||||
|
"select",
|
||||||
|
{
|
||||||
|
value: isTrue ? "on" : "off",
|
||||||
|
class: inputCls + " cursor-pointer",
|
||||||
|
onChange: (e: Event) => {
|
||||||
|
const on = (e.target as HTMLSelectElement).value === "on";
|
||||||
|
set(cap ? (on ? "True" : "False") : on ? "true" : "false");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h("option", { value: "on" }, "Açık"),
|
||||||
|
h("option", { value: "off" }, "Kapalı"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sk === "date" && !isReadonly.value) {
|
||||||
|
return h("input", {
|
||||||
|
type: "date",
|
||||||
|
value: s,
|
||||||
|
class: inputCls,
|
||||||
|
onInput: (e: Event) => set((e.target as HTMLInputElement).value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sk === "image") {
|
||||||
|
return h("div", { class: "flex items-center gap-3" }, [
|
||||||
|
h("img", {
|
||||||
|
src: s,
|
||||||
|
class:
|
||||||
|
"w-14 h-14 rounded-lg object-cover border border-gray-200 bg-gray-50 shrink-0",
|
||||||
|
loading: "lazy",
|
||||||
|
onError: (e: Event) =>
|
||||||
|
((e.target as HTMLImageElement).style.opacity = "0.2"),
|
||||||
|
}),
|
||||||
|
h("input", {
|
||||||
|
type: "text",
|
||||||
|
value: s,
|
||||||
|
disabled: isReadonly.value,
|
||||||
|
class: inputCls + " font-mono text-xs",
|
||||||
|
onInput: (e: Event) => set((e.target as HTMLInputElement).value),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sk === "long") {
|
||||||
|
const rows = Math.min(8, Math.max(3, Math.ceil(s.length / 80)));
|
||||||
|
return h("textarea", {
|
||||||
|
value: s,
|
||||||
|
rows,
|
||||||
|
disabled: isReadonly.value,
|
||||||
|
class: inputCls + " resize-y leading-relaxed",
|
||||||
|
onInput: (e: Event) => set((e.target as HTMLTextAreaElement).value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return h("input", {
|
||||||
|
type: "text",
|
||||||
|
value: s,
|
||||||
|
disabled: isReadonly.value,
|
||||||
|
class: inputCls,
|
||||||
|
onInput: (e: Event) => set((e.target as HTMLInputElement).value),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
<p
|
<p
|
||||||
class="px-2 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400"
|
class="px-2 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400"
|
||||||
>
|
>
|
||||||
{{ page }}
|
{{ PAGE_LABELS[page] ?? page }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
v-for="ds in group"
|
v-for="ds in group"
|
||||||
@@ -117,8 +117,10 @@
|
|||||||
>
|
>
|
||||||
<template v-if="selected">
|
<template v-if="selected">
|
||||||
<h2 class="font-semibold">
|
<h2 class="font-semibold">
|
||||||
{{ selected.page }} /
|
{{ PAGE_LABELS[selected.page] ?? selected.page }}
|
||||||
<span class="text-[#3FA0C7]">{{ selected.locale }}</span>
|
<span class="text-[#3FA0C7]">{{
|
||||||
|
selected.locale.toUpperCase()
|
||||||
|
}}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<span v-if="selectedUpdatedAt" class="text-xs text-gray-400">
|
<span v-if="selectedUpdatedAt" class="text-xs text-gray-400">
|
||||||
Son güncelleme: {{ formatDate(selectedUpdatedAt) }}
|
Son güncelleme: {{ formatDate(selectedUpdatedAt) }}
|
||||||
@@ -133,6 +135,13 @@
|
|||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<template v-if="selected">
|
<template v-if="selected">
|
||||||
<button
|
<button
|
||||||
|
class="text-xs text-gray-400 hover:text-gray-600 px-2 py-1.5 cursor-pointer"
|
||||||
|
@click="toggleMode"
|
||||||
|
>
|
||||||
|
{{ viewMode === "form" ? "Gelişmiş (JSON)" : "← Form Görünümü" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="viewMode === 'json'"
|
||||||
class="text-sm border border-gray-300 hover:bg-gray-50 rounded-lg px-3 py-1.5 cursor-pointer"
|
class="text-sm border border-gray-300 hover:bg-gray-50 rounded-lg px-3 py-1.5 cursor-pointer"
|
||||||
@click="formatJson"
|
@click="formatJson"
|
||||||
>
|
>
|
||||||
@@ -166,8 +175,28 @@
|
|||||||
>{{ publishState?.log || "..." }}</pre
|
>{{ publishState?.log || "..." }}</pre
|
||||||
>
|
>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selected && viewMode === 'form'"
|
||||||
|
class="flex-1 overflow-y-auto p-5 space-y-3"
|
||||||
|
>
|
||||||
|
<template v-if="model && !Array.isArray(model)">
|
||||||
|
<AdminField
|
||||||
|
v-for="k in rootKeys"
|
||||||
|
:key="k"
|
||||||
|
:parent="model"
|
||||||
|
:field="k"
|
||||||
|
:depth="0"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<AdminField
|
||||||
|
v-else-if="model"
|
||||||
|
:parent="rootWrap"
|
||||||
|
:field="selected.page"
|
||||||
|
:depth="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
v-if="selected"
|
v-else-if="selected"
|
||||||
v-model="editorText"
|
v-model="editorText"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
class="flex-1 w-full resize-none font-mono text-[13px] leading-relaxed p-5 bg-[#1e1e2e] text-[#e6e6ef] focus:outline-none"
|
class="flex-1 w-full resize-none font-mono text-[13px] leading-relaxed p-5 bg-[#1e1e2e] text-[#e6e6ef] focus:outline-none"
|
||||||
@@ -203,6 +232,14 @@ type Dataset = {
|
|||||||
|
|
||||||
const TOKEN_KEY = "muco-admin-token";
|
const TOKEN_KEY = "muco-admin-token";
|
||||||
|
|
||||||
|
const PAGE_LABELS: Record<string, string> = {
|
||||||
|
home: "Ana Sayfa",
|
||||||
|
menu: "Menü",
|
||||||
|
campaigns: "Kampanyalar",
|
||||||
|
legal: "Yasal Metinler",
|
||||||
|
"sinirsiz-kahvalti": "Sınırsız Kahvaltı",
|
||||||
|
};
|
||||||
|
|
||||||
const token = ref<string | null>(null);
|
const token = ref<string | null>(null);
|
||||||
const password = ref("");
|
const password = ref("");
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
@@ -214,9 +251,50 @@ const datasets = ref<Dataset[]>([]);
|
|||||||
const selected = ref<Dataset | null>(null);
|
const selected = ref<Dataset | null>(null);
|
||||||
const selectedUpdatedAt = ref<string | null>(null);
|
const selectedUpdatedAt = ref<string | null>(null);
|
||||||
const editorText = ref("");
|
const editorText = ref("");
|
||||||
const savedText = ref("");
|
const savedText = ref(""); // compact JSON snapshot of last saved content
|
||||||
|
const model = ref<any>(null); // reactive form model
|
||||||
|
const viewMode = ref<"form" | "json">("form");
|
||||||
|
|
||||||
const dirty = computed(() => editorText.value !== savedText.value);
|
const dirty = computed(() => {
|
||||||
|
if (!selected.value) return false;
|
||||||
|
if (viewMode.value === "json") {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(editorText.value)) !== savedText.value;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(model.value) !== savedText.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Root-level form alanları: locale gizli, meta en sonda
|
||||||
|
const rootKeys = computed(() => {
|
||||||
|
if (!model.value || Array.isArray(model.value)) return [];
|
||||||
|
return Object.keys(model.value)
|
||||||
|
.filter((k) => k !== "locale")
|
||||||
|
.sort((a, b) => (a === "meta" ? 1 : b === "meta" ? -1 : 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kök dizi (ör. kampanyalar) için sarmalayıcı
|
||||||
|
const rootWrap = computed(() => ({
|
||||||
|
[selected.value?.page ?? "liste"]: model.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function toggleMode() {
|
||||||
|
if (viewMode.value === "form") {
|
||||||
|
editorText.value = JSON.stringify(model.value, null, 2);
|
||||||
|
jsonError.value = "";
|
||||||
|
viewMode.value = "json";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
model.value = JSON.parse(editorText.value);
|
||||||
|
jsonError.value = "";
|
||||||
|
viewMode.value = "form";
|
||||||
|
} catch (e: any) {
|
||||||
|
jsonError.value = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type PublishState = {
|
type PublishState = {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
@@ -316,8 +394,9 @@ async function select(ds: Dataset) {
|
|||||||
const row = await api(`/datasets/${ds.page}/${ds.locale}`);
|
const row = await api(`/datasets/${ds.page}/${ds.locale}`);
|
||||||
selected.value = ds;
|
selected.value = ds;
|
||||||
selectedUpdatedAt.value = row.updated_at;
|
selectedUpdatedAt.value = row.updated_at;
|
||||||
|
model.value = row.content;
|
||||||
|
savedText.value = JSON.stringify(row.content);
|
||||||
editorText.value = JSON.stringify(row.content, null, 2);
|
editorText.value = JSON.stringify(row.content, null, 2);
|
||||||
savedText.value = editorText.value;
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
flash(e.message);
|
flash(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -346,19 +425,25 @@ function formatJson() {
|
|||||||
async function save() {
|
async function save() {
|
||||||
if (!selected.value) return;
|
if (!selected.value) return;
|
||||||
let content: unknown;
|
let content: unknown;
|
||||||
|
if (viewMode.value === "json") {
|
||||||
try {
|
try {
|
||||||
content = JSON.parse(editorText.value);
|
content = JSON.parse(editorText.value);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
jsonError.value = e.message;
|
jsonError.value = e.message;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
content = JSON.parse(JSON.stringify(model.value));
|
||||||
|
}
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
try {
|
try {
|
||||||
const row = await api(
|
const row = await api(
|
||||||
`/datasets/${selected.value.page}/${selected.value.locale}`,
|
`/datasets/${selected.value.page}/${selected.value.locale}`,
|
||||||
{ method: "PUT", body: JSON.stringify({ content }) },
|
{ method: "PUT", body: JSON.stringify({ content }) },
|
||||||
);
|
);
|
||||||
savedText.value = editorText.value;
|
model.value = content;
|
||||||
|
savedText.value = JSON.stringify(content);
|
||||||
|
editorText.value = JSON.stringify(content, null, 2);
|
||||||
selectedUpdatedAt.value = row.updated_at;
|
selectedUpdatedAt.value = row.updated_at;
|
||||||
flash("Kaydedildi ✓ (Siteye yansıtmak için 'DB → JSON Dosyalarına Aktar')");
|
flash("Kaydedildi ✓ (Siteye yansıtmak için 'DB → JSON Dosyalarına Aktar')");
|
||||||
await loadDatasets();
|
await loadDatasets();
|
||||||
|
|||||||
Reference in New Issue
Block a user