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:
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from "vue-router";
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="shell">
|
||||
<aside class="side">
|
||||
<div class="brand">Montech <span>/adm</span></div>
|
||||
<nav>
|
||||
<RouterLink to="/">داشبورد</RouterLink>
|
||||
<RouterLink to="/settings">تنظیمات سایت</RouterLink>
|
||||
<RouterLink to="/sections">محتوای لندینگ</RouterLink>
|
||||
<RouterLink to="/blog">بلاگ</RouterLink>
|
||||
<RouterLink to="/gallery">گالری</RouterLink>
|
||||
<RouterLink to="/seo">سئو صفحات</RouterLink>
|
||||
<RouterLink to="/media">رسانه</RouterLink>
|
||||
</nav>
|
||||
<button class="btn ghost" @click="onLogout">خروج</button>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
onMounted(() => {
|
||||
auth.fetchMe().catch(() => auth.logout());
|
||||
});
|
||||
|
||||
function onLogout() {
|
||||
auth.logout();
|
||||
router.push({ name: "login" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shell { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
|
||||
.side {
|
||||
border-left: 1px solid var(--border);
|
||||
background: #12151b;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.brand { font-weight: 700; font-size: 1.1rem; }
|
||||
.brand span { color: var(--accent); }
|
||||
nav { display: flex; flex-direction: column; gap: 0.35rem; flex: 1; }
|
||||
nav a {
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
nav a.router-link-active {
|
||||
background: #1d2430;
|
||||
color: var(--text);
|
||||
}
|
||||
.main { padding: 1.5rem; }
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.side { border-left: 0; border-bottom: 1px solid var(--border); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
import axios from "axios";
|
||||
|
||||
const TOKEN_KEY = "montech_adm_token";
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: "/api",
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
export function setToken(token: string | null) {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "./styles.css";
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount("#app");
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { getToken } from "../lib/api";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory("/adm/"),
|
||||
routes: [
|
||||
{ path: "/login", name: "login", component: () => import("../views/LoginView.vue"), meta: { public: true } },
|
||||
{
|
||||
path: "/",
|
||||
component: () => import("../layouts/AdminLayout.vue"),
|
||||
children: [
|
||||
{ path: "", name: "dashboard", component: () => import("../views/DashboardView.vue") },
|
||||
{ path: "settings", name: "settings", component: () => import("../views/SettingsView.vue") },
|
||||
{ path: "sections", name: "sections", component: () => import("../views/SectionsView.vue") },
|
||||
{ path: "sections/:key", name: "section-edit", component: () => import("../views/SectionEditView.vue") },
|
||||
{ path: "blog", name: "blog", component: () => import("../views/BlogListView.vue") },
|
||||
{ path: "blog/new", name: "blog-new", component: () => import("../views/BlogEditView.vue") },
|
||||
{ path: "blog/:id", name: "blog-edit", component: () => import("../views/BlogEditView.vue") },
|
||||
{ path: "gallery", name: "gallery", component: () => import("../views/GalleryView.vue") },
|
||||
{ path: "seo", name: "seo", component: () => import("../views/SeoView.vue") },
|
||||
{ path: "media", name: "media", component: () => import("../views/MediaView.vue") },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.public) return true;
|
||||
if (!getToken()) return { name: "login", query: { redirect: to.fullPath } };
|
||||
return true;
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { api, getToken, setToken } from "../lib/api";
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const token = ref<string | null>(getToken());
|
||||
const admin = ref<{ id: string; mobile: string; name: string; email?: string | null } | null>(null);
|
||||
|
||||
async function login(mobile: string, password: string) {
|
||||
const { data } = await api.post("/auth/login", { mobile, password });
|
||||
setToken(data.accessToken);
|
||||
token.value = data.accessToken;
|
||||
admin.value = data.admin;
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
if (!token.value) return null;
|
||||
const { data } = await api.get("/auth/me");
|
||||
admin.value = data;
|
||||
return data;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
setToken(null);
|
||||
token.value = null;
|
||||
admin.value = null;
|
||||
}
|
||||
|
||||
return { token, admin, login, fetchMe, logout };
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #171a21;
|
||||
--border: #2a2f3a;
|
||||
--text: #e8e9ed;
|
||||
--muted: #9aa0ab;
|
||||
--accent: #c8ff2e;
|
||||
--danger: #ff6b6b;
|
||||
--radius: 10px;
|
||||
font-family: "Segoe UI", Tahoma, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input, textarea, select {
|
||||
font: inherit;
|
||||
}
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
background: var(--accent);
|
||||
color: #111;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn.danger { background: var(--danger); color: #fff; }
|
||||
.input, .textarea {
|
||||
width: 100%;
|
||||
background: #0c0e12;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.textarea { min-height: 140px; resize: vertical; }
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
}
|
||||
.label { display: block; margin-bottom: 0.35rem; color: var(--muted); font-size: 0.85rem; }
|
||||
.field { margin-bottom: 0.9rem; }
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th, .table td {
|
||||
text-align: right;
|
||||
padding: 0.65rem 0.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.muted { color: var(--muted); }
|
||||
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<object, object, unknown>;
|
||||
export default component;
|
||||
}
|
||||
Reference in New Issue
Block a user