Initial Montech monorepo: Nuxt landing, Vue admin, Nest API.

Scaffold SSR site with CMS/blog/gallery/SEO admin and deploy configs for montech.ir.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-30 18:42:28 +03:30
co-authored by Cursor
commit 7e8e1b1f1b
96 changed files with 19336 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
<template>
<div>
<div class="row" style="justify-content: space-between; margin-bottom: 1rem">
<h1>{{ isNew ? "پست جدید" : "ویرایش پست" }}</h1>
<RouterLink class="btn ghost" to="/blog">بازگشت</RouterLink>
</div>
<form class="card" @submit.prevent="save">
<div class="grid-2">
<div class="field">
<label class="label">عنوان</label>
<input v-model="form.title" class="input" required />
</div>
<div class="field">
<label class="label">اسلاگ</label>
<input v-model="form.slug" class="input" dir="ltr" required />
</div>
</div>
<div class="field">
<label class="label">خلاصه</label>
<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" />
</div>
<div class="grid-2">
<div class="field">
<label class="label">کاور URL</label>
<input v-model="form.coverUrl" class="input" dir="ltr" />
</div>
<div class="field">
<label class="label">SEO Title</label>
<input v-model="form.seoTitle" class="input" />
</div>
<div class="field">
<label class="label">SEO Description</label>
<input v-model="form.seoDesc" class="input" />
</div>
<div class="field">
<label class="label">Canonical</label>
<input v-model="form.canonical" class="input" dir="ltr" />
</div>
<div class="field">
<label class="label">OG Image</label>
<input v-model="form.ogImage" class="input" dir="ltr" />
</div>
<div class="field">
<label class="label"><input v-model="form.published" type="checkbox" /> منتشر شود</label>
</div>
</div>
<p v-if="msg" class="muted">{{ msg }}</p>
<button class="btn">ذخیره</button>
</form>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { api } from "../lib/api";
const route = useRoute();
const router = useRouter();
const isNew = computed(() => route.name === "blog-new");
const msg = ref("");
const form = reactive({
title: "",
slug: "",
excerpt: "",
content: "",
coverUrl: "",
seoTitle: "",
seoDesc: "",
ogImage: "",
canonical: "",
published: false,
});
onMounted(async () => {
if (isNew.value) return;
const { data } = await api.get(`/admin/blog/${route.params.id}`);
Object.assign(form, data);
});
async function save() {
msg.value = "";
try {
if (isNew.value) {
const { data } = await api.post("/admin/blog", form);
msg.value = "ایجاد شد.";
router.replace(`/blog/${data.id}`);
} else {
await api.put(`/admin/blog/${route.params.id}`, form);
msg.value = "ذخیره شد.";
}
} catch {
msg.value = "خطا در ذخیره.";
}
}
</script>
+42
View File
@@ -0,0 +1,42 @@
<template>
<div>
<div class="row" style="justify-content: space-between; margin-bottom: 1rem">
<h1>بلاگ</h1>
<RouterLink class="btn" to="/blog/new">پست جدید</RouterLink>
</div>
<table class="table">
<thead>
<tr><th>عنوان</th><th>اسلاگ</th><th>وضعیت</th><th></th></tr>
</thead>
<tbody>
<tr v-for="p in posts" :key="p.id">
<td>{{ p.title }}</td>
<td class="muted" dir="ltr">{{ p.slug }}</td>
<td>{{ p.published ? "منتشر" : "پیش‌نویس" }}</td>
<td class="row">
<RouterLink :to="`/blog/${p.id}`">ویرایش</RouterLink>
<button class="btn danger" @click="remove(p.id)">حذف</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../lib/api";
const posts = ref<any[]>([]);
async function load() {
const { data } = await api.get("/admin/blog");
posts.value = data;
}
onMounted(load);
async function remove(id: string) {
if (!confirm("حذف شود؟")) return;
await api.delete(`/admin/blog/${id}`);
await load();
}
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<div>
<h1>داشبورد</h1>
<p class="muted">سلام {{ auth.admin?.name || "ادمین" }} — مدیریت محتوای montech.ir</p>
<div class="grid-2" style="margin-top: 1.25rem">
<RouterLink class="card" to="/sections">ویرایش بخش‌های لندینگ</RouterLink>
<RouterLink class="card" to="/blog">مدیریت بلاگ</RouterLink>
<RouterLink class="card" to="/gallery">گالری</RouterLink>
<RouterLink class="card" to="/seo">سئو صفحات</RouterLink>
</div>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
</script>
+68
View File
@@ -0,0 +1,68 @@
<template>
<div>
<h1>گالری</h1>
<form class="card" style="margin-bottom: 1rem" @submit.prevent="create">
<div class="grid-2">
<div class="field">
<label class="label">عنوان</label>
<input v-model="form.title" class="input" required />
</div>
<div class="field">
<label class="label">آدرس تصویر</label>
<input v-model="form.imageUrl" class="input" dir="ltr" required />
</div>
<div class="field">
<label class="label">دسته</label>
<input v-model="form.category" class="input" />
</div>
<div class="field">
<label class="label">ترتیب</label>
<input v-model.number="form.sortOrder" type="number" class="input" />
</div>
</div>
<button class="btn">افزودن</button>
</form>
<table class="table">
<thead>
<tr><th>عنوان</th><th>دسته</th><th>ترتیب</th><th></th></tr>
</thead>
<tbody>
<tr v-for="g in items" :key="g.id">
<td>{{ g.title }}</td>
<td class="muted">{{ g.category || "—" }}</td>
<td>{{ g.sortOrder }}</td>
<td><button class="btn danger" @click="remove(g.id)">حذف</button></td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { api } from "../lib/api";
const items = ref<any[]>([]);
const form = reactive({ title: "", imageUrl: "", category: "", sortOrder: 0, published: true });
async function load() {
const { data } = await api.get("/admin/gallery");
items.value = data;
}
onMounted(load);
async function create() {
await api.post("/admin/gallery", form);
form.title = "";
form.imageUrl = "";
form.category = "";
form.sortOrder = 0;
await load();
}
async function remove(id: string) {
if (!confirm("حذف شود؟")) return;
await api.delete(`/admin/gallery/${id}`);
await load();
}
</script>
+58
View File
@@ -0,0 +1,58 @@
<template>
<div class="wrap">
<form class="card form" @submit.prevent="submit">
<h1>ورود ادمین Montech</h1>
<p class="muted">موبایل و رمز حساب ادمین را وارد کنید.</p>
<div class="field">
<label class="label">موبایل</label>
<input v-model="mobile" class="input" dir="ltr" autocomplete="username" />
</div>
<div class="field">
<label class="label">رمز</label>
<input v-model="password" type="password" class="input" dir="ltr" autocomplete="current-password" />
</div>
<p v-if="error" class="err">{{ error }}</p>
<button class="btn" :disabled="loading">{{ loading ? "..." : "ورود" }}</button>
</form>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const mobile = ref("09121234567");
const password = ref("");
const loading = ref(false);
const error = ref("");
async function submit() {
loading.value = true;
error.value = "";
try {
await auth.login(mobile.value, password.value);
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
router.replace(redirect);
} catch {
error.value = "ورود ناموفق بود.";
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.wrap {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1rem;
}
.form { width: min(420px, 100%); }
h1 { margin: 0 0 0.4rem; font-size: 1.35rem; }
.err { color: var(--danger); }
</style>
+59
View File
@@ -0,0 +1,59 @@
<template>
<div>
<h1>رسانه</h1>
<form class="card" style="margin-bottom: 1rem" @submit.prevent="upload">
<input type="file" @change="onFile" />
<button class="btn" :disabled="!file">آپلود</button>
<p v-if="msg" class="muted">{{ msg }}</p>
</form>
<table class="table">
<thead>
<tr><th>فایل</th><th>URL</th><th>حجم</th><th></th></tr>
</thead>
<tbody>
<tr v-for="m in items" :key="m.id">
<td>{{ m.filename }}</td>
<td class="muted" dir="ltr">{{ m.url }}</td>
<td>{{ Math.round(m.size / 1024) }} KB</td>
<td><button class="btn danger" @click="remove(m.id)">حذف</button></td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../lib/api";
const items = ref<any[]>([]);
const file = ref<File | null>(null);
const msg = ref("");
async function load() {
const { data } = await api.get("/admin/media");
items.value = data;
}
onMounted(load);
function onFile(e: Event) {
const input = e.target as HTMLInputElement;
file.value = input.files?.[0] || null;
}
async function upload() {
if (!file.value) return;
const fd = new FormData();
fd.append("file", file.value);
const { data } = await api.post("/admin/media/upload", fd);
msg.value = `آپلود شد: ${data.url}`;
file.value = null;
await load();
}
async function remove(id: string) {
if (!confirm("حذف شود؟")) return;
await api.delete(`/admin/media/${id}`);
await load();
}
</script>
+40
View File
@@ -0,0 +1,40 @@
<template>
<div>
<div class="row" style="justify-content: space-between; margin-bottom: 1rem">
<h1>بخش: {{ key }}</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>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { api } from "../lib/api";
const route = useRoute();
const key = String(route.params.key);
const jsonText = ref("{}");
const msg = ref("");
onMounted(async () => {
const { data } = await api.get(`/admin/sections/${key}`);
jsonText.value = JSON.stringify(data.data, null, 2);
});
async function save() {
msg.value = "";
try {
const parsed = JSON.parse(jsonText.value);
await api.put(`/admin/sections/${key}`, { data: parsed });
msg.value = "ذخیره شد.";
} catch {
msg.value = "JSON نامعتبر یا خطای سرور.";
}
}
</script>
+29
View File
@@ -0,0 +1,29 @@
<template>
<div>
<h1>محتوای لندینگ</h1>
<p class="muted">هر بخش JSON ساخت‌یافته دارد.</p>
<table class="table">
<thead>
<tr><th>کلید</th><th>آخرین ویرایش</th><th></th></tr>
</thead>
<tbody>
<tr v-for="s in sections" :key="s.id">
<td>{{ s.key }}</td>
<td class="muted">{{ new Date(s.updatedAt).toLocaleString("fa-IR") }}</td>
<td><RouterLink :to="`/sections/${s.key}`">ویرایش</RouterLink></td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../lib/api";
const sections = ref<any[]>([]);
onMounted(async () => {
const { data } = await api.get("/admin/sections");
sections.value = data;
});
</script>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div>
<h1>سئو صفحات</h1>
<div v-for="page in pages" :key="page.id" class="card" style="margin-bottom: 1rem">
<h2 style="margin-top: 0">{{ page.title }} <span class="muted">({{ page.key }})</span></h2>
<div class="grid-2">
<div class="field">
<label class="label">Title</label>
<input v-model="page.seo.title" class="input" />
</div>
<div class="field">
<label class="label">Canonical</label>
<input v-model="page.seo.canonical" class="input" dir="ltr" />
</div>
<div class="field" style="grid-column: 1 / -1">
<label class="label">Description</label>
<textarea v-model="page.seo.description" class="textarea" />
</div>
<div class="field">
<label class="label">OG Image</label>
<input v-model="page.seo.ogImage" class="input" dir="ltr" />
</div>
<div class="field">
<label class="label"><input v-model="page.seo.noIndex" type="checkbox" /> noindex</label>
</div>
</div>
<button class="btn" @click="save(page)">ذخیره</button>
<span v-if="msg[page.key]" class="muted" style="margin-right: 0.75rem">{{ msg[page.key] }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { api } from "../lib/api";
const pages = ref<any[]>([]);
const msg = reactive<Record<string, string>>({});
onMounted(async () => {
const { data } = await api.get("/admin/pages");
pages.value = data.map((p: any) => ({
...p,
seo: p.seo || { title: p.title, description: "", canonical: "", ogImage: "", noIndex: false },
}));
});
async function save(page: any) {
await api.put(`/admin/pages/${page.key}/seo`, page.seo);
msg[page.key] = "ذخیره شد.";
}
</script>
+70
View File
@@ -0,0 +1,70 @@
<template>
<div>
<h1>تنظیمات سایت</h1>
<form v-if="form" class="card" @submit.prevent="save">
<div class="grid-2">
<div class="field">
<label class="label">نام برند</label>
<input v-model="form.brandName" class="input" />
</div>
<div class="field">
<label class="label">تگ‌لاین</label>
<input v-model="form.tagline" 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>
<input v-model="form.address" class="input" />
</div>
<div class="field">
<label class="label">Timezone</label>
<input v-model="form.timezone" class="input" dir="ltr" />
</div>
<div class="field">
<label class="label">لوگو URL</label>
<input v-model="form.logoUrl" class="input" dir="ltr" />
</div>
</div>
<div class="field">
<label class="label">شبکه‌های اجتماعی (JSON)</label>
<textarea v-model="socialsText" class="textarea" dir="ltr" />
</div>
<p v-if="msg" class="muted">{{ msg }}</p>
<button class="btn">ذخیره</button>
</form>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../lib/api";
const form = ref<any>(null);
const socialsText = ref("[]");
const msg = ref("");
onMounted(async () => {
const { data } = await api.get("/admin/settings");
form.value = data;
socialsText.value = JSON.stringify(data.socials ?? [], null, 2);
});
async function save() {
msg.value = "";
try {
form.value.socials = JSON.parse(socialsText.value);
const { data } = await api.put("/admin/settings", form.value);
form.value = data;
msg.value = "ذخیره شد.";
} catch {
msg.value = "خطا در ذخیره یا JSON نامعتبر.";
}
}
</script>