feat: monorepo with NestJS API, admin panel and DB-driven website

Restructure the repo into a pnpm + Turborepo monorepo:

- apps/web: Nuxt 4 site, now server-rendered and reading all content
  from the API; pages cached with SWR and purged on content changes
- apps/api: NestJS 12 + Prisma 7 + PostgreSQL. Auth with short-lived
  access tokens and rotating httpOnly refresh cookies, roles (ADMIN,
  EDITOR), catalog, editable site sections, media upload to S3-compatible
  storage (converted to WebP), contact messages, audit log
- apps/admin: Vue 3 admin panel (Ant Design Vue, Pinia, Tailwind) for
  products, categories, site sections, messages, media and users
- packages/shared: Zod schemas and types shared by API and admin

Also:
- seed script that loads the original 8 categories / 89 products and
  section content
- Docker images for api (with a one-off migrate service), web, admin,
  and an nginx gateway for both domains
- dev and production compose files, daily database backup service
- API e2e tests

Removes the committed static out/ build and the old root Dockerfile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
S. Esmaeili
2026-10-21 20:31:05 +03:30
co-authored by Claude Opus 5.5
parent 31076174e2
commit 789d224ea0
395 changed files with 19070 additions and 11592 deletions
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { FORM_ERRORS } from "@/composables/useZodForm";
/** a-form-item that shows the zod error for `path` (provided by useZodForm) */
const props = defineProps<{ path: string; label?: string; hint?: string; required?: boolean }>();
const errors = inject(FORM_ERRORS, null);
const error = computed(() => errors?.value[props.path]);
</script>
<template>
<a-form-item
:label="label"
:required="required"
:validate-status="error ? 'error' : undefined"
:help="error || hint"
>
<slot />
</a-form-item>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { ICON_NAMES, type IconName } from "@imen/shared";
import { computed } from "vue";
import { ICONS } from "./icons";
/** Picks one of the Lucide icons the website knows how to render */
const value = defineModel<string>({ required: true });
defineProps<{ allowClear?: boolean }>();
const options = ICON_NAMES.map((name) => ({ value: name, label: name }));
const current = computed(() => ICONS[value.value as IconName]);
</script>
<template>
<div class="flex items-center gap-2">
<span class="flex size-8 shrink-0 items-center justify-center rounded-md border border-gray-200 text-[#ff5a00]">
<component :is="current" v-if="current" class="size-4" />
</span>
<a-select
v-model:value="value"
class="min-w-0 flex-1"
show-search
:allow-clear="allowClear"
:options="options"
placeholder="انتخاب آیکون"
>
<template #option="{ value: name }">
<span class="flex items-center gap-2" dir="ltr">
<component :is="ICONS[name as IconName]" class="size-4" />
{{ name }}
</span>
</template>
</a-select>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { DeleteOutlined, PictureOutlined } from "@ant-design/icons-vue";
import type { MediaDto } from "@imen/shared";
import { ref } from "vue";
import MediaPicker from "./MediaPicker.vue";
/** Image preview with "choose" / "remove" buttons; the parent decides what to store */
defineProps<{ url?: string | null; clearable?: boolean }>();
const emit = defineEmits<{ pick: [media: MediaDto]; clear: [] }>();
const open = ref(false);
</script>
<template>
<div class="flex items-start gap-4">
<div
class="flex size-28 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-dashed border-gray-300 bg-gray-50"
>
<img v-if="url" :src="url" alt="" class="size-full object-contain" />
<PictureOutlined v-else class="text-2xl text-gray-300" />
</div>
<a-space direction="vertical">
<a-button @click="open = true">
<template #icon><PictureOutlined /></template>
{{ url ? "تغییر تصویر" : "انتخاب یا آپلود تصویر" }}
</a-button>
<a-button v-if="url && clearable" danger @click="emit('clear')">
<template #icon><DeleteOutlined /></template>
حذف تصویر
</a-button>
</a-space>
<MediaPicker v-model:open="open" @select="emit('pick', $event)" />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ImageRef } from "@imen/shared";
import FormField from "./FormField.vue";
import ImageField from "./ImageField.vue";
/** Edits an { url, alt } image stored inside section content */
const image = defineModel<ImageRef>({ required: true });
defineProps<{ path: string; label: string }>();
</script>
<template>
<FormField :path="`${path}.url`" :label="label" required>
<ImageField
:url="image.url"
@pick="(m) => (image = { url: m.url, alt: image.alt || m.alt })"
/>
</FormField>
<FormField :path="`${path}.alt`" label="متن جایگزین تصویر (alt)" hint="برای سئو و دسترسی‌پذیری؛ تصویر را در یک جمله توصیف کنید">
<a-input v-model:value="image.alt" />
</FormField>
</template>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts" generic="T">
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from "@ant-design/icons-vue";
/** Editable list: add, remove and reorder items; each item is rendered by the default slot */
const items = defineModel<T[]>({ required: true });
const props = withDefaults(
defineProps<{
create: () => T;
addLabel?: string;
max?: number;
/** title shown on each card, e.g. (item, i) => item.title */
itemTitle?: (item: T, index: number) => string;
}>(),
{ addLabel: "افزودن مورد", max: 50 },
);
const move = (i: number, d: -1 | 1) => {
const list = [...items.value];
const [it] = list.splice(i, 1);
list.splice(i + d, 0, it as T);
items.value = list;
};
const remove = (i: number) => {
items.value = items.value.filter((_, j) => j !== i);
};
const add = () => {
items.value = [...items.value, props.create()];
};
</script>
<template>
<div class="space-y-3">
<div
v-for="(item, i) in items"
:key="i"
class="rounded-lg border border-gray-200 bg-white p-4"
>
<div class="mb-3 flex items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-600">
{{ (i + 1).toLocaleString("fa-IR") }}. {{ itemTitle?.(item, i) ?? "" }}
</span>
<a-space :size="4">
<a-button size="small" :disabled="i === 0" title="بالا" @click="move(i, -1)">
<template #icon><ArrowUpOutlined /></template>
</a-button>
<a-button size="small" :disabled="i === items.length - 1" title="پایین" @click="move(i, 1)">
<template #icon><ArrowDownOutlined /></template>
</a-button>
<a-popconfirm title="این مورد حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(i)">
<a-button size="small" danger title="حذف">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</a-space>
</div>
<slot :item="item" :index="i" />
</div>
<a-button type="dashed" block :disabled="items.length >= max" @click="add">
<template #icon><PlusOutlined /></template>
{{ addLabel }}
</a-button>
</div>
</template>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { InboxOutlined } from "@ant-design/icons-vue";
import type { MediaDto, Paginated } from "@imen/shared";
import { message } from "ant-design-vue";
import { ref, watch } from "vue";
import { api, errorMessage } from "@/api/http";
/** Modal to upload a new image or pick one from the media library */
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ select: [media: MediaDto] }>();
const tab = ref<"library" | "upload">("library");
const items = ref<MediaDto[]>([]);
const total = ref(0);
const page = ref(1);
const q = ref("");
const loading = ref(false);
const uploading = ref(false);
const PAGE_SIZE = 24;
async function load() {
loading.value = true;
try {
const res = await api<Paginated<MediaDto>>("/admin/media", {
query: { page: page.value, pageSize: PAGE_SIZE, q: q.value || undefined },
});
items.value = res.items;
total.value = res.total;
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
}
watch(open, (v) => v && load());
function choose(m: MediaDto) {
emit("select", m);
open.value = false;
}
/** Upload, then return the new file straight to the caller */
async function upload(file: File) {
uploading.value = true;
try {
const body = new FormData();
body.append("file", file);
const media = await api<MediaDto>("/admin/media", { method: "POST", body });
void message.success("تصویر آپلود شد");
choose(media);
} catch (e) {
void message.error(errorMessage(e));
} finally {
uploading.value = false;
}
return false; // stop a-upload's own request
}
</script>
<template>
<a-modal v-model:open="open" title="انتخاب تصویر" :footer="null" width="900px" destroy-on-close>
<a-tabs v-model:active-key="tab">
<a-tab-pane key="library" tab="کتابخانه رسانه">
<a-input-search
v-model:value="q"
placeholder="جستجو در نام فایل یا متن جایگزین"
allow-clear
class="mb-4"
@search="() => { page = 1; load(); }"
/>
<a-spin :spinning="loading">
<a-empty v-if="!items.length && !loading" description="تصویری پیدا نشد" />
<div class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-6">
<button
v-for="m in items"
:key="m.id"
type="button"
class="group overflow-hidden rounded-lg border border-gray-200 bg-gray-50 text-start transition hover:border-[#ff5a00] focus:border-[#ff5a00] focus:outline-none"
:title="m.originalName"
@click="choose(m)"
>
<img :src="m.url" :alt="m.alt" class="aspect-square w-full object-contain" loading="lazy" />
<div class="truncate px-1.5 py-1 text-[11px] text-gray-500" dir="ltr">{{ m.originalName }}</div>
</button>
</div>
</a-spin>
<div class="mt-4 flex justify-center">
<a-pagination
v-model:current="page"
:total="total"
:page-size="PAGE_SIZE"
:show-size-changer="false"
@change="load"
/>
</div>
</a-tab-pane>
<a-tab-pane key="upload" tab="آپلود تصویر جدید">
<a-spin :spinning="uploading">
<a-upload-dragger
accept="image/jpeg,image/png,image/webp,image/avif,image/gif"
:show-upload-list="false"
:before-upload="upload"
>
<p class="ant-upload-drag-icon"><InboxOutlined /></p>
<p class="ant-upload-text">تصویر را اینجا رها کنید یا کلیک کنید</p>
<p class="ant-upload-hint">
JPG، PNG، WebP، AVIF یا GIF — حداکثر ۱۵ مگابایت. تصویر به‌صورت خودکار بهینه و به WebP تبدیل می‌شود.
</p>
</a-upload-dragger>
</a-spin>
</a-tab-pane>
</a-tabs>
</a-modal>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
defineProps<{ title: string; subtitle?: string; back?: string }>();
</script>
<template>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<RouterLink v-if="back" :to="back" class="text-gray-400 hover:text-gray-700" title="بازگشت">→</RouterLink>
<div>
<h1 class="text-xl font-bold text-gray-800">{{ title }}</h1>
<p v-if="subtitle" class="mt-1 text-sm text-gray-500">{{ subtitle }}</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { SectionHeadData } from "@imen/shared";
import FormField from "./FormField.vue";
/** Tag / title / highlight / description fields shared by most sections */
const head = defineModel<SectionHeadData>({ required: true });
withDefaults(defineProps<{ path?: string; withHighlight?: boolean; withDesc?: boolean }>(), {
path: "head",
withHighlight: true,
withDesc: true,
});
</script>
<template>
<div class="grid gap-x-4 md:grid-cols-2">
<FormField :path="`${path}.tag`" label="برچسب بالای عنوان" hint="مثلاً «۰۱ — محصولات»" required>
<a-input v-model:value="head.tag" />
</FormField>
<FormField :path="`${path}.title`" label="عنوان" hint="برای شکستن خط، Enter بزنید" required>
<a-textarea v-model:value="head.title" :auto-size="{ minRows: 2, maxRows: 4 }" />
</FormField>
<FormField v-if="withHighlight" :path="`${path}.highlight`" label="خط رنگی انتهای عنوان (اختیاری)">
<a-input v-model:value="head.highlight" />
</FormField>
<FormField v-if="withDesc" :path="`${path}.desc`" label="توضیح زیر عنوان" class="md:col-span-2">
<a-textarea v-model:value="head.desc" :auto-size="{ minRows: 2, maxRows: 5 }" />
</FormField>
</div>
</template>
+104
View File
@@ -0,0 +1,104 @@
import type { IconName } from "@imen/shared";
import {
Award,
BadgeCheck,
Building2,
ClipboardCheck,
Clock,
Construction,
Ear,
Factory,
FileCheck,
FileSearch,
Flame,
Footprints,
Fuel,
Gauge,
Glasses,
Globe,
GraduationCap,
Hand,
Handshake,
HardHat,
Headset,
HeartPulse,
Leaf,
Lock,
Mail,
MapPin,
Medal,
Microscope,
Package,
PackageCheck,
Phone,
Pickaxe,
ShieldAlert,
ShieldCheck,
ShieldPlus,
Shirt,
Siren,
Star,
Target,
ThumbsUp,
TrafficCone,
TriangleAlert,
Truck,
Users,
Warehouse,
Wind,
Wrench,
Zap,
} from "lucide-vue-next";
import type { Component } from "vue";
/** Same icon set as the website's DynamicIcon (ICON_NAMES in @imen/shared) */
export const ICONS: Record<IconName, Component> = {
ShieldCheck,
ShieldPlus,
ShieldAlert,
BadgeCheck,
Award,
Medal,
PackageCheck,
Package,
Truck,
Warehouse,
Globe,
Users,
Handshake,
Headset,
ClipboardCheck,
FileCheck,
FileSearch,
GraduationCap,
Gauge,
Microscope,
Wrench,
HardHat,
Shirt,
Footprints,
Hand,
Glasses,
Ear,
Wind,
TriangleAlert,
Siren,
TrafficCone,
Construction,
Factory,
Building2,
Fuel,
Pickaxe,
Flame,
Zap,
Leaf,
HeartPulse,
Clock,
Phone,
Mail,
MapPin,
Star,
ThumbsUp,
Target,
Lock,
};