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:
co-authored by
Claude Opus 5.5
parent
31076174e2
commit
789d224ea0
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user