From bbbd2ca3b4fb5e7204b62e71563d7d6688ddeff2 Mon Sep 17 00:00:00 2001 From: Diana Siwa Date: Wed, 7 Oct 2026 00:16:56 +0200 Subject: [PATCH 1/7] added the mannequin functionality --- scripts/preCommit.sh | 0 src/dal/entity/garment.entity.ts | 16 + src/dal/entity/outfit.entity.ts | 9 + .../postgres/Migration20261006120001.ts | 17 + .../sqlite/Migration20261006120000.ts | 17 + src/wardrobe/canvas-settings.service.ts | 102 +++++ src/wardrobe/garment.service.ts | 37 ++ src/wardrobe/outfit-canvas.util.ts | 40 ++ src/wardrobe/outfit.controller.ts | 66 +++- src/wardrobe/outfit.service.ts | 211 ++++++++++- src/wardrobe/settings.controller.ts | 85 +++++ src/wardrobe/wardrobe.controller.ts | 58 +++ src/wardrobe/wardrobe.module.ts | 6 +- views/outfits/index.hbs | 78 ++-- views/outfits/show.hbs | 348 ++++++++++++++++-- views/partials/navbar.hbs | 8 +- views/settings/index.hbs | 52 +++ views/wardrobe/show.hbs | 132 +++++++ 18 files changed, 1202 insertions(+), 80 deletions(-) mode change 100755 => 100644 scripts/preCommit.sh create mode 100644 src/dal/migrations/postgres/Migration20261006120001.ts create mode 100644 src/dal/migrations/sqlite/Migration20261006120000.ts create mode 100644 src/wardrobe/canvas-settings.service.ts create mode 100644 src/wardrobe/outfit-canvas.util.ts create mode 100644 src/wardrobe/settings.controller.ts create mode 100644 views/settings/index.hbs diff --git a/scripts/preCommit.sh b/scripts/preCommit.sh old mode 100755 new mode 100644 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/wardrobe/canvas-settings.service.ts b/src/wardrobe/canvas-settings.service.ts new file mode 100644 index 00000000..67edda27 --- /dev/null +++ b/src/wardrobe/canvas-settings.service.ts @@ -0,0 +1,102 @@ +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..3a185e9f 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,15 @@ 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 +203,15 @@ 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 +394,26 @@ 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..49e5f44f --- /dev/null +++ b/src/wardrobe/outfit-canvas.util.ts @@ -0,0 +1,40 @@ +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..3562740d 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,7 @@ export class OutfitService { id: { $in: garmentIds }, }); outfit.garments.set(garments); + outfit.slots = await this.applyGarmentCanvasDefaults(dto.slots ?? [], garments); } if (userId != null) { @@ -115,6 +120,7 @@ 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 +135,7 @@ export class OutfitService { parseSlotsFromBody( category: string | string[] | undefined, garmentId: string | string[] | undefined, + previousSlots: OutfitSlot[] = [], ): OutfitSlot[] { const cats = Array.isArray(category) ? category @@ -140,10 +147,206 @@ 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..5b743b76 --- /dev/null +++ b/src/wardrobe/settings.controller.ts @@ -0,0 +1,85 @@ +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'; +import { GarmentService } from './garment.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..aeeca2c2 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,51 @@ 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..b3940085 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,8 @@ 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/outfits/index.hbs b/views/outfits/index.hbs index 1c8112dd..c345205d 100644 --- a/views/outfits/index.hbs +++ b/views/outfits/index.hbs @@ -8,19 +8,19 @@ > - {{#if outfits.length}} -
- {{#each outfits}} + {{#if outfitCards.length}} +
+ {{#each outfitCards}}
-
+
- {{this.name}} + {{this.outfit.name}} - {{#if this.notes}} -

{{this.notes}}

+ + {{#if this.outfit.notes}} +

{{this.outfit.notes}}

{{/if}} - - {{#each this.garments}} {{#if this.photo}} + +
{{/each}} diff --git a/views/outfits/show.hbs b/views/outfits/show.hbs index c77fa783..5a43ceab 100644 --- a/views/outfits/show.hbs +++ b/views/outfits/show.hbs @@ -23,50 +23,99 @@ {{#if outfit.notes}}

{{outfit.notes}}

- {{/if}} {{!-- Garments grid --}} -

{{t 'lang.GARMENTS_IN_OUTFIT'}}

- {{#if garments.length}} -
- {{#each garments}} - +
+

{{t 'lang.GARMENTS_IN_OUTFIT'}}

+ +
+ + {{#if canvasGarments.length}} +
+ + {{/if}} + {{!-- Actions --}}
+ + + + {{>dock}} diff --git a/views/partials/navbar.hbs b/views/partials/navbar.hbs index 50090b8e..d15d434d 100644 --- a/views/partials/navbar.hbs +++ b/views/partials/navbar.hbs @@ -59,7 +59,9 @@ https://daisyui.com/components/drawer/#navbar-menu-for-desktop--sidebar-drawer-f >❤ {{t 'lang.SUPPORT'}} - {{/if}} {{#if authEnabled}} {{#if user}} + {{/if}} +
  • ⚙ Settings
  • + {{#if authEnabled}} {{#if user}}
  • {{t 'lang.LOGOUT'}}
  • {{user.email}}
  • {{else}} {{#unless signupsDisabled}} @@ -89,7 +91,9 @@ https://daisyui.com/components/drawer/#navbar-menu-for-desktop--sidebar-drawer-f >❤ {{t 'lang.SUPPORT'}} - {{/if}} {{#if authEnabled}} {{#if user}} + {{/if}} +
  • ⚙ Settings
  • + {{#if authEnabled}} {{#if user}}
  • {{t 'lang.LOGOUT'}}
  • {{user.email}}
  • {{else}} {{#unless signupsDisabled}} diff --git a/views/settings/index.hbs b/views/settings/index.hbs new file mode 100644 index 00000000..45298b09 --- /dev/null +++ b/views/settings/index.hbs @@ -0,0 +1,52 @@ +{{>navbar}} + +
    +
    + ← +

    Canvas settings

    +
    + +
    +
    +

    Mannequin

    +
    + Mannequin preview + +
    +
    + + +
    +

    PNG with transparency recommended. This image is used behind every canvas.

    +
    + +
    +

    Default garment placement

    +

    These defaults are used when a garment is first created or added to an outfit. Canvas coordinates use 0–1000.

    + +
    +
    + + + + {{#each categories}} + + + + + + + + + {{/each}} + +
    CategoryXYRotationScaleLayer
    {{label}}
    +
    +
    +
    +
    +
    +
    + +{{>dock}} diff --git a/views/wardrobe/show.hbs b/views/wardrobe/show.hbs index 08082397..f4c71ab0 100644 --- a/views/wardrobe/show.hbs +++ b/views/wardrobe/show.hbs @@ -24,6 +24,138 @@

    {{garment.name}}

    + {{!-- Garment mannequin canvas --}} +
    +
    + + + {{#if canvasGarment.garment.photo}} +
    +
    + + + + +
    + {{canvasGarment.garment.name}} +
    + {{else}} +
    + No photo +
    + {{/if}} +
    +
    + + {{#if canvasGarment.garment.photo}} + + {{/if}} + {{#if garment.photo}}
    Date: Wed, 7 Oct 2026 17:54:00 +0200 Subject: [PATCH 2/7] moving settings to profile, making code prettier --- package-lock.json | 176 +++++++++++++++++++++++- package.json | 3 +- src/i18n/en/lang.json | 3 +- src/wardrobe/canvas-settings.service.ts | 69 ++++++++-- src/wardrobe/garment.service.ts | 14 +- src/wardrobe/outfit-canvas.util.ts | 13 +- src/wardrobe/outfit.service.ts | 38 +++-- src/wardrobe/settings.controller.ts | 10 +- src/wardrobe/wardrobe.controller.ts | 16 ++- src/wardrobe/wardrobe.module.ts | 14 +- views/auth/profile.hbs | 1 + views/partials/navbar.hbs | 1 - 12 files changed, 315 insertions(+), 43 deletions(-) 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/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 index 67edda27..4d6a3d64 100644 --- a/src/wardrobe/canvas-settings.service.ts +++ b/src/wardrobe/canvas-settings.service.ts @@ -20,14 +20,62 @@ export interface CanvasSettings { 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 }, + [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, + }, }, }; @@ -89,7 +137,10 @@ export class CanvasSettingsService { return this.mannequinPath; } - private merge(base: CanvasSettings, value: Partial): CanvasSettings { + private merge( + base: CanvasSettings, + value: Partial, + ): CanvasSettings { const categories: Record = {}; for (const category of Object.values(GarmentCategory)) { categories[category] = { diff --git a/src/wardrobe/garment.service.ts b/src/wardrobe/garment.service.ts index 3a185e9f..f4634281 100644 --- a/src/wardrobe/garment.service.ts +++ b/src/wardrobe/garment.service.ts @@ -142,7 +142,11 @@ export class GarmentService { } const settings = await this.canvasSettingsService.getSettings(); - const defaults = getDefaultGarmentTransform(dto.category, undefined, settings); + const defaults = getDefaultGarmentTransform( + dto.category, + undefined, + settings, + ); const garment = this.garmentRepository.create({ name: dto.name, category: dto.category, @@ -204,7 +208,11 @@ export class GarmentService { } const settings = await this.canvasSettingsService.getSettings(); - const defaults = getDefaultGarmentTransform(dto.category, undefined, settings); + const defaults = getDefaultGarmentTransform( + dto.category, + undefined, + settings, + ); const garment = this.garmentRepository.create({ name: dto.name, category: dto.category, @@ -414,6 +422,4 @@ export class GarmentService { await this.garmentRepository.getEntityManager().flush(); return garment; } - - } diff --git a/src/wardrobe/outfit-canvas.util.ts b/src/wardrobe/outfit-canvas.util.ts index 49e5f44f..8f74d9c3 100644 --- a/src/wardrobe/outfit-canvas.util.ts +++ b/src/wardrobe/outfit-canvas.util.ts @@ -1,5 +1,8 @@ import { GarmentCategory } from './garment-category.enum'; -import { CanvasSettings, DEFAULT_CANVAS_SETTINGS } from './canvas-settings.service'; +import { + CanvasSettings, + DEFAULT_CANVAS_SETTINGS, +} from './canvas-settings.service'; export interface OutfitCanvasTransform { x: number; @@ -21,7 +24,10 @@ export function getDefaultGarmentTransform( garment?: GarmentCanvasDefaults, settings?: CanvasSettings, ): OutfitCanvasTransform { - const categoryDefault = getCategoryDefaultGarmentTransform(category, settings); + const categoryDefault = getCategoryDefaultGarmentTransform( + category, + settings, + ); return { x: garment?.canvasPositionX ?? categoryDefault.x, y: garment?.canvasPositionY ?? categoryDefault.y, @@ -35,6 +41,7 @@ export function getCategoryDefaultGarmentTransform( category: string, settings: CanvasSettings = DEFAULT_CANVAS_SETTINGS, ): OutfitCanvasTransform { - const value = settings.categories[category] ?? settings.categories[GarmentCategory.OTHER]; + const value = + settings.categories[category] ?? settings.categories[GarmentCategory.OTHER]; return { ...value }; } diff --git a/src/wardrobe/outfit.service.ts b/src/wardrobe/outfit.service.ts index 3562740d..b49fda0e 100644 --- a/src/wardrobe/outfit.service.ts +++ b/src/wardrobe/outfit.service.ts @@ -87,7 +87,10 @@ export class OutfitService { id: { $in: garmentIds }, }); outfit.garments.set(garments); - outfit.slots = await this.applyGarmentCanvasDefaults(dto.slots ?? [], garments); + outfit.slots = await this.applyGarmentCanvasDefaults( + dto.slots ?? [], + garments, + ); } if (userId != null) { @@ -120,7 +123,10 @@ export class OutfitService { id: { $in: garmentIds }, }); outfit.garments.set(garments); - outfit.slots = await this.applyGarmentCanvasDefaults(dto.slots ?? [], garments); + outfit.slots = await this.applyGarmentCanvasDefaults( + dto.slots ?? [], + garments, + ); } await this.outfitRepository.getEntityManager().flush(); @@ -193,15 +199,15 @@ export class OutfitService { garments: Garment[], ): Promise { const settings = await this.canvasSettingsService.getSettings(); - const garmentsById = new Map(garments.map((garment) => [garment.id, garment])); + 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; + let nextZIndex = usedZIndexes.size ? Math.max(...usedZIndexes) + 1 : 0; return slots.map((slot) => { if (slot.garmentId == null) return slot; @@ -209,7 +215,11 @@ export class OutfitService { const garment = garmentsById.get(slot.garmentId); if (!garment) return slot; - const defaults = getDefaultGarmentTransform(garment.category, garment, settings); + const defaults = getDefaultGarmentTransform( + garment.category, + garment, + settings, + ); return { ...slot, @@ -233,7 +243,11 @@ export class OutfitService { const garment = garmentsById.get(slot.garmentId); if (!garment) return null; - const defaults = getDefaultGarmentTransform(garment.category, garment, settings); + const defaults = getDefaultGarmentTransform( + garment.category, + garment, + settings, + ); return { slotIndex: index, @@ -248,7 +262,9 @@ export class OutfitService { }; }) .filter( - (item): item is { + ( + item, + ): item is { slotIndex: number; garment: Garment; position: { x: number; y: number }; @@ -324,7 +340,9 @@ export class OutfitService { 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); + .filter( + ({ slot, index }) => slot.garmentId != null && index !== slotIndex, + ); if (direction === 'up') { const above = candidates diff --git a/src/wardrobe/settings.controller.ts b/src/wardrobe/settings.controller.ts index 5b743b76..a074c084 100644 --- a/src/wardrobe/settings.controller.ts +++ b/src/wardrobe/settings.controller.ts @@ -55,7 +55,10 @@ export class SettingsController { 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) { + 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 }; @@ -67,7 +70,10 @@ export class SettingsController { @UseGuards(ConditionalAuthGuard) @Post('mannequin') - async uploadMannequin(@Req() req: FastifyRequest, @Res() reply: FastifyReply) { + async uploadMannequin( + @Req() req: FastifyRequest, + @Res() reply: FastifyReply, + ) { const upload = await req.file(); await this.canvasSettings.storeMannequin(upload); return reply.redirect('/settings', 303); diff --git a/src/wardrobe/wardrobe.controller.ts b/src/wardrobe/wardrobe.controller.ts index aeeca2c2..71313c51 100644 --- a/src/wardrobe/wardrobe.controller.ts +++ b/src/wardrobe/wardrobe.controller.ts @@ -390,12 +390,16 @@ export class WardrobeController { throw new BadRequestException('Invalid garment canvas transform'); } - await this.garmentService.updateCanvasTransform(id, { - x, - y, - rotation, - scale, - }, userId); + await this.garmentService.updateCanvasTransform( + id, + { + x, + y, + rotation, + scale, + }, + userId, + ); return { ok: true }; } diff --git a/src/wardrobe/wardrobe.module.ts b/src/wardrobe/wardrobe.module.ts index b3940085..dc6a51ea 100644 --- a/src/wardrobe/wardrobe.module.ts +++ b/src/wardrobe/wardrobe.module.ts @@ -25,8 +25,18 @@ import { SettingsController } from './settings.controller'; MikroOrmModule.forFeature([Garment, Outfit, OutfitCalendar, User]), WeatherModule, ], - controllers: [WardrobeController, OutfitController, CalendarController, SettingsController], - providers: [GarmentService, OutfitService, CalendarService, CanvasSettingsService], + 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/partials/navbar.hbs b/views/partials/navbar.hbs index d15d434d..5084c7a9 100644 --- a/views/partials/navbar.hbs +++ b/views/partials/navbar.hbs @@ -60,7 +60,6 @@ https://daisyui.com/components/drawer/#navbar-menu-for-desktop--sidebar-drawer-f > {{/if}} -
  • ⚙ Settings
  • {{#if authEnabled}} {{#if user}}
  • {{t 'lang.LOGOUT'}}
  • {{user.email}}
  • From e27953a92a7d5a4cd3dcdc9b23712de6087e1163 Mon Sep 17 00:00:00 2001 From: Diana Siwa Date: Wed, 7 Oct 2026 18:01:15 +0200 Subject: [PATCH 3/7] removing garment service thats unused --- src/wardrobe/settings.controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/wardrobe/settings.controller.ts b/src/wardrobe/settings.controller.ts index a074c084..57982b32 100644 --- a/src/wardrobe/settings.controller.ts +++ b/src/wardrobe/settings.controller.ts @@ -18,7 +18,6 @@ import { ConfigService } from '@nestjs/config'; import { ConditionalAuthGuard } from '../auth/conditional-auth.guard'; import { GarmentCategory } from './garment-category.enum'; import { CanvasSettingsService } from './canvas-settings.service'; -import { GarmentService } from './garment.service'; @Controller('settings') export class SettingsController { From ab6795e822d7b5f053dfe9501f7d190d16a727fa Mon Sep 17 00:00:00 2001 From: diana-siwa <45698128+diana-siwa@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:25:01 +0200 Subject: [PATCH 4/7] Adding a default mannequin.png --- public/assets/mannequin.png | Bin 0 -> 40093 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/assets/mannequin.png diff --git a/public/assets/mannequin.png b/public/assets/mannequin.png new file mode 100644 index 0000000000000000000000000000000000000000..2f2e7ddd7c6663176974b1c7ab1f924f16749398 GIT binary patch literal 40093 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 z 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

    (5ZnzQCZqgy^{$tG`r#l-E!IK&Y=Lhtn+?jtJ z%?#kTAvE8MY#+N3wkCwzMzt3audNtn#L0oT6x;=CfUE&>xS zTh-k&JG%|li9%&tV6HdjLht^$S&f_$jiuG4it&OHaY^E@=+^2~V_ zL(svG#xyYV?a5^F;m!^|X@D3rx5V6yq1-@w&)xG)&X{KDcLQRM4a+Ja);T4`!y9@u zs2PsvI_}S%^i^H^cPEqCiF3|Aaez3#aL%1Dt@)Ij;)j7!0kba z(UgZ;0pel!XiR_|I}pK`=|NRh?>XnfRshn>=1hD{L^|xn@(hS&UEZ@PM-g@}06E`G z*oe&1++IpEbW~P6^>!u__WZtc?%tg{cXSKDH709`c?C-CekNXrR2cj~HtibNPOD6m zRdSFgJ&0y(1eXpapOXGr8)p*CtaHpio=&HSH*Vb6$^qmG!puE^QsBpQN|Gq~rCVkI z5Y969%PKd>d1UAW5T}W$Gl@E#>ZbSX{GF<*R$JQ|kubSHXN+_<*~O!t15v?70mvtC z2(dvb84n`ktY8~EZhgh~s;b+XVu+#^cCL5c%g`)5Em*zTr%C@-N>~LTp8!PCX-mZ{ zPNS@p5_Kk*tk%HCm2VDIceZv0Rn3?kaG<5b3c&AzCxPu zw$U#$*ReW=_1gppu^EAS7-Xm?jEu1}#oVkwPpU@C0+7dArOf*n%a~wf8{>K#9gxkL zZ%~9GY;|u|DI9komQ(IhkE~cxc{7$9nNNdJG?+baMl1kB9r=}0-fDK0)h5qV5e#Cc|@3)K(0L6a)X=`L=r&ECXdSDhv1)?W8cTE z0wgK6(@@Dx(NrFo(lkqdYO8Cnkn&^;K+Y@5Qk?=c!5L56Q|`77rVtAuY@gDoGv3l}dyDK`sqIHluH zJu?!L;ZrV;0+92;hEZI#GgZ9;BR?FtvrKWL;X{V4znuw=T~-}3nC56oZD0Wm-(@5% zLJU}~$^$L{dGga2ME0P1l?WcNNB+VLWKcCz-EIy-2Bz$LHL`ZIu-zu*ojE7&mdjpN zu8{(eC;$EQqL)JmS45(h7@;b#jXcCQCoqU~+ZTvgYnl+DW}4|Q$sNN@AuW5RFh$ zND8}h=$F7Eq`8?c4sBO%kaB}O*^)Li61o>fZC|s5r;gqr8)Wj)j>9b4c0;U5bJ0u^ z4J++$oqLc=oUsJsjc#F9JSnRHV9OrnrwGt5mrgj3ushB~+X7c)7i zn5vjSO`-Yl4-=5KLD5g{Y|P|K@K6BqMDbN5QM)QJ-jo(iR7m=u7v z+@DT+Ar^Asg@9{0OYq{ec$_bwfdrn-g1r|F=GIUS+-u& zjVV1Nf-!A7av?HP6A@7r)6Bi1fxrkIJVJ)4!lc4o5wkZdm8=4g-#Wz7XJYKXE-Kf| zKq?BA%#cNHP&17}Z3H8Wajdv*6C%uxlSYG$D%+GnLZ{ff1T%9Q8j#Q=Dze z&bw=*#KwR{gJXxLX;3u{8fnmLOkHZ^FpDe#=yc0e6DG%OGy%+UQnedZ6bS^VLPWq! zP*G6jz(n2=J*TF(RJADpDFAuQa3#{=g9n#I=E=#)4uF&L^b0^9 z}s{zGqbzf)DtdA`v21vLf8?JS73TmML2T+VWx?s zh_%cQ=fhZ46&$fJ(On`w+V;TIgq$~UqpGZ#L}_Z!sE{a7iOIHF1&L3YHN8|T5xpK` ze5Ra(0+3&ydzewTXs?QdSBz{=G*UWHX^5LBDYz3eoO5vA!TType^Tezzde~uA6&b3 z&9=Gcp2$a)O3jFfQP&M7lPQ{dk}7Xih00fO%pe#{1QH`+j2uI}+V}nQA%qf1lo0FJ z95xNLS@$JCzsyV%W&p@#TxhCfc?V)f1R-RHDN8lp!@8dQpst#ur;s;6Mai*8Q%@i! zu+*r5z+o);a;j}5wG)E#o`~s6-}hfSK0clR=*lT50D06QPSFvYH!>|D45pn#A zA09rq)pqUQh{P|LivNgRKnBY|B_EVSlKw3}Z0gDXOs6~dnc23#e>Q-1;3ztFaL%Ff z9-bMFX|#$<7v8|r0z}1ip?^Mx_(F`aL=y!dA6hGJM<5U} zk{B*4k-J;hleeq7-nt3`5t*u*4ON8oW&zE@KV~44f@7OBuQ8|ywJ9x+Nj!}6=VGOUv@l}ld53{v|4kv(gRaO0LZ!*1CdH<9eZ7eYD%}g8-ymJ_A z;v50YG=7NJ_Hm;aDU%B^d||m-evOD`1rh}y;|j6g7mZ&t(`N@!hV@+c&>++_Af<|- zn3VxyP+c(bFT8UL=bnz%t(gQ>TZm``kc>RjoFPC-BW~=ZXF7cHHbG`1gNW#Y_TlAj zwR*Pi`;x2|fBJZbN&#+f!9RFq>%+;f~qzW+I&7QmCXVWV6|E~iScs; ze1@T%=oqJ~SEh=y%n?iTwt$&b-hVKg&TiLD^VEip2neM1p^2U_vkkXI`sB(Mxda-Q zz%opW=-L{ZLyVXE5U%g-?Ug)aWBtzlGE{??C^wktBH+@^kO_ze&2({2{;`(0R|ZP6 z7HJG56471f+~IP$d}|FF!{Q(mm5|I*!IgQ8Xy&G)QQB2{N(l8sc7IKIx7rVZ@ z*>zow(p4`2>AFBBx`U{m2_&lN@KdTH|g5$S_Vj2T%2CVF1g^-pKB>B-ZiichB#G?U4J8GaJ_uxhHwL{$+& zfS4khp^pkB54Z#jat^NYIj162YzQ%0(4ec;swC+p2Lbl?FGuIbnuso#}MvN4vZG%cuE0h@9>3)JM+oTYcz0h!Ry))9i{E`aYs< zTP#;Av|W#`?-3*>@M{{+)hKbXSlkwgKctG51r`M$@XpE3Wb$Jo`j)944*-c0(S?9j z+hNspSavN|U5B>s(1$RHHAE(Qd$u?G>1;NWr~O?nfA)*r{{F@PR+aCyecu@v!G4yxmv!{ckMr3*xmcjtIu3*3oJ^A1!A^`2lwt(i|U^w z#+O4E|5PMQf<*KoqLYZI3Qby406Q26&MUlg^SZ9T@7NtM^HUeOB_jRs&M)4(fA`+M z9v#oW)-=tns6vb73X8Ty5QU%zyhl}exITcL6Yt!-oiBe3!#~`(diC#bzVy=jRaF(y z-U5)cmR;y~_xHaOLU=qejGg}-j4j=sUVc*kox%%6a#o{ZAw!74Y=%Xn|HFmzH+RQ4` zwyLW4SLps_)%HJFt-4Q*8p!qQ*Y&OMeCOxy-oE|D{N%_B^p&WX8Ne|FB|%TB2L}83 zYO#8Iw!8Da%lps#@P%71eQ^2W#no&!D>LaIcRQC~0kC*@$Ul4c=X-|-cW&Lid;bp) zPmW*eW4s*9uJnDlDry(2rdeFLaPiG+*RTHj-i5ty1I_I}|N7VCr|rAE@y%~e9~^%8 z+{y9DjXv6ph@67Nh}vdnc5vz9g@c=)`^N?%#i#-+J@= z^ Date: Sun, 11 Oct 2026 14:30:07 +0200 Subject: [PATCH 5/7] adding a secret code for registration in ENV --- .env | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 131989231dbfb39c7290685697da9cf407621348 Mon Sep 17 00:00:00 2001 From: diana-siwa <45698128+diana-siwa@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:32:59 +0200 Subject: [PATCH 6/7] adding a secret code register logic --- src/auth/auth.controller.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) 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') From 7828953d5cc475c77cfaa80b3b55f32acb9b9106 Mon Sep 17 00:00:00 2001 From: diana-siwa <45698128+diana-siwa@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:35:15 +0200 Subject: [PATCH 7/7] Adding secret inputbox --- views/auth/register.hbs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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}}