From 027d28005d9f521c2c764337b71218394738f1cf Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 30 Nov 2024 18:38:56 +0100 Subject: [PATCH] feat: Add icon handle in metadata to handle manifest icons in AdminCP --- apps/frontend/src/plugins/admin/langs/en.json | 3 + packages/backend/package.json | 1 + packages/backend/src/app.module.ts | 2 +- .../admin/settings/main/main.controller.ts | 2 +- .../core/admin/settings/main/main.module.ts | 2 +- .../{edit.main.service.ts => edit.service.ts} | 33 ++++-- .../{helpers.service.ts => helpers.ts} | 44 ++++---- .../settings/metadata/metadata.controller.ts | 17 ++- .../settings/metadata/metadata.module.ts | 7 +- .../metadata/services/edit.service.ts | 100 +++++++++++++++-- .../metadata/services/show.service.ts | 23 ++-- .../theme-editor/service/edit.service.ts | 21 ++-- .../theme-editor/theme-editor.controller.ts | 49 ++++---- packages/backend/src/helpers/files/files.mdx | 42 ------- .../backend/src/helpers/files/files.pipe.ts | 106 +++++++++++++----- .../src/helpers/upload-files.decorator.ts | 14 +++ .../files/item-preview-files-input.tsx | 2 - .../views/core/settings/metadata/content.tsx | 14 +++ .../hooks/use-metadata-settings-admin-api.ts | 6 + .../shared/src/admin/settings/metadata.dto.ts | 29 ++++- pnpm-lock.yaml | 25 +++-- 21 files changed, 355 insertions(+), 187 deletions(-) rename packages/backend/src/core/admin/settings/main/services/{edit.main.service.ts => edit.service.ts} (81%) rename packages/backend/src/core/admin/settings/metadata/{helpers.service.ts => helpers.ts} (68%) delete mode 100644 packages/backend/src/helpers/files/files.mdx create mode 100644 packages/backend/src/helpers/upload-files.decorator.ts diff --git a/apps/frontend/src/plugins/admin/langs/en.json b/apps/frontend/src/plugins/admin/langs/en.json index 7c6cd6737..a7eba223f 100644 --- a/apps/frontend/src/plugins/admin/langs/en.json +++ b/apps/frontend/src/plugins/admin/langs/en.json @@ -252,6 +252,9 @@ "label": "Start URL", "desc": "When user launches the app, this is the page that will be loaded." }, + "icon": { + "label": "Icon" + }, "display": { "label": "Display", "desc": "The display property controls how the app is displayed.", diff --git a/packages/backend/package.json b/packages/backend/package.json index a7bd72245..4f3af7dbf 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -93,6 +93,7 @@ "drizzle-orm": "^0.36.4", "helmet": "^8.0.0", "pg": "^8.13.1", + "rxjs": "^7.8.1", "sharp": "^0.33.5", "ua-parser-js": "^2.0.0", "vitnode-shared": "workspace:*" diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index fa3d65497..c9e048ed4 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -94,7 +94,7 @@ const parseFrontendUrlFromEnv = () => { }; const parseBackendUrlFromEnv = () => { - const envUrl = process.env.NEXT_PUBLIC_BACKEND_URL; + const envUrl = process.env.NEXT_PUBLIC_BACKEND_CLIENT_URL; const frontendUrl = envUrl ? envUrl : 'http://localhost:8080'; const urlObj = new URL(frontendUrl); diff --git a/packages/backend/src/core/admin/settings/main/main.controller.ts b/packages/backend/src/core/admin/settings/main/main.controller.ts index fde03e2d1..c8d8e511e 100644 --- a/packages/backend/src/core/admin/settings/main/main.controller.ts +++ b/packages/backend/src/core/admin/settings/main/main.controller.ts @@ -3,7 +3,7 @@ import { Body, Put } from '@nestjs/common'; import { ApiOkResponse } from '@nestjs/swagger'; import { MainSettingsAdminBody } from 'vitnode-shared/admin/settings/main.dto'; -import { EditMainSettingsAdminService } from './services/edit.main.service'; +import { EditMainSettingsAdminService } from './services/edit.service'; @Controllers({ plugin_name: 'Core', diff --git a/packages/backend/src/core/admin/settings/main/main.module.ts b/packages/backend/src/core/admin/settings/main/main.module.ts index f3d60366d..547c89458 100644 --- a/packages/backend/src/core/admin/settings/main/main.module.ts +++ b/packages/backend/src/core/admin/settings/main/main.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { MainSettingsAdminController } from './main.controller'; -import { EditMainSettingsAdminService } from './services/edit.main.service'; +import { EditMainSettingsAdminService } from './services/edit.service'; @Module({ providers: [EditMainSettingsAdminService], diff --git a/packages/backend/src/core/admin/settings/main/services/edit.main.service.ts b/packages/backend/src/core/admin/settings/main/services/edit.service.ts similarity index 81% rename from packages/backend/src/core/admin/settings/main/services/edit.main.service.ts rename to packages/backend/src/core/admin/settings/main/services/edit.service.ts index 9ee79d788..22edda43d 100644 --- a/packages/backend/src/core/admin/settings/main/services/edit.main.service.ts +++ b/packages/backend/src/core/admin/settings/main/services/edit.service.ts @@ -7,6 +7,8 @@ import { join } from 'path'; import { MainSettingsAdminBody } from 'vitnode-shared/admin/settings/main.dto'; import { ManifestWithLang } from 'vitnode-shared/manifest.dto'; +import { getManifest, ManifestType } from '../../metadata/helpers'; + @Injectable() export class EditMainSettingsAdminService { constructor(private readonly databaseService: InternalDatabaseService) {} @@ -14,9 +16,13 @@ export class EditMainSettingsAdminService { protected async updateDescription({ languages, site_description, + site_name, + site_short_name, }: { languages: { code: string }[]; site_description: MainSettingsAdminBody['site_description']; + site_name: MainSettingsAdminBody['site_name']; + site_short_name: MainSettingsAdminBody['site_short_name']; }) { const update = await Promise.all( (site_description ?? []).map(async el => { @@ -32,22 +38,25 @@ export class EditMainSettingsAdminService { throw new InternalServerErrorException(); } - const path = join( - ABSOLUTE_PATHS.uploads.public, - 'assets', - item.language_code, - 'manifest.webmanifest', - ); - const manifest: ManifestWithLang = JSON.parse( - await readFile(path, 'utf8'), - ); - const newData: ManifestWithLang = { + const manifest = await getManifest({ lang_code: item.language_code }); + const newData: ManifestType = { ...manifest, lang: el.language_code, description: item.value, + name: site_name, + short_name: site_short_name, }; - await writeFile(path, JSON.stringify(newData, null, 2), 'utf8'); + await writeFile( + join( + ABSOLUTE_PATHS.uploads.public, + 'assets', + item.language_code, + 'manifest.webmanifest', + ), + JSON.stringify(newData, null, 2), + 'utf8', + ); return el.language_code; }), @@ -112,6 +121,8 @@ export class EditMainSettingsAdminService { await this.updateDescription({ languages, site_description, + site_name, + site_short_name, }); return { diff --git a/packages/backend/src/core/admin/settings/metadata/helpers.service.ts b/packages/backend/src/core/admin/settings/metadata/helpers.ts similarity index 68% rename from packages/backend/src/core/admin/settings/metadata/helpers.service.ts rename to packages/backend/src/core/admin/settings/metadata/helpers.ts index b48b93223..41e60f7a8 100644 --- a/packages/backend/src/core/admin/settings/metadata/helpers.service.ts +++ b/packages/backend/src/core/admin/settings/metadata/helpers.ts @@ -1,9 +1,10 @@ import { ABSOLUTE_PATHS } from '@/app.module'; -import { Injectable, NotFoundException } from '@nestjs/common'; +import { NotFoundException } from '@nestjs/common'; import { existsSync } from 'fs'; import { readFile } from 'fs/promises'; import { join } from 'path'; import { ManifestDisplay } from 'vitnode-shared/admin/settings/metadata.enum'; +import { FileObj } from 'vitnode-shared/utils/files.dto'; export interface ManifestType { background_color: string; @@ -16,12 +17,12 @@ export interface ManifestType { | 'standalone' | 'window-controls-overlay' )[]; - icons?: { + icons?: (FileObj & { purpose?: 'any' | 'badge' | 'maskable' | 'monochrome'; sizes?: string; src: string; type?: string; - }[]; + })[]; id: string; lang: string; name: string; @@ -56,27 +57,24 @@ export interface ManifestType { theme_color: string; } -@Injectable() -export class HelpersShowMetadataAdminService { - async getManifest({ +export const getManifest = async ({ + lang_code, +}: { + lang_code: string; +}): Promise => { + const path = join( + ABSOLUTE_PATHS.uploads.public, + 'assets', lang_code, - }: { - lang_code: string; - }): Promise { - const path = join( - ABSOLUTE_PATHS.uploads.public, - 'assets', - lang_code, - 'manifest.webmanifest', - ); + 'manifest.webmanifest', + ); - if (!existsSync(path)) { - throw new NotFoundException('MANIFEST_NOT_FOUND'); - } + if (!existsSync(path)) { + throw new NotFoundException('MANIFEST_NOT_FOUND'); + } - const file = await readFile(path, 'utf8'); - const data: ManifestType = JSON.parse(file); + const file = await readFile(path, 'utf8'); + const data: ManifestType = JSON.parse(file); - return data; - } -} + return data; +}; diff --git a/packages/backend/src/core/admin/settings/metadata/metadata.controller.ts b/packages/backend/src/core/admin/settings/metadata/metadata.controller.ts index 04481636f..545a96244 100644 --- a/packages/backend/src/core/admin/settings/metadata/metadata.controller.ts +++ b/packages/backend/src/core/admin/settings/metadata/metadata.controller.ts @@ -1,5 +1,7 @@ import { Controllers } from '@/helpers/controller.decorator'; -import { Body, Get, Put } from '@nestjs/common'; +import { FilesValidationPipe } from '@/helpers/files/files.pipe'; +import { UploadFilesMethod } from '@/helpers/upload-files.decorator'; +import { Body, Get, Put, UploadedFiles } from '@nestjs/common'; import { ApiOkResponse } from '@nestjs/swagger'; import { ShowMetadataAdminBody, @@ -26,10 +28,21 @@ export class MetadataAdminController { type: ShowMetadataAdminObj, }) @Put() + @UploadFilesMethod({ fields: ['icon'] }) async edit( + @UploadedFiles( + new FilesValidationPipe({ + icon: { + maxSize: 1024 * 1024, // 1 MB + acceptMimeType: ['image/png', 'image/jpeg', 'image/webp'], + maxCount: 1, + }, + }), + ) + files: Pick, @Body() body: ShowMetadataAdminBody, ): Promise { - return this.editService.edit(body); + return this.editService.edit({ body, files }); } @ApiOkResponse({ diff --git a/packages/backend/src/core/admin/settings/metadata/metadata.module.ts b/packages/backend/src/core/admin/settings/metadata/metadata.module.ts index ca269812f..19550f79c 100644 --- a/packages/backend/src/core/admin/settings/metadata/metadata.module.ts +++ b/packages/backend/src/core/admin/settings/metadata/metadata.module.ts @@ -1,16 +1,11 @@ import { Module } from '@nestjs/common'; -import { HelpersShowMetadataAdminService } from './helpers.service'; import { MetadataAdminController } from './metadata.controller'; import { EditMetadataAdminService } from './services/edit.service'; import { ShowMetadataAdminService } from './services/show.service'; @Module({ - providers: [ - ShowMetadataAdminService, - HelpersShowMetadataAdminService, - EditMetadataAdminService, - ], + providers: [ShowMetadataAdminService, EditMetadataAdminService], controllers: [MetadataAdminController], }) export class MetadataSettingsAdminModule {} diff --git a/packages/backend/src/core/admin/settings/metadata/services/edit.service.ts b/packages/backend/src/core/admin/settings/metadata/services/edit.service.ts index 02b0bef48..45fd8b9cb 100644 --- a/packages/backend/src/core/admin/settings/metadata/services/edit.service.ts +++ b/packages/backend/src/core/admin/settings/metadata/services/edit.service.ts @@ -1,23 +1,78 @@ import { ABSOLUTE_PATHS } from '@/app.module'; +import { FilesHelperService } from '@/helpers/files/files-helper.service'; import { InternalDatabaseService } from '@/utils/database/internal_database.service'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { writeFile } from 'fs/promises'; import { join } from 'path'; +import sharp from 'sharp'; import { ShowMetadataAdminBody, ShowMetadataAdminObj, } from 'vitnode-shared/admin/settings/metadata.dto'; +import { getManifest, ManifestType } from '../helpers'; + @Injectable() export class EditMetadataAdminService { constructor( private readonly configService: ConfigService, private readonly databaseService: InternalDatabaseService, + private readonly filesHelper: FilesHelperService, ) {} - async edit(data: ShowMetadataAdminBody): Promise { + private async processIcon(icon: ShowMetadataAdminBody['icon']) { + if (!icon) return; + const resolutions = [512, 192, 144, 96, 72, 48, 36]; + + return await Promise.all( + resolutions.map(async resolution => { + const file = await sharp(Buffer.from(icon.buffer)) + .resize(resolution, resolution, { + fit: 'contain', + kernel: 'lanczos3', + withoutEnlargement: true, + }) + .png({ quality: 100, compressionLevel: 9 }) + .toBuffer(); + const fileSize = (await sharp(file).metadata()).size; + if (!fileSize) { + throw new InternalServerErrorException('File size not found'); + } + + const newFile: ShowMetadataAdminBody['icon'] = { + ...icon, + buffer: file, + originalname: icon.originalname.replace( + '.png', + `-${resolution}x${resolution}.png`, + ), + size: fileSize, + }; + + const uploadObj = await this.filesHelper.upload({ + file: newFile, + folder: 'manifest', + plugin_code: 'core', + }); + + return { + ...uploadObj, + resolution, + }; + }), + ); + } + + async edit({ + body: { remove_icon, ...body }, + files, + }: { + body: Omit; + files: Pick; + }): Promise { const frontendUrl: string = this.configService.getOrThrow('frontend_url'); + const backendUrl: string = this.configService.getOrThrow('backend_url'); const langs = await this.databaseService.db.query.core_languages.findMany({ columns: { code: true, @@ -25,15 +80,46 @@ export class EditMetadataAdminService { orderBy: (table, { desc }) => desc(table.default), }); + if (remove_icon || files.icon) { + const manifest = await getManifest({ + lang_code: 'en', + }); + + if (manifest.icons) { + await Promise.all( + manifest.icons.map(async icon => { + await this.filesHelper.delete({ + dir_folder: icon.dir_folder, + file_name: icon.file_name, + }); + }), + ); + } + } + + const images = await this.processIcon(files.icon); + const updateManifests = await Promise.all( langs.map(async ({ code }) => { - const dataToUpdate: ShowMetadataAdminObj = { - background_color: data.background_color, - start_url: `${frontendUrl}/${code}${data.start_url}`, - theme_color: data.theme_color, - display: data.display, - id: `${frontendUrl}/${code}${data.start_url}`, + const manifest = await getManifest({ + lang_code: code, + }); + const dataToUpdate: Omit = { + ...manifest, + background_color: body.background_color, + start_url: `${frontendUrl}/${code}${body.start_url}`, + theme_color: body.theme_color, + display: body.display, + id: `${frontendUrl}/${code}${body.start_url}`, lang: code, + icons: images + ? images.map(image => ({ + sizes: `${image.resolution}x${image.resolution}`, + src: `${backendUrl}/public/${image.dir_folder}/${image.file_name}`, + type: 'image/png', + ...image, + })) + : manifest.icons, }; await writeFile( diff --git a/packages/backend/src/core/admin/settings/metadata/services/show.service.ts b/packages/backend/src/core/admin/settings/metadata/services/show.service.ts index e88e33afa..b78f9ff63 100644 --- a/packages/backend/src/core/admin/settings/metadata/services/show.service.ts +++ b/packages/backend/src/core/admin/settings/metadata/services/show.service.ts @@ -1,17 +1,13 @@ import { Injectable } from '@nestjs/common'; import { ShowMetadataAdminObj } from 'vitnode-shared/admin/settings/metadata.dto'; -import { HelpersShowMetadataAdminService } from '../helpers.service'; +import { getManifest } from '../helpers'; @Injectable() export class ShowMetadataAdminService { - constructor( - private readonly helperService: HelpersShowMetadataAdminService, - ) {} - async show(): Promise { - // TODO: Add cache - const manifest = await this.helperService.getManifest({ lang_code: 'en' }); + const manifest = await getManifest({ lang_code: 'en' }); + const icon = manifest.icons?.[0]; return { background_color: manifest.background_color, @@ -20,6 +16,19 @@ export class ShowMetadataAdminService { display: manifest.display, id: manifest.id, lang: manifest.lang, + icon: icon + ? { + file_name: icon.file_name, + dir_folder: icon.dir_folder, + extension: icon.extension, + file_name_original: icon.file_name_original, + file_size: icon.file_size, + height: icon.height, + width: icon.width, + mimetype: icon.mimetype, + secure: icon.secure, + } + : undefined, }; } } diff --git a/packages/backend/src/core/admin/styles/theme-editor/service/edit.service.ts b/packages/backend/src/core/admin/styles/theme-editor/service/edit.service.ts index 575e0e83a..2e9ae53c2 100644 --- a/packages/backend/src/core/admin/styles/theme-editor/service/edit.service.ts +++ b/packages/backend/src/core/admin/styles/theme-editor/service/edit.service.ts @@ -12,15 +12,18 @@ export class EditThemeEditorStylesAdminService { constructor(private readonly filesHelper: FilesHelperService) {} async update({ - logo_dark, - mobile_logo_dark, - logo_light, - mobile_logo_light, - delete_logos: deleteLogosBody, - mobile_width, - width, - text, - }: EditThemeEditorStylesAdminBody): Promise { + body: { text, width, mobile_width, delete_logos: deleteLogosBody }, + files: { logo_dark, mobile_logo_dark, logo_light, mobile_logo_light }, + }: { + body: Omit< + EditThemeEditorStylesAdminBody, + 'logo_dark' | 'logo_light' | 'mobile_logo_dark' | 'mobile_logo_light' + >; + files: Pick< + EditThemeEditorStylesAdminBody, + 'logo_dark' | 'logo_light' | 'mobile_logo_dark' | 'mobile_logo_light' + >; + }): Promise { const config = getConfigFile(); const delete_logos = deleteLogosBody.split(','); diff --git a/packages/backend/src/core/admin/styles/theme-editor/theme-editor.controller.ts b/packages/backend/src/core/admin/styles/theme-editor/theme-editor.controller.ts index e3478992a..22b125b5e 100644 --- a/packages/backend/src/core/admin/styles/theme-editor/theme-editor.controller.ts +++ b/packages/backend/src/core/admin/styles/theme-editor/theme-editor.controller.ts @@ -1,8 +1,8 @@ import { Controllers } from '@/helpers/controller.decorator'; import { FilesValidationPipe } from '@/helpers/files/files.pipe'; -import { Body, Put, UploadedFiles, UseInterceptors } from '@nestjs/common'; -import { FileFieldsInterceptor } from '@nestjs/platform-express'; -import { ApiBody, ApiConsumes, ApiOkResponse } from '@nestjs/swagger'; +import { UploadFilesMethod } from '@/helpers/upload-files.decorator'; +import { Body, Put, UploadedFiles } from '@nestjs/common'; +import { ApiOkResponse } from '@nestjs/swagger'; import { EditThemeEditorStylesAdminBody, EditThemeEditorStylesAdminObj, @@ -21,24 +21,19 @@ export class ThemeEditorStylesAdminController { private readonly editService: EditThemeEditorStylesAdminService, ) {} - @ApiBody({ - description: 'Edit theme editor settings', - type: EditThemeEditorStylesAdminBody, - }) - @ApiConsumes('multipart/form-data') @ApiOkResponse({ description: 'Theme editor settings updated', type: EditThemeEditorStylesAdminObj, }) @Put() - @UseInterceptors( - FileFieldsInterceptor([ - { name: 'logo_dark', maxCount: 1 }, - { name: 'mobile_logo_dark', maxCount: 1 }, - { name: 'logo_light', maxCount: 1 }, - { name: 'mobile_logo_light', maxCount: 1 }, - ]), - ) + @UploadFilesMethod({ + fields: [ + 'logo_dark', + 'mobile_logo_dark', + 'logo_light', + 'mobile_logo_light', + ], + }) async updateThemeEditor( @UploadedFiles( new FilesValidationPipe({ @@ -51,6 +46,7 @@ export class ThemeEditorStylesAdminController { 'image/webp', ], isOptional: true, + maxCount: 1, }, mobile_logo_dark: { maxSize: 1024 * 1024, // 1 MB @@ -61,6 +57,7 @@ export class ThemeEditorStylesAdminController { 'image/webp', ], isOptional: true, + maxCount: 1, }, logo_light: { maxSize: 1024 * 1024, // 1 MB @@ -71,6 +68,7 @@ export class ThemeEditorStylesAdminController { 'image/webp', ], isOptional: true, + maxCount: 1, }, mobile_logo_light: { maxSize: 1024 * 1024, // 1 MB @@ -81,27 +79,20 @@ export class ThemeEditorStylesAdminController { 'image/webp', ], isOptional: true, + maxCount: 1, }, }), ) - files: { - logo_dark?: Express.Multer.File[]; - logo_light?: Express.Multer.File[]; - mobile_logo_dark?: Express.Multer.File[]; - mobile_logo_light?: Express.Multer.File[]; - }, - @Body() - body: Omit< + files: Pick< EditThemeEditorStylesAdminBody, 'logo_dark' | 'logo_light' | 'mobile_logo_dark' | 'mobile_logo_light' >, + @Body() + body: EditThemeEditorStylesAdminBody, ): Promise { return await this.editService.update({ - ...body, - logo_dark: files.logo_dark?.at(0), - mobile_logo_dark: files.mobile_logo_dark?.at(0), - logo_light: files.logo_light?.at(0), - mobile_logo_light: files.mobile_logo_light?.at(0), + body, + files, }); } } diff --git a/packages/backend/src/helpers/files/files.mdx b/packages/backend/src/helpers/files/files.mdx deleted file mode 100644 index ebebb0ee2..000000000 --- a/packages/backend/src/helpers/files/files.mdx +++ /dev/null @@ -1,42 +0,0 @@ -```ts - @Post() - @ApiConsumes('multipart/form-data') - @ApiBody({ - description: 'List of cats', - type: FileUploadDto, - }) - @UseInterceptors( - FileFieldsInterceptor([ - { name: 'avatar' }, - { name: 'background', maxCount: 1 }, - ]), - ) - uploadFile( - @UploadedFiles( - new FilesValidationPipe({ - avatar: { maxSize: 279458000, acceptMimeType: [] }, - }), - ) - files: { - avatar?: Express.Multer.File[]; - background?: Express.Multer.File[]; - }, - ) { - console.log('files', files); - - return 'test'; - } -``` - -```ts -class FileUploadDto { - @ApiPropertyOptional({ - type: 'array', - items: { type: 'string', format: 'binary' }, - }) - avatar: Express.Multer.File[]; - - @ApiPropertyOptional({ type: 'string', format: 'binary' }) - background: Express.Multer.File; -} -``` diff --git a/packages/backend/src/helpers/files/files.pipe.ts b/packages/backend/src/helpers/files/files.pipe.ts index 6f299dfa3..2ed730718 100644 --- a/packages/backend/src/helpers/files/files.pipe.ts +++ b/packages/backend/src/helpers/files/files.pipe.ts @@ -13,20 +13,64 @@ export class FilesValidationPipe implements PipeTransform { { acceptMimeType: string[]; isOptional?: boolean; + maxCount: number; maxSize: number; } >, ) {} - transform( + private validateFieldExists(file: Express.Multer.File): void { + if (!this.options[file.fieldname]) { + throw new InternalServerErrorException( + `Invalid file field ${file.fieldname} in FilesValidationPipe`, + ); + } + } + + private validateFileCount( + files: Express.Multer.File[], + file: Express.Multer.File, + ): void { + const { maxCount } = this.options[file.fieldname]; + const fieldCount = files.filter(f => f.fieldname === file.fieldname).length; + + if (maxCount && fieldCount > maxCount) { + throw new BadRequestException( + `Exceeded maximum file count of ${maxCount} for ${file.fieldname} field`, + ); + } + } + + private validateFileSize(file: Express.Multer.File): void { + const { maxSize } = this.options[file.fieldname]; + + if (file.size > maxSize) { + throw new BadRequestException( + `File ${file.originalname} (${file.size} bytes) exceeds size limit of ${maxSize} bytes for ${file.fieldname} field`, + ); + } + } + + private validateFileType(file: Express.Multer.File): void { + const { acceptMimeType } = this.options[file.fieldname]; + + if (acceptMimeType.length && !acceptMimeType.includes(file.mimetype)) { + throw new BadRequestException( + `Invalid file type for ${file.originalname} (${file.mimetype})`, + ); + } + } + + private validateInput( filesFromArgs: | Express.Multer.File | Express.Multer.File[] | Record, - ): Record { + ): Express.Multer.File[] { const checkIfIsRecord = !!( Array.isArray(filesFromArgs) ? filesFromArgs : [filesFromArgs] ).at(0)?.fieldname; + if (checkIfIsRecord) { throw new BadRequestException('Invalid file format'); } @@ -38,41 +82,41 @@ export class FilesValidationPipe implements PipeTransform { .map(key => filesFromArgs[key]) .flatMap(files => files); + return files; + } + + transform( + filesFromArgs: + | Express.Multer.File + | Express.Multer.File[] + | Record, + ): Record { + const files = this.validateInput(filesFromArgs); + // Validate files files.forEach(file => { - if (!this.options[file.fieldname]) { - throw new InternalServerErrorException( - `Invalid file field ${file.fieldname} in FilesValidationPipe`, - ); - } + this.validateFieldExists(file); + this.validateFileCount(files, file); + this.validateFileType(file); + this.validateFileSize(file); + }); - // Validate file size - if (file.size > this.options[file.fieldname].maxSize) { - throw new BadRequestException( - `File ${file.originalname} (${file.size} bytes) exceeds size limit of ${this.options[file.fieldname].maxSize} bytes for ${file.fieldname} field`, - ); + const groupByFieldName: Record< + string, + Express.Multer.File | Express.Multer.File[] + > = files.reduce((acc, file) => { + if (!acc[file.fieldname]) { + acc[file.fieldname] = + this.options[file.fieldname].maxCount === 1 ? undefined : []; } - - // Validate file type - if ( - this.options[file.fieldname].acceptMimeType.length && - !this.options[file.fieldname].acceptMimeType.includes(file.mimetype) - ) { - throw new BadRequestException( - `Invalid file type for ${file.originalname} (${file.mimetype})`, - ); + if (this.options[file.fieldname].maxCount === 1) { + acc[file.fieldname] = file; + } else { + (acc[file.fieldname] as Express.Multer.File[]).push(file); } - }); - - const groupByFieldName: Record = - files.reduce((acc, file) => { - if (!acc[file.fieldname]) { - acc[file.fieldname] = []; - } - acc[file.fieldname].push(file); - return acc; - }, {}); + return acc; + }, {}); // Validate if required fields are present Object.keys(this.options).forEach(fieldName => { diff --git a/packages/backend/src/helpers/upload-files.decorator.ts b/packages/backend/src/helpers/upload-files.decorator.ts new file mode 100644 index 000000000..c9511083b --- /dev/null +++ b/packages/backend/src/helpers/upload-files.decorator.ts @@ -0,0 +1,14 @@ +import { applyDecorators, UseInterceptors } from '@nestjs/common'; +import { FileFieldsInterceptor } from '@nestjs/platform-express'; +import { ApiConsumes } from '@nestjs/swagger'; + +export function UploadFilesMethod({ fields }: { fields: string[] }) { + const decorators: ClassDecorator[] = [ + ApiConsumes('multipart/form-data'), + UseInterceptors( + FileFieldsInterceptor(fields.map(field => ({ name: field }))), + ), + ]; + + return applyDecorators(...decorators); +} diff --git a/packages/frontend/src/components/helpers/files/item-preview-files-input.tsx b/packages/frontend/src/components/helpers/files/item-preview-files-input.tsx index 92a36155f..9f28ff59b 100644 --- a/packages/frontend/src/components/helpers/files/item-preview-files-input.tsx +++ b/packages/frontend/src/components/helpers/files/item-preview-files-input.tsx @@ -86,10 +86,8 @@ export const ItemPreviewFilesInput = ({ dir_folder={file.dir_folder} file_name={file.file_name} fill - height={file.height ?? 100} mimetype={file.mimetype} sizes="100px" - width={file.width ?? 100} /> )} diff --git a/packages/frontend/src/views/admin/views/core/settings/metadata/content.tsx b/packages/frontend/src/views/admin/views/core/settings/metadata/content.tsx index 088195092..8691cbbe2 100644 --- a/packages/frontend/src/views/admin/views/core/settings/metadata/content.tsx +++ b/packages/frontend/src/views/admin/views/core/settings/metadata/content.tsx @@ -3,6 +3,7 @@ import { AutoForm } from '@/components/form/auto-form'; import { AutoFormColorPicker } from '@/components/form/fields/color-picker'; import { AutoFormLabel } from '@/components/form/fields/common/label'; +import { AutoFormFileInput } from '@/components/form/fields/file-input'; import { AutoFormRadioGroup } from '@/components/form/fields/radio-group'; import { Input } from '@/components/ui/input'; import { CONFIG } from '@/helpers/config-with-env'; @@ -92,6 +93,19 @@ export const ContentMetadataSettingsAdmin = (data: ShowMetadataAdminObj) => { label: t('background_color'), component: AutoFormColorPicker, }, + { + id: 'icon', + label: t('icon.label'), + component: props => ( + + ), + }, ]} formSchema={formSchema} onSubmit={onSubmit} diff --git a/packages/frontend/src/views/admin/views/core/settings/metadata/hooks/use-metadata-settings-admin-api.ts b/packages/frontend/src/views/admin/views/core/settings/metadata/hooks/use-metadata-settings-admin-api.ts index 51af3f22e..aeac6f322 100644 --- a/packages/frontend/src/views/admin/views/core/settings/metadata/hooks/use-metadata-settings-admin-api.ts +++ b/packages/frontend/src/views/admin/views/core/settings/metadata/hooks/use-metadata-settings-admin-api.ts @@ -1,5 +1,6 @@ import { convertColor, getHSLFromString } from '@/helpers/colors'; import { CONFIG } from '@/helpers/config-with-env'; +import { zodFile } from '@/helpers/zod'; import { useTranslations } from 'next-intl'; import { toast } from 'sonner'; import { ManifestDisplay } from 'vitnode-shared/admin/settings/metadata.enum'; @@ -13,6 +14,7 @@ export const useMetadataSettingsAdminApi = ({ theme_color, background_color, display, + icon, }: React.ComponentProps) => { const t = useTranslations('core.global'); const themeColor = convertColor.hexToHSL(theme_color); @@ -38,6 +40,10 @@ export const useMetadataSettingsAdminApi = ({ ? `hsl(${backgroundColor.h}, ${backgroundColor.s}%, ${backgroundColor.l}%)` : '', ), + icon: zodFile + .nullable() + .default(icon ?? null) + .optional(), }); const onSubmit = async (values: z.infer) => { diff --git a/packages/shared/src/admin/settings/metadata.dto.ts b/packages/shared/src/admin/settings/metadata.dto.ts index cac6941ed..296b42512 100644 --- a/packages/shared/src/admin/settings/metadata.dto.ts +++ b/packages/shared/src/admin/settings/metadata.dto.ts @@ -1,6 +1,13 @@ -import { ApiProperty, OmitType } from '@nestjs/swagger'; -import { IsEnum, IsString } from 'class-validator'; - +import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { + IsBoolean, + IsEnum, + IsObject, + IsOptional, + IsString, +} from 'class-validator'; + +import { FileObj } from '../../utils/files.dto'; import { ManifestDisplay } from './metadata.enum'; export class ShowMetadataAdminObj { @@ -12,6 +19,11 @@ export class ShowMetadataAdminObj { @IsEnum(ManifestDisplay) display: ManifestDisplay; + @ApiPropertyOptional() + @IsObject() + @IsOptional() + icon?: FileObj; + @ApiProperty({ example: '/' }) @IsString() id: string; @@ -32,4 +44,13 @@ export class ShowMetadataAdminObj { export class ShowMetadataAdminBody extends OmitType(ShowMetadataAdminObj, [ 'id', 'lang', -]) {} + 'icon', +]) { + @ApiPropertyOptional({ type: 'string', format: 'binary' }) + icon?: Express.Multer.File; + + @ApiPropertyOptional() + @IsBoolean() + @IsOptional() + remove_icon?: boolean; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cb47ef39..a5591f971 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,10 +34,10 @@ importers: version: 10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11) '@nestjs/schedule': specifier: ^4.1.1 - version: 4.1.1(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11) + version: 4.1.1(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.11)(reflect-metadata@0.2.2)(rxjs@7.8.1)) '@nestjs/throttler': specifier: ^6.2.1 - version: 6.2.1(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11)(reflect-metadata@0.2.2) + version: 6.2.1(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.11)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2) '@react-email/components': specifier: ^0.0.28 version: 0.0.28(react-dom@19.0.0-rc.1(react@19.0.0-rc.1))(react@19.0.0-rc.1) @@ -195,7 +195,7 @@ importers: version: 10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/swagger': specifier: ^8.0.7 - version: 8.0.7(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + version: 8.0.7(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.11)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) devDependencies: '@types/multer': specifier: ^1.4.12 @@ -232,10 +232,10 @@ importers: version: 10.2.0(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)) '@nestjs/serve-static': specifier: ^4.0.2 - version: 4.0.2(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11)(express@4.21.1) + version: 4.0.2(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.11)(reflect-metadata@0.2.2)(rxjs@7.8.1))(express@4.21.1) '@nestjs/swagger': specifier: ^8.0.7 - version: 8.0.7(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + version: 8.0.7(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.11)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) '@react-email/render': specifier: ^1.0.2 version: 1.0.2(react-dom@19.0.0-rc.1(react@19.0.0-rc.1))(react@19.0.0-rc.1) @@ -257,6 +257,9 @@ importers: pg: specifier: ^8.13.1 version: 8.13.1 + rxjs: + specifier: ^7.8.1 + version: 7.8.1 sharp: specifier: ^0.33.5 version: 0.33.5 @@ -275,7 +278,7 @@ importers: version: 10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11) '@nestjs/schedule': specifier: ^4.1.1 - version: 4.1.1(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11) + version: 4.1.1(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.11)(reflect-metadata@0.2.2)(rxjs@7.8.1)) '@react-email/components': specifier: ^0.0.28 version: 0.0.28(react-dom@19.0.0-rc.1(react@19.0.0-rc.1))(react@19.0.0-rc.1) @@ -807,7 +810,7 @@ importers: version: 10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/swagger': specifier: ^8.0.7 - version: 8.0.7(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + version: 8.0.7(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.11)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) devDependencies: '@types/multer': specifier: ^1.4.12 @@ -7999,7 +8002,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/schedule@4.1.1(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11)': + '@nestjs/schedule@4.1.1(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.11)(reflect-metadata@0.2.2)(rxjs@7.8.1))': dependencies: '@nestjs/common': 10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.11)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -8028,7 +8031,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/serve-static@4.0.2(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11)(express@4.21.1)': + '@nestjs/serve-static@4.0.2(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.11)(reflect-metadata@0.2.2)(rxjs@7.8.1))(express@4.21.1)': dependencies: '@nestjs/common': 10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.11)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -8036,7 +8039,7 @@ snapshots: optionalDependencies: express: 4.21.1 - '@nestjs/swagger@8.0.7(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': + '@nestjs/swagger@8.0.7(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.11)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.15.1 '@nestjs/common': 10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -8051,7 +8054,7 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.1 - '@nestjs/throttler@6.2.1(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11)(reflect-metadata@0.2.2)': + '@nestjs/throttler@6.2.1(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.11)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)': dependencies: '@nestjs/common': 10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.11(@nestjs/common@10.4.11(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.11)(reflect-metadata@0.2.2)(rxjs@7.8.1)