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:
AyrisAI
2026-07-21 01:36:28 +03:00
co-authored by Claude Fable 5
parent 3420d32271
commit 9ee4c94278
3 changed files with 484 additions and 13 deletions
+1
View File
@@ -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']
+385
View File
@@ -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
View File
@@ -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();