diff --git a/scripts/scale-seed.js b/scripts/scale-seed.js new file mode 100644 index 0000000..3962cbd --- /dev/null +++ b/scripts/scale-seed.js @@ -0,0 +1,56 @@ +const Database = require('better-sqlite3'); +const { randomUUID } = require('crypto'); + +const dbPath = process.argv[2]; +const n = parseInt(process.argv[3], 10); +if (!dbPath || !n) { + console.error('usage: node seed.js '); + process.exit(1); +} + +const categories = ['accessories', 'bags', 'outerwear', 'dresses', 'tops', 'bottoms', 'footwear', 'other']; +const colors = ['red', 'pink', 'orange', 'yellow', 'green', 'blue', 'purple', 'black', 'white', 'grey', 'beige', 'brown', 'gold', 'silver', 'pattern', 'other']; +const adjectives = ['Blue', 'Vintage', 'Cotton', 'Linen', 'Striped', 'Oversized', 'Slim-fit', 'Wool', 'Denim', 'Floral', 'Plaid', 'Cropped']; +const nouns = ['Sweater', 'Jeans', 'Jacket', 'T-Shirt', 'Dress', 'Skirt', 'Sneakers', 'Boots', 'Scarf', 'Hat', 'Coat', 'Shorts']; + +const db = new Database(dbPath); +db.pragma('journal_mode = WAL'); + +const insertGarment = db.prepare(` + INSERT INTO garment (shareable_id, flagged, banned, name, category, color, brand, size, notes, photo_id, owner_id, date_aquired, washing_details, archived) + VALUES (@shareableId, 0, 0, @name, @category, @color, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0) +`); + +const insertMany = db.transaction((count) => { + for (let i = 1; i <= count; i++) { + const adj = adjectives[i % adjectives.length]; + const noun = nouns[i % nouns.length]; + insertGarment.run({ + shareableId: randomUUID(), + name: `${adj} ${noun} #${i}`, + category: categories[i % categories.length], + color: colors[i % colors.length], + }); + } +}); + +insertMany(n); + +// Seed a few drawers to exercise the drawers UX at the same scale. +const insertDrawer = db.prepare(`INSERT INTO drawer (name, notes, owner_id) VALUES (?, ?, NULL)`); +const insertDrawerGarment = db.prepare(`INSERT INTO drawer_garments (drawer_id, garment_id) VALUES (?, ?)`); +const allIds = db.prepare('SELECT id FROM garment').all().map((r) => r.id); + +if (allIds.length > 0) { + // One small drawer (~5 items, or fewer if the catalog is tiny) + const smallId = insertDrawer.run('Winter Coats', 'Cold weather outerwear').lastInsertRowid; + allIds.slice(0, Math.min(5, allIds.length)).forEach((gid) => insertDrawerGarment.run(smallId, gid)); + + // One drawer sized ~10% of the catalog (capped) to see how a "big" drawer renders + const bigCount = Math.min(Math.max(Math.floor(allIds.length * 0.1), 1), 200); + const bigId = insertDrawer.run('Everything Casual', 'A deliberately large drawer to stress-test the UI').lastInsertRowid; + allIds.slice(0, bigCount).forEach((gid) => insertDrawerGarment.run(bigId, gid)); +} + +db.pragma('wal_checkpoint(TRUNCATE)'); +console.log(`Seeded ${n} garments and drawers into ${dbPath}`); diff --git a/scripts/scale-shots.js b/scripts/scale-shots.js new file mode 100644 index 0000000..3b879a4 --- /dev/null +++ b/scripts/scale-shots.js @@ -0,0 +1,34 @@ +const { chromium } = require('playwright'); + +const viewports = { + mobile: { width: 390, height: 844 }, + desktop: { width: 1440, height: 900 }, +}; + +const pages = [ + { name: 'wardrobe', path: '/wardrobe' }, + { name: 'drawers-index', path: '/drawers' }, + { name: 'drawer-new-picker', path: '/drawers/new' }, +]; + +async function main() { + const n = process.argv[2]; + const browser = await chromium.launch(); + for (const [vpName, vp] of Object.entries(viewports)) { + const context = await browser.newContext({ viewport: vp }); + const page = await context.newPage(); + for (const p of pages) { + const start = Date.now(); + await page.goto(`http://localhost:3000${p.path}`, { waitUntil: 'load', timeout: 60000 }); + const loadMs = Date.now() - start; + const domNodes = await page.evaluate(() => document.querySelectorAll('*').length); + const imgCount = await page.evaluate(() => document.querySelectorAll('img').length); + console.log(`n=${n} ${vpName} ${p.name}: load=${loadMs}ms domNodes=${domNodes} imgTags=${imgCount}`); + await page.screenshot({ path: `/tmp/scale-test/screenshots/${p.name}-${vpName}-n${n}.png`, fullPage: p.name !== 'wardrobe' }); + } + await context.close(); + } + await browser.close(); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/scripts/verify-lib10.js b/scripts/verify-lib10.js new file mode 100644 index 0000000..711b96e --- /dev/null +++ b/scripts/verify-lib10.js @@ -0,0 +1,74 @@ +const { chromium } = require('playwright'); +const fs = require('fs'); + +const viewports = { + mobile: { width: 390, height: 844 }, + desktop: { width: 1440, height: 900 }, +}; + +async function main() { + fs.mkdirSync('/tmp/lib10-shots', { recursive: true }); + const browser = await chromium.launch(); + + for (const [vpName, vp] of Object.entries(viewports)) { + const context = await browser.newContext({ viewport: vp }); + const page = await context.newPage(); + + await page.goto('http://localhost:3000/drawers/new', { waitUntil: 'load' }); + const domNodes = await page.evaluate(() => document.querySelectorAll('*').length); + const cardCount = await page.locator('.garment-card').count(); + const searchBox = await page.locator('#picker-search').boundingBox(); + const categoryBox = await page.locator('#picker-category').boundingBox(); + console.log( + `[${vpName}] /drawers/new domNodes=${domNodes} renderedCards=${cardCount} searchInputWidth=${searchBox.width}px categorySelectWidth=${categoryBox.width}px`, + ); + await page.screenshot({ path: `/tmp/lib10-shots/picker-initial-${vpName}.png` }); + + // type into search, expect an htmx-driven server round trip that shrinks the grid + await page.fill('#picker-search', 'Denim'); + await page.waitForTimeout(500); // debounce + request + const cardsAfterSearch = await page.locator('.garment-card').count(); + const searchBoxAfterType = await page.locator('#picker-search').boundingBox(); + console.log( + `[${vpName}] AFTER search "Denim": renderedCards=${cardsAfterSearch} searchInputWidthWhileTyping=${searchBoxAfterType.width}px`, + ); + await page.screenshot({ path: `/tmp/lib10-shots/picker-after-search-${vpName}.png` }); + + // select a garment, then change the search so it's no longer in the grid + await page.locator('.picker-checkbox').first().evaluate((el) => { + el.checked = true; + el.dispatchEvent(new Event('change', { bubbles: true })); + }); + const selectedCountAfterCheck = await page.locator('#selected-inputs .selected-hidden').count(); + await page.fill('#picker-search', 'zzzznomatch'); + await page.waitForTimeout(500); + const emptyVisible = await page.locator('text=' + (await page.locator('#garment-picker-grid p').textContent().catch(() => ''))); + const cardsAfterNoMatch = await page.locator('.garment-card').count(); + const selectedCountAfterFilter = await page.locator('#selected-inputs .selected-hidden').count(); + console.log( + `[${vpName}] selected after check=${selectedCountAfterCheck}, cards after no-match search=${cardsAfterNoMatch}, selected still tracked=${selectedCountAfterFilter}`, + ); + + // category filter + await page.fill('#picker-search', ''); + await page.selectOption('#picker-category', 'outerwear'); + await page.waitForTimeout(500); + const cardsAfterCategory = await page.locator('.garment-card').count(); + console.log(`[${vpName}] AFTER category=outerwear: renderedCards=${cardsAfterCategory}`); + await page.screenshot({ path: `/tmp/lib10-shots/picker-after-category-${vpName}.png` }); + + // lazy loading check + const lazyImgCount = await page.locator('.garment-card img[loading="lazy"]').count(); + const totalImgCount = await page.locator('.garment-card img').count(); + console.log(`[${vpName}] lazy-loaded images: ${lazyImgCount}/${totalImgCount}`); + + await context.close(); + } + + await browser.close(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/verify-lib8.js b/scripts/verify-lib8.js new file mode 100644 index 0000000..ad11e45 --- /dev/null +++ b/scripts/verify-lib8.js @@ -0,0 +1,81 @@ +const { chromium } = require('playwright'); +const fs = require('fs'); + +const viewports = { + mobile: { width: 390, height: 844 }, + desktop: { width: 1440, height: 900 }, +}; + +async function main() { + fs.mkdirSync('/tmp/lib8-shots', { recursive: true }); + const browser = await chromium.launch(); + + for (const [vpName, vp] of Object.entries(viewports)) { + const context = await browser.newContext({ viewport: vp }); + const page = await context.newPage(); + + // /wardrobe pagination + await page.goto('http://localhost:3000/wardrobe', { waitUntil: 'load' }); + const wardrobeDom = await page.evaluate(() => document.querySelectorAll('*').length); + console.log(`[${vpName}] /wardrobe page1 domNodes=${wardrobeDom}`); + await page.screenshot({ path: `/tmp/lib8-shots/wardrobe-${vpName}.png` }); + + // /drawers index (thumbnail cap) + await page.goto('http://localhost:3000/drawers', { waitUntil: 'load' }); + const drawersDom = await page.evaluate(() => document.querySelectorAll('*').length); + console.log(`[${vpName}] /drawers index domNodes=${drawersDom}`); + await page.screenshot({ path: `/tmp/lib8-shots/drawers-index-${vpName}.png`, fullPage: true }); + + // /drawers/2 (100-item drawer) pagination + await page.goto('http://localhost:3000/drawers/2', { waitUntil: 'load' }); + const drawerShowDom = await page.evaluate(() => document.querySelectorAll('*').length); + console.log(`[${vpName}] /drawers/2 domNodes=${drawerShowDom}`); + await page.screenshot({ path: `/tmp/lib8-shots/drawer-show-${vpName}.png`, fullPage: true }); + + // /drawers/new picker search + await page.goto('http://localhost:3000/drawers/new', { waitUntil: 'load' }); + const pickerDomBefore = await page.evaluate(() => document.querySelectorAll('*').length); + const cardsBefore = await page.locator('.garment-card:not(.hidden)').count(); + console.log(`[${vpName}] /drawers/new BEFORE search: domNodes=${pickerDomBefore} visibleCards=${cardsBefore}`); + await page.screenshot({ path: `/tmp/lib8-shots/picker-before-${vpName}.png` }); + + await page.fill('#picker-search', 'Denim'); + await page.waitForTimeout(400); // debounce + const cardsAfterSearch = await page.locator('.garment-card:not(.hidden)').count(); + console.log(`[${vpName}] /drawers/new AFTER search "Denim": visibleCards=${cardsAfterSearch}`); + await page.screenshot({ path: `/tmp/lib8-shots/picker-after-search-${vpName}.png` }); + + await page.fill('#picker-search', 'zzzznomatch'); + await page.waitForTimeout(400); + const emptyVisible = await page.locator('#picker-empty').isVisible(); + const cardsAfterNoMatch = await page.locator('.garment-card:not(.hidden)').count(); + console.log(`[${vpName}] /drawers/new AFTER no-match search: visibleCards=${cardsAfterNoMatch} emptyMessageVisible=${emptyVisible}`); + + await page.fill('#picker-search', ''); + await page.selectOption('#picker-category', 'outerwear'); + await page.waitForTimeout(400); + const cardsAfterCategory = await page.locator('.garment-card:not(.hidden)').count(); + console.log(`[${vpName}] /drawers/new AFTER category=outerwear: visibleCards=${cardsAfterCategory}`); + await page.screenshot({ path: `/tmp/lib8-shots/picker-after-category-${vpName}.png` }); + + // checkbox selection persists through a filter change + await page.selectOption('#picker-category', ''); + await page.waitForTimeout(200); + await page.locator('.garment-card input[type=checkbox]').first().evaluate((el) => { + el.checked = true; + }); + await page.fill('#picker-search', 'Denim'); + await page.waitForTimeout(400); + const checkedCount = await page.locator('.garment-card input[type=checkbox]:checked').count(); + console.log(`[${vpName}] /drawers/new checked garments still checked after filtering: ${checkedCount}`); + + await context.close(); + } + + await browser.close(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/dal/entity/drawer.entity.ts b/src/dal/entity/drawer.entity.ts new file mode 100644 index 0000000..1d55d22 --- /dev/null +++ b/src/dal/entity/drawer.entity.ts @@ -0,0 +1,34 @@ +import { + Collection, + Entity, + ManyToMany, + ManyToOne, + PrimaryKey, + Property, + type Ref, +} from '@mikro-orm/core'; +import { Garment } from './garment.entity'; +import { User } from './user.entity'; + +@Entity() +export class Drawer { + @PrimaryKey() + public id!: number; + + @Property() + public name!: string; + + @Property({ nullable: true }) + public notes?: string; + + @ManyToMany(() => Garment, (garment) => garment.drawers, { owner: true }) + public garments = new Collection(this); + + @ManyToOne({ + entity: () => User, + deleteRule: 'cascade', + ref: true, + nullable: true, + }) + public owner?: Ref; +} diff --git a/src/dal/entity/garment.entity.ts b/src/dal/entity/garment.entity.ts index 49cf4df..dfb4927 100644 --- a/src/dal/entity/garment.entity.ts +++ b/src/dal/entity/garment.entity.ts @@ -9,6 +9,7 @@ import { Property, type Ref, } from '@mikro-orm/core'; +import { Drawer } from './drawer.entity'; import { File } from './file.entity'; import { Outfit } from './outfit.entity'; import { ShareableId } from './shareableId.entity'; @@ -64,4 +65,7 @@ export class Garment extends ShareableId { @ManyToMany(() => Outfit, (outfit) => outfit.garments) public outfits = new Collection(this); + + @ManyToMany(() => Drawer, (drawer) => drawer.garments) + public drawers = new Collection(this); } diff --git a/src/dal/migrations/postgres/Migration20260722221513.ts b/src/dal/migrations/postgres/Migration20260722221513.ts new file mode 100644 index 0000000..a270869 --- /dev/null +++ b/src/dal/migrations/postgres/Migration20260722221513.ts @@ -0,0 +1,26 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260722221513 extends Migration { + + override async up(): Promise { + this.addSql(`create table "drawer" ("id" serial primary key, "name" varchar(255) not null, "notes" varchar(255) null, "owner_id" int null);`); + + this.addSql(`create table "drawer_garments" ("drawer_id" int not null, "garment_id" int not null, constraint "drawer_garments_pkey" primary key ("drawer_id", "garment_id"));`); + + this.addSql(`alter table "drawer" add constraint "drawer_owner_id_foreign" foreign key ("owner_id") references "user" ("id") on update cascade on delete cascade;`); + + this.addSql(`alter table "drawer_garments" add constraint "drawer_garments_drawer_id_foreign" foreign key ("drawer_id") references "drawer" ("id") on update cascade on delete cascade;`); + this.addSql(`alter table "drawer_garments" add constraint "drawer_garments_garment_id_foreign" foreign key ("garment_id") references "garment" ("id") on update cascade on delete cascade;`); + } + + override async down(): Promise { + this.addSql(`alter table "drawer_garments" drop constraint "drawer_garments_drawer_id_foreign";`); + + this.addSql(`alter table "drawer_garments" drop constraint "drawer_garments_garment_id_foreign";`); + + this.addSql(`drop table if exists "drawer" cascade;`); + + this.addSql(`drop table if exists "drawer_garments" cascade;`); + } + +} diff --git a/src/dal/migrations/sqlite/.snapshot-sqlite3.db.json b/src/dal/migrations/sqlite/.snapshot-sqlite3.db.json index 25b38e4..fd375a6 100644 --- a/src/dal/migrations/sqlite/.snapshot-sqlite3.db.json +++ b/src/dal/migrations/sqlite/.snapshot-sqlite3.db.json @@ -1175,6 +1175,214 @@ }, "nativeEnums": {} }, + { + "columns": { + "id": { + "name": "id", + "type": "integer", + "unsigned": false, + "autoincrement": true, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "integer" + }, + "name": { + "name": "name", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "notes": { + "name": "notes", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "owner_id": { + "name": "owner_id", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "integer" + } + }, + "name": "drawer", + "indexes": [ + { + "columnNames": [ + "owner_id" + ], + "composite": false, + "keyName": "drawer_owner_id_index", + "constraint": false, + "primary": false, + "unique": false + }, + { + "keyName": "primary", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "drawer_owner_id_foreign": { + "constraintName": "drawer_owner_id_foreign", + "columnNames": [ + "owner_id" + ], + "localTableName": "drawer", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "user", + "deleteRule": "cascade", + "updateRule": "cascade" + } + }, + "nativeEnums": {} + }, + { + "columns": { + "drawer_id": { + "name": "drawer_id", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "integer" + }, + "garment_id": { + "name": "garment_id", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "integer" + } + }, + "name": "drawer_garments", + "indexes": [ + { + "columnNames": [ + "drawer_id" + ], + "composite": false, + "keyName": "drawer_garments_drawer_id_index", + "constraint": false, + "primary": false, + "unique": false + }, + { + "columnNames": [ + "garment_id" + ], + "composite": false, + "keyName": "drawer_garments_garment_id_index", + "constraint": false, + "primary": false, + "unique": false + }, + { + "keyName": "primary", + "columnNames": [ + "drawer_id", + "garment_id" + ], + "composite": true, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "drawer_garments_drawer_id_foreign": { + "constraintName": "drawer_garments_drawer_id_foreign", + "columnNames": [ + "drawer_id" + ], + "localTableName": "drawer_garments", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "drawer", + "deleteRule": "cascade", + "updateRule": "cascade" + }, + "drawer_garments_garment_id_foreign": { + "constraintName": "drawer_garments_garment_id_foreign", + "columnNames": [ + "garment_id" + ], + "localTableName": "drawer_garments", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "garment", + "deleteRule": "cascade", + "updateRule": "cascade" + } + }, + "nativeEnums": {} + }, { "columns": { "id": { diff --git a/src/dal/migrations/sqlite/Migration20260722221511.ts b/src/dal/migrations/sqlite/Migration20260722221511.ts new file mode 100644 index 0000000..26ba8c8 --- /dev/null +++ b/src/dal/migrations/sqlite/Migration20260722221511.ts @@ -0,0 +1,14 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260722221511 extends Migration { + + override async up(): Promise { + this.addSql(`create table \`drawer\` (\`id\` integer not null primary key autoincrement, \`name\` text not null, \`notes\` text null, \`owner_id\` integer null, constraint \`drawer_owner_id_foreign\` foreign key(\`owner_id\`) references \`user\`(\`id\`) on delete cascade on update cascade);`); + this.addSql(`create index \`drawer_owner_id_index\` on \`drawer\` (\`owner_id\`);`); + + this.addSql(`create table \`drawer_garments\` (\`drawer_id\` integer not null, \`garment_id\` integer not null, constraint \`drawer_garments_drawer_id_foreign\` foreign key(\`drawer_id\`) references \`drawer\`(\`id\`) on delete cascade on update cascade, constraint \`drawer_garments_garment_id_foreign\` foreign key(\`garment_id\`) references \`garment\`(\`id\`) on delete cascade on update cascade, primary key (\`drawer_id\`, \`garment_id\`));`); + this.addSql(`create index \`drawer_garments_drawer_id_index\` on \`drawer_garments\` (\`drawer_id\`);`); + this.addSql(`create index \`drawer_garments_garment_id_index\` on \`drawer_garments\` (\`garment_id\`);`); + } + +} diff --git a/src/i18n/de/lang.json b/src/i18n/de/lang.json index 62b675a..9f2daa9 100644 --- a/src/i18n/de/lang.json +++ b/src/i18n/de/lang.json @@ -123,6 +123,11 @@ "ALL_CATEGORIES": "Alle Kategorien", "CLEAR": "Zurücksetzen", "RESULTS": "Ergebnisse", + "PREVIOUS": "Zurück", + "NEXT": "Weiter", + "PAGE": "Seite", + "MORE": "mehr", + "NO_GARMENTS_MATCH": "Keine Kleidungsstücke entsprechen deiner Suche.", "FILTER_SEARCH": "Suche filtern", "FILTERS": "Filter", "GARMENT_TYPE": "Kleidungsart", diff --git a/src/i18n/en/lang.json b/src/i18n/en/lang.json index e625d79..c613bc2 100644 --- a/src/i18n/en/lang.json +++ b/src/i18n/en/lang.json @@ -98,6 +98,16 @@ "NO_OUTFITS": "No outfits yet.", "ADD_FIRST_OUTFIT": "Build your first outfit", "GARMENTS_IN_OUTFIT": "Garments in this outfit", + "DRAWERS": "Drawers", + "NEW_DRAWER": "New Drawer", + "EDIT_DRAWER": "Edit Drawer", + "NO_DRAWERS": "No drawers yet.", + "ADD_FIRST_DRAWER": "Create your first drawer", + "GARMENTS_IN_DRAWER": "Garments in this drawer", + "NO_GARMENTS_IN_DRAWER": "No garments added yet.", + "NO_GARMENTS_FOR_DRAWER": "Add some garments to your wardrobe to organize them into drawers.", + "VIEW_IN_WARDROBE": "View in wardrobe", + "ITEMS": "items", "ADD_PHOTO": "Add Photo", "UPDATE_PHOTO": "Update Photo", "BG_REMOVAL_TOGGLE": "Auto-remove background", @@ -130,6 +140,11 @@ "ALL_CATEGORIES": "All Categories", "CLEAR": "Clear", "RESULTS": "results", + "PREVIOUS": "Previous", + "NEXT": "Next", + "PAGE": "Page", + "MORE": "more", + "NO_GARMENTS_MATCH": "No garments match your search.", "FILTER_SEARCH": "Filter Search", "FILTERS": "Filters", "GARMENT_TYPE": "Garment Type", diff --git a/src/i18n/es/lang.json b/src/i18n/es/lang.json index 553e368..45751f6 100644 --- a/src/i18n/es/lang.json +++ b/src/i18n/es/lang.json @@ -123,6 +123,11 @@ "ALL_CATEGORIES": "Todas las categorías", "CLEAR": "Limpiar", "RESULTS": "resultados", + "PREVIOUS": "Anterior", + "NEXT": "Siguiente", + "PAGE": "Página", + "MORE": "más", + "NO_GARMENTS_MATCH": "Ninguna prenda coincide con tu búsqueda.", "FILTER_SEARCH": "Filtrar búsqueda", "FILTERS": "Filtros", "GARMENT_TYPE": "Tipo de prenda", diff --git a/src/i18n/fr/lang.json b/src/i18n/fr/lang.json index 5d332a3..84aabdc 100644 --- a/src/i18n/fr/lang.json +++ b/src/i18n/fr/lang.json @@ -123,6 +123,11 @@ "ALL_CATEGORIES": "Toutes les catégories", "CLEAR": "Effacer", "RESULTS": "résultats", + "PREVIOUS": "Précédent", + "NEXT": "Suivant", + "PAGE": "Page", + "MORE": "de plus", + "NO_GARMENTS_MATCH": "Aucun vêtement ne correspond à votre recherche.", "FILTER_SEARCH": "Filtrer la recherche", "FILTERS": "Filtres", "GARMENT_TYPE": "Type de vêtement", diff --git a/src/i18n/it/lang.json b/src/i18n/it/lang.json index 021aae7..3e91e5a 100644 --- a/src/i18n/it/lang.json +++ b/src/i18n/it/lang.json @@ -123,6 +123,11 @@ "ALL_CATEGORIES": "Tutte le Categorie", "CLEAR": "Pulisci", "RESULTS": "risultati", + "PREVIOUS": "Precedente", + "NEXT": "Successivo", + "PAGE": "Pagina", + "MORE": "altri", + "NO_GARMENTS_MATCH": "Nessun capo corrisponde alla tua ricerca.", "FILTER_SEARCH": "Ricerca Filtrata", "FILTERS": "Filtri", "GARMENT_TYPE": "Tipo di Capo", diff --git a/src/i18n/ru/lang.json b/src/i18n/ru/lang.json index 69dfb87..532d803 100644 --- a/src/i18n/ru/lang.json +++ b/src/i18n/ru/lang.json @@ -114,6 +114,11 @@ "ALL_CATEGORIES": "Все категории", "CLEAR": "Очистить", "RESULTS": "результатов", + "PREVIOUS": "Назад", + "NEXT": "Далее", + "PAGE": "Страница", + "MORE": "ещё", + "NO_GARMENTS_MATCH": "Нет вещей, соответствующих запросу.", "FILTER_SEARCH": "Фильтр поиска", "FILTERS": "Фильтры", "GARMENT_TYPE": "Тип вещи", diff --git a/src/wardrobe/drawer.controller.ts b/src/wardrobe/drawer.controller.ts new file mode 100644 index 0000000..e6e5367 --- /dev/null +++ b/src/wardrobe/drawer.controller.ts @@ -0,0 +1,278 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Logger, + Param, + ParseIntPipe, + Post, + Query, + Render, + Req, + Res, + UseGuards, +} from '@nestjs/common'; +import { FastifyReply, FastifyRequest } from 'fastify'; +import { I18n, I18nContext } from 'nestjs-i18n'; +import { ConditionalAuthGuard } from '../auth/conditional-auth.guard'; +import { Payload } from '../auth/dto/payload.dto'; +import { GarmentCategory } from './garment-category.enum'; +import { DrawerService } from './drawer.service'; +import { GarmentService } from './garment.service'; + +@UseGuards(ConditionalAuthGuard) +@Controller('drawers') +export class DrawerController { + private readonly logger = new Logger(DrawerController.name); + + constructor( + private readonly drawerService: DrawerService, + private readonly garmentService: GarmentService, + ) {} + + private userId(req: FastifyRequest): number | undefined { + return (req['user'] as Payload | undefined)?.userId; + } + + private parseGarmentIds(garmentId: string | string[] | undefined): number[] { + const ids = Array.isArray(garmentId) + ? garmentId + : garmentId + ? [garmentId] + : []; + return ids.map(Number).filter((id) => !Number.isNaN(id)); + } + + private async availableCategories( + userId: number | undefined, + i18n: I18nContext, + ) { + const filters = await this.garmentService.findAvailableFilters(userId); + const enumValues = Object.values(GarmentCategory) as string[]; + const customCategories = filters.categories.filter( + (c) => !enumValues.includes(c), + ); + return [...enumValues, ...customCategories].map((value) => ({ + value, + label: this.garmentService.resolveCategoryLabel(value, i18n), + })); + } + + @Get() + @Render('drawers/index') + async index(@Req() req: FastifyRequest) { + const drawers = await this.drawerService.findAll(this.userId(req)); + // Preview thumbnails are capped so a large drawer doesn't turn its index + // card into a multi-screen-tall block that pushes other drawers off-screen. + const drawersWithPreview = await Promise.all( + drawers.map(async (d) => { + const { items, total } = await this.drawerService.findPreviewGarments( + d.id, + ); + return { + id: d.id, + name: d.name, + notes: d.notes, + previewGarments: items, + totalGarmentCount: total, + overflowCount: Math.max(0, total - items.length), + }; + }), + ); + return { drawers: drawersWithPreview }; + } + + @Get('new') + @Render('drawers/form') + async newForm(@Req() req: FastifyRequest, @I18n() i18n: I18nContext) { + const userId = this.userId(req); + const [{ items: garments, total, page, totalPages }, categories] = + await Promise.all([ + this.garmentService.findAllPaginated(userId, { limit: '48' }), + this.availableCategories(userId, i18n), + ]); + return { + drawer: null, + garments, + selectedGarmentIds: [], + selectedGarments: [], + categories, + total, + page, + totalPages, + showPagination: totalPages > 1, + hasPrevPage: page > 1, + hasNextPage: page < totalPages, + prevPageUrl: `/drawers/picker?page=${page - 1}`, + nextPageUrl: `/drawers/picker?page=${page + 1}`, + }; + } + + // Backs the search/category-filtered, paginated picker grid on + // /drawers/new and /drawers/:id/edit so the DOM stays bounded regardless + // of how many garments the user owns (see LIB-8/LIB-10). + @Get('picker') + async picker( + @Req() req: FastifyRequest, + @Res() reply: FastifyReply, + @Query('keyword') keyword: string | undefined, + @Query('category') category: string | undefined, + @Query('page') page: string | undefined, + @Query('garmentId') garmentId: string | string[] | undefined, + ) { + const userId = this.userId(req); + const selectedGarmentIds = this.parseGarmentIds(garmentId); + const { + items: garments, + page: currentPage, + totalPages, + } = await this.garmentService.findAllPaginated(userId, { + keyword, + category, + page, + limit: '48', + }); + return reply.viewPartial('partials/garment_picker_grid', { + garments, + selectedGarmentIds, + page: currentPage, + totalPages, + showPagination: totalPages > 1, + hasPrevPage: currentPage > 1, + hasNextPage: currentPage < totalPages, + prevPageUrl: `/drawers/picker?page=${currentPage - 1}`, + nextPageUrl: `/drawers/picker?page=${currentPage + 1}`, + }); + } + + @Post() + async create( + @Body() + body: { + name: string; + notes?: string; + garmentId?: string | string[]; + }, + @Req() req: FastifyRequest, + @Res() reply: FastifyReply, + ) { + const drawer = await this.drawerService.create( + { + name: body.name, + notes: body.notes, + garmentIds: this.parseGarmentIds(body.garmentId), + }, + this.userId(req), + ); + return reply.redirect(`/drawers/${drawer.id}`, 302); + } + + @Get(':id') + @Render('drawers/show') + async show( + @Param('id', ParseIntPipe) id: number, + @Req() req: FastifyRequest, + @Query('page') page: string | undefined, + ) { + const userId = this.userId(req); + // findOneMeta checks access without populating the (potentially huge) + // garments collection; the paginated garment page is fetched separately + // so DOM/HTML size stays bounded regardless of drawer size. + const drawer = await this.drawerService.findOneMeta(id, userId); + const { + items, + total, + page: currentPage, + totalPages, + } = await this.garmentService.findAllPaginated(userId, { + drawerId: String(id), + page, + }); + return { + drawer, + garments: items, + total, + page: currentPage, + totalPages, + showPagination: totalPages > 1, + hasPrevPage: currentPage > 1, + hasNextPage: currentPage < totalPages, + prevPageUrl: `/drawers/${id}?page=${currentPage - 1}`, + nextPageUrl: `/drawers/${id}?page=${currentPage + 1}`, + }; + } + + @Get(':id/edit') + @Render('drawers/form') + async editForm( + @Param('id', ParseIntPipe) id: number, + @Req() req: FastifyRequest, + @I18n() i18n: I18nContext, + ) { + const userId = this.userId(req); + const [drawer, { items: garments, total, page, totalPages }, categories] = + await Promise.all([ + this.drawerService.findOne(id, userId), + this.garmentService.findAllPaginated(userId, { limit: '48' }), + this.availableCategories(userId, i18n), + ]); + const selectedGarments = drawer.garments.getItems().map((g) => ({ + id: g.id, + name: g.name, + })); + const selectedGarmentIds = selectedGarments.map((g) => g.id); + return { + drawer, + garments, + selectedGarmentIds, + selectedGarments, + categories, + total, + page, + totalPages, + showPagination: totalPages > 1, + hasPrevPage: page > 1, + hasNextPage: page < totalPages, + prevPageUrl: `/drawers/picker?page=${page - 1}`, + nextPageUrl: `/drawers/picker?page=${page + 1}`, + }; + } + + @Post(':id') + async update( + @Param('id', ParseIntPipe) id: number, + @Body() + body: { + name?: string; + notes?: string; + garmentId?: string | string[]; + }, + @Req() req: FastifyRequest, + @Res() reply: FastifyReply, + ) { + await this.drawerService.update( + id, + { + name: body.name, + notes: body.notes, + garmentIds: this.parseGarmentIds(body.garmentId), + }, + this.userId(req), + ); + return reply.redirect(`/drawers/${id}`, 302); + } + + @Delete(':id') + @HttpCode(200) + async remove( + @Param('id', ParseIntPipe) id: number, + @Req() req: FastifyRequest, + @Res() reply: FastifyReply, + ) { + await this.drawerService.remove(id, this.userId(req)); + reply.header('HX-Redirect', '/drawers'); + return reply.send(); + } +} diff --git a/src/wardrobe/drawer.service.ts b/src/wardrobe/drawer.service.ts new file mode 100644 index 0000000..1cfaa6f --- /dev/null +++ b/src/wardrobe/drawer.service.ts @@ -0,0 +1,125 @@ +import { EntityRepository, wrap } from '@mikro-orm/core'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { Drawer } from '../dal/entity/drawer.entity'; +import { Garment } from '../dal/entity/garment.entity'; +import { User } from '../dal/entity/user.entity'; +import { CreateDrawerDto } from './dto/create-drawer.dto'; +import { UpdateDrawerDto } from './dto/update-drawer.dto'; + +@Injectable() +export class DrawerService { + private readonly logger = new Logger(DrawerService.name); + + constructor( + @InjectRepository(Drawer) + private readonly drawerRepository: EntityRepository, + @InjectRepository(Garment) + private readonly garmentRepository: EntityRepository, + @InjectRepository(User) + private readonly userRepository: EntityRepository, + ) {} + + private static readonly PREVIEW_LIMIT = 6; + + async findAll(userId?: number): Promise { + // Preview thumbnails are fetched separately (findPreviewGarments) so this + // list doesn't have to populate every garment in every drawer just to + // render the drawers index. + const where = userId != null ? { owner: { id: userId } } : { owner: null }; + return this.drawerRepository.find(where, { orderBy: { name: 'ASC' } }); + } + + async findPreviewGarments( + drawerId: number, + limit = DrawerService.PREVIEW_LIMIT, + ): Promise<{ items: Garment[]; total: number }> { + const [items, total] = await this.garmentRepository.findAndCount( + { drawers: { id: drawerId } }, + { populate: ['photo'], orderBy: { id: 'DESC' }, limit }, + ); + return { items, total }; + } + + private checkAccess( + drawer: Drawer | null, + userId?: number, + ): asserts drawer is Drawer { + if (!drawer) throw new NotFoundException('Drawer not found'); + if (userId != null) { + if (drawer.owner?.id !== userId) throw new ForbiddenException(); + } else { + if (drawer.owner != null) throw new ForbiddenException(); + } + } + + /** Drawer metadata (name/notes/owner) without populating its garments collection. */ + async findOneMeta(id: number, userId?: number): Promise { + const drawer = await this.drawerRepository.findOne(id); + this.checkAccess(drawer, userId); + return drawer; + } + + async findOne(id: number, userId?: number): Promise { + const drawer = await this.drawerRepository.findOne(id, { + populate: ['garments', 'garments.photo'], + }); + this.checkAccess(drawer, userId); + return drawer; + } + + async create(dto: CreateDrawerDto, userId?: number): Promise { + const drawer = this.drawerRepository.create({ + name: dto.name, + notes: dto.notes, + }); + + if (dto.garmentIds?.length) { + const garments = await this.garmentRepository.find({ + id: { $in: dto.garmentIds }, + }); + drawer.garments.set(garments); + } + + if (userId != null) { + const user = await this.userRepository.findOneOrFail(userId); + drawer.owner = user as any; + } + + await this.drawerRepository.getEntityManager().persistAndFlush(drawer); + return drawer; + } + + async update( + id: number, + dto: UpdateDrawerDto, + userId?: number, + ): Promise { + const drawer = await this.findOne(id, userId); + + wrap(drawer).assign({ + name: dto.name ?? drawer.name, + notes: dto.notes ?? drawer.notes, + }); + + if (dto.garmentIds !== undefined) { + const garments = await this.garmentRepository.find({ + id: { $in: dto.garmentIds }, + }); + drawer.garments.set(garments); + } + + await this.drawerRepository.getEntityManager().flush(); + return drawer; + } + + async remove(id: number, userId?: number): Promise { + const drawer = await this.findOne(id, userId); + await this.drawerRepository.getEntityManager().removeAndFlush(drawer); + } +} diff --git a/src/wardrobe/dto/create-drawer.dto.ts b/src/wardrobe/dto/create-drawer.dto.ts new file mode 100644 index 0000000..f9e912c --- /dev/null +++ b/src/wardrobe/dto/create-drawer.dto.ts @@ -0,0 +1,5 @@ +export interface CreateDrawerDto { + name: string; + notes?: string; + garmentIds?: number[]; +} diff --git a/src/wardrobe/dto/search-garment.dto.ts b/src/wardrobe/dto/search-garment.dto.ts index f237fa6..6f9c09d 100644 --- a/src/wardrobe/dto/search-garment.dto.ts +++ b/src/wardrobe/dto/search-garment.dto.ts @@ -7,4 +7,7 @@ export interface SearchGarmentDto { brand?: string; size?: string; archived?: string; + drawerId?: string; + page?: string; + limit?: string; } diff --git a/src/wardrobe/dto/update-drawer.dto.ts b/src/wardrobe/dto/update-drawer.dto.ts new file mode 100644 index 0000000..6d7c9f3 --- /dev/null +++ b/src/wardrobe/dto/update-drawer.dto.ts @@ -0,0 +1,5 @@ +export interface UpdateDrawerDto { + name?: string; + notes?: string; + garmentIds?: number[]; +} diff --git a/src/wardrobe/garment.service.ts b/src/wardrobe/garment.service.ts index e6ee028..43a4ad1 100644 --- a/src/wardrobe/garment.service.ts +++ b/src/wardrobe/garment.service.ts @@ -53,17 +53,18 @@ export class GarmentService { return value; } - async findAll( + private buildWhere( userId?: number, dto: SearchGarmentDto = {}, viewOwner?: number, - ): Promise { + ): FilterQuery { const normalizedSize = this.normalizeSize(dto.size); const searchConditions: FilterQuery = { ...(dto.category ? { category: dto.category } : {}), ...(dto.color ? { color: dto.color } : {}), ...(normalizedSize ? { size: normalizedSize } : {}), ...(dto.archived !== 'true' ? { archived: false } : {}), + ...(dto.drawerId ? { drawers: { id: Number(dto.drawerId) } } : {}), ...(dto.keyword ? { $or: [ @@ -77,23 +78,65 @@ export class GarmentService { if (userId != null) { if (viewOwner != null && viewOwner !== userId) { - return this.garmentRepository.find( - { owner: { id: viewOwner }, ...searchConditions }, - { populate: ['photo'], orderBy: { id: 'DESC' } }, - ); + return { owner: { id: viewOwner }, ...searchConditions }; } - return this.garmentRepository.find( - { owner: { id: userId }, ...searchConditions }, - { populate: ['photo'], orderBy: { id: 'DESC' } }, - ); + return { owner: { id: userId }, ...searchConditions }; } // AUTH_ENABLED=false: only return garments that belong to no user + return { owner: null, ...searchConditions }; + } + + async findAll( + userId?: number, + dto: SearchGarmentDto = {}, + viewOwner?: number, + ): Promise { return this.garmentRepository.find( - { owner: null, ...searchConditions }, - { populate: ['photo'], orderBy: { id: 'DESC' } }, + this.buildWhere(userId, dto, viewOwner), + { + populate: ['photo'], + orderBy: { id: 'DESC' }, + }, ); } + private clampLimit(input?: string): number { + const n = Number(input); + if (!Number.isFinite(n) || n <= 0) return 48; + return Math.min(Math.floor(n), 96); + } + + private clampPage(input: string | undefined, totalPages: number): number { + const n = Number(input); + if (!Number.isFinite(n) || n < 1) return 1; + return Math.min(Math.floor(n), totalPages); + } + + async findAllPaginated( + userId?: number, + dto: SearchGarmentDto = {}, + viewOwner?: number, + ): Promise<{ + items: Garment[]; + total: number; + page: number; + limit: number; + totalPages: number; + }> { + const where = this.buildWhere(userId, dto, viewOwner); + const limit = this.clampLimit(dto.limit); + const total = await this.garmentRepository.count(where); + const totalPages = Math.max(1, Math.ceil(total / limit)); + const page = this.clampPage(dto.page, totalPages); + const items = await this.garmentRepository.find(where, { + populate: ['photo'], + orderBy: { id: 'DESC' }, + limit, + offset: (page - 1) * limit, + }); + return { items, total, page, limit, totalPages }; + } + async findOne( id: number, userId?: number, diff --git a/src/wardrobe/wardrobe.controller.ts b/src/wardrobe/wardrobe.controller.ts index f328482..4a2f723 100644 --- a/src/wardrobe/wardrobe.controller.ts +++ b/src/wardrobe/wardrobe.controller.ts @@ -21,6 +21,7 @@ import { Payload } from '../auth/dto/payload.dto'; import { GarmentCategory } from './garment-category.enum'; import { GarmentColor } from './garment-color.enum'; import { GarmentService } from './garment.service'; +import { DrawerService } from './drawer.service'; import { WardrobeShareService } from '../wardrobe-share/wardrobe-share.service'; import { SharePermission } from '../dal/entity/wardrobe-share.entity'; import type { SearchGarmentDto } from './dto/search-garment.dto'; @@ -34,6 +35,7 @@ export class WardrobeController { constructor( private readonly garmentService: GarmentService, private readonly shareService: WardrobeShareService, + private readonly drawerService: DrawerService, ) {} private userId(req: any): number | undefined { @@ -78,19 +80,50 @@ export class WardrobeController { } } - const [garments, filters] = await Promise.all([ - this.garmentService.findAll(userId, query, viewOwner), + const [ + { items: garments, total, page, totalPages }, + filters, + availableDrawers, + ] = await Promise.all([ + this.garmentService.findAllPaginated(userId, query, viewOwner), this.garmentService.findAvailableFilters(viewOwner ?? userId), + this.drawerService.findAll(viewOwner ?? userId), ]); const availableCategories = filters.categories.map((value) => ({ value, label: this.garmentService.resolveCategoryLabel(value, i18n), })); + + // Preserve active filters (minus page) across prev/next links. + const filterParams = new URLSearchParams(); + if (query.keyword) filterParams.set('keyword', query.keyword); + if (query.category) filterParams.set('category', query.category); + if (query.color) filterParams.set('color', query.color); + if (query.size) filterParams.set('size', query.size); + if (query.archived) filterParams.set('archived', query.archived); + if (query.drawerId) filterParams.set('drawerId', query.drawerId); + if (viewOwner) filterParams.set('ownerId', String(viewOwner)); + const filterQueryString = filterParams.toString(); + const pageUrl = (targetPage: number) => + `/wardrobe?${filterQueryString ? filterQueryString + '&' : ''}page=${targetPage}`; + return { garments, + total, + page, + totalPages, + showPagination: totalPages > 1, + hasPrevPage: page > 1, + hasNextPage: page < totalPages, + prevPageUrl: pageUrl(page - 1), + nextPageUrl: pageUrl(page + 1), availableCategories, colors: Object.values(GarmentColor), availableSizes: filters.sizes, + availableDrawers: availableDrawers.map((d) => ({ + id: d.id, + name: d.name, + })), search: query, sharedWardrobes, viewOwner: viewOwner ?? null, diff --git a/src/wardrobe/wardrobe.module.ts b/src/wardrobe/wardrobe.module.ts index ff8ffcc..48a2a04 100644 --- a/src/wardrobe/wardrobe.module.ts +++ b/src/wardrobe/wardrobe.module.ts @@ -1,5 +1,6 @@ import { MikroOrmModule } from '@mikro-orm/nestjs'; import { Module } from '@nestjs/common'; +import { Drawer } from '../dal/entity/drawer.entity'; import { Garment } from '../dal/entity/garment.entity'; import { Outfit } from '../dal/entity/outfit.entity'; import { OutfitCalendar } from '../dal/entity/outfit-calendar.entity'; @@ -10,19 +11,26 @@ import { WardrobeShareModule } from '../wardrobe-share/wardrobe-share.module'; import { GarmentService } from './garment.service'; import { OutfitService } from './outfit.service'; import { CalendarService } from './calendar.service'; +import { DrawerService } from './drawer.service'; import { CalendarController } from './calendar.controller'; import { WardrobeController } from './wardrobe.controller'; import { OutfitController } from './outfit.controller'; +import { DrawerController } from './drawer.controller'; @Module({ imports: [ AuthModule, FileModule, WardrobeShareModule, - MikroOrmModule.forFeature([Garment, Outfit, OutfitCalendar, User]), + MikroOrmModule.forFeature([Garment, Outfit, OutfitCalendar, Drawer, User]), ], - controllers: [WardrobeController, OutfitController, CalendarController], - providers: [GarmentService, OutfitService, CalendarService], - exports: [GarmentService, OutfitService, CalendarService], + controllers: [ + WardrobeController, + OutfitController, + CalendarController, + DrawerController, + ], + providers: [GarmentService, OutfitService, CalendarService, DrawerService], + exports: [GarmentService, OutfitService, CalendarService, DrawerService], }) export class WardrobeModule {} diff --git a/views/drawers/form.hbs b/views/drawers/form.hbs new file mode 100644 index 0000000..2303391 --- /dev/null +++ b/views/drawers/form.hbs @@ -0,0 +1,181 @@ +{{>navbar}} + +
+
+ + + + + +

+ {{#if drawer}}{{t 'lang.EDIT_DRAWER'}}{{else}}{{t + 'lang.NEW_DRAWER'}}{{/if}} +

+
+ +
+
+ + +
+ +
+ + +
+ +
+
+ {{t 'lang.GARMENTS_IN_DRAWER'}} + {{selectedGarmentIds.length}} {{t 'lang.SELECTED'}} +
+ {{#if total}} +
+ + +
+
+ {{> garment_picker_grid garments=garments selectedGarmentIds=selectedGarmentIds page=page totalPages=totalPages showPagination=showPagination hasPrevPage=hasPrevPage hasNextPage=hasNextPage prevPageUrl=prevPageUrl nextPageUrl=nextPageUrl}} +
+
+ {{#each selectedGarments}} + + {{/each}} +
+ {{else}} +

+ {{t 'lang.NO_GARMENTS_FOR_DRAWER'}} +

+ {{/if}} +
+ + + +
+ + {{t 'lang.CANCEL'}} +
+
+
+ +{{>dock}} diff --git a/views/drawers/index.hbs b/views/drawers/index.hbs new file mode 100644 index 0000000..e90f11a --- /dev/null +++ b/views/drawers/index.hbs @@ -0,0 +1,94 @@ +{{>navbar}} + +
+
+

{{t 'lang.DRAWERS'}}

+ + {{t 'lang.NEW_DRAWER'}} +
+ + {{#if drawers.length}} + + {{else}} +
+ + + +

{{t 'lang.NO_DRAWERS'}}

+ {{t 'lang.ADD_FIRST_DRAWER'}} +
+ {{/if}} +
+ +{{>dock}} diff --git a/views/drawers/show.hbs b/views/drawers/show.hbs new file mode 100644 index 0000000..4f43c58 --- /dev/null +++ b/views/drawers/show.hbs @@ -0,0 +1,143 @@ +{{>navbar}} + +
+
+ + + + + +

{{drawer.name}}

+
+ + {{#if drawer.notes}} +

{{drawer.notes}}

+ {{/if}} + +
+

{{t 'lang.GARMENTS_IN_DRAWER'}} ({{total}})

+ {{t 'lang.VIEW_IN_WARDROBE'}} +
+ {{#if garments.length}} + + + {{#if showPagination}} +
+ {{#if hasPrevPage}} + ← {{t 'lang.PREVIOUS'}} + {{else}} + + {{/if}} + {{t 'lang.PAGE'}} {{page}} / {{totalPages}} + {{#if hasNextPage}} + {{t 'lang.NEXT'}} → + {{else}} + + {{/if}} +
+ {{/if}} + + {{else}} +

+ {{t 'lang.NO_GARMENTS_IN_DRAWER'}} +

+ {{/if}} + +
+
+ + + + + {{t 'lang.EDIT_DRAWER'}} + + + +
+
+ +{{>dock}} diff --git a/views/partials/dock.hbs b/views/partials/dock.hbs index e13e89a..b25b784 100644 --- a/views/partials/dock.hbs +++ b/views/partials/dock.hbs @@ -28,4 +28,10 @@ {{t 'lang.CALENDAR'}} + + + + + {{t 'lang.DRAWERS'}} + \ No newline at end of file diff --git a/views/partials/garment_picker_grid.hbs b/views/partials/garment_picker_grid.hbs new file mode 100644 index 0000000..535b622 --- /dev/null +++ b/views/partials/garment_picker_grid.hbs @@ -0,0 +1,96 @@ +{{#if garments.length}} +
+ {{#each garments}} + + {{/each}} +
+ +{{#if showPagination}} +
+ {{#if hasPrevPage}} + + {{else}} + + {{/if}} + {{t 'lang.PAGE'}} {{page}} / {{totalPages}} + {{#if hasNextPage}} + + {{else}} + + {{/if}} +
+{{/if}} +{{else}} +

+ {{t 'lang.NO_GARMENTS_MATCH'}} +

+{{/if}} diff --git a/views/wardrobe/index.hbs b/views/wardrobe/index.hbs index fa46193..58873fe 100644 --- a/views/wardrobe/index.hbs +++ b/views/wardrobe/index.hbs @@ -20,7 +20,7 @@ {{/if}} -

{{garments.length}} {{t 'lang.RESULTS'}}

+

{{total}} {{t 'lang.RESULTS'}}

{{#if garments.length}}
@@ -28,7 +28,7 @@
{{#if this.photo}} - {{this.name}} + {{this.name}} {{else}} + + {{#if showPagination}} +
+ {{#if hasPrevPage}} + ← {{t 'lang.PREVIOUS'}} + {{else}} + + {{/if}} + {{t 'lang.PAGE'}} {{page}} / {{totalPages}} + {{#if hasNextPage}} + {{t 'lang.NEXT'}} → + {{else}} + + {{/if}} +
+ {{/if}} + {{else}}
@@ -94,6 +111,12 @@ {{t 'lang.ARCHIVED'}} × {{/if}} + {{#if search.drawerId}} + + {{#each availableDrawers}}{{#ifEquals this.id ../search.drawerId}}{{this.name}}{{/ifEquals}}{{/each}} × + + {{/if}}
{{!-- Search input row --}} @@ -102,6 +125,7 @@ + {{#if viewOwner}}{{/if}} {{/if}} + {{!-- Drawers --}} + {{#if availableDrawers.length}} +
+

{{t 'lang.DRAWERS'}}

+
+ {{#each availableDrawers}} + + {{/each}} +
+
+ {{/if}} + {{!-- Archived --}}

{{t 'lang.ARCHIVED'}}

@@ -189,10 +229,14 @@ for el in set el.checked to false end + for el in + set el.checked to false + end set #active-category.value to '' set #active-color.value to '' set #active-size.value to '' set #active-archived.value to '' + set #active-drawer.value to '' call #filter-modal.close() call #search-form.submit()"> {{t 'lang.CLEAR_FILTERS'}} @@ -212,10 +256,14 @@ set archived to '' get the first if it then set archived to 'true' end + set drawerId to '' + get the first + if it then set drawerId to it.value end set #active-category.value to category set #active-color.value to color set #active-size.value to size set #active-archived.value to archived + set #active-drawer.value to drawerId call #filter-modal.close() call #search-form.submit()"> {{t 'lang.APPLY_FILTERS'}}