diff --git a/.env b/.env index 1c2d2489..29b84995 100644 --- a/.env +++ b/.env @@ -1,8 +1,11 @@ APP_NAME=Libre Closet #PWA_ENABLED=true #AUTH_ENABLED=true -#DISABLE_REGISTRATION=true #CLOUD_ENABLED=true +#DISABLE_REGISTRATION=true + +#CHANGE THIS TO REQUIRE A CODE TO REGISTER WHEN DISABLE_REGISTRATION=false +#REGISTRATION_SECRET_CODE="RegisterCode" # CHANGE FOR PRODUCTION! PUBLIC_VAPID_KEY=BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U diff --git a/package-lock.json b/package-lock.json index 8769f864..12523fd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,9 +88,10 @@ "eslint-plugin-prettier": "^5.2.2", "globals": "^16.0.0", "jest": "^30.0.0", + "node-gyp": "^12.4.0", "npm-check-updates": "^19.1.1", "patch-package": "^8.0.1", - "prettier": "^3.4.2", + "prettier": "^3.9.9", "source-map-support": "^0.5.21", "ts-jest": "^29.2.5", "ts-loader": "^9.5.2", @@ -4775,6 +4776,19 @@ "node": ">=18" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -10453,6 +10467,16 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -13443,6 +13467,16 @@ "node": ">=8" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -14063,6 +14097,13 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -19895,6 +19936,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -20189,6 +20243,47 @@ } } }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -20225,6 +20320,22 @@ "mailgun.js": "^8.0.1" } }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -21537,9 +21648,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", "dev": true, "license": "MIT", "bin": { @@ -21623,6 +21734,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -24153,6 +24274,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -24207,6 +24345,26 @@ "streamx": "^2.15.0" } }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/tarn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", @@ -25271,6 +25429,16 @@ "through": "^2.3.8" } }, + "node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 45007527..4c679baa 100644 --- a/package.json +++ b/package.json @@ -117,9 +117,10 @@ "eslint-plugin-prettier": "^5.2.2", "globals": "^16.0.0", "jest": "^30.0.0", + "node-gyp": "^12.4.0", "npm-check-updates": "^19.1.1", "patch-package": "^8.0.1", - "prettier": "^3.4.2", + "prettier": "^3.9.9", "source-map-support": "^0.5.21", "ts-jest": "^29.2.5", "ts-loader": "^9.5.2", diff --git a/public/assets/mannequin.png b/public/assets/mannequin.png new file mode 100644 index 00000000..2f2e7ddd Binary files /dev/null and b/public/assets/mannequin.png differ diff --git a/scripts/preCommit.sh b/scripts/preCommit.sh old mode 100755 new mode 100644 diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index a4c9f6d3..fc223e06 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -48,15 +48,25 @@ export class AuthController { ...((reply as any).locals ?? {}), }); } - - const jwt = await this.authService.register(body.email, body.password); - reply.setCookie('access_token', jwt, { - path: '/', - maxAge: 365 * 24 * 60 * 60 * 1000, // 365 days - httpOnly: true, // Prevents client-side JS from reading it - }); - return reply.redirect('/auth/profile', 302); - } + + const allowedSecretCode = process.env.REGISTRATION_SECRET_CODE; + + if (allowedSecretCode && (body as any).secretCode !== allowedSecretCode) { + return reply.view('auth/register', { + layout: 'layout', + input: body, + errorMessage: 'Registration requires a valid Admin Approval Code.', + ...((reply as any).locals ?? {}), + }); + } + const jwt = await this.authService.register(body.email, body.password); + reply.setCookie('access_token', jwt, { + path: '/', + maxAge: 365 * 24 * 60 * 60 * 1000, + httpOnly: true, + }); + return reply.redirect('/auth/profile', 302); + } @UseGuards(RegistrationGuard) @Render('auth/register') diff --git a/src/dal/entity/garment.entity.ts b/src/dal/entity/garment.entity.ts index 49cf4dff..d6b22464 100644 --- a/src/dal/entity/garment.entity.ts +++ b/src/dal/entity/garment.entity.ts @@ -45,6 +45,22 @@ export class Garment extends ShareableId { @Property({ default: false }) public archived = false; + + /** + * Default canvas transform for this garment on the 1000x1000 outfit canvas. + * Outfit slots may override these values. + */ + @Property({ default: 500 }) + public canvasPositionX = 500; + + @Property({ default: 500 }) + public canvasPositionY = 500; + + @Property({ default: 0 }) + public canvasRotation = 0; + + @Property({ default: 1 }) + public canvasScale = 1; @Property({ nullable: true, columnType: 'text' }) public washingDetails?: string; diff --git a/src/dal/entity/outfit.entity.ts b/src/dal/entity/outfit.entity.ts index 84381e7d..139f9931 100644 --- a/src/dal/entity/outfit.entity.ts +++ b/src/dal/entity/outfit.entity.ts @@ -11,9 +11,18 @@ import { Garment } from './garment.entity'; import { ShareableId } from './shareableId.entity'; import { User } from './user.entity'; +export interface OutfitSlotPosition { + x: number; + y: number; +} + export interface OutfitSlot { category: string; garmentId: number | null; + position?: OutfitSlotPosition; + rotation?: number; + scale?: number; + zIndex?: number; } @Entity() diff --git a/src/dal/migrations/postgres/Migration20261006120001.ts b/src/dal/migrations/postgres/Migration20261006120001.ts new file mode 100644 index 00000000..9cda7e2a --- /dev/null +++ b/src/dal/migrations/postgres/Migration20261006120001.ts @@ -0,0 +1,17 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20261006120001 extends Migration { + override async up(): Promise { + this.addSql(`alter table "garment" add column "canvas_position_x" double precision not null default 500;`); + this.addSql(`alter table "garment" add column "canvas_position_y" double precision not null default 500;`); + this.addSql(`alter table "garment" add column "canvas_rotation" double precision not null default 0;`); + this.addSql(`alter table "garment" add column "canvas_scale" double precision not null default 1;`); + } + + override async down(): Promise { + this.addSql(`alter table "garment" drop column "canvas_position_x";`); + this.addSql(`alter table "garment" drop column "canvas_position_y";`); + this.addSql(`alter table "garment" drop column "canvas_rotation";`); + this.addSql(`alter table "garment" drop column "canvas_scale";`); + } +} diff --git a/src/dal/migrations/sqlite/Migration20261006120000.ts b/src/dal/migrations/sqlite/Migration20261006120000.ts new file mode 100644 index 00000000..16af54a8 --- /dev/null +++ b/src/dal/migrations/sqlite/Migration20261006120000.ts @@ -0,0 +1,17 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20261006120000 extends Migration { + override async up(): Promise { + this.addSql(`alter table \`garment\` add column \`canvas_position_x\` real not null default 500;`); + this.addSql(`alter table \`garment\` add column \`canvas_position_y\` real not null default 500;`); + this.addSql(`alter table \`garment\` add column \`canvas_rotation\` real not null default 0;`); + this.addSql(`alter table \`garment\` add column \`canvas_scale\` real not null default 1;`); + } + + override async down(): Promise { + this.addSql(`alter table \`garment\` drop column \`canvas_position_x\`;`); + this.addSql(`alter table \`garment\` drop column \`canvas_position_y\`;`); + this.addSql(`alter table \`garment\` drop column \`canvas_rotation\`;`); + this.addSql(`alter table \`garment\` drop column \`canvas_scale\`;`); + } +} diff --git a/src/i18n/en/lang.json b/src/i18n/en/lang.json index affed76f..40206ee4 100644 --- a/src/i18n/en/lang.json +++ b/src/i18n/en/lang.json @@ -227,6 +227,7 @@ "EMAIL_MUST_MATCH": "Emails must match" }, "WARDROBE_SHARING": "Wardrobe Sharing", + "CANVAS_SETTINGS": "Canvas Settings", "SHARE_WARDROBE": "Share Your Wardrobe", "SHARE_WARDROBE_DESC": "Invite someone to view or manage your wardrobe.", "PERMISSION_VIEW": "View only", @@ -310,4 +311,4 @@ "SUPPORTER_SIGNATURE": "- Gian, Lazztech LLC Founder and Developer", "SUPPORTER_CTA": "Support on Ko-fi", "SUPPORTER_DISMISS": "Not now" -} \ No newline at end of file +} diff --git a/src/wardrobe/canvas-settings.service.ts b/src/wardrobe/canvas-settings.service.ts new file mode 100644 index 00000000..4d6a3d64 --- /dev/null +++ b/src/wardrobe/canvas-settings.service.ts @@ -0,0 +1,153 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import sharp from 'sharp'; +import { MultipartFile } from '@fastify/multipart'; +import { GarmentCategory } from './garment-category.enum'; + +export interface CanvasCategorySetting { + x: number; + y: number; + rotation: number; + scale: number; + zIndex: number; +} + +export interface CanvasSettings { + categories: Record; +} + +export const DEFAULT_CANVAS_SETTINGS: CanvasSettings = { + categories: { + [GarmentCategory.ACCESSORIES]: { + x: 680, + y: 330, + rotation: 0, + scale: 0.7, + zIndex: 60, + }, + [GarmentCategory.BAGS]: { + x: 730, + y: 560, + rotation: 0, + scale: 0.8, + zIndex: 50, + }, + [GarmentCategory.OUTERWEAR]: { + x: 500, + y: 350, + rotation: 0, + scale: 1, + zIndex: 40, + }, + [GarmentCategory.DRESSES]: { + x: 500, + y: 500, + rotation: 0, + scale: 1, + zIndex: 30, + }, + [GarmentCategory.TOPS]: { + x: 500, + y: 330, + rotation: 0, + scale: 1, + zIndex: 20, + }, + [GarmentCategory.BOTTOMS]: { + x: 500, + y: 610, + rotation: 0, + scale: 1, + zIndex: 20, + }, + [GarmentCategory.FOOTWEAR]: { + x: 500, + y: 820, + rotation: 0, + scale: 0.75, + zIndex: 10, + }, + [GarmentCategory.OTHER]: { + x: 500, + y: 500, + rotation: 0, + scale: 1, + zIndex: 20, + }, + }, +}; + +@Injectable() +export class CanvasSettingsService { + private readonly settingsPath: string; + private readonly mannequinPath: string; + + constructor(private readonly configService: ConfigService) { + const dataPath = configService.getOrThrow('DATA_PATH'); + this.settingsPath = path.join(dataPath, 'canvas-settings.json'); + this.mannequinPath = path.join(dataPath, 'mannequin.png'); + } + + async getSettings(): Promise { + try { + const raw = await fs.readFile(this.settingsPath, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + return this.merge(DEFAULT_CANVAS_SETTINGS, parsed); + } catch { + return structuredClone(DEFAULT_CANVAS_SETTINGS); + } + } + + async saveSettings(settings: CanvasSettings): Promise { + const normalized = this.merge(DEFAULT_CANVAS_SETTINGS, settings); + await fs.mkdir(path.dirname(this.settingsPath), { recursive: true }); + await fs.writeFile(this.settingsPath, JSON.stringify(normalized, null, 2)); + return normalized; + } + + async storeMannequin(upload: MultipartFile | undefined): Promise { + if (!upload) throw new BadRequestException('No mannequin image uploaded'); + if (upload.mimetype !== 'image/png') { + upload.file.resume(); + throw new BadRequestException('Mannequin must be a PNG image'); + } + + const buffer = await upload.toBuffer(); + const metadata = await sharp(buffer).metadata(); + if (!metadata.width || !metadata.height) { + throw new BadRequestException('Invalid mannequin image'); + } + + await fs.mkdir(path.dirname(this.mannequinPath), { recursive: true }); + await fs.writeFile(this.mannequinPath, buffer); + } + + async mannequinExists(): Promise { + try { + await fs.access(this.mannequinPath); + return true; + } catch { + return false; + } + } + + getMannequinPath(): string { + return this.mannequinPath; + } + + private merge( + base: CanvasSettings, + value: Partial, + ): CanvasSettings { + const categories: Record = {}; + for (const category of Object.values(GarmentCategory)) { + categories[category] = { + ...base.categories[category], + ...(value.categories?.[category] ?? {}), + }; + } + return { categories }; + } +} diff --git a/src/wardrobe/garment.service.ts b/src/wardrobe/garment.service.ts index e6ee028b..f4634281 100644 --- a/src/wardrobe/garment.service.ts +++ b/src/wardrobe/garment.service.ts @@ -18,6 +18,8 @@ import { UpdateGarmentDto } from './dto/update-garment.dto'; import { SearchGarmentDto } from './dto/search-garment.dto'; import { GarmentCategory } from './garment-category.enum'; import { WardrobeShareService } from '../wardrobe-share/wardrobe-share.service'; +import { getDefaultGarmentTransform } from './outfit-canvas.util'; +import { CanvasSettingsService } from './canvas-settings.service'; const CANONICAL_SIZES = [ 'XX-Small', @@ -43,6 +45,7 @@ export class GarmentService { private readonly userRepository: EntityRepository, private readonly fileService: FileService, private readonly shareService: WardrobeShareService, + private readonly canvasSettingsService: CanvasSettingsService, ) {} resolveCategoryLabel(value: string, i18n: I18nContext): string { @@ -138,9 +141,19 @@ export class GarmentService { } } + const settings = await this.canvasSettingsService.getSettings(); + const defaults = getDefaultGarmentTransform( + dto.category, + undefined, + settings, + ); const garment = this.garmentRepository.create({ name: dto.name, category: dto.category, + canvasPositionX: defaults.x, + canvasPositionY: defaults.y, + canvasRotation: defaults.rotation, + canvasScale: defaults.scale, brand: dto.brand, color: dto.color, size: this.normalizeSize(dto.size), @@ -194,9 +207,19 @@ export class GarmentService { } } + const settings = await this.canvasSettingsService.getSettings(); + const defaults = getDefaultGarmentTransform( + dto.category, + undefined, + settings, + ); const garment = this.garmentRepository.create({ name: dto.name, category: dto.category, + canvasPositionX: defaults.x, + canvasPositionY: defaults.y, + canvasRotation: defaults.rotation, + canvasScale: defaults.scale, brand: dto.brand, color: dto.color as any, size: this.normalizeSize(dto.size), @@ -379,4 +402,24 @@ export class GarmentService { if (['xxs', '2xs', '2xsmall', 'xxsmall'].includes(s)) return 'XX-Small'; return input.trim(); } + async updateCanvasTransform( + id: number, + transform: { + x: number; + y: number; + rotation: number; + scale: number; + }, + userId?: number, + ): Promise { + const garment = await this.findOne(id, userId); + + garment.canvasPositionX = transform.x; + garment.canvasPositionY = transform.y; + garment.canvasRotation = transform.rotation; + garment.canvasScale = transform.scale; + + await this.garmentRepository.getEntityManager().flush(); + return garment; + } } diff --git a/src/wardrobe/outfit-canvas.util.ts b/src/wardrobe/outfit-canvas.util.ts new file mode 100644 index 00000000..8f74d9c3 --- /dev/null +++ b/src/wardrobe/outfit-canvas.util.ts @@ -0,0 +1,47 @@ +import { GarmentCategory } from './garment-category.enum'; +import { + CanvasSettings, + DEFAULT_CANVAS_SETTINGS, +} from './canvas-settings.service'; + +export interface OutfitCanvasTransform { + x: number; + y: number; + rotation: number; + scale: number; + zIndex?: number; +} + +export interface GarmentCanvasDefaults { + canvasPositionX?: number; + canvasPositionY?: number; + canvasRotation?: number; + canvasScale?: number; +} + +export function getDefaultGarmentTransform( + category: string, + garment?: GarmentCanvasDefaults, + settings?: CanvasSettings, +): OutfitCanvasTransform { + const categoryDefault = getCategoryDefaultGarmentTransform( + category, + settings, + ); + return { + x: garment?.canvasPositionX ?? categoryDefault.x, + y: garment?.canvasPositionY ?? categoryDefault.y, + rotation: garment?.canvasRotation ?? categoryDefault.rotation, + scale: garment?.canvasScale ?? categoryDefault.scale, + zIndex: categoryDefault.zIndex, + }; +} + +export function getCategoryDefaultGarmentTransform( + category: string, + settings: CanvasSettings = DEFAULT_CANVAS_SETTINGS, +): OutfitCanvasTransform { + const value = + settings.categories[category] ?? settings.categories[GarmentCategory.OTHER]; + return { ...value }; +} diff --git a/src/wardrobe/outfit.controller.ts b/src/wardrobe/outfit.controller.ts index be6aa384..95ae7d1c 100644 --- a/src/wardrobe/outfit.controller.ts +++ b/src/wardrobe/outfit.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -6,6 +7,7 @@ import { HttpCode, Logger, Param, + Patch, ParseIntPipe, Post, Query, @@ -41,7 +43,14 @@ export class OutfitController { @Render('outfits/index') async index(@Req() req: FastifyRequest) { const outfits = await this.outfitService.findAll(this.userId(req)); - return { outfits }; + const canvasGarments = await Promise.all( + outfits.map((outfit) => this.outfitService.buildCanvasGarments(outfit)), + ); + const outfitCards = outfits.map((outfit, index) => ({ + outfit, + canvasGarments: canvasGarments[index], + })); + return { outfits, outfitCards }; } @Get('new') @@ -131,7 +140,55 @@ export class OutfitController { ) { const outfit = await this.outfitService.findOne(id, this.userId(req)); const garments = outfit.garments.getItems(); - return { outfit, garments }; + const canvasGarments = await this.outfitService.buildCanvasGarments(outfit); + return { outfit, garments, canvasGarments }; + } + + @Patch(':id/slots/:slotIndex/transform') + async updateSlotTransform( + @Param('id', ParseIntPipe) id: number, + @Param('slotIndex', ParseIntPipe) slotIndex: number, + @Body() + body: { + x?: number; + y?: number; + rotation?: number; + scale?: number; + }, + @Req() req: FastifyRequest, + ) { + return this.outfitService.updateSlotTransform( + id, + slotIndex, + { + x: Number(body.x), + y: Number(body.y), + rotation: Number(body.rotation), + scale: Number(body.scale), + }, + this.userId(req), + ); + } + + @Patch(':id/slots/:slotIndex/layer') + async updateSlotLayer( + @Param('id', ParseIntPipe) id: number, + @Param('slotIndex', ParseIntPipe) slotIndex: number, + @Body() body: { direction?: 'up' | 'down' }, + @Req() req: FastifyRequest, + ) { + if (body.direction !== 'up' && body.direction !== 'down') { + throw new BadRequestException('Invalid layer direction'); + } + + const outfit = await this.outfitService.updateSlotLayer( + id, + slotIndex, + body.direction, + this.userId(req), + ); + + return { ok: true, slots: outfit.slots }; } @Get(':id/edit') @@ -178,9 +235,14 @@ export class OutfitController { @Req() req: FastifyRequest, @Res() reply: FastifyReply, ) { + const existingOutfit = await this.outfitService.findOne( + id, + this.userId(req), + ); const slots = this.outfitService.parseSlotsFromBody( body.category, body.garmentId, + existingOutfit.slots, ); await this.outfitService.update( diff --git a/src/wardrobe/outfit.service.ts b/src/wardrobe/outfit.service.ts index db429964..b49fda0e 100644 --- a/src/wardrobe/outfit.service.ts +++ b/src/wardrobe/outfit.service.ts @@ -5,6 +5,7 @@ import { Injectable, Logger, NotFoundException, + BadRequestException, } from '@nestjs/common'; import { I18nContext } from 'nestjs-i18n'; import { Garment } from '../dal/entity/garment.entity'; @@ -14,6 +15,8 @@ import { GarmentCategory } from './garment-category.enum'; import { GarmentService } from './garment.service'; import { CreateOutfitDto } from './dto/create-outfit.dto'; import { UpdateOutfitDto } from './dto/update-outfit.dto'; +import { getDefaultGarmentTransform } from './outfit-canvas.util'; +import { CanvasSettingsService } from './canvas-settings.service'; @Injectable() export class OutfitService { private readonly logger = new Logger(OutfitService.name); @@ -26,6 +29,7 @@ export class OutfitService { @InjectRepository(User) private readonly userRepository: EntityRepository, private readonly garmentService: GarmentService, + private readonly canvasSettingsService: CanvasSettingsService, ) {} async findAll(userId?: number): Promise { @@ -83,6 +87,10 @@ export class OutfitService { id: { $in: garmentIds }, }); outfit.garments.set(garments); + outfit.slots = await this.applyGarmentCanvasDefaults( + dto.slots ?? [], + garments, + ); } if (userId != null) { @@ -115,6 +123,10 @@ export class OutfitService { id: { $in: garmentIds }, }); outfit.garments.set(garments); + outfit.slots = await this.applyGarmentCanvasDefaults( + dto.slots ?? [], + garments, + ); } await this.outfitRepository.getEntityManager().flush(); @@ -129,6 +141,7 @@ export class OutfitService { parseSlotsFromBody( category: string | string[] | undefined, garmentId: string | string[] | undefined, + previousSlots: OutfitSlot[] = [], ): OutfitSlot[] { const cats = Array.isArray(category) ? category @@ -140,10 +153,218 @@ export class OutfitService { : garmentId ? [garmentId] : []; - return cats.map((cat, i) => ({ - category: cat, - garmentId: ids[i] ? Number(ids[i]) : null, - })); + + const usedPreviousIndexes = new Set(); + + return cats.map((cat, i) => { + const nextGarmentId = ids[i] ? Number(ids[i]) : null; + + // Prefer the same array index, but fall back to matching the same + // category/garment so Sortable row reordering does not reset transforms. + let previousIndex = i; + if ( + !previousSlots[previousIndex] || + usedPreviousIndexes.has(previousIndex) || + previousSlots[previousIndex].category !== cat || + previousSlots[previousIndex].garmentId !== nextGarmentId + ) { + previousIndex = previousSlots.findIndex( + (slot, index) => + !usedPreviousIndexes.has(index) && + slot.category === cat && + slot.garmentId === nextGarmentId, + ); + } + + if (previousIndex >= 0 && previousSlots[previousIndex]) { + usedPreviousIndexes.add(previousIndex); + const previous = previousSlots[previousIndex]; + + return { + ...previous, + category: cat, + garmentId: nextGarmentId, + }; + } + + return { + category: cat, + garmentId: nextGarmentId, + }; + }); + } + + private async applyGarmentCanvasDefaults( + slots: OutfitSlot[], + garments: Garment[], + ): Promise { + const settings = await this.canvasSettingsService.getSettings(); + const garmentsById = new Map( + garments.map((garment) => [garment.id, garment]), + ); + const usedZIndexes = new Set( + slots + .map((slot) => slot.zIndex) + .filter((z): z is number => Number.isFinite(z)), + ); + let nextZIndex = usedZIndexes.size ? Math.max(...usedZIndexes) + 1 : 0; + + return slots.map((slot) => { + if (slot.garmentId == null) return slot; + + const garment = garmentsById.get(slot.garmentId); + if (!garment) return slot; + + const defaults = getDefaultGarmentTransform( + garment.category, + garment, + settings, + ); + + return { + ...slot, + position: slot.position ?? { x: defaults.x, y: defaults.y }, + rotation: slot.rotation ?? defaults.rotation, + scale: slot.scale ?? defaults.scale, + zIndex: slot.zIndex ?? defaults.zIndex ?? nextZIndex++, + }; + }); + } + + async buildCanvasGarments(outfit: Outfit) { + const settings = await this.canvasSettingsService.getSettings(); + const garmentsById = new Map( + outfit.garments.getItems().map((garment) => [garment.id, garment]), + ); + + return (outfit.slots ?? []) + .map((slot, index) => { + if (slot.garmentId == null) return null; + + const garment = garmentsById.get(slot.garmentId); + if (!garment) return null; + const defaults = getDefaultGarmentTransform( + garment.category, + garment, + settings, + ); + + return { + slotIndex: index, + garment, + position: { + x: slot.position?.x ?? defaults.x, + y: slot.position?.y ?? defaults.y, + }, + rotation: slot.rotation ?? defaults.rotation, + scale: slot.scale ?? defaults.scale, + zIndex: slot.zIndex ?? defaults.zIndex ?? index, + }; + }) + .filter( + ( + item, + ): item is { + slotIndex: number; + garment: Garment; + position: { x: number; y: number }; + rotation: number; + scale: number; + zIndex: number; + } => item !== null, + ); + } + + async updateSlotTransform( + id: number, + slotIndex: number, + transform: { + x: number; + y: number; + rotation: number; + scale: number; + }, + userId?: number, + ): Promise { + const outfit = await this.findOne(id, userId); + const slots = outfit.slots ? [...outfit.slots] : []; + + if ( + !Number.isInteger(slotIndex) || + slotIndex < 0 || + slotIndex >= slots.length + ) { + throw new NotFoundException('Outfit slot not found'); + } + + if ( + !Number.isFinite(transform.x) || + !Number.isFinite(transform.y) || + !Number.isFinite(transform.rotation) || + !Number.isFinite(transform.scale) || + transform.scale <= 0 + ) { + throw new BadRequestException('Invalid outfit slot transform'); + } + + const slot = slots[slotIndex]; + slots[slotIndex] = { + ...slot, + position: { x: transform.x, y: transform.y }, + rotation: transform.rotation, + scale: transform.scale, + }; + + outfit.slots = slots; + await this.outfitRepository.getEntityManager().flush(); + + return outfit; + } + + async updateSlotLayer( + id: number, + slotIndex: number, + direction: 'up' | 'down', + userId?: number, + ): Promise { + const outfit = await this.findOne(id, userId); + const slots = outfit.slots ? [...outfit.slots] : []; + + if (slotIndex < 0 || slotIndex >= slots.length) { + throw new NotFoundException('Outfit slot not found'); + } + + const current = slots[slotIndex]; + if (current.garmentId == null) return outfit; + + const currentZ = current.zIndex ?? slotIndex; + const candidates = slots + .map((slot, index) => ({ slot, index, z: slot.zIndex ?? index })) + .filter( + ({ slot, index }) => slot.garmentId != null && index !== slotIndex, + ); + + if (direction === 'up') { + const above = candidates + .filter(({ z }) => z > currentZ) + .sort((a, b) => a.z - b.z)[0]; + if (above) { + slots[slotIndex] = { ...current, zIndex: above.z }; + slots[above.index] = { ...slots[above.index], zIndex: currentZ }; + } + } else { + const below = candidates + .filter(({ z }) => z < currentZ) + .sort((a, b) => b.z - a.z)[0]; + if (below) { + slots[slotIndex] = { ...current, zIndex: below.z }; + slots[below.index] = { ...slots[below.index], zIndex: currentZ }; + } + } + + outfit.slots = slots; + await this.outfitRepository.getEntityManager().flush(); + return outfit; } buildCategoryRows( diff --git a/src/wardrobe/settings.controller.ts b/src/wardrobe/settings.controller.ts new file mode 100644 index 00000000..57982b32 --- /dev/null +++ b/src/wardrobe/settings.controller.ts @@ -0,0 +1,90 @@ +import { + BadRequestException, + Controller, + Get, + Header, + Post, + Render, + Req, + Res, + StreamableFile, + UseGuards, +} from '@nestjs/common'; +import { I18n, I18nContext } from 'nestjs-i18n'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { ConfigService } from '@nestjs/config'; +import { ConditionalAuthGuard } from '../auth/conditional-auth.guard'; +import { GarmentCategory } from './garment-category.enum'; +import { CanvasSettingsService } from './canvas-settings.service'; + +@Controller('settings') +export class SettingsController { + constructor( + private readonly canvasSettings: CanvasSettingsService, + private readonly configService: ConfigService, + ) {} + + @UseGuards(ConditionalAuthGuard) + @Get() + @Render('settings/index') + async index(@I18n() i18n: I18nContext) { + const settings = await this.canvasSettings.getSettings(); + const categories = Object.values(GarmentCategory).map((value) => ({ + value, + label: i18n.t(`lang.CATEGORY_${value.toUpperCase()}`), + setting: settings.categories[value], + })); + return { + categories, + mannequinUrl: '/settings/mannequin', + }; + } + + @UseGuards(ConditionalAuthGuard) + @Post() + async save(@Req() req: FastifyRequest, @Res() reply: FastifyReply) { + const body = (req.body ?? {}) as Record; + const categories: Record = {}; + + for (const category of Object.values(GarmentCategory)) { + const x = Number(body[`${category}__x`]); + const y = Number(body[`${category}__y`]); + const rotation = Number(body[`${category}__rotation`]); + const scale = Number(body[`${category}__scale`]); + const zIndex = Number(body[`${category}__zIndex`]); + if ( + ![x, y, rotation, scale, zIndex].every(Number.isFinite) || + scale <= 0 + ) { + throw new BadRequestException(`Invalid settings for ${category}`); + } + categories[category] = { x, y, rotation, scale, zIndex }; + } + + await this.canvasSettings.saveSettings({ categories }); + return reply.redirect('/settings', 303); + } + + @UseGuards(ConditionalAuthGuard) + @Post('mannequin') + async uploadMannequin( + @Req() req: FastifyRequest, + @Res() reply: FastifyReply, + ) { + const upload = await req.file(); + await this.canvasSettings.storeMannequin(upload); + return reply.redirect('/settings', 303); + } + + @Get('mannequin') + @Header('content-type', 'image/png') + @Header('cache-control', 'no-cache') + async mannequin(): Promise { + const mannequinPath = (await this.canvasSettings.mannequinExists()) + ? this.canvasSettings.getMannequinPath() + : path.join(process.cwd(), 'public', 'assets', 'mannequin.png'); + return new StreamableFile(fs.createReadStream(mannequinPath)); + } +} diff --git a/src/wardrobe/wardrobe.controller.ts b/src/wardrobe/wardrobe.controller.ts index 900d4d09..71313c51 100644 --- a/src/wardrobe/wardrobe.controller.ts +++ b/src/wardrobe/wardrobe.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -8,6 +9,7 @@ import { Logger, Param, ParseIntPipe, + Patch, Post, Query, Render, @@ -20,6 +22,8 @@ import { ConditionalAuthGuard } from '../auth/conditional-auth.guard'; import { Payload } from '../auth/dto/payload.dto'; import { GarmentCategory } from './garment-category.enum'; import { GarmentColor } from './garment-color.enum'; +import { getDefaultGarmentTransform } from './outfit-canvas.util'; +import { CanvasSettingsService } from './canvas-settings.service'; import { GarmentService } from './garment.service'; import { WardrobeShareService } from '../wardrobe-share/wardrobe-share.service'; import { SharePermission } from '../dal/entity/wardrobe-share.entity'; @@ -34,6 +38,7 @@ export class WardrobeController { constructor( private readonly garmentService: GarmentService, private readonly shareService: WardrobeShareService, + private readonly canvasSettingsService: CanvasSettingsService, ) {} private userId(req: any): number | undefined { @@ -225,6 +230,14 @@ export class WardrobeController { viewOwner: viewOwner ?? null, justCreated: created === '1', justSavedPhoto: photoSaved === '1', + canvasGarment: { + garment, + ...getDefaultGarmentTransform( + garment.category, + garment, + await this.canvasSettingsService.getSettings(), + ), + }, }; } @@ -342,6 +355,55 @@ export class WardrobeController { return reply.redirect(`/wardrobe/${cloned.id}`, 302); } + @Patch(':id/canvas-transform') + async updateCanvasTransform( + @Param('id', ParseIntPipe) id: number, + @Body() + body: { + x?: number; + y?: number; + rotation?: number; + scale?: number; + }, + @Req() req: FastifyRequest, + ) { + const userId = this.userId(req); + const viewOwner = undefined; + const garment = await this.garmentService.findOne(id, userId, viewOwner); + + if (userId != null && garment.owner?.id !== userId) { + throw new ForbiddenException(); + } + + const x = Number(body.x); + const y = Number(body.y); + const rotation = Number(body.rotation); + const scale = Number(body.scale); + + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + !Number.isFinite(rotation) || + !Number.isFinite(scale) || + scale <= 0 + ) { + throw new BadRequestException('Invalid garment canvas transform'); + } + + await this.garmentService.updateCanvasTransform( + id, + { + x, + y, + rotation, + scale, + }, + userId, + ); + + return { ok: true }; + } + @Post(':id') async update( @Param('id', ParseIntPipe) id: number, diff --git a/src/wardrobe/wardrobe.module.ts b/src/wardrobe/wardrobe.module.ts index 527c8a3e..dc6a51ea 100644 --- a/src/wardrobe/wardrobe.module.ts +++ b/src/wardrobe/wardrobe.module.ts @@ -14,6 +14,8 @@ import { CalendarController } from './calendar.controller'; import { WardrobeController } from './wardrobe.controller'; import { OutfitController } from './outfit.controller'; import { WeatherModule } from '../weather/weather.module'; +import { CanvasSettingsService } from './canvas-settings.service'; +import { SettingsController } from './settings.controller'; @Module({ imports: [ @@ -23,8 +25,18 @@ import { WeatherModule } from '../weather/weather.module'; MikroOrmModule.forFeature([Garment, Outfit, OutfitCalendar, User]), WeatherModule, ], - controllers: [WardrobeController, OutfitController, CalendarController], - providers: [GarmentService, OutfitService, CalendarService], + controllers: [ + WardrobeController, + OutfitController, + CalendarController, + SettingsController, + ], + providers: [ + GarmentService, + OutfitService, + CalendarService, + CanvasSettingsService, + ], exports: [GarmentService, OutfitService, CalendarService], }) export class WardrobeModule {} diff --git a/views/auth/profile.hbs b/views/auth/profile.hbs index 60e1f29e..03112003 100644 --- a/views/auth/profile.hbs +++ b/views/auth/profile.hbs @@ -5,6 +5,7 @@ {{t 'lang.CHANGE_PASSWORD'}} + {{t 'lang.CANVAS_SETTINGS'}} {{#if authEnabled}} {{t 'lang.WARDROBE_SHARING'}} {{/if}} diff --git a/views/auth/register.hbs b/views/auth/register.hbs index 29a646df..6b21a052 100644 --- a/views/auth/register.hbs +++ b/views/auth/register.hbs @@ -54,6 +54,21 @@ {{#each (filterErrors validationErrors "password")}}

{{this}}

{{/each}} + + + + {{#if errorMessage}} +

{{errorMessage}}

+ {{/if}}