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

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

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

+6
View File
@@ -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`;
});
+40
View File
@@ -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;
});
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}
+8
View File
@@ -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
+119
View File
@@ -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`
+41
View File
@@ -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,
},
],
};
+55
View File
@@ -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";
}
}
+15927
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
+20
View File
@@ -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"
}
}
+32
View File
@@ -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];
+1
View File
@@ -0,0 +1 @@
export * from "./brand";
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
View File