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
+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"
}