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 */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AdminField: typeof import('./src/components/Admin/Field.vue')['default']
|
||||
Button: typeof import('./src/components/Button.vue')['default']
|
||||
Footer: typeof import('./src/components/Footer.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>
|
||||
+98
-13
@@ -46,7 +46,7 @@
|
||||
<p
|
||||
class="px-2 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
{{ page }}
|
||||
{{ PAGE_LABELS[page] ?? page }}
|
||||
</p>
|
||||
<button
|
||||
v-for="ds in group"
|
||||
@@ -117,8 +117,10 @@
|
||||
>
|
||||
<template v-if="selected">
|
||||
<h2 class="font-semibold">
|
||||
{{ selected.page }} /
|
||||
<span class="text-[#3FA0C7]">{{ selected.locale }}</span>
|
||||
{{ PAGE_LABELS[selected.page] ?? selected.page }}
|
||||
<span class="text-[#3FA0C7]">{{
|
||||
selected.locale.toUpperCase()
|
||||
}}</span>
|
||||
</h2>
|
||||
<span v-if="selectedUpdatedAt" class="text-xs text-gray-400">
|
||||
Son güncelleme: {{ formatDate(selectedUpdatedAt) }}
|
||||
@@ -133,6 +135,13 @@
|
||||
<div class="flex-1"></div>
|
||||
<template v-if="selected">
|
||||
<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"
|
||||
@click="formatJson"
|
||||
>
|
||||
@@ -166,8 +175,28 @@
|
||||
>{{ 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
|
||||
v-if="selected"
|
||||
v-else-if="selected"
|
||||
v-model="editorText"
|
||||
spellcheck="false"
|
||||
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 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 password = ref("");
|
||||
const busy = ref(false);
|
||||
@@ -214,9 +251,50 @@ const datasets = ref<Dataset[]>([]);
|
||||
const selected = ref<Dataset | null>(null);
|
||||
const selectedUpdatedAt = ref<string | null>(null);
|
||||
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 = {
|
||||
running: boolean;
|
||||
@@ -316,8 +394,9 @@ async function select(ds: Dataset) {
|
||||
const row = await api(`/datasets/${ds.page}/${ds.locale}`);
|
||||
selected.value = ds;
|
||||
selectedUpdatedAt.value = row.updated_at;
|
||||
model.value = row.content;
|
||||
savedText.value = JSON.stringify(row.content);
|
||||
editorText.value = JSON.stringify(row.content, null, 2);
|
||||
savedText.value = editorText.value;
|
||||
} catch (e: any) {
|
||||
flash(e.message);
|
||||
} finally {
|
||||
@@ -346,11 +425,15 @@ function formatJson() {
|
||||
async function save() {
|
||||
if (!selected.value) return;
|
||||
let content: unknown;
|
||||
try {
|
||||
content = JSON.parse(editorText.value);
|
||||
} catch (e: any) {
|
||||
jsonError.value = e.message;
|
||||
return;
|
||||
if (viewMode.value === "json") {
|
||||
try {
|
||||
content = JSON.parse(editorText.value);
|
||||
} catch (e: any) {
|
||||
jsonError.value = e.message;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
content = JSON.parse(JSON.stringify(model.value));
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
@@ -358,7 +441,9 @@ async function save() {
|
||||
`/datasets/${selected.value.page}/${selected.value.locale}`,
|
||||
{ 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;
|
||||
flash("Kaydedildi ✓ (Siteye yansıtmak için 'DB → JSON Dosyalarına Aktar')");
|
||||
await loadDatasets();
|
||||
|
||||
Reference in New Issue
Block a user