Initial Montech monorepo: Nuxt landing, Vue admin, Nest API.
Scaffold SSR site with CMS/blog/gallery/SEO admin and deploy configs for montech.ir. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div class="relative min-h-screen bg-ink text-bone selection:bg-volt selection:text-ink">
|
||||
<div class="noise" aria-hidden="true" />
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-ink: #0a0a0b;
|
||||
--color-coal: #121215;
|
||||
--color-smoke: #1b1b1f;
|
||||
--color-bone: #f2f2ee;
|
||||
--color-ash: #8b8b93;
|
||||
--color-volt: #c8ff2e;
|
||||
|
||||
--font-display: "Syne", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-body: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: "Instrument Serif", ui-serif, Georgia, serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
html { background: var(--color-ink); }
|
||||
body {
|
||||
@apply bg-ink text-bone font-body antialiased;
|
||||
overflow-x: clip;
|
||||
}
|
||||
::selection {
|
||||
background: var(--color-volt);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
html.lenis, html.lenis body { height: auto; }
|
||||
.lenis.lenis-smooth { scroll-behavior: auto !important; }
|
||||
|
||||
.noise {
|
||||
position: fixed;
|
||||
inset: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
opacity: 0.032;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
animation: grain 8s steps(10) infinite;
|
||||
}
|
||||
@keyframes grain {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
10% { transform: translate(-5%, -8%); }
|
||||
20% { transform: translate(-12%, 4%); }
|
||||
30% { transform: translate(6%, -14%); }
|
||||
40% { transform: translate(-4%, 12%); }
|
||||
50% { transform: translate(-12%, 7%); }
|
||||
60% { transform: translate(11%, 0%); }
|
||||
70% { transform: translate(0%, 10%); }
|
||||
80% { transform: translate(3%, 13%); }
|
||||
90% { transform: translate(-8%, 8%); }
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
.animate-marquee {
|
||||
animation: marquee var(--marquee-duration, 24s) linear infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.15; }
|
||||
}
|
||||
.animate-blink { animation: blink 1.4s ease-in-out infinite; }
|
||||
|
||||
@keyframes spin-slow {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin-slow { animation: spin-slow 18s linear infinite; }
|
||||
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(24px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-up {
|
||||
animation: fade-up 0.9s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.text-outline {
|
||||
-webkit-text-stroke: 1.5px rgba(242, 242, 238, 0.22);
|
||||
color: transparent;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<section id="top" class="relative flex min-h-svh flex-col overflow-hidden">
|
||||
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(200,255,46,0.08),transparent_55%)]" />
|
||||
<div class="pointer-events-none absolute inset-0 bg-gradient-to-b from-ink/40 via-transparent to-ink" />
|
||||
|
||||
<div class="relative flex flex-1 flex-col justify-end px-6 pb-12 pt-36 md:px-10 md:pb-16 md:pt-44">
|
||||
<div
|
||||
class="mb-10 flex items-center justify-between font-mono text-[10px] tracking-[0.3em] text-ash animate-fade-up md:mb-16 md:text-[11px]"
|
||||
style="animation-delay: 0.2s"
|
||||
>
|
||||
<span class="flex items-center gap-3">
|
||||
<span class="h-1.5 w-1.5 animate-blink rounded-full bg-volt" />
|
||||
{{ data.eyebrow }}
|
||||
</span>
|
||||
<span class="hidden md:block">{{ data.location }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-12 lg:flex-row lg:items-end lg:justify-between">
|
||||
<h1 class="font-display text-[clamp(3rem,12.5vw,11.5rem)] font-extrabold uppercase leading-[0.92] tracking-[-0.03em]">
|
||||
<span
|
||||
v-for="(line, i) in data.lines"
|
||||
:key="i"
|
||||
class="block overflow-hidden pb-[0.08em] -mb-[0.08em] animate-fade-up"
|
||||
:style="{ animationDelay: `${0.15 + i * 0.12}s` }"
|
||||
>
|
||||
<template v-if="line.includes(data.highlightWord)">
|
||||
<span
|
||||
v-html="
|
||||
line.replace(
|
||||
data.highlightWord,
|
||||
`<span class='font-serif normal-case italic tracking-normal text-volt'>${data.highlightWord}</span>`,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ line }}<span v-if="i === data.lines.length - 1" class="text-volt">.</span>
|
||||
</template>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div class="flex shrink-0 flex-col items-start gap-10 animate-fade-up lg:items-end lg:pb-3" style="animation-delay: 0.55s">
|
||||
<p class="max-w-sm text-sm leading-relaxed text-ash md:text-base lg:text-right">
|
||||
{{ data.body }}
|
||||
</p>
|
||||
<a
|
||||
href="#work"
|
||||
class="group flex items-center gap-4 font-mono text-[11px] tracking-[0.3em] text-bone transition-colors hover:text-volt"
|
||||
>
|
||||
{{ data.cta }}
|
||||
<span class="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 transition-all group-hover:border-volt group-hover:bg-volt group-hover:text-ink">
|
||||
↓
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-14 flex items-end justify-between border-t border-white/[0.07] pt-6 font-mono text-[10px] tracking-[0.3em] text-ash animate-fade-up md:mt-20"
|
||||
style="animation-delay: 0.8s"
|
||||
>
|
||||
<span>SCROLL</span>
|
||||
<span class="hidden sm:block">{{ data.coords }}</span>
|
||||
<span class="hidden sm:block">{{ data.projectsLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
eyebrow: string;
|
||||
location: string;
|
||||
lines: string[];
|
||||
highlightWord: string;
|
||||
body: string;
|
||||
cta: string;
|
||||
coords: string;
|
||||
projectsLabel: string;
|
||||
};
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<section id="manifesto" class="px-6 py-28 md:px-10 md:py-40">
|
||||
<div class="mx-auto max-w-[1500px]">
|
||||
<SectionTag index="01" :title="data.tag" right="The short version" />
|
||||
<p class="mt-14 max-w-5xl font-display text-[clamp(2rem,6vw,5.5rem)] font-extrabold uppercase leading-[1.05] tracking-[-0.02em]">
|
||||
<span
|
||||
v-for="(w, i) in data.words"
|
||||
:key="i"
|
||||
class="mr-[0.28em] inline-block"
|
||||
:class="{
|
||||
'text-outline': w.outline,
|
||||
'text-volt': w.volt,
|
||||
}"
|
||||
>{{ w.text }}</span>
|
||||
</p>
|
||||
<p class="mt-10 max-w-xl text-sm leading-relaxed text-ash md:text-base">{{ data.body }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
tag: string;
|
||||
words: Array<{ text: string; outline?: boolean; volt?: boolean }>;
|
||||
body: string;
|
||||
};
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<section id="process" class="px-6 py-28 md:px-10 md:py-40">
|
||||
<div class="mx-auto max-w-[1500px]">
|
||||
<SectionTag index="04" :title="data.tag" right="12 weeks, typical" />
|
||||
<div class="mt-16 grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div v-for="step in data.steps" :key="step.n" class="border-t border-white/[0.1] pt-6">
|
||||
<p class="font-mono text-[11px] tracking-[0.25em] text-volt">{{ step.n }}</p>
|
||||
<h3 class="mt-4 font-display text-2xl font-bold uppercase tracking-[-0.02em]">{{ step.title }}</h3>
|
||||
<p class="mt-3 text-sm leading-relaxed text-ash">{{ step.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
tag: string;
|
||||
steps: Array<{ n: string; title: string; desc: string }>;
|
||||
};
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="flex items-end justify-between gap-6 border-b border-white/[0.07] pb-4 font-mono text-[11px] tracking-[0.28em] text-ash">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-volt">{{ index }}</span>
|
||||
<span class="text-bone">{{ title }}</span>
|
||||
</div>
|
||||
<span v-if="right" class="hidden sm:block">{{ right }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ index: string; title: string; right?: string }>();
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<section id="services" class="px-6 py-28 md:px-10 md:py-40">
|
||||
<div class="mx-auto max-w-[1500px]">
|
||||
<SectionTag index="02" :title="data.tag" :right="`${String(data.items.length).padStart(2, '0')} Capabilities`" />
|
||||
<ul class="mt-16 divide-y divide-white/[0.07] border-y border-white/[0.07]">
|
||||
<li
|
||||
v-for="(s, i) in data.items"
|
||||
:key="s.n"
|
||||
class="group cursor-pointer py-8 md:py-10"
|
||||
@click="open = open === i ? -1 : i"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-6">
|
||||
<div class="flex flex-1 flex-col gap-3 md:flex-row md:items-baseline md:gap-10">
|
||||
<span class="font-mono text-[11px] tracking-[0.25em] text-ash">{{ s.n }}</span>
|
||||
<h3 class="font-display text-2xl font-bold uppercase tracking-[-0.02em] md:text-4xl group-hover:text-volt transition-colors">
|
||||
{{ s.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<span class="font-mono text-volt">{{ open === i ? "—" : "+" }}</span>
|
||||
</div>
|
||||
<div v-if="open === i" class="mt-6 max-w-3xl pl-0 md:pl-16">
|
||||
<p class="text-sm leading-relaxed text-ash md:text-base">{{ s.desc }}</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="t in s.tags"
|
||||
:key="t"
|
||||
class="border border-white/10 px-3 py-1 font-mono text-[10px] tracking-[0.18em] text-ash"
|
||||
>{{ t }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
tag: string;
|
||||
items: Array<{ n: string; title: string; desc: string; tags: string[] }>;
|
||||
};
|
||||
}>();
|
||||
const open = ref(0);
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<footer id="contact" class="relative overflow-hidden border-t border-white/[0.07]">
|
||||
<div class="border-b border-white/[0.07] py-8 md:py-10">
|
||||
<SiteMarquee
|
||||
:items="data.marquee"
|
||||
:duration="20"
|
||||
class-name="font-display text-xl font-bold uppercase tracking-[-0.01em] md:text-3xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-[1500px] px-6 py-20 md:px-10 md:py-28">
|
||||
<SectionTag index="06" :title="data.tag" right="Response within 24h" />
|
||||
<div class="mt-14 grid gap-12 lg:grid-cols-[1.4fr_1fr]">
|
||||
<div>
|
||||
<h2 class="font-display text-[clamp(2.5rem,7vw,6rem)] font-extrabold uppercase leading-[0.95] tracking-[-0.03em]">
|
||||
{{ data.cta }}
|
||||
</h2>
|
||||
<a
|
||||
:href="`mailto:${data.email}`"
|
||||
class="mt-8 inline-flex items-center gap-3 border border-volt bg-volt px-6 py-3 font-mono text-[11px] tracking-[0.22em] text-ink transition hover:bg-transparent hover:text-volt"
|
||||
>
|
||||
{{ data.email }} ↗
|
||||
</a>
|
||||
</div>
|
||||
<div class="grid gap-8 sm:grid-cols-3 lg:grid-cols-1">
|
||||
<div>
|
||||
<p class="font-mono text-[10px] tracking-[0.25em] text-ash">NEW BUSINESS</p>
|
||||
<p class="mt-2 text-sm">{{ data.email }}</p>
|
||||
<p class="text-sm text-ash">{{ data.phone }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-mono text-[10px] tracking-[0.25em] text-ash">STUDIO</p>
|
||||
<p v-for="line in data.address" :key="line" class="mt-1 text-sm text-ash">{{ line }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-mono text-[10px] tracking-[0.25em] text-ash">SOCIALS</p>
|
||||
<p v-for="s in data.socials" :key="s" class="mt-1 text-sm text-ash">{{ s }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-20 flex flex-wrap items-center justify-between gap-4 border-t border-white/[0.07] pt-6 font-mono text-[10px] tracking-[0.2em] text-ash">
|
||||
<span>© {{ year }} MONTECH SOFTWARE GROUP</span>
|
||||
<span>{{ brand }}®</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
brand?: string;
|
||||
data: {
|
||||
tag: string;
|
||||
marquee: string[];
|
||||
cta: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string[];
|
||||
socials: string[];
|
||||
};
|
||||
}>();
|
||||
const year = new Date().getFullYear();
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="overflow-hidden" :class="className">
|
||||
<div class="animate-marquee flex w-max" :style="{ '--marquee-duration': `${duration}s` } as any">
|
||||
<div v-for="dup in 2" :key="dup" class="flex shrink-0 items-center">
|
||||
<span v-for="(item, i) in items" :key="`${dup}-${i}`" class="flex items-center">
|
||||
<span class="mx-5 whitespace-nowrap md:mx-8" :class="itemClassName">{{ item }}</span>
|
||||
<span class="text-volt/70" :class="separatorClassName">{{ separator }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
items: string[];
|
||||
duration?: number;
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
separatorClassName?: string;
|
||||
separator?: string;
|
||||
}>(),
|
||||
{
|
||||
duration: 24,
|
||||
className: "",
|
||||
itemClassName: "",
|
||||
separatorClassName: "",
|
||||
separator: "✦",
|
||||
},
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<header class="fixed inset-x-0 top-0 z-50 border-b border-white/[0.06] bg-ink/70 backdrop-blur-md">
|
||||
<div class="mx-auto flex max-w-[1500px] items-center justify-between px-6 py-4 md:px-10">
|
||||
<NuxtLink to="/" class="font-display text-lg font-extrabold tracking-tight">
|
||||
{{ brand }}<span class="align-super text-[0.55em] text-volt">®</span>
|
||||
</NuxtLink>
|
||||
<nav class="hidden items-center gap-8 font-mono text-[11px] tracking-[0.22em] text-ash md:flex">
|
||||
<a href="#services" class="hover:text-volt">SERVICES</a>
|
||||
<a href="#work" class="hover:text-volt">WORK</a>
|
||||
<a href="#team" class="hover:text-volt">TEAM</a>
|
||||
<NuxtLink to="/blog" class="hover:text-volt">BLOG</NuxtLink>
|
||||
<NuxtLink to="/gallery" class="hover:text-volt">GALLERY</NuxtLink>
|
||||
<a href="#contact" class="text-bone hover:text-volt">CONTACT</a>
|
||||
</nav>
|
||||
<span class="font-mono text-[10px] tracking-[0.2em] text-ash tabular-nums">{{ time }}</span>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ brand?: string; timezone?: string }>();
|
||||
const brand = computed(() => props.brand || "Montech");
|
||||
const time = ref("");
|
||||
|
||||
onMounted(() => {
|
||||
const tz = props.timezone || "Asia/Tehran";
|
||||
const tick = () => {
|
||||
time.value = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: tz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date());
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
onUnmounted(() => clearInterval(id));
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<section class="border-y border-white/[0.07] bg-coal px-6 py-20 md:px-10 md:py-28">
|
||||
<div class="mx-auto grid max-w-[1500px] gap-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div v-for="(s, i) in data.items" :key="i">
|
||||
<p class="font-display text-5xl font-extrabold tracking-[-0.03em] text-volt md:text-6xl">
|
||||
{{ s.value }}{{ s.suffix }}
|
||||
</p>
|
||||
<p class="mt-3 font-mono text-[11px] tracking-[0.22em] text-ash">{{ s.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
items: Array<{ value: number; suffix: string; label: string }>;
|
||||
};
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<section id="team" class="px-6 py-28 md:px-10 md:py-40">
|
||||
<div class="mx-auto max-w-[1500px]">
|
||||
<SectionTag index="05" :title="data.tag" right="Leadership" />
|
||||
<div class="mb-16 mt-14 flex flex-col justify-between gap-8 md:flex-row md:items-end">
|
||||
<h2 class="font-display text-[clamp(2.6rem,8vw,7.5rem)] font-extrabold uppercase leading-[0.92] tracking-[-0.02em]">
|
||||
{{ data.headline.split(" ").slice(0, -1).join(" ") }}
|
||||
<span class="font-serif normal-case italic tracking-normal text-volt">
|
||||
{{ data.headline.split(" ").slice(-1)[0] }}
|
||||
</span>
|
||||
</h2>
|
||||
<p class="max-w-xs text-sm leading-relaxed text-ash md:text-right">{{ data.body }}</p>
|
||||
</div>
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<figure v-for="m in data.members" :key="m.name" class="group">
|
||||
<div class="aspect-[3/4] bg-gradient-to-br from-smoke to-ink" />
|
||||
<figcaption class="mt-4">
|
||||
<p class="font-display text-xl font-bold uppercase tracking-[-0.02em] group-hover:text-volt transition-colors">
|
||||
{{ m.name }}
|
||||
</p>
|
||||
<p class="mt-1 font-mono text-[10px] tracking-[0.22em] text-ash">{{ m.role }}</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
tag: string;
|
||||
headline: string;
|
||||
body: string;
|
||||
members: Array<{ name: string; role: string }>;
|
||||
};
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<section id="work" class="px-6 py-28 md:px-10 md:py-40">
|
||||
<div class="mx-auto max-w-[1500px]">
|
||||
<SectionTag index="03" :title="data.tag" right="2023 — 2025" />
|
||||
<div class="mt-16 grid gap-6 md:grid-cols-2">
|
||||
<article
|
||||
v-for="p in data.items"
|
||||
:key="p.n"
|
||||
class="group relative overflow-hidden border border-white/[0.07] bg-coal p-6 md:p-8"
|
||||
>
|
||||
<div class="mb-16 aspect-[16/10] bg-gradient-to-br from-smoke to-ink" />
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-mono text-[10px] tracking-[0.25em] text-ash">{{ p.n }} / {{ p.year }}</p>
|
||||
<h3 class="mt-2 font-display text-3xl font-extrabold uppercase tracking-[-0.02em] group-hover:text-volt transition-colors">
|
||||
{{ p.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-ash">{{ p.cat }}</p>
|
||||
</div>
|
||||
<span class="text-volt">↗</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
tag: string;
|
||||
items: Array<{ n: string; title: string; cat: string; year: string }>;
|
||||
};
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
export function useApiBase() {
|
||||
const config = useRuntimeConfig();
|
||||
if (import.meta.server) return config.apiBase as string;
|
||||
return (config.public.apiBase as string) || "";
|
||||
}
|
||||
|
||||
export async function fetchPublicSite() {
|
||||
const base = useApiBase();
|
||||
return await $fetch(`${base}/api/public/site`);
|
||||
}
|
||||
|
||||
export async function fetchPublicBlog() {
|
||||
const base = useApiBase();
|
||||
return await $fetch(`${base}/api/public/blog`);
|
||||
}
|
||||
|
||||
export async function fetchPublicPost(slug: string) {
|
||||
const base = useApiBase();
|
||||
return await $fetch(`${base}/api/public/blog/${slug}`);
|
||||
}
|
||||
|
||||
export async function fetchPublicGallery() {
|
||||
const base = useApiBase();
|
||||
return await $fetch(`${base}/api/public/gallery`);
|
||||
}
|
||||
|
||||
export async function fetchPage(key: string) {
|
||||
const base = useApiBase();
|
||||
return await $fetch(`${base}/api/public/pages/${key}`);
|
||||
}
|
||||
|
||||
export function applyPageSeo(seo?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
ogImage?: string | null;
|
||||
canonical?: string | null;
|
||||
noIndex?: boolean;
|
||||
} | null) {
|
||||
if (!seo) return;
|
||||
useSeoMeta({
|
||||
title: seo.title,
|
||||
description: seo.description,
|
||||
ogTitle: seo.title,
|
||||
ogDescription: seo.description,
|
||||
ogImage: seo.ogImage || undefined,
|
||||
robots: seo.noIndex ? "noindex,nofollow" : "index,follow",
|
||||
});
|
||||
if (seo.canonical) {
|
||||
useHead({
|
||||
link: [{ rel: "canonical", href: seo.canonical }],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2025-01-01",
|
||||
devtools: { enabled: false },
|
||||
css: ["~/assets/css/main.css"],
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
runtimeConfig: {
|
||||
apiBase: process.env.NUXT_API_BASE || "http://127.0.0.1:3050",
|
||||
public: {
|
||||
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || "https://montech.ir",
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || "",
|
||||
},
|
||||
},
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: { lang: "en" },
|
||||
link: [
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=Space+Grotesk:wght@300;400;500;600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
nitro: {
|
||||
preset: "node-server",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@montech/landing",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev --port 3000",
|
||||
"build": "nuxt build",
|
||||
"preview": "nuxt preview --port 3051",
|
||||
"start": "node .output/server/index.mjs",
|
||||
"lint": "echo \"landing lint placeholder\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@montech/shared": "*",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"lenis": "^1.3.26",
|
||||
"lucide-vue-next": "^0.511.0",
|
||||
"nuxt": "^3.16.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<article v-if="post" class="min-h-screen px-6 pb-24 pt-28 md:px-10">
|
||||
<SiteNavbar brand="Montech" />
|
||||
<div class="mx-auto max-w-[760px]">
|
||||
<NuxtLink to="/blog" class="font-mono text-[11px] tracking-[0.22em] text-ash hover:text-volt">← BACK</NuxtLink>
|
||||
<h1 class="mt-8 font-display text-4xl font-extrabold uppercase tracking-[-0.03em] md:text-6xl">
|
||||
{{ post.title }}
|
||||
</h1>
|
||||
<p class="mt-4 font-mono text-[11px] tracking-[0.2em] text-ash">
|
||||
{{ post.publishedAt ? new Date(post.publishedAt).toLocaleDateString("en-GB") : "" }}
|
||||
</p>
|
||||
<div class="prose-montech mt-10 space-y-4 text-base leading-relaxed text-bone/90" v-html="post.content" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const slug = String(route.params.slug);
|
||||
const { data: post } = await useAsyncData(`blog-${slug}`, () => fetchPublicPost(slug));
|
||||
|
||||
if (!post.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: "Post not found" });
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: post.value.seoTitle || post.value.title,
|
||||
description: post.value.seoDesc || post.value.excerpt,
|
||||
ogTitle: post.value.seoTitle || post.value.title,
|
||||
ogDescription: post.value.seoDesc || post.value.excerpt,
|
||||
ogImage: post.value.ogImage || post.value.coverUrl || undefined,
|
||||
});
|
||||
if (post.value.canonical) {
|
||||
useHead({ link: [{ rel: "canonical", href: post.value.canonical }] });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="min-h-screen px-6 pb-24 pt-28 md:px-10">
|
||||
<SiteNavbar :brand="'Montech'" />
|
||||
<div class="mx-auto max-w-[1100px]">
|
||||
<h1 class="font-display text-5xl font-extrabold uppercase tracking-[-0.03em] md:text-7xl">Blog</h1>
|
||||
<p class="mt-4 max-w-xl text-ash">Notes on product, engineering and shipping software.</p>
|
||||
<ul class="mt-16 divide-y divide-white/[0.07] border-y border-white/[0.07]">
|
||||
<li v-for="post in posts" :key="post.id" class="py-8">
|
||||
<NuxtLink :to="`/blog/${post.slug}`" class="group block">
|
||||
<p class="font-mono text-[10px] tracking-[0.22em] text-ash">
|
||||
{{ post.publishedAt ? new Date(post.publishedAt).toLocaleDateString("en-GB") : "" }}
|
||||
</p>
|
||||
<h2 class="mt-2 font-display text-2xl font-bold uppercase tracking-[-0.02em] group-hover:text-volt md:text-3xl">
|
||||
{{ post.title }}
|
||||
</h2>
|
||||
<p class="mt-3 max-w-2xl text-sm text-ash">{{ post.excerpt }}</p>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!posts?.length" class="mt-10 text-ash">No posts yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: posts } = await useAsyncData("blog-list", () => fetchPublicBlog());
|
||||
const { data: page } = await useAsyncData("page-blog", () => fetchPage("blog"));
|
||||
applyPageSeo(page.value?.seo);
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="min-h-screen px-6 pb-24 pt-28 md:px-10">
|
||||
<SiteNavbar brand="Montech" />
|
||||
<div class="mx-auto max-w-[1500px]">
|
||||
<h1 class="font-display text-5xl font-extrabold uppercase tracking-[-0.03em] md:text-7xl">Gallery</h1>
|
||||
<p class="mt-4 max-w-xl text-ash">Selected visuals from projects and studio life.</p>
|
||||
<div class="mt-16 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<figure v-for="item in items" :key="item.id" class="overflow-hidden border border-white/[0.07] bg-coal">
|
||||
<div
|
||||
class="aspect-[4/3] bg-cover bg-center"
|
||||
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : {}"
|
||||
/>
|
||||
<figcaption class="p-4">
|
||||
<p class="font-display text-lg font-bold uppercase">{{ item.title }}</p>
|
||||
<p v-if="item.category" class="mt-1 font-mono text-[10px] tracking-[0.2em] text-ash">{{ item.category }}</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<p v-if="!items?.length" class="mt-10 text-ash">Gallery is empty.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: items } = await useAsyncData("gallery", () => fetchPublicGallery());
|
||||
const { data: page } = await useAsyncData("page-gallery", () => fetchPage("gallery"));
|
||||
applyPageSeo(page.value?.seo);
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div v-if="site">
|
||||
<SiteNavbar :brand="site.settings.brandName" :timezone="site.settings.timezone" />
|
||||
<main>
|
||||
<HeroSection :data="site.sections.hero" />
|
||||
<section class="relative overflow-hidden py-16 md:py-24">
|
||||
<div class="-ml-[5%] w-[110%] -rotate-2 bg-volt py-4 text-ink md:py-5">
|
||||
<SiteMarquee
|
||||
:items="site.sections.marquee.items"
|
||||
:duration="22"
|
||||
class-name="font-display text-xl font-bold uppercase tracking-[-0.01em] md:text-3xl"
|
||||
item-class-name="mx-4 md:mx-6"
|
||||
separator-class-name="text-ink/50"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<ManifestoSection :data="site.sections.manifesto" />
|
||||
<ServicesSection :data="site.sections.services" />
|
||||
<WorkSection :data="site.sections.work" />
|
||||
<ProcessSection :data="site.sections.process" />
|
||||
<StatsSection :data="site.sections.stats" />
|
||||
<TeamSection :data="site.sections.team" />
|
||||
</main>
|
||||
<SiteFooter :brand="site.settings.brandName" :data="site.sections.footer" />
|
||||
</div>
|
||||
<div v-else class="grid min-h-screen place-items-center text-ash">
|
||||
Unable to load site content. Is the API running?
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: site } = await useAsyncData("public-site", () => fetchPublicSite());
|
||||
const homePage = computed(() => site.value?.pages?.find((p: any) => p.key === "home"));
|
||||
applyPageSeo(homePage.value?.seo);
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
import Lenis from "lenis";
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
if (!import.meta.client) return;
|
||||
const lenis = new Lenis({ lerp: 0.09, smoothWheel: true });
|
||||
let raf = 0;
|
||||
const loop = (time: number) => {
|
||||
lenis.raf(time);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
nuxtApp.hook("app:unmounted", () => {
|
||||
cancelAnimationFrame(raf);
|
||||
lenis.destroy();
|
||||
});
|
||||
return {
|
||||
provide: { lenis },
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect width="64" height="64" rx="14" fill="#0A0A0B"/>
|
||||
<circle cx="32" cy="32" r="17" fill="none" stroke="#C8FF2E" stroke-width="7"/>
|
||||
<text x="32" y="38" text-anchor="middle" fill="#F2F2EE" font-family="Arial,sans-serif" font-size="14" font-weight="700">M</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
@@ -0,0 +1,6 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const site = config.public.siteUrl || "https://montech.ir";
|
||||
setHeader(event, "Content-Type", "text/plain");
|
||||
return `User-agent: *\nAllow: /\nDisallow: /adm\nSitemap: ${site}/sitemap.xml\n`;
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const site = config.public.siteUrl || "https://montech.ir";
|
||||
const apiBase = config.apiBase || "http://127.0.0.1:3050";
|
||||
|
||||
let posts: Array<{ slug: string; updatedAt?: string; publishedAt?: string }> = [];
|
||||
try {
|
||||
posts = await $fetch(`${apiBase}/api/public/blog`);
|
||||
} catch {
|
||||
posts = [];
|
||||
}
|
||||
|
||||
const staticUrls = ["", "/blog", "/gallery"];
|
||||
const urls = [
|
||||
...staticUrls.map((path) => ({ loc: `${site}${path || "/"}`, changefreq: "weekly", priority: path === "" ? "1.0" : "0.8" })),
|
||||
...posts.map((p) => ({
|
||||
loc: `${site}/blog/${p.slug}`,
|
||||
changefreq: "monthly",
|
||||
priority: "0.6",
|
||||
lastmod: p.updatedAt || p.publishedAt,
|
||||
})),
|
||||
];
|
||||
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls
|
||||
.map(
|
||||
(u) => ` <url>
|
||||
<loc>${u.loc}</loc>
|
||||
${"lastmod" in u && u.lastmod ? `<lastmod>${new Date(u.lastmod as string).toISOString()}</lastmod>` : ""}
|
||||
<changefreq>${u.changefreq}</changefreq>
|
||||
<priority>${u.priority}</priority>
|
||||
</url>`,
|
||||
)
|
||||
.join("\n")}
|
||||
</urlset>`;
|
||||
|
||||
setHeader(event, "Content-Type", "application/xml");
|
||||
return body;
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
Reference in New Issue
Block a user