commit 7e8e1b1f1b591a2438e8542d7ba170e941dc93ab Author: Mahdi Date: Wed Sep 30 18:42:28 2026 +0330 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7edb67f --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a364717 --- /dev/null +++ b/README.md @@ -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` diff --git a/apps/adm/index.html b/apps/adm/index.html new file mode 100644 index 0000000..6116fb9 --- /dev/null +++ b/apps/adm/index.html @@ -0,0 +1,13 @@ + + + + + + + Montech Admin + + +
+ + + diff --git a/apps/adm/package.json b/apps/adm/package.json new file mode 100644 index 0000000..b24370f --- /dev/null +++ b/apps/adm/package.json @@ -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" + } +} diff --git a/apps/adm/src/App.vue b/apps/adm/src/App.vue new file mode 100644 index 0000000..543cfdb --- /dev/null +++ b/apps/adm/src/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/apps/adm/src/layouts/AdminLayout.vue b/apps/adm/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..202dd73 --- /dev/null +++ b/apps/adm/src/layouts/AdminLayout.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/apps/adm/src/lib/api.ts b/apps/adm/src/lib/api.ts new file mode 100644 index 0000000..30df757 --- /dev/null +++ b/apps/adm/src/lib/api.ts @@ -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); +} diff --git a/apps/adm/src/main.ts b/apps/adm/src/main.ts new file mode 100644 index 0000000..8dd6185 --- /dev/null +++ b/apps/adm/src/main.ts @@ -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"); diff --git a/apps/adm/src/router/index.ts b/apps/adm/src/router/index.ts new file mode 100644 index 0000000..813243d --- /dev/null +++ b/apps/adm/src/router/index.ts @@ -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; diff --git a/apps/adm/src/stores/auth.ts b/apps/adm/src/stores/auth.ts new file mode 100644 index 0000000..5b47a40 --- /dev/null +++ b/apps/adm/src/stores/auth.ts @@ -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(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 }; +}); diff --git a/apps/adm/src/styles.css b/apps/adm/src/styles.css new file mode 100644 index 0000000..7006e52 --- /dev/null +++ b/apps/adm/src/styles.css @@ -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)); +} diff --git a/apps/adm/src/views/BlogEditView.vue b/apps/adm/src/views/BlogEditView.vue new file mode 100644 index 0000000..058a077 --- /dev/null +++ b/apps/adm/src/views/BlogEditView.vue @@ -0,0 +1,100 @@ +