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,25 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { HealthModule } from "./health/health.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { ContentModule } from "./content/content.module";
|
||||
import { BlogModule } from "./blog/blog.module";
|
||||
import { GalleryModule } from "./gallery/gallery.module";
|
||||
import { MediaModule } from "./media/media.module";
|
||||
import { SeoModule } from "./seo/seo.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
ContentModule,
|
||||
BlogModule,
|
||||
GalleryModule,
|
||||
MediaModule,
|
||||
SeoModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Controller, Post, Body, Get, UseGuards, Req } from "@nestjs/common";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { LoginDto } from "./dto";
|
||||
import { JwtAuthGuard } from "./jwt-auth.guard";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(private auth: AuthService) {}
|
||||
|
||||
@Post("login")
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.auth.login(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("me")
|
||||
me(@Req() req: { user: { id: string } }) {
|
||||
return this.auth.me(req.user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { JwtStrategy } from "./jwt.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || "montech-dev-secret-change-me",
|
||||
signOptions: { expiresIn: "7d" },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import * as bcrypt from "bcryptjs";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { LoginDto } from "./dto";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwt: JwtService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
const admin = await this.prisma.admin.findUnique({ where: { mobile: dto.mobile } });
|
||||
if (!admin) throw new UnauthorizedException("Invalid credentials");
|
||||
const ok = await bcrypt.compare(dto.password, admin.passwordHash);
|
||||
if (!ok) throw new UnauthorizedException("Invalid credentials");
|
||||
const token = await this.jwt.signAsync({
|
||||
sub: admin.id,
|
||||
mobile: admin.mobile,
|
||||
name: admin.name,
|
||||
});
|
||||
return {
|
||||
accessToken: token,
|
||||
admin: { id: admin.id, mobile: admin.mobile, name: admin.name, email: admin.email },
|
||||
};
|
||||
}
|
||||
|
||||
async me(adminId: string) {
|
||||
const admin = await this.prisma.admin.findUnique({
|
||||
where: { id: adminId },
|
||||
select: { id: true, mobile: true, name: true, email: true },
|
||||
});
|
||||
if (!admin) throw new UnauthorizedException();
|
||||
return admin;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
mobile!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard("jwt") {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || "montech-dev-secret-change-me",
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: { sub: string; mobile: string; name: string }) {
|
||||
return { id: payload.sub, mobile: payload.mobile, name: payload.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { BlogService } from "./blog.service";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
import { CreateBlogDto, UpdateBlogDto } from "./blog.dto";
|
||||
|
||||
@Controller()
|
||||
export class BlogController {
|
||||
constructor(private blog: BlogService) {}
|
||||
|
||||
@Get("public/blog")
|
||||
listPublic() {
|
||||
return this.blog.listPublic();
|
||||
}
|
||||
|
||||
@Get("public/blog/:slug")
|
||||
getPublic(@Param("slug") slug: string) {
|
||||
return this.blog.getBySlug(slug);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/blog")
|
||||
listAdmin() {
|
||||
return this.blog.listAdmin();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/blog/:id")
|
||||
getAdmin(@Param("id") id: string) {
|
||||
return this.blog.getAdmin(id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post("admin/blog")
|
||||
create(@Body() dto: CreateBlogDto) {
|
||||
return this.blog.create(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put("admin/blog/:id")
|
||||
update(@Param("id") id: string, @Body() dto: UpdateBlogDto) {
|
||||
return this.blog.update(id, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete("admin/blog/:id")
|
||||
remove(@Param("id") id: string) {
|
||||
return this.blog.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
|
||||
export class CreateBlogDto {
|
||||
@IsString() @MinLength(1) slug!: string;
|
||||
@IsString() @MinLength(1) title!: string;
|
||||
@IsOptional() @IsString() excerpt?: string;
|
||||
@IsOptional() @IsString() content?: string;
|
||||
@IsOptional() @IsString() coverUrl?: string;
|
||||
@IsOptional() @IsBoolean() published?: boolean;
|
||||
@IsOptional() @IsString() seoTitle?: string;
|
||||
@IsOptional() @IsString() seoDesc?: string;
|
||||
@IsOptional() @IsString() ogImage?: string;
|
||||
@IsOptional() @IsString() canonical?: string;
|
||||
}
|
||||
|
||||
export class UpdateBlogDto {
|
||||
@IsOptional() @IsString() @MinLength(1) slug?: string;
|
||||
@IsOptional() @IsString() @MinLength(1) title?: string;
|
||||
@IsOptional() @IsString() excerpt?: string;
|
||||
@IsOptional() @IsString() content?: string;
|
||||
@IsOptional() @IsString() coverUrl?: string;
|
||||
@IsOptional() @IsBoolean() published?: boolean;
|
||||
@IsOptional() @IsString() seoTitle?: string;
|
||||
@IsOptional() @IsString() seoDesc?: string;
|
||||
@IsOptional() @IsString() ogImage?: string;
|
||||
@IsOptional() @IsString() canonical?: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BlogService } from "./blog.service";
|
||||
import { BlogController } from "./blog.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [BlogController],
|
||||
providers: [BlogService],
|
||||
})
|
||||
export class BlogModule {}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class BlogService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
listPublic() {
|
||||
return this.prisma.blogPost.findMany({
|
||||
where: { published: true },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
excerpt: true,
|
||||
coverUrl: true,
|
||||
publishedAt: true,
|
||||
seoTitle: true,
|
||||
seoDesc: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getBySlug(slug: string) {
|
||||
const post = await this.prisma.blogPost.findFirst({
|
||||
where: { slug, published: true },
|
||||
});
|
||||
if (!post) throw new NotFoundException();
|
||||
return post;
|
||||
}
|
||||
|
||||
listAdmin() {
|
||||
return this.prisma.blogPost.findMany({ orderBy: { updatedAt: "desc" } });
|
||||
}
|
||||
|
||||
getAdmin(id: string) {
|
||||
return this.prisma.blogPost.findUniqueOrThrow({ where: { id } });
|
||||
}
|
||||
|
||||
create(data: {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
content?: string;
|
||||
coverUrl?: string;
|
||||
published?: boolean;
|
||||
seoTitle?: string;
|
||||
seoDesc?: string;
|
||||
ogImage?: string;
|
||||
canonical?: string;
|
||||
}) {
|
||||
return this.prisma.blogPost.create({
|
||||
data: {
|
||||
...data,
|
||||
publishedAt: data.published ? new Date() : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: {
|
||||
slug?: string;
|
||||
title?: string;
|
||||
excerpt?: string;
|
||||
content?: string;
|
||||
coverUrl?: string | null;
|
||||
published?: boolean;
|
||||
seoTitle?: string | null;
|
||||
seoDesc?: string | null;
|
||||
ogImage?: string | null;
|
||||
canonical?: string | null;
|
||||
},
|
||||
) {
|
||||
const existing = await this.prisma.blogPost.findUniqueOrThrow({ where: { id } });
|
||||
const publishedAt =
|
||||
data.published === true && !existing.publishedAt
|
||||
? new Date()
|
||||
: data.published === false
|
||||
? null
|
||||
: existing.publishedAt;
|
||||
return this.prisma.blogPost.update({
|
||||
where: { id },
|
||||
data: { ...data, publishedAt },
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.blogPost.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common";
|
||||
import { ContentService } from "./content.service";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
import { IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
class UpdateSettingsDto {
|
||||
@IsOptional() @IsString() brandName?: string;
|
||||
@IsOptional() @IsString() tagline?: string;
|
||||
@IsOptional() @IsString() email?: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsString() address?: string;
|
||||
@IsOptional() @IsString() timezone?: string;
|
||||
@IsOptional() socials?: object;
|
||||
@IsOptional() @IsString() logoUrl?: string;
|
||||
}
|
||||
|
||||
class UpsertSectionDto {
|
||||
@IsObject()
|
||||
data!: object;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class ContentController {
|
||||
constructor(private content: ContentService) {}
|
||||
|
||||
@Get("public/site")
|
||||
publicSite() {
|
||||
return this.content.getPublicSite();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/settings")
|
||||
getSettings() {
|
||||
return this.content.getSettings();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put("admin/settings")
|
||||
updateSettings(@Body() dto: UpdateSettingsDto) {
|
||||
return this.content.updateSettings(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/sections")
|
||||
listSections() {
|
||||
return this.content.listSections();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/sections/:key")
|
||||
getSection(@Param("key") key: string) {
|
||||
return this.content.getSection(key);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put("admin/sections/:key")
|
||||
upsertSection(@Param("key") key: string, @Body() dto: UpsertSectionDto) {
|
||||
return this.content.upsertSection(key, dto.data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ContentService } from "./content.service";
|
||||
import { ContentController } from "./content.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [ContentController],
|
||||
providers: [ContentService],
|
||||
exports: [ContentService],
|
||||
})
|
||||
export class ContentModule {}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class ContentService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
getSettings() {
|
||||
return this.prisma.siteSettings.findUniqueOrThrow({ where: { id: "default" } });
|
||||
}
|
||||
|
||||
updateSettings(data: {
|
||||
brandName?: string;
|
||||
tagline?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
timezone?: string;
|
||||
socials?: object;
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
return this.prisma.siteSettings.update({ where: { id: "default" }, data });
|
||||
}
|
||||
|
||||
async listSections() {
|
||||
return this.prisma.sectionContent.findMany({ orderBy: { key: "asc" } });
|
||||
}
|
||||
|
||||
async getSection(key: string) {
|
||||
const row = await this.prisma.sectionContent.findUnique({ where: { key } });
|
||||
if (!row) throw new NotFoundException(`Section ${key} not found`);
|
||||
return row;
|
||||
}
|
||||
|
||||
async upsertSection(key: string, data: object) {
|
||||
return this.prisma.sectionContent.upsert({
|
||||
where: { key },
|
||||
create: { key, data },
|
||||
update: { data },
|
||||
});
|
||||
}
|
||||
|
||||
async getPublicSite() {
|
||||
const [settings, sections, pages] = await Promise.all([
|
||||
this.getSettings(),
|
||||
this.listSections(),
|
||||
this.prisma.page.findMany({ include: { seo: true } }),
|
||||
]);
|
||||
const sectionMap: Record<string, unknown> = {};
|
||||
for (const s of sections) sectionMap[s.key] = s.data;
|
||||
return { settings, sections: sectionMap, pages };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, MinLength } from "class-validator";
|
||||
import { GalleryService } from "./gallery.service";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
|
||||
class GalleryDto {
|
||||
@IsString() @MinLength(1) title!: string;
|
||||
@IsString() @MinLength(1) imageUrl!: string;
|
||||
@IsOptional() @IsString() category?: string;
|
||||
@IsOptional() @IsInt() sortOrder?: number;
|
||||
@IsOptional() @IsBoolean() published?: boolean;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class GalleryController {
|
||||
constructor(private gallery: GalleryService) {}
|
||||
|
||||
@Get("public/gallery")
|
||||
listPublic() {
|
||||
return this.gallery.listPublic();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/gallery")
|
||||
listAdmin() {
|
||||
return this.gallery.listAdmin();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post("admin/gallery")
|
||||
create(@Body() dto: GalleryDto) {
|
||||
return this.gallery.create(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put("admin/gallery/:id")
|
||||
update(@Param("id") id: string, @Body() dto: Partial<GalleryDto>) {
|
||||
return this.gallery.update(id, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete("admin/gallery/:id")
|
||||
remove(@Param("id") id: string) {
|
||||
return this.gallery.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { GalleryService } from "./gallery.service";
|
||||
import { GalleryController } from "./gallery.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [GalleryController],
|
||||
providers: [GalleryService],
|
||||
})
|
||||
export class GalleryModule {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class GalleryService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
listPublic() {
|
||||
return this.prisma.galleryItem.findMany({
|
||||
where: { published: true },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
}
|
||||
|
||||
listAdmin() {
|
||||
return this.prisma.galleryItem.findMany({
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
}
|
||||
|
||||
create(data: {
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
category?: string;
|
||||
sortOrder?: number;
|
||||
published?: boolean;
|
||||
}) {
|
||||
return this.prisma.galleryItem.create({ data });
|
||||
}
|
||||
|
||||
update(
|
||||
id: string,
|
||||
data: {
|
||||
title?: string;
|
||||
imageUrl?: string;
|
||||
category?: string | null;
|
||||
sortOrder?: number;
|
||||
published?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.prisma.galleryItem.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.galleryItem.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Controller("health")
|
||||
class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return { ok: true, service: "montech-api" };
|
||||
}
|
||||
}
|
||||
|
||||
@Module({ controllers: [HealthController] })
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { join } from "path";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.setGlobalPrefix("api");
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const uploadsRoot = process.env.UPLOADS_DIR || join(process.cwd(), "..", "..", "uploads");
|
||||
app.useStaticAssets(uploadsRoot, { prefix: "/api/uploads/" });
|
||||
|
||||
const origin = process.env.CORS_ORIGIN || "http://localhost:3000,http://localhost:5174";
|
||||
app.enableCors({
|
||||
origin: origin.split(",").map((s) => s.trim()),
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT || 3050);
|
||||
await app.listen(port, "0.0.0.0");
|
||||
console.log(`Montech API listening on :${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { memoryStorage } from "multer";
|
||||
import { MediaService } from "./media.service";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
|
||||
@Controller("admin/media")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MediaController {
|
||||
constructor(private media: MediaService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.media.list();
|
||||
}
|
||||
|
||||
@Post("upload")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
upload(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.media.saveFile(file);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
remove(@Param("id") id: string) {
|
||||
return this.media.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MediaService } from "./media.service";
|
||||
import { MediaController } from "./media.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { join } from "path";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
uploadsRoot() {
|
||||
return process.env.UPLOADS_DIR || join(process.cwd(), "..", "..", "uploads");
|
||||
}
|
||||
|
||||
ensureDir() {
|
||||
const dir = this.uploadsRoot();
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
list() {
|
||||
return this.prisma.media.findMany({ orderBy: { createdAt: "desc" } });
|
||||
}
|
||||
|
||||
async saveFile(file: Express.Multer.File) {
|
||||
const dir = this.ensureDir();
|
||||
const ext = file.originalname.includes(".")
|
||||
? file.originalname.slice(file.originalname.lastIndexOf("."))
|
||||
: "";
|
||||
const filename = `${randomUUID()}${ext}`;
|
||||
const { writeFileSync } = await import("fs");
|
||||
writeFileSync(join(dir, filename), file.buffer);
|
||||
const url = `/api/uploads/${filename}`;
|
||||
return this.prisma.media.create({
|
||||
data: {
|
||||
filename,
|
||||
url,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.media.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { PrismaService } from "./prisma.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common";
|
||||
import { IsBoolean, IsOptional, IsString } from "class-validator";
|
||||
import { SeoService } from "./seo.service";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
|
||||
class SeoDto {
|
||||
@IsOptional() @IsString() title?: string;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
@IsOptional() @IsString() ogImage?: string;
|
||||
@IsOptional() @IsString() canonical?: string;
|
||||
@IsOptional() @IsBoolean() noIndex?: boolean;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class SeoController {
|
||||
constructor(private seo: SeoService) {}
|
||||
|
||||
@Get("public/pages/:key")
|
||||
getPublic(@Param("key") key: string) {
|
||||
return this.seo.getByKey(key);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/pages")
|
||||
list() {
|
||||
return this.seo.listPages();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put("admin/pages/:key/seo")
|
||||
update(@Param("key") key: string, @Body() dto: SeoDto) {
|
||||
return this.seo.updateSeo(key, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SeoService } from "./seo.service";
|
||||
import { SeoController } from "./seo.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [SeoController],
|
||||
providers: [SeoService],
|
||||
})
|
||||
export class SeoModule {}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class SeoService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
listPages() {
|
||||
return this.prisma.page.findMany({ include: { seo: true }, orderBy: { key: "asc" } });
|
||||
}
|
||||
|
||||
async getByKey(key: string) {
|
||||
const page = await this.prisma.page.findUnique({
|
||||
where: { key },
|
||||
include: { seo: true },
|
||||
});
|
||||
if (!page) throw new NotFoundException();
|
||||
return page;
|
||||
}
|
||||
|
||||
async updateSeo(
|
||||
key: string,
|
||||
data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
ogImage?: string | null;
|
||||
canonical?: string | null;
|
||||
noIndex?: boolean;
|
||||
},
|
||||
) {
|
||||
const page = await this.prisma.page.findUniqueOrThrow({ where: { key } });
|
||||
return this.prisma.pageSeo.upsert({
|
||||
where: { pageId: page.id },
|
||||
create: {
|
||||
pageId: page.id,
|
||||
title: data.title || page.title,
|
||||
description: data.description || "",
|
||||
ogImage: data.ogImage,
|
||||
canonical: data.canonical,
|
||||
noIndex: data.noIndex ?? false,
|
||||
},
|
||||
update: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user