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:
+25
@@ -0,0 +1,25 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.output/
|
||||
.nuxt/
|
||||
.nitro/
|
||||
.turbo/
|
||||
coverage/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
.DS_Store
|
||||
apps/api/prisma/*.db
|
||||
apps/api/prisma/*.db-journal
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
deploy/.env
|
||||
apps/api/.env
|
||||
~$*
|
||||
.idea/
|
||||
.vscode/
|
||||
*.tsbuildinfo
|
||||
montech-node_modules.tgz
|
||||
graphify-out/
|
||||
**/graphify-out/
|
||||
@@ -0,0 +1,44 @@
|
||||
# Montech Landing
|
||||
|
||||
مونوریپوی لندینگ + پنل ادمین + API برای [montech.ir](https://montech.ir).
|
||||
|
||||
| بخش | تکنولوژی |
|
||||
|---|---|
|
||||
| لندینگ | Nuxt 3 (SSR) + Tailwind 4 + Lenis |
|
||||
| ادمین | Vue 3 + Vite (`/adm`) |
|
||||
| API | NestJS + Prisma + PostgreSQL |
|
||||
| استقرار | Nginx + PM2 روی همان سرور CRM |
|
||||
|
||||
```text
|
||||
apps/
|
||||
landing/ # سایت عمومی
|
||||
adm/ # پنل ادمین
|
||||
api/ # NestJS
|
||||
packages/
|
||||
shared/ # برند و ثابتها
|
||||
deploy/ # nginx, pm2, env
|
||||
```
|
||||
|
||||
## توسعه محلی
|
||||
|
||||
نیاز: Node 20+، PostgreSQL.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build -w @montech/shared
|
||||
cp apps/api/.env.example apps/api/.env
|
||||
# DATABASE_URL را تنظیم کنید
|
||||
cd apps/api && npx prisma migrate dev --name init && npx prisma db seed && cd ../..
|
||||
|
||||
npm run dev:api # :3050
|
||||
npm run dev:landing # :3000
|
||||
npm run dev:adm # :5174 → /adm/
|
||||
```
|
||||
|
||||
ادمین پیشفرض seed: موبایل `09121234567` / رمز از `ADMIN_PASSWORD` در `.env`.
|
||||
|
||||
## استقرار
|
||||
|
||||
نگاه کنید به [`deploy/DEPLOY.md`](deploy/DEPLOY.md).
|
||||
|
||||
ریموت: `http://get.feep.ir/Mahdi/Montech_Landing.git`
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<title>Montech Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@montech/adm",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "echo \"adm lint placeholder\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@montech/shared": "*",
|
||||
"axios": "^1.8.4",
|
||||
"pinia": "^3.0.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.2",
|
||||
"vue-tsc": "^2.2.8"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.json" }]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: "/adm/",
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "src") },
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.MONTECH_DEV_API || "http://127.0.0.1:3050",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
DATABASE_URL="postgresql://montech:montech@127.0.0.1:5432/montech?schema=public"
|
||||
PORT=3050
|
||||
JWT_SECRET="change-me-in-production"
|
||||
CORS_ORIGIN="http://localhost:3000,http://localhost:5174,https://montech.ir"
|
||||
UPLOADS_DIR=
|
||||
ADMIN_MOBILE=09121234567
|
||||
ADMIN_PASSWORD=change-me-strong
|
||||
ADMIN_NAME=Montech Admin
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@montech/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/src/main.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"lint": "echo \"api lint placeholder\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@montech/shared": "*",
|
||||
"@nestjs/common": "^11.0.12",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.12",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.12",
|
||||
"@prisma/client": "^6.5.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"multer": "^1.4.5-lts.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.5",
|
||||
"@nestjs/schematics": "^11.0.2",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"prisma": "^6.5.0",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
-- CreateSchema
|
||||
CREATE SCHEMA IF NOT EXISTS "public";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Admin" (
|
||||
"id" TEXT NOT NULL,
|
||||
"mobile" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Admin_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "SiteSettings" (
|
||||
"id" TEXT NOT NULL DEFAULT 'default',
|
||||
"brandName" TEXT NOT NULL DEFAULT 'Montech',
|
||||
"tagline" TEXT NOT NULL DEFAULT 'Software Development Group',
|
||||
"email" TEXT NOT NULL DEFAULT 'hello@montech.ir',
|
||||
"phone" TEXT NOT NULL DEFAULT '+98 21 0000 0000',
|
||||
"address" TEXT NOT NULL DEFAULT 'Tehran, IR',
|
||||
"timezone" TEXT NOT NULL DEFAULT 'Asia/Tehran',
|
||||
"socials" JSONB NOT NULL DEFAULT '[]',
|
||||
"logoUrl" TEXT,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "Page" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Page_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "PageSeo" (
|
||||
"id" TEXT NOT NULL,
|
||||
"pageId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"ogImage" TEXT,
|
||||
"canonical" TEXT,
|
||||
"noIndex" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "PageSeo_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "SectionContent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SectionContent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "BlogPost" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"excerpt" TEXT NOT NULL DEFAULT '',
|
||||
"content" TEXT NOT NULL DEFAULT '',
|
||||
"coverUrl" TEXT,
|
||||
"published" BOOLEAN NOT NULL DEFAULT false,
|
||||
"publishedAt" TIMESTAMP(3),
|
||||
"seoTitle" TEXT,
|
||||
"seoDesc" TEXT,
|
||||
"ogImage" TEXT,
|
||||
"canonical" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "BlogPost_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "GalleryItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"imageUrl" TEXT NOT NULL,
|
||||
"category" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"published" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GalleryItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "Media" (
|
||||
"id" TEXT NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Media_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "Admin_mobile_key" ON "Admin"("mobile");
|
||||
CREATE UNIQUE INDEX "Admin_email_key" ON "Admin"("email");
|
||||
CREATE UNIQUE INDEX "Page_key_key" ON "Page"("key");
|
||||
CREATE UNIQUE INDEX "PageSeo_pageId_key" ON "PageSeo"("pageId");
|
||||
CREATE UNIQUE INDEX "SectionContent_key_key" ON "SectionContent"("key");
|
||||
CREATE UNIQUE INDEX "BlogPost_slug_key" ON "BlogPost"("slug");
|
||||
|
||||
ALTER TABLE "PageSeo" ADD CONSTRAINT "PageSeo_pageId_fkey" FOREIGN KEY ("pageId") REFERENCES "Page"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
# This file tells Prisma which database provider to use for migrations.
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,95 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Admin {
|
||||
id String @id @default(cuid())
|
||||
mobile String @unique
|
||||
email String? @unique
|
||||
passwordHash String
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model SiteSettings {
|
||||
id String @id @default("default")
|
||||
brandName String @default("Montech")
|
||||
tagline String @default("Software Development Group")
|
||||
email String @default("hello@montech.ir")
|
||||
phone String @default("+98 21 0000 0000")
|
||||
address String @default("Tehran, IR")
|
||||
timezone String @default("Asia/Tehran")
|
||||
socials Json @default("[]")
|
||||
logoUrl String?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Page {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
title String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
seo PageSeo?
|
||||
}
|
||||
|
||||
model PageSeo {
|
||||
id String @id @default(cuid())
|
||||
pageId String @unique
|
||||
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
description String @default("")
|
||||
ogImage String?
|
||||
canonical String?
|
||||
noIndex Boolean @default(false)
|
||||
}
|
||||
|
||||
model SectionContent {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
data Json
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model BlogPost {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
title String
|
||||
excerpt String @default("")
|
||||
content String @default("")
|
||||
coverUrl String?
|
||||
published Boolean @default(false)
|
||||
publishedAt DateTime?
|
||||
seoTitle String?
|
||||
seoDesc String?
|
||||
ogImage String?
|
||||
canonical String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model GalleryItem {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
imageUrl String
|
||||
category String?
|
||||
sortOrder Int @default(0)
|
||||
published Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Media {
|
||||
id String @id @default(cuid())
|
||||
filename String
|
||||
url String
|
||||
mimeType String
|
||||
size Int
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as bcrypt from "bcryptjs";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const defaultSections: Record<string, unknown> = {
|
||||
hero: {
|
||||
eyebrow: "SOFTWARE DEVELOPMENT GROUP",
|
||||
location: "[ EST. 2020 — TEHRAN / REMOTE ]",
|
||||
lines: ["We craft", "software that", "performs"],
|
||||
highlightWord: "software",
|
||||
body: "We are a senior engineering group shipping web platforms, mobile apps and cloud infrastructure for companies that refuse to blend in.",
|
||||
cta: "SEE OUR WORK",
|
||||
badge: "MONTECH ENGINEERING • SINCE 2020 •",
|
||||
coords: "35.6892° N / 51.3890° E",
|
||||
projectsLabel: "120+ PROJECTS SHIPPED",
|
||||
},
|
||||
marquee: {
|
||||
items: [
|
||||
"WEB PLATFORMS",
|
||||
"MOBILE APPS",
|
||||
"CLOUD & DEVOPS",
|
||||
"AI SOLUTIONS",
|
||||
"DESIGN SYSTEMS",
|
||||
"API ARCHITECTURE",
|
||||
],
|
||||
},
|
||||
manifesto: {
|
||||
tag: "Who we are",
|
||||
words: [
|
||||
{ text: "We", outline: false },
|
||||
{ text: "build", outline: false },
|
||||
{ text: "software", outline: true },
|
||||
{ text: "that", outline: false },
|
||||
{ text: "moves", outline: false },
|
||||
{ text: "the", outline: false },
|
||||
{ text: "needle.", outline: false },
|
||||
{ text: "MONTECH", outline: false, volt: true },
|
||||
],
|
||||
body: "Strategy, design and engineering under one roof — shipped with obsessive attention to performance and craft.",
|
||||
},
|
||||
services: {
|
||||
tag: "What we do",
|
||||
items: [
|
||||
{
|
||||
n: "01",
|
||||
title: "Product Strategy",
|
||||
desc: "We turn fuzzy ideas into validated roadmaps. Discovery sprints, user research and rapid prototyping de-risk your build before a single line of code is written.",
|
||||
tags: ["Discovery Sprints", "User Research", "Roadmapping", "MVP Scoping"],
|
||||
},
|
||||
{
|
||||
n: "02",
|
||||
title: "Web Engineering",
|
||||
desc: "Blazing-fast, scalable web platforms with modern stacks. From marketing sites to complex SaaS dashboards — engineered for performance, accessibility and conversion.",
|
||||
tags: ["React / Next.js", "Nuxt", "Node.js", "NestJS"],
|
||||
},
|
||||
{
|
||||
n: "03",
|
||||
title: "Mobile Applications",
|
||||
desc: "Native-quality iOS and Android experiences from a single codebase — or fully native when it matters.",
|
||||
tags: ["React Native", "Flutter", "Swift", "Kotlin"],
|
||||
},
|
||||
{
|
||||
n: "04",
|
||||
title: "Cloud & DevOps",
|
||||
desc: "Infrastructure that scales with your ambition. Resilient cloud systems, automated deployments, secure and observable platforms.",
|
||||
tags: ["AWS / GCP", "Kubernetes", "Terraform", "CI/CD"],
|
||||
},
|
||||
{
|
||||
n: "05",
|
||||
title: "AI & Data",
|
||||
desc: "Practical AI, not science projects. LLM integrations, intelligent automation and data pipelines.",
|
||||
tags: ["LLM Integration", "ML Pipelines", "Data Engineering", "Analytics"],
|
||||
},
|
||||
],
|
||||
},
|
||||
work: {
|
||||
tag: "Selected work",
|
||||
items: [
|
||||
{ n: "01", title: "PULSEBOARD", cat: "Fintech Analytics Platform", year: "2024" },
|
||||
{ n: "02", title: "AEROLOOP", cat: "Logistics SaaS", year: "2024" },
|
||||
{ n: "03", title: "MEDSYNC", cat: "HealthTech Platform", year: "2023" },
|
||||
{ n: "04", title: "NOVAPAY", cat: "Payment Infrastructure", year: "2023" },
|
||||
],
|
||||
},
|
||||
process: {
|
||||
tag: "How we ship",
|
||||
steps: [
|
||||
{ n: "01", title: "DISCOVER", desc: "Workshops, research and alignment on outcomes that matter." },
|
||||
{ n: "02", title: "DESIGN", desc: "Systems, flows and interfaces that feel inevitable." },
|
||||
{ n: "03", title: "DEVELOP", desc: "Senior engineers shipping production-ready software." },
|
||||
{ n: "04", title: "DEPLOY & SCALE", desc: "CI/CD, observability and infrastructure that grows with you." },
|
||||
],
|
||||
},
|
||||
stats: {
|
||||
items: [
|
||||
{ value: 120, suffix: "+", label: "Projects shipped" },
|
||||
{ value: 40, suffix: "+", label: "Clients worldwide" },
|
||||
{ value: 15, suffix: "", label: "Engineers & designers" },
|
||||
{ value: 98, suffix: "%", label: "Client retention" },
|
||||
],
|
||||
},
|
||||
team: {
|
||||
tag: "The collective",
|
||||
headline: "Meet the collective",
|
||||
body: "A tight crew of senior builders — product, design and engineering.",
|
||||
members: [
|
||||
{ name: "Alexandra Reyes", role: "CEO & CO-FOUNDER" },
|
||||
{ name: "Marcus Chen", role: "CTO & CO-FOUNDER" },
|
||||
{ name: "Sofia Laurent", role: "HEAD OF DESIGN" },
|
||||
{ name: "David Okafor", role: "PRINCIPAL ENGINEER" },
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
tag: "Contact",
|
||||
marquee: ["GOT A PROJECT IN MIND", "SAY HELLO", "LET'S WORK TOGETHER"],
|
||||
cta: "Start a project",
|
||||
email: "hello@montech.ir",
|
||||
phone: "+98 21 0000 0000",
|
||||
address: ["Tehran", "Iran"],
|
||||
socials: ["LinkedIn", "GitHub", "Dribbble"],
|
||||
},
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const mobile = process.env.ADMIN_MOBILE || "09121234567";
|
||||
const password = process.env.ADMIN_PASSWORD || "change-me-strong";
|
||||
const name = process.env.ADMIN_NAME || "Montech Admin";
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
|
||||
await prisma.admin.upsert({
|
||||
where: { mobile },
|
||||
create: { mobile, passwordHash: hash, name, email: "admin@montech.ir" },
|
||||
update: { passwordHash: hash, name },
|
||||
});
|
||||
|
||||
await prisma.siteSettings.upsert({
|
||||
where: { id: "default" },
|
||||
create: {
|
||||
id: "default",
|
||||
brandName: "Montech",
|
||||
tagline: "Software Development Group",
|
||||
email: "hello@montech.ir",
|
||||
phone: "+98 21 0000 0000",
|
||||
address: "Tehran, IR",
|
||||
timezone: "Asia/Tehran",
|
||||
socials: [
|
||||
{ label: "LinkedIn", url: "https://linkedin.com" },
|
||||
{ label: "GitHub", url: "https://github.com" },
|
||||
],
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const pages = [
|
||||
{
|
||||
key: "home",
|
||||
title: "Home",
|
||||
seo: {
|
||||
title: "Montech — Software Development Group",
|
||||
description:
|
||||
"Montech engineers high-performance web platforms, mobile apps and cloud infrastructure. Based in Tehran, shipping worldwide.",
|
||||
canonical: "https://montech.ir/",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "blog",
|
||||
title: "Blog",
|
||||
seo: {
|
||||
title: "Blog — Montech",
|
||||
description: "Notes on product, engineering and shipping software.",
|
||||
canonical: "https://montech.ir/blog",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "gallery",
|
||||
title: "Gallery",
|
||||
seo: {
|
||||
title: "Gallery — Montech",
|
||||
description: "Selected visuals from Montech projects and studio life.",
|
||||
canonical: "https://montech.ir/gallery",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const p of pages) {
|
||||
const page = await prisma.page.upsert({
|
||||
where: { key: p.key },
|
||||
create: { key: p.key, title: p.title },
|
||||
update: { title: p.title },
|
||||
});
|
||||
await prisma.pageSeo.upsert({
|
||||
where: { pageId: page.id },
|
||||
create: { pageId: page.id, ...p.seo },
|
||||
update: { ...p.seo },
|
||||
});
|
||||
}
|
||||
|
||||
for (const [key, data] of Object.entries(defaultSections)) {
|
||||
await prisma.sectionContent.upsert({
|
||||
where: { key },
|
||||
create: { key, data: data as object },
|
||||
update: { data: data as object },
|
||||
});
|
||||
}
|
||||
|
||||
const existingPost = await prisma.blogPost.findUnique({ where: { slug: "welcome-to-montech" } });
|
||||
if (!existingPost) {
|
||||
await prisma.blogPost.create({
|
||||
data: {
|
||||
slug: "welcome-to-montech",
|
||||
title: "Welcome to Montech",
|
||||
excerpt: "We build software that performs — and this is where we write about it.",
|
||||
content:
|
||||
"<p>Montech is a software development group shipping web platforms, mobile apps and cloud infrastructure.</p><p>This blog covers product craft, engineering practice and lessons from the studio.</p>",
|
||||
published: true,
|
||||
publishedAt: new Date(),
|
||||
seoTitle: "Welcome to Montech — Blog",
|
||||
seoDesc: "Introducing Montech and how we ship software.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seed complete. Admin:", mobile);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { HealthModule } from "./health/health.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { ContentModule } from "./content/content.module";
|
||||
import { BlogModule } from "./blog/blog.module";
|
||||
import { GalleryModule } from "./gallery/gallery.module";
|
||||
import { MediaModule } from "./media/media.module";
|
||||
import { SeoModule } from "./seo/seo.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
ContentModule,
|
||||
BlogModule,
|
||||
GalleryModule,
|
||||
MediaModule,
|
||||
SeoModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Controller, Post, Body, Get, UseGuards, Req } from "@nestjs/common";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { LoginDto } from "./dto";
|
||||
import { JwtAuthGuard } from "./jwt-auth.guard";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(private auth: AuthService) {}
|
||||
|
||||
@Post("login")
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.auth.login(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("me")
|
||||
me(@Req() req: { user: { id: string } }) {
|
||||
return this.auth.me(req.user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { JwtStrategy } from "./jwt.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || "montech-dev-secret-change-me",
|
||||
signOptions: { expiresIn: "7d" },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import * as bcrypt from "bcryptjs";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { LoginDto } from "./dto";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwt: JwtService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
const admin = await this.prisma.admin.findUnique({ where: { mobile: dto.mobile } });
|
||||
if (!admin) throw new UnauthorizedException("Invalid credentials");
|
||||
const ok = await bcrypt.compare(dto.password, admin.passwordHash);
|
||||
if (!ok) throw new UnauthorizedException("Invalid credentials");
|
||||
const token = await this.jwt.signAsync({
|
||||
sub: admin.id,
|
||||
mobile: admin.mobile,
|
||||
name: admin.name,
|
||||
});
|
||||
return {
|
||||
accessToken: token,
|
||||
admin: { id: admin.id, mobile: admin.mobile, name: admin.name, email: admin.email },
|
||||
};
|
||||
}
|
||||
|
||||
async me(adminId: string) {
|
||||
const admin = await this.prisma.admin.findUnique({
|
||||
where: { id: adminId },
|
||||
select: { id: true, mobile: true, name: true, email: true },
|
||||
});
|
||||
if (!admin) throw new UnauthorizedException();
|
||||
return admin;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
mobile!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard("jwt") {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || "montech-dev-secret-change-me",
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: { sub: string; mobile: string; name: string }) {
|
||||
return { id: payload.sub, mobile: payload.mobile, name: payload.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { BlogService } from "./blog.service";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
import { CreateBlogDto, UpdateBlogDto } from "./blog.dto";
|
||||
|
||||
@Controller()
|
||||
export class BlogController {
|
||||
constructor(private blog: BlogService) {}
|
||||
|
||||
@Get("public/blog")
|
||||
listPublic() {
|
||||
return this.blog.listPublic();
|
||||
}
|
||||
|
||||
@Get("public/blog/:slug")
|
||||
getPublic(@Param("slug") slug: string) {
|
||||
return this.blog.getBySlug(slug);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/blog")
|
||||
listAdmin() {
|
||||
return this.blog.listAdmin();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/blog/:id")
|
||||
getAdmin(@Param("id") id: string) {
|
||||
return this.blog.getAdmin(id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post("admin/blog")
|
||||
create(@Body() dto: CreateBlogDto) {
|
||||
return this.blog.create(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put("admin/blog/:id")
|
||||
update(@Param("id") id: string, @Body() dto: UpdateBlogDto) {
|
||||
return this.blog.update(id, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete("admin/blog/:id")
|
||||
remove(@Param("id") id: string) {
|
||||
return this.blog.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
|
||||
export class CreateBlogDto {
|
||||
@IsString() @MinLength(1) slug!: string;
|
||||
@IsString() @MinLength(1) title!: string;
|
||||
@IsOptional() @IsString() excerpt?: string;
|
||||
@IsOptional() @IsString() content?: string;
|
||||
@IsOptional() @IsString() coverUrl?: string;
|
||||
@IsOptional() @IsBoolean() published?: boolean;
|
||||
@IsOptional() @IsString() seoTitle?: string;
|
||||
@IsOptional() @IsString() seoDesc?: string;
|
||||
@IsOptional() @IsString() ogImage?: string;
|
||||
@IsOptional() @IsString() canonical?: string;
|
||||
}
|
||||
|
||||
export class UpdateBlogDto {
|
||||
@IsOptional() @IsString() @MinLength(1) slug?: string;
|
||||
@IsOptional() @IsString() @MinLength(1) title?: string;
|
||||
@IsOptional() @IsString() excerpt?: string;
|
||||
@IsOptional() @IsString() content?: string;
|
||||
@IsOptional() @IsString() coverUrl?: string;
|
||||
@IsOptional() @IsBoolean() published?: boolean;
|
||||
@IsOptional() @IsString() seoTitle?: string;
|
||||
@IsOptional() @IsString() seoDesc?: string;
|
||||
@IsOptional() @IsString() ogImage?: string;
|
||||
@IsOptional() @IsString() canonical?: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BlogService } from "./blog.service";
|
||||
import { BlogController } from "./blog.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [BlogController],
|
||||
providers: [BlogService],
|
||||
})
|
||||
export class BlogModule {}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class BlogService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
listPublic() {
|
||||
return this.prisma.blogPost.findMany({
|
||||
where: { published: true },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
excerpt: true,
|
||||
coverUrl: true,
|
||||
publishedAt: true,
|
||||
seoTitle: true,
|
||||
seoDesc: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getBySlug(slug: string) {
|
||||
const post = await this.prisma.blogPost.findFirst({
|
||||
where: { slug, published: true },
|
||||
});
|
||||
if (!post) throw new NotFoundException();
|
||||
return post;
|
||||
}
|
||||
|
||||
listAdmin() {
|
||||
return this.prisma.blogPost.findMany({ orderBy: { updatedAt: "desc" } });
|
||||
}
|
||||
|
||||
getAdmin(id: string) {
|
||||
return this.prisma.blogPost.findUniqueOrThrow({ where: { id } });
|
||||
}
|
||||
|
||||
create(data: {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
content?: string;
|
||||
coverUrl?: string;
|
||||
published?: boolean;
|
||||
seoTitle?: string;
|
||||
seoDesc?: string;
|
||||
ogImage?: string;
|
||||
canonical?: string;
|
||||
}) {
|
||||
return this.prisma.blogPost.create({
|
||||
data: {
|
||||
...data,
|
||||
publishedAt: data.published ? new Date() : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: {
|
||||
slug?: string;
|
||||
title?: string;
|
||||
excerpt?: string;
|
||||
content?: string;
|
||||
coverUrl?: string | null;
|
||||
published?: boolean;
|
||||
seoTitle?: string | null;
|
||||
seoDesc?: string | null;
|
||||
ogImage?: string | null;
|
||||
canonical?: string | null;
|
||||
},
|
||||
) {
|
||||
const existing = await this.prisma.blogPost.findUniqueOrThrow({ where: { id } });
|
||||
const publishedAt =
|
||||
data.published === true && !existing.publishedAt
|
||||
? new Date()
|
||||
: data.published === false
|
||||
? null
|
||||
: existing.publishedAt;
|
||||
return this.prisma.blogPost.update({
|
||||
where: { id },
|
||||
data: { ...data, publishedAt },
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.blogPost.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common";
|
||||
import { ContentService } from "./content.service";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
import { IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
class UpdateSettingsDto {
|
||||
@IsOptional() @IsString() brandName?: string;
|
||||
@IsOptional() @IsString() tagline?: string;
|
||||
@IsOptional() @IsString() email?: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsString() address?: string;
|
||||
@IsOptional() @IsString() timezone?: string;
|
||||
@IsOptional() socials?: object;
|
||||
@IsOptional() @IsString() logoUrl?: string;
|
||||
}
|
||||
|
||||
class UpsertSectionDto {
|
||||
@IsObject()
|
||||
data!: object;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class ContentController {
|
||||
constructor(private content: ContentService) {}
|
||||
|
||||
@Get("public/site")
|
||||
publicSite() {
|
||||
return this.content.getPublicSite();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/settings")
|
||||
getSettings() {
|
||||
return this.content.getSettings();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put("admin/settings")
|
||||
updateSettings(@Body() dto: UpdateSettingsDto) {
|
||||
return this.content.updateSettings(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/sections")
|
||||
listSections() {
|
||||
return this.content.listSections();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/sections/:key")
|
||||
getSection(@Param("key") key: string) {
|
||||
return this.content.getSection(key);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put("admin/sections/:key")
|
||||
upsertSection(@Param("key") key: string, @Body() dto: UpsertSectionDto) {
|
||||
return this.content.upsertSection(key, dto.data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ContentService } from "./content.service";
|
||||
import { ContentController } from "./content.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [ContentController],
|
||||
providers: [ContentService],
|
||||
exports: [ContentService],
|
||||
})
|
||||
export class ContentModule {}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class ContentService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
getSettings() {
|
||||
return this.prisma.siteSettings.findUniqueOrThrow({ where: { id: "default" } });
|
||||
}
|
||||
|
||||
updateSettings(data: {
|
||||
brandName?: string;
|
||||
tagline?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
timezone?: string;
|
||||
socials?: object;
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
return this.prisma.siteSettings.update({ where: { id: "default" }, data });
|
||||
}
|
||||
|
||||
async listSections() {
|
||||
return this.prisma.sectionContent.findMany({ orderBy: { key: "asc" } });
|
||||
}
|
||||
|
||||
async getSection(key: string) {
|
||||
const row = await this.prisma.sectionContent.findUnique({ where: { key } });
|
||||
if (!row) throw new NotFoundException(`Section ${key} not found`);
|
||||
return row;
|
||||
}
|
||||
|
||||
async upsertSection(key: string, data: object) {
|
||||
return this.prisma.sectionContent.upsert({
|
||||
where: { key },
|
||||
create: { key, data },
|
||||
update: { data },
|
||||
});
|
||||
}
|
||||
|
||||
async getPublicSite() {
|
||||
const [settings, sections, pages] = await Promise.all([
|
||||
this.getSettings(),
|
||||
this.listSections(),
|
||||
this.prisma.page.findMany({ include: { seo: true } }),
|
||||
]);
|
||||
const sectionMap: Record<string, unknown> = {};
|
||||
for (const s of sections) sectionMap[s.key] = s.data;
|
||||
return { settings, sections: sectionMap, pages };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, MinLength } from "class-validator";
|
||||
import { GalleryService } from "./gallery.service";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
|
||||
class GalleryDto {
|
||||
@IsString() @MinLength(1) title!: string;
|
||||
@IsString() @MinLength(1) imageUrl!: string;
|
||||
@IsOptional() @IsString() category?: string;
|
||||
@IsOptional() @IsInt() sortOrder?: number;
|
||||
@IsOptional() @IsBoolean() published?: boolean;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class GalleryController {
|
||||
constructor(private gallery: GalleryService) {}
|
||||
|
||||
@Get("public/gallery")
|
||||
listPublic() {
|
||||
return this.gallery.listPublic();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/gallery")
|
||||
listAdmin() {
|
||||
return this.gallery.listAdmin();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post("admin/gallery")
|
||||
create(@Body() dto: GalleryDto) {
|
||||
return this.gallery.create(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put("admin/gallery/:id")
|
||||
update(@Param("id") id: string, @Body() dto: Partial<GalleryDto>) {
|
||||
return this.gallery.update(id, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete("admin/gallery/:id")
|
||||
remove(@Param("id") id: string) {
|
||||
return this.gallery.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { GalleryService } from "./gallery.service";
|
||||
import { GalleryController } from "./gallery.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [GalleryController],
|
||||
providers: [GalleryService],
|
||||
})
|
||||
export class GalleryModule {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class GalleryService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
listPublic() {
|
||||
return this.prisma.galleryItem.findMany({
|
||||
where: { published: true },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
}
|
||||
|
||||
listAdmin() {
|
||||
return this.prisma.galleryItem.findMany({
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
}
|
||||
|
||||
create(data: {
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
category?: string;
|
||||
sortOrder?: number;
|
||||
published?: boolean;
|
||||
}) {
|
||||
return this.prisma.galleryItem.create({ data });
|
||||
}
|
||||
|
||||
update(
|
||||
id: string,
|
||||
data: {
|
||||
title?: string;
|
||||
imageUrl?: string;
|
||||
category?: string | null;
|
||||
sortOrder?: number;
|
||||
published?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.prisma.galleryItem.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.galleryItem.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Controller("health")
|
||||
class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return { ok: true, service: "montech-api" };
|
||||
}
|
||||
}
|
||||
|
||||
@Module({ controllers: [HealthController] })
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { join } from "path";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.setGlobalPrefix("api");
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const uploadsRoot = process.env.UPLOADS_DIR || join(process.cwd(), "..", "..", "uploads");
|
||||
app.useStaticAssets(uploadsRoot, { prefix: "/api/uploads/" });
|
||||
|
||||
const origin = process.env.CORS_ORIGIN || "http://localhost:3000,http://localhost:5174";
|
||||
app.enableCors({
|
||||
origin: origin.split(",").map((s) => s.trim()),
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT || 3050);
|
||||
await app.listen(port, "0.0.0.0");
|
||||
console.log(`Montech API listening on :${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { memoryStorage } from "multer";
|
||||
import { MediaService } from "./media.service";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
|
||||
@Controller("admin/media")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MediaController {
|
||||
constructor(private media: MediaService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.media.list();
|
||||
}
|
||||
|
||||
@Post("upload")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
upload(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.media.saveFile(file);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
remove(@Param("id") id: string) {
|
||||
return this.media.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MediaService } from "./media.service";
|
||||
import { MediaController } from "./media.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { join } from "path";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
uploadsRoot() {
|
||||
return process.env.UPLOADS_DIR || join(process.cwd(), "..", "..", "uploads");
|
||||
}
|
||||
|
||||
ensureDir() {
|
||||
const dir = this.uploadsRoot();
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
list() {
|
||||
return this.prisma.media.findMany({ orderBy: { createdAt: "desc" } });
|
||||
}
|
||||
|
||||
async saveFile(file: Express.Multer.File) {
|
||||
const dir = this.ensureDir();
|
||||
const ext = file.originalname.includes(".")
|
||||
? file.originalname.slice(file.originalname.lastIndexOf("."))
|
||||
: "";
|
||||
const filename = `${randomUUID()}${ext}`;
|
||||
const { writeFileSync } = await import("fs");
|
||||
writeFileSync(join(dir, filename), file.buffer);
|
||||
const url = `/api/uploads/${filename}`;
|
||||
return this.prisma.media.create({
|
||||
data: {
|
||||
filename,
|
||||
url,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.media.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { PrismaService } from "./prisma.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common";
|
||||
import { IsBoolean, IsOptional, IsString } from "class-validator";
|
||||
import { SeoService } from "./seo.service";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
|
||||
class SeoDto {
|
||||
@IsOptional() @IsString() title?: string;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
@IsOptional() @IsString() ogImage?: string;
|
||||
@IsOptional() @IsString() canonical?: string;
|
||||
@IsOptional() @IsBoolean() noIndex?: boolean;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class SeoController {
|
||||
constructor(private seo: SeoService) {}
|
||||
|
||||
@Get("public/pages/:key")
|
||||
getPublic(@Param("key") key: string) {
|
||||
return this.seo.getByKey(key);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/pages")
|
||||
list() {
|
||||
return this.seo.listPages();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put("admin/pages/:key/seo")
|
||||
update(@Param("key") key: string, @Body() dto: SeoDto) {
|
||||
return this.seo.updateSeo(key, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SeoService } from "./seo.service";
|
||||
import { SeoController } from "./seo.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [SeoController],
|
||||
providers: [SeoService],
|
||||
})
|
||||
export class SeoModule {}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class SeoService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
listPages() {
|
||||
return this.prisma.page.findMany({ include: { seo: true }, orderBy: { key: "asc" } });
|
||||
}
|
||||
|
||||
async getByKey(key: string) {
|
||||
const page = await this.prisma.page.findUnique({
|
||||
where: { key },
|
||||
include: { seo: true },
|
||||
});
|
||||
if (!page) throw new NotFoundException();
|
||||
return page;
|
||||
}
|
||||
|
||||
async updateSeo(
|
||||
key: string,
|
||||
data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
ogImage?: string | null;
|
||||
canonical?: string | null;
|
||||
noIndex?: boolean;
|
||||
},
|
||||
) {
|
||||
const page = await this.prisma.page.findUniqueOrThrow({ where: { key } });
|
||||
return this.prisma.pageSeo.upsert({
|
||||
where: { pageId: page.id },
|
||||
create: {
|
||||
pageId: page.id,
|
||||
title: data.title || page.title,
|
||||
description: data.description || "",
|
||||
ogImage: data.ogImage,
|
||||
canonical: data.canonical,
|
||||
noIndex: data.noIndex ?? false,
|
||||
},
|
||||
update: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*", "prisma/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div class="relative min-h-screen bg-ink text-bone selection:bg-volt selection:text-ink">
|
||||
<div class="noise" aria-hidden="true" />
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-ink: #0a0a0b;
|
||||
--color-coal: #121215;
|
||||
--color-smoke: #1b1b1f;
|
||||
--color-bone: #f2f2ee;
|
||||
--color-ash: #8b8b93;
|
||||
--color-volt: #c8ff2e;
|
||||
|
||||
--font-display: "Syne", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-body: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: "Instrument Serif", ui-serif, Georgia, serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
html { background: var(--color-ink); }
|
||||
body {
|
||||
@apply bg-ink text-bone font-body antialiased;
|
||||
overflow-x: clip;
|
||||
}
|
||||
::selection {
|
||||
background: var(--color-volt);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
html.lenis, html.lenis body { height: auto; }
|
||||
.lenis.lenis-smooth { scroll-behavior: auto !important; }
|
||||
|
||||
.noise {
|
||||
position: fixed;
|
||||
inset: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
opacity: 0.032;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
animation: grain 8s steps(10) infinite;
|
||||
}
|
||||
@keyframes grain {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
10% { transform: translate(-5%, -8%); }
|
||||
20% { transform: translate(-12%, 4%); }
|
||||
30% { transform: translate(6%, -14%); }
|
||||
40% { transform: translate(-4%, 12%); }
|
||||
50% { transform: translate(-12%, 7%); }
|
||||
60% { transform: translate(11%, 0%); }
|
||||
70% { transform: translate(0%, 10%); }
|
||||
80% { transform: translate(3%, 13%); }
|
||||
90% { transform: translate(-8%, 8%); }
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
.animate-marquee {
|
||||
animation: marquee var(--marquee-duration, 24s) linear infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.15; }
|
||||
}
|
||||
.animate-blink { animation: blink 1.4s ease-in-out infinite; }
|
||||
|
||||
@keyframes spin-slow {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin-slow { animation: spin-slow 18s linear infinite; }
|
||||
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(24px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-up {
|
||||
animation: fade-up 0.9s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.text-outline {
|
||||
-webkit-text-stroke: 1.5px rgba(242, 242, 238, 0.22);
|
||||
color: transparent;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<section id="top" class="relative flex min-h-svh flex-col overflow-hidden">
|
||||
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(200,255,46,0.08),transparent_55%)]" />
|
||||
<div class="pointer-events-none absolute inset-0 bg-gradient-to-b from-ink/40 via-transparent to-ink" />
|
||||
|
||||
<div class="relative flex flex-1 flex-col justify-end px-6 pb-12 pt-36 md:px-10 md:pb-16 md:pt-44">
|
||||
<div
|
||||
class="mb-10 flex items-center justify-between font-mono text-[10px] tracking-[0.3em] text-ash animate-fade-up md:mb-16 md:text-[11px]"
|
||||
style="animation-delay: 0.2s"
|
||||
>
|
||||
<span class="flex items-center gap-3">
|
||||
<span class="h-1.5 w-1.5 animate-blink rounded-full bg-volt" />
|
||||
{{ data.eyebrow }}
|
||||
</span>
|
||||
<span class="hidden md:block">{{ data.location }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-12 lg:flex-row lg:items-end lg:justify-between">
|
||||
<h1 class="font-display text-[clamp(3rem,12.5vw,11.5rem)] font-extrabold uppercase leading-[0.92] tracking-[-0.03em]">
|
||||
<span
|
||||
v-for="(line, i) in data.lines"
|
||||
:key="i"
|
||||
class="block overflow-hidden pb-[0.08em] -mb-[0.08em] animate-fade-up"
|
||||
:style="{ animationDelay: `${0.15 + i * 0.12}s` }"
|
||||
>
|
||||
<template v-if="line.includes(data.highlightWord)">
|
||||
<span
|
||||
v-html="
|
||||
line.replace(
|
||||
data.highlightWord,
|
||||
`<span class='font-serif normal-case italic tracking-normal text-volt'>${data.highlightWord}</span>`,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ line }}<span v-if="i === data.lines.length - 1" class="text-volt">.</span>
|
||||
</template>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div class="flex shrink-0 flex-col items-start gap-10 animate-fade-up lg:items-end lg:pb-3" style="animation-delay: 0.55s">
|
||||
<p class="max-w-sm text-sm leading-relaxed text-ash md:text-base lg:text-right">
|
||||
{{ data.body }}
|
||||
</p>
|
||||
<a
|
||||
href="#work"
|
||||
class="group flex items-center gap-4 font-mono text-[11px] tracking-[0.3em] text-bone transition-colors hover:text-volt"
|
||||
>
|
||||
{{ data.cta }}
|
||||
<span class="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 transition-all group-hover:border-volt group-hover:bg-volt group-hover:text-ink">
|
||||
↓
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-14 flex items-end justify-between border-t border-white/[0.07] pt-6 font-mono text-[10px] tracking-[0.3em] text-ash animate-fade-up md:mt-20"
|
||||
style="animation-delay: 0.8s"
|
||||
>
|
||||
<span>SCROLL</span>
|
||||
<span class="hidden sm:block">{{ data.coords }}</span>
|
||||
<span class="hidden sm:block">{{ data.projectsLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
eyebrow: string;
|
||||
location: string;
|
||||
lines: string[];
|
||||
highlightWord: string;
|
||||
body: string;
|
||||
cta: string;
|
||||
coords: string;
|
||||
projectsLabel: string;
|
||||
};
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<section id="manifesto" class="px-6 py-28 md:px-10 md:py-40">
|
||||
<div class="mx-auto max-w-[1500px]">
|
||||
<SectionTag index="01" :title="data.tag" right="The short version" />
|
||||
<p class="mt-14 max-w-5xl font-display text-[clamp(2rem,6vw,5.5rem)] font-extrabold uppercase leading-[1.05] tracking-[-0.02em]">
|
||||
<span
|
||||
v-for="(w, i) in data.words"
|
||||
:key="i"
|
||||
class="mr-[0.28em] inline-block"
|
||||
:class="{
|
||||
'text-outline': w.outline,
|
||||
'text-volt': w.volt,
|
||||
}"
|
||||
>{{ w.text }}</span>
|
||||
</p>
|
||||
<p class="mt-10 max-w-xl text-sm leading-relaxed text-ash md:text-base">{{ data.body }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
tag: string;
|
||||
words: Array<{ text: string; outline?: boolean; volt?: boolean }>;
|
||||
body: string;
|
||||
};
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<section id="process" class="px-6 py-28 md:px-10 md:py-40">
|
||||
<div class="mx-auto max-w-[1500px]">
|
||||
<SectionTag index="04" :title="data.tag" right="12 weeks, typical" />
|
||||
<div class="mt-16 grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div v-for="step in data.steps" :key="step.n" class="border-t border-white/[0.1] pt-6">
|
||||
<p class="font-mono text-[11px] tracking-[0.25em] text-volt">{{ step.n }}</p>
|
||||
<h3 class="mt-4 font-display text-2xl font-bold uppercase tracking-[-0.02em]">{{ step.title }}</h3>
|
||||
<p class="mt-3 text-sm leading-relaxed text-ash">{{ step.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
tag: string;
|
||||
steps: Array<{ n: string; title: string; desc: string }>;
|
||||
};
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="flex items-end justify-between gap-6 border-b border-white/[0.07] pb-4 font-mono text-[11px] tracking-[0.28em] text-ash">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-volt">{{ index }}</span>
|
||||
<span class="text-bone">{{ title }}</span>
|
||||
</div>
|
||||
<span v-if="right" class="hidden sm:block">{{ right }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ index: string; title: string; right?: string }>();
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<section id="services" class="px-6 py-28 md:px-10 md:py-40">
|
||||
<div class="mx-auto max-w-[1500px]">
|
||||
<SectionTag index="02" :title="data.tag" :right="`${String(data.items.length).padStart(2, '0')} Capabilities`" />
|
||||
<ul class="mt-16 divide-y divide-white/[0.07] border-y border-white/[0.07]">
|
||||
<li
|
||||
v-for="(s, i) in data.items"
|
||||
:key="s.n"
|
||||
class="group cursor-pointer py-8 md:py-10"
|
||||
@click="open = open === i ? -1 : i"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-6">
|
||||
<div class="flex flex-1 flex-col gap-3 md:flex-row md:items-baseline md:gap-10">
|
||||
<span class="font-mono text-[11px] tracking-[0.25em] text-ash">{{ s.n }}</span>
|
||||
<h3 class="font-display text-2xl font-bold uppercase tracking-[-0.02em] md:text-4xl group-hover:text-volt transition-colors">
|
||||
{{ s.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<span class="font-mono text-volt">{{ open === i ? "—" : "+" }}</span>
|
||||
</div>
|
||||
<div v-if="open === i" class="mt-6 max-w-3xl pl-0 md:pl-16">
|
||||
<p class="text-sm leading-relaxed text-ash md:text-base">{{ s.desc }}</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="t in s.tags"
|
||||
:key="t"
|
||||
class="border border-white/10 px-3 py-1 font-mono text-[10px] tracking-[0.18em] text-ash"
|
||||
>{{ t }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
tag: string;
|
||||
items: Array<{ n: string; title: string; desc: string; tags: string[] }>;
|
||||
};
|
||||
}>();
|
||||
const open = ref(0);
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<footer id="contact" class="relative overflow-hidden border-t border-white/[0.07]">
|
||||
<div class="border-b border-white/[0.07] py-8 md:py-10">
|
||||
<SiteMarquee
|
||||
:items="data.marquee"
|
||||
:duration="20"
|
||||
class-name="font-display text-xl font-bold uppercase tracking-[-0.01em] md:text-3xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-[1500px] px-6 py-20 md:px-10 md:py-28">
|
||||
<SectionTag index="06" :title="data.tag" right="Response within 24h" />
|
||||
<div class="mt-14 grid gap-12 lg:grid-cols-[1.4fr_1fr]">
|
||||
<div>
|
||||
<h2 class="font-display text-[clamp(2.5rem,7vw,6rem)] font-extrabold uppercase leading-[0.95] tracking-[-0.03em]">
|
||||
{{ data.cta }}
|
||||
</h2>
|
||||
<a
|
||||
:href="`mailto:${data.email}`"
|
||||
class="mt-8 inline-flex items-center gap-3 border border-volt bg-volt px-6 py-3 font-mono text-[11px] tracking-[0.22em] text-ink transition hover:bg-transparent hover:text-volt"
|
||||
>
|
||||
{{ data.email }} ↗
|
||||
</a>
|
||||
</div>
|
||||
<div class="grid gap-8 sm:grid-cols-3 lg:grid-cols-1">
|
||||
<div>
|
||||
<p class="font-mono text-[10px] tracking-[0.25em] text-ash">NEW BUSINESS</p>
|
||||
<p class="mt-2 text-sm">{{ data.email }}</p>
|
||||
<p class="text-sm text-ash">{{ data.phone }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-mono text-[10px] tracking-[0.25em] text-ash">STUDIO</p>
|
||||
<p v-for="line in data.address" :key="line" class="mt-1 text-sm text-ash">{{ line }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-mono text-[10px] tracking-[0.25em] text-ash">SOCIALS</p>
|
||||
<p v-for="s in data.socials" :key="s" class="mt-1 text-sm text-ash">{{ s }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-20 flex flex-wrap items-center justify-between gap-4 border-t border-white/[0.07] pt-6 font-mono text-[10px] tracking-[0.2em] text-ash">
|
||||
<span>© {{ year }} MONTECH SOFTWARE GROUP</span>
|
||||
<span>{{ brand }}®</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
brand?: string;
|
||||
data: {
|
||||
tag: string;
|
||||
marquee: string[];
|
||||
cta: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string[];
|
||||
socials: string[];
|
||||
};
|
||||
}>();
|
||||
const year = new Date().getFullYear();
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="overflow-hidden" :class="className">
|
||||
<div class="animate-marquee flex w-max" :style="{ '--marquee-duration': `${duration}s` } as any">
|
||||
<div v-for="dup in 2" :key="dup" class="flex shrink-0 items-center">
|
||||
<span v-for="(item, i) in items" :key="`${dup}-${i}`" class="flex items-center">
|
||||
<span class="mx-5 whitespace-nowrap md:mx-8" :class="itemClassName">{{ item }}</span>
|
||||
<span class="text-volt/70" :class="separatorClassName">{{ separator }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
items: string[];
|
||||
duration?: number;
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
separatorClassName?: string;
|
||||
separator?: string;
|
||||
}>(),
|
||||
{
|
||||
duration: 24,
|
||||
className: "",
|
||||
itemClassName: "",
|
||||
separatorClassName: "",
|
||||
separator: "✦",
|
||||
},
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<header class="fixed inset-x-0 top-0 z-50 border-b border-white/[0.06] bg-ink/70 backdrop-blur-md">
|
||||
<div class="mx-auto flex max-w-[1500px] items-center justify-between px-6 py-4 md:px-10">
|
||||
<NuxtLink to="/" class="font-display text-lg font-extrabold tracking-tight">
|
||||
{{ brand }}<span class="align-super text-[0.55em] text-volt">®</span>
|
||||
</NuxtLink>
|
||||
<nav class="hidden items-center gap-8 font-mono text-[11px] tracking-[0.22em] text-ash md:flex">
|
||||
<a href="#services" class="hover:text-volt">SERVICES</a>
|
||||
<a href="#work" class="hover:text-volt">WORK</a>
|
||||
<a href="#team" class="hover:text-volt">TEAM</a>
|
||||
<NuxtLink to="/blog" class="hover:text-volt">BLOG</NuxtLink>
|
||||
<NuxtLink to="/gallery" class="hover:text-volt">GALLERY</NuxtLink>
|
||||
<a href="#contact" class="text-bone hover:text-volt">CONTACT</a>
|
||||
</nav>
|
||||
<span class="font-mono text-[10px] tracking-[0.2em] text-ash tabular-nums">{{ time }}</span>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ brand?: string; timezone?: string }>();
|
||||
const brand = computed(() => props.brand || "Montech");
|
||||
const time = ref("");
|
||||
|
||||
onMounted(() => {
|
||||
const tz = props.timezone || "Asia/Tehran";
|
||||
const tick = () => {
|
||||
time.value = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: tz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date());
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
onUnmounted(() => clearInterval(id));
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<section class="border-y border-white/[0.07] bg-coal px-6 py-20 md:px-10 md:py-28">
|
||||
<div class="mx-auto grid max-w-[1500px] gap-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div v-for="(s, i) in data.items" :key="i">
|
||||
<p class="font-display text-5xl font-extrabold tracking-[-0.03em] text-volt md:text-6xl">
|
||||
{{ s.value }}{{ s.suffix }}
|
||||
</p>
|
||||
<p class="mt-3 font-mono text-[11px] tracking-[0.22em] text-ash">{{ s.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
items: Array<{ value: number; suffix: string; label: string }>;
|
||||
};
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<section id="team" class="px-6 py-28 md:px-10 md:py-40">
|
||||
<div class="mx-auto max-w-[1500px]">
|
||||
<SectionTag index="05" :title="data.tag" right="Leadership" />
|
||||
<div class="mb-16 mt-14 flex flex-col justify-between gap-8 md:flex-row md:items-end">
|
||||
<h2 class="font-display text-[clamp(2.6rem,8vw,7.5rem)] font-extrabold uppercase leading-[0.92] tracking-[-0.02em]">
|
||||
{{ data.headline.split(" ").slice(0, -1).join(" ") }}
|
||||
<span class="font-serif normal-case italic tracking-normal text-volt">
|
||||
{{ data.headline.split(" ").slice(-1)[0] }}
|
||||
</span>
|
||||
</h2>
|
||||
<p class="max-w-xs text-sm leading-relaxed text-ash md:text-right">{{ data.body }}</p>
|
||||
</div>
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<figure v-for="m in data.members" :key="m.name" class="group">
|
||||
<div class="aspect-[3/4] bg-gradient-to-br from-smoke to-ink" />
|
||||
<figcaption class="mt-4">
|
||||
<p class="font-display text-xl font-bold uppercase tracking-[-0.02em] group-hover:text-volt transition-colors">
|
||||
{{ m.name }}
|
||||
</p>
|
||||
<p class="mt-1 font-mono text-[10px] tracking-[0.22em] text-ash">{{ m.role }}</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
tag: string;
|
||||
headline: string;
|
||||
body: string;
|
||||
members: Array<{ name: string; role: string }>;
|
||||
};
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<section id="work" class="px-6 py-28 md:px-10 md:py-40">
|
||||
<div class="mx-auto max-w-[1500px]">
|
||||
<SectionTag index="03" :title="data.tag" right="2023 — 2025" />
|
||||
<div class="mt-16 grid gap-6 md:grid-cols-2">
|
||||
<article
|
||||
v-for="p in data.items"
|
||||
:key="p.n"
|
||||
class="group relative overflow-hidden border border-white/[0.07] bg-coal p-6 md:p-8"
|
||||
>
|
||||
<div class="mb-16 aspect-[16/10] bg-gradient-to-br from-smoke to-ink" />
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-mono text-[10px] tracking-[0.25em] text-ash">{{ p.n }} / {{ p.year }}</p>
|
||||
<h3 class="mt-2 font-display text-3xl font-extrabold uppercase tracking-[-0.02em] group-hover:text-volt transition-colors">
|
||||
{{ p.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-ash">{{ p.cat }}</p>
|
||||
</div>
|
||||
<span class="text-volt">↗</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
tag: string;
|
||||
items: Array<{ n: string; title: string; cat: string; year: string }>;
|
||||
};
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
export function useApiBase() {
|
||||
const config = useRuntimeConfig();
|
||||
if (import.meta.server) return config.apiBase as string;
|
||||
return (config.public.apiBase as string) || "";
|
||||
}
|
||||
|
||||
export async function fetchPublicSite() {
|
||||
const base = useApiBase();
|
||||
return await $fetch(`${base}/api/public/site`);
|
||||
}
|
||||
|
||||
export async function fetchPublicBlog() {
|
||||
const base = useApiBase();
|
||||
return await $fetch(`${base}/api/public/blog`);
|
||||
}
|
||||
|
||||
export async function fetchPublicPost(slug: string) {
|
||||
const base = useApiBase();
|
||||
return await $fetch(`${base}/api/public/blog/${slug}`);
|
||||
}
|
||||
|
||||
export async function fetchPublicGallery() {
|
||||
const base = useApiBase();
|
||||
return await $fetch(`${base}/api/public/gallery`);
|
||||
}
|
||||
|
||||
export async function fetchPage(key: string) {
|
||||
const base = useApiBase();
|
||||
return await $fetch(`${base}/api/public/pages/${key}`);
|
||||
}
|
||||
|
||||
export function applyPageSeo(seo?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
ogImage?: string | null;
|
||||
canonical?: string | null;
|
||||
noIndex?: boolean;
|
||||
} | null) {
|
||||
if (!seo) return;
|
||||
useSeoMeta({
|
||||
title: seo.title,
|
||||
description: seo.description,
|
||||
ogTitle: seo.title,
|
||||
ogDescription: seo.description,
|
||||
ogImage: seo.ogImage || undefined,
|
||||
robots: seo.noIndex ? "noindex,nofollow" : "index,follow",
|
||||
});
|
||||
if (seo.canonical) {
|
||||
useHead({
|
||||
link: [{ rel: "canonical", href: seo.canonical }],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2025-01-01",
|
||||
devtools: { enabled: false },
|
||||
css: ["~/assets/css/main.css"],
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
runtimeConfig: {
|
||||
apiBase: process.env.NUXT_API_BASE || "http://127.0.0.1:3050",
|
||||
public: {
|
||||
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || "https://montech.ir",
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || "",
|
||||
},
|
||||
},
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: { lang: "en" },
|
||||
link: [
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=Space+Grotesk:wght@300;400;500;600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
nitro: {
|
||||
preset: "node-server",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@montech/landing",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev --port 3000",
|
||||
"build": "nuxt build",
|
||||
"preview": "nuxt preview --port 3051",
|
||||
"start": "node .output/server/index.mjs",
|
||||
"lint": "echo \"landing lint placeholder\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@montech/shared": "*",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"lenis": "^1.3.26",
|
||||
"lucide-vue-next": "^0.511.0",
|
||||
"nuxt": "^3.16.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<article v-if="post" class="min-h-screen px-6 pb-24 pt-28 md:px-10">
|
||||
<SiteNavbar brand="Montech" />
|
||||
<div class="mx-auto max-w-[760px]">
|
||||
<NuxtLink to="/blog" class="font-mono text-[11px] tracking-[0.22em] text-ash hover:text-volt">← BACK</NuxtLink>
|
||||
<h1 class="mt-8 font-display text-4xl font-extrabold uppercase tracking-[-0.03em] md:text-6xl">
|
||||
{{ post.title }}
|
||||
</h1>
|
||||
<p class="mt-4 font-mono text-[11px] tracking-[0.2em] text-ash">
|
||||
{{ post.publishedAt ? new Date(post.publishedAt).toLocaleDateString("en-GB") : "" }}
|
||||
</p>
|
||||
<div class="prose-montech mt-10 space-y-4 text-base leading-relaxed text-bone/90" v-html="post.content" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const slug = String(route.params.slug);
|
||||
const { data: post } = await useAsyncData(`blog-${slug}`, () => fetchPublicPost(slug));
|
||||
|
||||
if (!post.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: "Post not found" });
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: post.value.seoTitle || post.value.title,
|
||||
description: post.value.seoDesc || post.value.excerpt,
|
||||
ogTitle: post.value.seoTitle || post.value.title,
|
||||
ogDescription: post.value.seoDesc || post.value.excerpt,
|
||||
ogImage: post.value.ogImage || post.value.coverUrl || undefined,
|
||||
});
|
||||
if (post.value.canonical) {
|
||||
useHead({ link: [{ rel: "canonical", href: post.value.canonical }] });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="min-h-screen px-6 pb-24 pt-28 md:px-10">
|
||||
<SiteNavbar :brand="'Montech'" />
|
||||
<div class="mx-auto max-w-[1100px]">
|
||||
<h1 class="font-display text-5xl font-extrabold uppercase tracking-[-0.03em] md:text-7xl">Blog</h1>
|
||||
<p class="mt-4 max-w-xl text-ash">Notes on product, engineering and shipping software.</p>
|
||||
<ul class="mt-16 divide-y divide-white/[0.07] border-y border-white/[0.07]">
|
||||
<li v-for="post in posts" :key="post.id" class="py-8">
|
||||
<NuxtLink :to="`/blog/${post.slug}`" class="group block">
|
||||
<p class="font-mono text-[10px] tracking-[0.22em] text-ash">
|
||||
{{ post.publishedAt ? new Date(post.publishedAt).toLocaleDateString("en-GB") : "" }}
|
||||
</p>
|
||||
<h2 class="mt-2 font-display text-2xl font-bold uppercase tracking-[-0.02em] group-hover:text-volt md:text-3xl">
|
||||
{{ post.title }}
|
||||
</h2>
|
||||
<p class="mt-3 max-w-2xl text-sm text-ash">{{ post.excerpt }}</p>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!posts?.length" class="mt-10 text-ash">No posts yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: posts } = await useAsyncData("blog-list", () => fetchPublicBlog());
|
||||
const { data: page } = await useAsyncData("page-blog", () => fetchPage("blog"));
|
||||
applyPageSeo(page.value?.seo);
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="min-h-screen px-6 pb-24 pt-28 md:px-10">
|
||||
<SiteNavbar brand="Montech" />
|
||||
<div class="mx-auto max-w-[1500px]">
|
||||
<h1 class="font-display text-5xl font-extrabold uppercase tracking-[-0.03em] md:text-7xl">Gallery</h1>
|
||||
<p class="mt-4 max-w-xl text-ash">Selected visuals from projects and studio life.</p>
|
||||
<div class="mt-16 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<figure v-for="item in items" :key="item.id" class="overflow-hidden border border-white/[0.07] bg-coal">
|
||||
<div
|
||||
class="aspect-[4/3] bg-cover bg-center"
|
||||
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : {}"
|
||||
/>
|
||||
<figcaption class="p-4">
|
||||
<p class="font-display text-lg font-bold uppercase">{{ item.title }}</p>
|
||||
<p v-if="item.category" class="mt-1 font-mono text-[10px] tracking-[0.2em] text-ash">{{ item.category }}</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<p v-if="!items?.length" class="mt-10 text-ash">Gallery is empty.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: items } = await useAsyncData("gallery", () => fetchPublicGallery());
|
||||
const { data: page } = await useAsyncData("page-gallery", () => fetchPage("gallery"));
|
||||
applyPageSeo(page.value?.seo);
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div v-if="site">
|
||||
<SiteNavbar :brand="site.settings.brandName" :timezone="site.settings.timezone" />
|
||||
<main>
|
||||
<HeroSection :data="site.sections.hero" />
|
||||
<section class="relative overflow-hidden py-16 md:py-24">
|
||||
<div class="-ml-[5%] w-[110%] -rotate-2 bg-volt py-4 text-ink md:py-5">
|
||||
<SiteMarquee
|
||||
:items="site.sections.marquee.items"
|
||||
:duration="22"
|
||||
class-name="font-display text-xl font-bold uppercase tracking-[-0.01em] md:text-3xl"
|
||||
item-class-name="mx-4 md:mx-6"
|
||||
separator-class-name="text-ink/50"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<ManifestoSection :data="site.sections.manifesto" />
|
||||
<ServicesSection :data="site.sections.services" />
|
||||
<WorkSection :data="site.sections.work" />
|
||||
<ProcessSection :data="site.sections.process" />
|
||||
<StatsSection :data="site.sections.stats" />
|
||||
<TeamSection :data="site.sections.team" />
|
||||
</main>
|
||||
<SiteFooter :brand="site.settings.brandName" :data="site.sections.footer" />
|
||||
</div>
|
||||
<div v-else class="grid min-h-screen place-items-center text-ash">
|
||||
Unable to load site content. Is the API running?
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: site } = await useAsyncData("public-site", () => fetchPublicSite());
|
||||
const homePage = computed(() => site.value?.pages?.find((p: any) => p.key === "home"));
|
||||
applyPageSeo(homePage.value?.seo);
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
import Lenis from "lenis";
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
if (!import.meta.client) return;
|
||||
const lenis = new Lenis({ lerp: 0.09, smoothWheel: true });
|
||||
let raf = 0;
|
||||
const loop = (time: number) => {
|
||||
lenis.raf(time);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
nuxtApp.hook("app:unmounted", () => {
|
||||
cancelAnimationFrame(raf);
|
||||
lenis.destroy();
|
||||
});
|
||||
return {
|
||||
provide: { lenis },
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect width="64" height="64" rx="14" fill="#0A0A0B"/>
|
||||
<circle cx="32" cy="32" r="17" fill="none" stroke="#C8FF2E" stroke-width="7"/>
|
||||
<text x="32" y="38" text-anchor="middle" fill="#F2F2EE" font-family="Arial,sans-serif" font-size="14" font-weight="700">M</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
@@ -0,0 +1,6 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const site = config.public.siteUrl || "https://montech.ir";
|
||||
setHeader(event, "Content-Type", "text/plain");
|
||||
return `User-agent: *\nAllow: /\nDisallow: /adm\nSitemap: ${site}/sitemap.xml\n`;
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const site = config.public.siteUrl || "https://montech.ir";
|
||||
const apiBase = config.apiBase || "http://127.0.0.1:3050";
|
||||
|
||||
let posts: Array<{ slug: string; updatedAt?: string; publishedAt?: string }> = [];
|
||||
try {
|
||||
posts = await $fetch(`${apiBase}/api/public/blog`);
|
||||
} catch {
|
||||
posts = [];
|
||||
}
|
||||
|
||||
const staticUrls = ["", "/blog", "/gallery"];
|
||||
const urls = [
|
||||
...staticUrls.map((path) => ({ loc: `${site}${path || "/"}`, changefreq: "weekly", priority: path === "" ? "1.0" : "0.8" })),
|
||||
...posts.map((p) => ({
|
||||
loc: `${site}/blog/${p.slug}`,
|
||||
changefreq: "monthly",
|
||||
priority: "0.6",
|
||||
lastmod: p.updatedAt || p.publishedAt,
|
||||
})),
|
||||
];
|
||||
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls
|
||||
.map(
|
||||
(u) => ` <url>
|
||||
<loc>${u.loc}</loc>
|
||||
${"lastmod" in u && u.lastmod ? `<lastmod>${new Date(u.lastmod as string).toISOString()}</lastmod>` : ""}
|
||||
<changefreq>${u.changefreq}</changefreq>
|
||||
<priority>${u.priority}</priority>
|
||||
</url>`,
|
||||
)
|
||||
.join("\n")}
|
||||
</urlset>`;
|
||||
|
||||
setHeader(event, "Content-Type", "application/xml");
|
||||
return body;
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
DATABASE_URL="postgresql://USER:PASS@127.0.0.1:15432/montech?schema=public"
|
||||
PORT=3050
|
||||
JWT_SECRET="replace-with-long-random-secret"
|
||||
CORS_ORIGIN="https://montech.ir,https://www.montech.ir"
|
||||
UPLOADS_DIR="/var/www/montech/uploads"
|
||||
ADMIN_MOBILE=09121234567
|
||||
ADMIN_PASSWORD=change-me-strong-platform-password
|
||||
ADMIN_NAME=Montech Admin
|
||||
@@ -0,0 +1,119 @@
|
||||
# استقرار Montech — `montech.ir`
|
||||
|
||||
> پکیجمنیجر = **npm**. به پروسههای CRM دست نزنید.
|
||||
|
||||
## آپدیت سریع (بعد از `git push`)
|
||||
|
||||
```bash
|
||||
ssh -p 3031 montime@185.50.38.53
|
||||
cd /var/www/montech
|
||||
git pull
|
||||
|
||||
npm run build -w @montech/shared
|
||||
cd apps/api && npx prisma migrate deploy && npx prisma generate && cd ../..
|
||||
npm run build -w @montech/api
|
||||
npm run build -w @montech/adm
|
||||
npm run build -w @montech/landing
|
||||
pm2 restart montech-api montech-landing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
هاست مشترک مونتایم: `185.50.38.53` (SSH پورت `3031`، کاربر `montime`)
|
||||
دامنه: `https://montech.ir`
|
||||
مسیر کد: `/var/www/montech`
|
||||
پورت داخلی API: `3050`
|
||||
پورت داخلی Nuxt SSR: `3051`
|
||||
PM2: `montech-api`, `montech-landing`
|
||||
کد از Gitea: `http://get.feep.ir/Mahdi/Montech_Landing.git`
|
||||
|
||||
پورتهای اشغالشده (دست نزنید): ۳۰۰۰ مونتایم · ۳۰۰۱ فوریسند · ۳۰۱۰ سوپرادمین · ۳۰۲۰ کاتالوگ · ۳۰۳۰ CRM · ۳۰۴۰ STT
|
||||
|
||||
---
|
||||
|
||||
## کارهایی که باید انجام شود
|
||||
|
||||
1. **DNS:** رکورد `A` برای `montech.ir` روی همان IP سرور.
|
||||
2. **Postgres:** دیتابیس و کاربر جدا (`montech`) روی `127.0.0.1:15432`.
|
||||
3. **دسترسی Gitea** برای clone/pull.
|
||||
|
||||
---
|
||||
|
||||
## روی سرور
|
||||
|
||||
```bash
|
||||
ssh -p 3031 montime@185.50.38.53
|
||||
```
|
||||
|
||||
### ۱) کلون
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/montech
|
||||
sudo chown montime:montime /var/www/montech
|
||||
git clone http://get.feep.ir/Mahdi/Montech_Landing.git /var/www/montech
|
||||
```
|
||||
|
||||
### ۲) پورت آزاد
|
||||
|
||||
```bash
|
||||
ss -tlnp | grep -E '3050|3051' || true
|
||||
```
|
||||
|
||||
### ۳) `.env` API
|
||||
|
||||
```bash
|
||||
cd /var/www/montech
|
||||
cp deploy/.env.production.example apps/api/.env
|
||||
nano apps/api/.env
|
||||
```
|
||||
|
||||
### ۴) نصب وابستگیها
|
||||
|
||||
```bash
|
||||
cd /var/www/montech
|
||||
npm ci
|
||||
# اگر symlink workspace خراب بود:
|
||||
# rm -rf node_modules/@montech/shared && ln -s ../../packages/shared node_modules/@montech/shared
|
||||
```
|
||||
|
||||
### ۵) migrate و بیلد
|
||||
|
||||
```bash
|
||||
cd /var/www/montech
|
||||
npm run build -w @montech/shared
|
||||
cd apps/api
|
||||
npx prisma migrate deploy
|
||||
npx prisma generate
|
||||
npx prisma db seed
|
||||
cd ../..
|
||||
npm run build -w @montech/api
|
||||
npm run build -w @montech/adm
|
||||
npm run build -w @montech/landing
|
||||
mkdir -p uploads
|
||||
```
|
||||
|
||||
### ۶) PM2
|
||||
|
||||
```bash
|
||||
pm2 start /var/www/montech/deploy/ecosystem.config.cjs
|
||||
pm2 save
|
||||
curl -sI http://127.0.0.1:3050/api/health
|
||||
curl -sI http://127.0.0.1:3051/
|
||||
```
|
||||
|
||||
### ۷) Nginx
|
||||
|
||||
```bash
|
||||
sudo cp /var/www/montech/deploy/nginx-montech.ir.conf /etc/nginx/sites-available/montech.ir
|
||||
sudo ln -sf /etc/nginx/sites-available/montech.ir /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### ۸) تست
|
||||
|
||||
1. `https://montech.ir` → لندینگ
|
||||
2. `https://montech.ir/adm/` → پنل ادمین (موبایل/رمز از seed)
|
||||
3. `https://montech.ir/blog` → بلاگ
|
||||
4. `https://montech.ir/api/health` → `{ ok: true }`
|
||||
|
||||
پاک نکنید: `uploads/` و `apps/api/.env`
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* PM2 — Montech on the shared montime host.
|
||||
* Ports: API 3050, Nuxt SSR 3051 (avoid CRM 3030/3040 and other occupied ports).
|
||||
*
|
||||
* pm2 start /var/www/montech/deploy/ecosystem.config.cjs
|
||||
* pm2 save
|
||||
*/
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "montech-api",
|
||||
cwd: "/var/www/montech/apps/api",
|
||||
script: "dist/src/main.js",
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
PORT: "3050",
|
||||
},
|
||||
max_memory_restart: "500M",
|
||||
time: true,
|
||||
},
|
||||
{
|
||||
name: "montech-landing",
|
||||
cwd: "/var/www/montech/apps/landing",
|
||||
script: ".output/server/index.mjs",
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
PORT: "3051",
|
||||
HOST: "127.0.0.1",
|
||||
NUXT_API_BASE: "http://127.0.0.1:3050",
|
||||
NUXT_PUBLIC_SITE_URL: "https://montech.ir",
|
||||
NUXT_PUBLIC_API_BASE: "",
|
||||
},
|
||||
max_memory_restart: "700M",
|
||||
time: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
# Place under /etc/nginx/sites-available/montech.ir
|
||||
# Prefer HTTP :80 if SSL terminates on the front proxy (same pattern as CRM).
|
||||
|
||||
upstream montech_api {
|
||||
server 127.0.0.1:3050;
|
||||
keepalive 8;
|
||||
}
|
||||
|
||||
upstream montech_landing {
|
||||
server 127.0.0.1:3051;
|
||||
keepalive 8;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name montech.ir www.montech.ir;
|
||||
|
||||
client_max_body_size 20M;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://montech_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location /adm/ {
|
||||
alias /var/www/montech/apps/adm/dist/;
|
||||
try_files $uri $uri/ /adm/index.html;
|
||||
}
|
||||
|
||||
# SPA fallback for Vue admin history mode
|
||||
location = /adm/index.html {
|
||||
alias /var/www/montech/apps/adm/dist/index.html;
|
||||
}
|
||||
|
||||
location = /adm {
|
||||
return 301 /adm/;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://montech_landing;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
Generated
+15927
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "montech-landing",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Montech landing + admin + API monorepo — Nuxt SSR, Vue admin, NestJS, PostgreSQL",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "npm run dev:landing",
|
||||
"dev:landing": "npm run dev -w @montech/landing",
|
||||
"dev:adm": "npm run dev -w @montech/adm",
|
||||
"dev:api": "npm run start:dev -w @montech/api",
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"build:shared": "npm run build -w @montech/shared",
|
||||
"build:api": "npm run build -w @montech/api",
|
||||
"build:adm": "npm run build -w @montech/adm",
|
||||
"build:landing": "npm run build -w @montech/landing",
|
||||
"db:generate": "npm run prisma:generate -w @montech/api",
|
||||
"db:migrate": "npm run prisma:migrate -w @montech/api",
|
||||
"db:seed": "npm run prisma:seed -w @montech/api"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@montech/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsc -p tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export const brand = {
|
||||
id: "montech",
|
||||
name: "Montech",
|
||||
nameFa: "مانتک",
|
||||
fullName: "Montech",
|
||||
company: "Montech",
|
||||
tagline: "Software Development Group",
|
||||
taglineFa: "گروه توسعه نرمافزار",
|
||||
domain: "montech.ir",
|
||||
email: "hello@montech.ir",
|
||||
timezone: "Asia/Tehran",
|
||||
logo: "/brand/logo.svg",
|
||||
} as const;
|
||||
|
||||
export type Brand = typeof brand;
|
||||
|
||||
export const SECTION_KEYS = [
|
||||
"hero",
|
||||
"marquee",
|
||||
"manifesto",
|
||||
"services",
|
||||
"work",
|
||||
"process",
|
||||
"stats",
|
||||
"team",
|
||||
"footer",
|
||||
] as const;
|
||||
|
||||
export type SectionKey = (typeof SECTION_KEYS)[number];
|
||||
|
||||
export const PAGE_KEYS = ["home", "blog", "gallery"] as const;
|
||||
export type PageKey = (typeof PAGE_KEYS)[number];
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./brand";
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "CommonJS",
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user