Make admin section editor form-based and localize Montech site to Persian.

Operators can edit landing copy as plain fields instead of JSON/HTML; default CMS seed and UI chrome are now Farsi/RTL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-30 20:33:37 +03:30
co-authored by Cursor
parent 0f4363174c
commit 3410f3e5ed
22 changed files with 462 additions and 218 deletions
+8 -8
View File
@@ -20,28 +20,28 @@
<textarea v-model="form.excerpt" class="textarea" />
</div>
<div class="field">
<label class="label">محتوا (HTML)</label>
<textarea v-model="form.content" class="textarea" dir="ltr" style="min-height: 240px" />
<label class="label">متن پست</label>
<textarea v-model="form.content" class="textarea" style="min-height: 240px" placeholder="متن را عادی بنویسید. برای پاراگراف جدید Enter بزنید." />
</div>
<div class="grid-2">
<div class="field">
<label class="label">کاور URL</label>
<input v-model="form.coverUrl" class="input" dir="ltr" />
<label class="label">آدرس تصویر کاور</label>
<input v-model="form.coverUrl" class="input" dir="ltr" placeholder="از بخش رسانه آپلود کنید و آدرس را اینجا بگذارید" />
</div>
<div class="field">
<label class="label">SEO Title</label>
<label class="label">عنوان سئو</label>
<input v-model="form.seoTitle" class="input" />
</div>
<div class="field">
<label class="label">SEO Description</label>
<label class="label">توضیح سئو</label>
<input v-model="form.seoDesc" class="input" />
</div>
<div class="field">
<label class="label">Canonical</label>
<label class="label">آدرس Canonical</label>
<input v-model="form.canonical" class="input" dir="ltr" />
</div>
<div class="field">
<label class="label">OG Image</label>
<label class="label">تصویر اشتراک‌گذاری</label>
<input v-model="form.ogImage" class="input" dir="ltr" />
</div>
<div class="field">
+225 -12
View File
@@ -1,40 +1,253 @@
<template>
<div>
<div class="row" style="justify-content: space-between; margin-bottom: 1rem">
<h1>بخش: {{ key }}</h1>
<h1>{{ titleFa }}</h1>
<RouterLink class="btn ghost" to="/sections">بازگشت</RouterLink>
</div>
<div class="card">
<textarea v-model="jsonText" class="textarea" dir="ltr" style="min-height: 420px" />
<p v-if="msg" class="muted">{{ msg }}</p>
<button class="btn" @click="save">ذخیره</button>
</div>
<form v-if="ready" class="card" @submit.prevent="save">
<!-- HERO -->
<template v-if="key === 'hero'">
<div class="field"><label class="label">برچسب بالای صفحه</label><input v-model="form.eyebrow" class="input" /></div>
<div class="field"><label class="label">محل / سال تأسیس</label><input v-model="form.location" class="input" /></div>
<div class="field"><label class="label">خط ۱ عنوان</label><input v-model="form.lines[0]" class="input" /></div>
<div class="field"><label class="label">خط ۲ عنوان</label><input v-model="form.lines[1]" class="input" /></div>
<div class="field"><label class="label">خط ۳ عنوان</label><input v-model="form.lines[2]" class="input" /></div>
<div class="field"><label class="label">کلمهٔ برجسته (رنگی)</label><input v-model="form.highlightWord" class="input" /></div>
<div class="field"><label class="label">توضیح کوتاه</label><textarea v-model="form.body" class="textarea" /></div>
<div class="field"><label class="label">متن دکمه</label><input v-model="form.cta" class="input" /></div>
<div class="field"><label class="label">مختصات / موقعیت</label><input v-model="form.coords" class="input" /></div>
<div class="field"><label class="label">برچسب پروژه‌ها</label><input v-model="form.projectsLabel" class="input" /></div>
</template>
<!-- MARQUEE -->
<template v-else-if="key === 'marquee'">
<div class="field">
<label class="label">موارد نوار متحرک (هر خط یک مورد)</label>
<textarea v-model="marqueeText" class="textarea" style="min-height: 200px" />
</div>
</template>
<!-- MANIFESTO -->
<template v-else-if="key === 'manifesto'">
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
<div class="field">
<label class="label">جملهٔ اصلی (کلمات را با فاصله جدا کنید؛ برای برجسته کردن کلمه را داخل *بگذارید*)</label>
<textarea v-model="manifestoWordsText" class="textarea" />
<p class="muted" style="margin-top: 0.35rem">مثال: ما نرم‌افزاری می‌سازیم که *تفاوت* ایجاد کند. MONTECH</p>
</div>
<div class="field"><label class="label">توضیح</label><textarea v-model="form.body" class="textarea" /></div>
</template>
<!-- SERVICES -->
<template v-else-if="key === 'services'">
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
<div v-for="(item, i) in form.items" :key="i" class="item-box">
<div class="row" style="justify-content: space-between">
<strong>خدمت {{ i + 1 }}</strong>
<button type="button" class="btn danger" @click="form.items.splice(i, 1)">حذف</button>
</div>
<div class="field"><label class="label">شماره</label><input v-model="item.n" class="input" /></div>
<div class="field"><label class="label">عنوان</label><input v-model="item.title" class="input" /></div>
<div class="field"><label class="label">توضیح</label><textarea v-model="item.desc" class="textarea" /></div>
<div class="field"><label class="label">برچسب‌ها (با ویرگول)</label><input :value="(item.tags || []).join('، ')" class="input" @input="item.tags = splitTags(($event.target as HTMLInputElement).value)" /></div>
</div>
<button type="button" class="btn ghost" @click="form.items.push({ n: String(form.items.length + 1).padStart(2, '0'), title: '', desc: '', tags: [] })">+ افزودن خدمت</button>
</template>
<!-- WORK -->
<template v-else-if="key === 'work'">
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
<div v-for="(item, i) in form.items" :key="i" class="item-box">
<div class="row" style="justify-content: space-between">
<strong>نمونه کار {{ i + 1 }}</strong>
<button type="button" class="btn danger" @click="form.items.splice(i, 1)">حذف</button>
</div>
<div class="field"><label class="label">شماره</label><input v-model="item.n" class="input" /></div>
<div class="field"><label class="label">نام پروژه</label><input v-model="item.title" class="input" /></div>
<div class="field"><label class="label">دسته‌بندی</label><input v-model="item.cat" class="input" /></div>
<div class="field"><label class="label">سال</label><input v-model="item.year" class="input" /></div>
</div>
<button type="button" class="btn ghost" @click="form.items.push({ n: String(form.items.length + 1).padStart(2, '0'), title: '', cat: '', year: '' })">+ افزودن پروژه</button>
</template>
<!-- PROCESS -->
<template v-else-if="key === 'process'">
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
<div v-for="(step, i) in form.steps" :key="i" class="item-box">
<div class="row" style="justify-content: space-between">
<strong>مرحله {{ i + 1 }}</strong>
<button type="button" class="btn danger" @click="form.steps.splice(i, 1)">حذف</button>
</div>
<div class="field"><label class="label">شماره</label><input v-model="step.n" class="input" /></div>
<div class="field"><label class="label">عنوان</label><input v-model="step.title" class="input" /></div>
<div class="field"><label class="label">توضیح</label><textarea v-model="step.desc" class="textarea" /></div>
</div>
<button type="button" class="btn ghost" @click="form.steps.push({ n: String(form.steps.length + 1).padStart(2, '0'), title: '', desc: '' })">+ افزودن مرحله</button>
</template>
<!-- STATS -->
<template v-else-if="key === 'stats'">
<div v-for="(item, i) in form.items" :key="i" class="item-box">
<div class="row" style="justify-content: space-between">
<strong>آمار {{ i + 1 }}</strong>
<button type="button" class="btn danger" @click="form.items.splice(i, 1)">حذف</button>
</div>
<div class="grid-2">
<div class="field"><label class="label">عدد</label><input v-model.number="item.value" type="number" class="input" /></div>
<div class="field"><label class="label">پسوند (+ یا %)</label><input v-model="item.suffix" class="input" /></div>
</div>
<div class="field"><label class="label">برچسب</label><input v-model="item.label" class="input" /></div>
</div>
<button type="button" class="btn ghost" @click="form.items.push({ value: 0, suffix: '+', label: '' })">+ افزودن آمار</button>
</template>
<!-- TEAM -->
<template v-else-if="key === 'team'">
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
<div class="field"><label class="label">تیتر بزرگ</label><input v-model="form.headline" class="input" /></div>
<div class="field"><label class="label">توضیح</label><textarea v-model="form.body" class="textarea" /></div>
<div v-for="(m, i) in form.members" :key="i" class="item-box">
<div class="row" style="justify-content: space-between">
<strong>عضو {{ i + 1 }}</strong>
<button type="button" class="btn danger" @click="form.members.splice(i, 1)">حذف</button>
</div>
<div class="field"><label class="label">نام</label><input v-model="m.name" class="input" /></div>
<div class="field"><label class="label">سمت</label><input v-model="m.role" class="input" /></div>
</div>
<button type="button" class="btn ghost" @click="form.members.push({ name: '', role: '' })">+ افزودن عضو</button>
</template>
<!-- FOOTER -->
<template v-else-if="key === 'footer'">
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
<div class="field"><label class="label">نوار متحرک پایین (هر خط یک مورد)</label><textarea v-model="footerMarqueeText" class="textarea" /></div>
<div class="field"><label class="label">متن دعوت به همکاری</label><input v-model="form.cta" class="input" /></div>
<div class="field"><label class="label">ایمیل</label><input v-model="form.email" class="input" dir="ltr" /></div>
<div class="field"><label class="label">تلفن</label><input v-model="form.phone" class="input" dir="ltr" /></div>
<div class="field"><label class="label">آدرس (هر خط یک سطر)</label><textarea v-model="footerAddressText" class="textarea" /></div>
<div class="field"><label class="label">شبکه‌های اجتماعی (هر خط یک مورد)</label><textarea v-model="footerSocialsText" class="textarea" /></div>
</template>
<template v-else>
<p class="muted">این بخش هنوز فرم اختصاصی ندارد. با پشتیبانی تماس بگیرید.</p>
</template>
<p v-if="msg" class="muted" style="margin-top: 1rem">{{ msg }}</p>
<button class="btn" style="margin-top: 0.75rem">ذخیره</button>
</form>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import { api } from "../lib/api";
const TITLES: Record<string, string> = {
hero: "صفحه اول (هیرو)",
marquee: "نوار متحرک",
manifesto: "معرفی / مانیفست",
services: "خدمات",
work: "نمونه کارها",
process: "فرآیند کار",
stats: "آمار",
team: "تیم",
footer: "تماس و فوتر",
};
const route = useRoute();
const key = String(route.params.key);
const jsonText = ref("{}");
const titleFa = computed(() => TITLES[key] || key);
const ready = ref(false);
const msg = ref("");
const form = reactive<any>({});
const marqueeText = ref("");
const manifestoWordsText = ref("");
const footerMarqueeText = ref("");
const footerAddressText = ref("");
const footerSocialsText = ref("");
function splitTags(v: string) {
return v
.split(/[,،]/)
.map((s) => s.trim())
.filter(Boolean);
}
function wordsToText(words: Array<{ text: string; outline?: boolean; volt?: boolean }>) {
return (words || [])
.map((w) => (w.outline || w.volt ? `*${w.text}*` : w.text))
.join(" ");
}
function textToWords(text: string) {
return text
.trim()
.split(/\s+/)
.filter(Boolean)
.map((token) => {
const m = token.match(/^\*(.+)\*$/);
if (m) return { text: m[1], outline: false, volt: true };
return { text: token, outline: false, volt: false };
});
}
onMounted(async () => {
const { data } = await api.get(`/admin/sections/${key}`);
jsonText.value = JSON.stringify(data.data, null, 2);
Object.assign(form, structuredClone(data.data));
if (key === "hero") {
form.lines = Array.isArray(form.lines) ? form.lines : ["", "", ""];
while (form.lines.length < 3) form.lines.push("");
}
if (key === "marquee") marqueeText.value = (form.items || []).join("\n");
if (key === "manifesto") manifestoWordsText.value = wordsToText(form.words || []);
if (key === "services" || key === "work" || key === "stats") form.items = form.items || [];
if (key === "process") form.steps = form.steps || [];
if (key === "team") form.members = form.members || [];
if (key === "footer") {
footerMarqueeText.value = (form.marquee || []).join("\n");
footerAddressText.value = (form.address || []).join("\n");
footerSocialsText.value = (form.socials || []).join("\n");
}
ready.value = true;
});
function buildPayload() {
if (key === "marquee") return { items: marqueeText.value.split("\n").map((s) => s.trim()).filter(Boolean) };
if (key === "manifesto") {
return { tag: form.tag, body: form.body, words: textToWords(manifestoWordsText.value) };
}
if (key === "footer") {
return {
...form,
marquee: footerMarqueeText.value.split("\n").map((s) => s.trim()).filter(Boolean),
address: footerAddressText.value.split("\n").map((s) => s.trim()).filter(Boolean),
socials: footerSocialsText.value.split("\n").map((s) => s.trim()).filter(Boolean),
};
}
return { ...form };
}
async function save() {
msg.value = "";
try {
const parsed = JSON.parse(jsonText.value);
await api.put(`/admin/sections/${key}`, { data: parsed });
await api.put(`/admin/sections/${key}`, { data: buildPayload() });
msg.value = "ذخیره شد.";
} catch {
msg.value = "JSON نامعتبر یا خطای سرور.";
msg.value = "خطا در ذخیره.";
}
}
</script>
<style scoped>
.item-box {
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.9rem;
margin-bottom: 0.9rem;
background: #12151b;
}
</style>
+18 -3
View File
@@ -1,14 +1,14 @@
<template>
<div>
<h1>محتوای لندینگ</h1>
<p class="muted">هر بخش JSON ساخت‌یافته دارد.</p>
<p class="muted">متن‌ها را مثل فرم معمولی ویرایش کنید — نیازی به کدنویسی نیست.</p>
<table class="table">
<thead>
<tr><th>کلید</th><th>آخرین ویرایش</th><th></th></tr>
<tr><th>بخش</th><th>آخرین ویرایش</th><th></th></tr>
</thead>
<tbody>
<tr v-for="s in sections" :key="s.id">
<td>{{ s.key }}</td>
<td>{{ label(s.key) }}</td>
<td class="muted">{{ new Date(s.updatedAt).toLocaleString("fa-IR") }}</td>
<td><RouterLink :to="`/sections/${s.key}`">ویرایش</RouterLink></td>
</tr>
@@ -21,7 +21,22 @@
import { onMounted, ref } from "vue";
import { api } from "../lib/api";
const TITLES: Record<string, string> = {
hero: "صفحه اول (هیرو)",
marquee: "نوار متحرک",
manifesto: "معرفی",
services: "خدمات",
work: "نمونه کارها",
process: "فرآیند کار",
stats: "آمار",
team: "تیم",
footer: "تماس و فوتر",
};
const sections = ref<any[]>([]);
function label(key: string) {
return TITLES[key] || key;
}
onMounted(async () => {
const { data } = await api.get("/admin/sections");
sections.value = data;