From 1e048894bd50fe45c3a9afba8883597f5dbad094 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 19 Aug 2026 21:24:47 +0200 Subject: [PATCH] feat: product images, and drop the page headers Removes the title-and-description block from every screen. It repeated what the sidenav already says and pushed the actual content down. Page actions moved onto the filter bar, so they cost no vertical space of their own, and the content now starts at the top of the viewport. Adds the product image: a picker with a live preview in the form, and a thumbnail column in the table. The image is uploaded after the item is saved, because it can only be attached to an item that exists. If that second call fails the message says the product saved but the image did not, rather than implying the whole thing failed. Also restores the products filter bar. It was written in the previous change but the edit that inserted it silently did not match, and the verification pass checked sorting on products and filtering on orders - so nothing caught that products had no filter bar at all. Searching and the status facet work there now. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/core/api/api.service.ts | 4 + src/app/core/api/catalogue.service.ts | 25 +++++ .../features/dashboard/dashboard.page.html | 7 +- src/app/features/dashboard/dashboard.page.ts | 18 +--- .../features/inventory/inventory.page.html | 11 +-- src/app/features/inventory/inventory.page.ts | 2 - src/app/features/orders/orders.page.html | 11 +-- src/app/features/orders/orders.page.ts | 2 - src/app/features/products/products.page.html | 97 +++++++++++++++++-- src/app/features/products/products.page.ts | 67 +++++++++++-- src/app/features/returns/returns.page.html | 5 - src/app/features/returns/returns.page.ts | 9 +- src/app/features/users/users.page.html | 11 +-- src/app/features/users/users.page.ts | 2 - src/app/shared/filters/filter-bar.ts | 6 ++ src/app/shared/ui/index.ts | 1 - src/app/shared/ui/page-header.ts | 29 ------ 17 files changed, 199 insertions(+), 108 deletions(-) delete mode 100644 src/app/shared/ui/page-header.ts diff --git a/src/app/core/api/api.service.ts b/src/app/core/api/api.service.ts index 49c43cd..f8135bf 100644 --- a/src/app/core/api/api.service.ts +++ b/src/app/core/api/api.service.ts @@ -22,6 +22,10 @@ export class ApiService { return this.http.post(this.url(path), body); } + put(path: string, body: unknown): Observable { + return this.http.put(this.url(path), body); + } + patch(path: string, body: unknown): Observable { return this.http.patch(this.url(path), body); } diff --git a/src/app/core/api/catalogue.service.ts b/src/app/core/api/catalogue.service.ts index 9009dfc..427a2bc 100644 --- a/src/app/core/api/catalogue.service.ts +++ b/src/app/core/api/catalogue.service.ts @@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { Item, Paginated } from '../models/api.models'; +import { environment } from '../../../environments/environment'; import { ApiService } from './api.service'; /** @@ -39,4 +40,28 @@ export class CatalogueService { remove(uuid: string): Observable { return this.api.delete(`/v1/items/${uuid}`); } + + /** + * Upload the product image. + * + * Sent as multipart form data. The Content-Type header is deliberately not + * set: the browser has to add it itself so it can include the multipart + * boundary, and setting it by hand produces a request the server cannot parse. + */ + uploadImage(uuid: string, file: File): Observable { + const form = new FormData(); + form.append('file', file); + return this.api.put(`/v1/items/${uuid}/image`, form); + } + + /** + * Absolute URL of an item's image. + * + * The API stores a relative path so the object store can move without + * rewriting stored data; this puts the configured host in front of it. + */ + imageUrl(item: Item): string | null { + const path = item.media?.main_image; + return path ? `${environment.apiBaseUrl}${path}` : null; + } } diff --git a/src/app/features/dashboard/dashboard.page.html b/src/app/features/dashboard/dashboard.page.html index 416eee9..60d8b80 100644 --- a/src/app/features/dashboard/dashboard.page.html +++ b/src/app/features/dashboard/dashboard.page.html @@ -1,8 +1,3 @@ - - @if (loading()) { } @else { @@ -141,7 +136,7 @@ } - + @if (statusMix().length === 0) {

No orders yet.

} @else { diff --git a/src/app/features/dashboard/dashboard.page.ts b/src/app/features/dashboard/dashboard.page.ts index af02d8c..52dfcef 100644 --- a/src/app/features/dashboard/dashboard.page.ts +++ b/src/app/features/dashboard/dashboard.page.ts @@ -8,13 +8,7 @@ import { CatalogueService } from '../../core/api/catalogue.service'; import { InventoryService } from '../../core/api/inventory.service'; import { OrdersService } from '../../core/api/orders.service'; import { InventoryItem, Item, OrderSummary } from '../../core/models/api.models'; -import { - BadgeComponent, - CardComponent, - MoneyPipe, - PageHeaderComponent, - SpinnerComponent, -} from '../../shared/ui'; +import { BadgeComponent, CardComponent, MoneyPipe, SpinnerComponent } from '../../shared/ui'; /** Order states whose value counts as money actually taken. */ const EARNED: readonly string[] = ['paid', 'ready_to_ship', 'shipped']; @@ -33,15 +27,7 @@ const EARNED: readonly string[] = ['paid', 'ready_to_ship', 'shipped']; @Component({ selector: 'ot-dashboard-page', standalone: true, - imports: [ - CommonModule, - RouterLink, - PageHeaderComponent, - CardComponent, - BadgeComponent, - SpinnerComponent, - MoneyPipe, - ], + imports: [CommonModule, RouterLink, CardComponent, BadgeComponent, SpinnerComponent, MoneyPipe], templateUrl: './dashboard.page.html', }) export class DashboardPage { diff --git a/src/app/features/inventory/inventory.page.html b/src/app/features/inventory/inventory.page.html index fe99938..5e43d86 100644 --- a/src/app/features/inventory/inventory.page.html +++ b/src/app/features/inventory/inventory.page.html @@ -1,10 +1,3 @@ - - Refresh - - @if (error(); as message) {
{{ message }} @@ -27,7 +20,9 @@ [selected]="selected()" (selectionChange)="selected.set($event)" (cleared)="clearFilters()" - /> + > + Refresh +
diff --git a/src/app/features/inventory/inventory.page.ts b/src/app/features/inventory/inventory.page.ts index 0d39270..2a47a87 100644 --- a/src/app/features/inventory/inventory.page.ts +++ b/src/app/features/inventory/inventory.page.ts @@ -11,7 +11,6 @@ import { BadgeComponent, ButtonComponent, EmptyStateComponent, - PageHeaderComponent, SpinnerComponent, } from '../../shared/ui'; @@ -28,7 +27,6 @@ import { imports: [ CommonModule, FormsModule, - PageHeaderComponent, ButtonComponent, BadgeComponent, EmptyStateComponent, diff --git a/src/app/features/orders/orders.page.html b/src/app/features/orders/orders.page.html index 77c0807..f3bf4c3 100644 --- a/src/app/features/orders/orders.page.html +++ b/src/app/features/orders/orders.page.html @@ -1,10 +1,3 @@ - - Refresh - - @if (error(); as message) {
{{ message }} @@ -22,7 +15,9 @@ [selected]="selected()" (selectionChange)="selected.set($event)" (cleared)="clearFilters()" - /> + > + Refresh +
diff --git a/src/app/features/orders/orders.page.ts b/src/app/features/orders/orders.page.ts index dc51a07..af74b66 100644 --- a/src/app/features/orders/orders.page.ts +++ b/src/app/features/orders/orders.page.ts @@ -14,7 +14,6 @@ import { EmptyStateComponent, ModalComponent, MoneyPipe, - PageHeaderComponent, SpinnerComponent, } from '../../shared/ui'; @@ -40,7 +39,6 @@ const STATUS_TONES: Record - Refresh - New product - +
+ + Refresh + New product + +
@if (error(); as message) {
@@ -32,6 +41,7 @@ + +
Image @for (item of visible(); track item.uuid) {
+ @if (imageUrl(item); as src) { + + } @else { + + } +

{{ item.name }}

{{ item.brand || '—' }}

@@ -154,6 +193,50 @@ + +
+ Product image +
+ @if (imagePreview(); as src) { + Product image preview + } @else { + + } +
+ +

+ JPEG, PNG, GIF or WebP, up to 5 MB. Shown to customers in the store. +

+
+
+
diff --git a/src/app/features/products/products.page.ts b/src/app/features/products/products.page.ts index f6d83c4..f3ae095 100644 --- a/src/app/features/products/products.page.ts +++ b/src/app/features/products/products.page.ts @@ -13,7 +13,6 @@ import { EmptyStateComponent, ModalComponent, MoneyPipe, - PageHeaderComponent, SpinnerComponent, } from '../../shared/ui'; @@ -30,7 +29,6 @@ import { imports: [ CommonModule, ReactiveFormsModule, - PageHeaderComponent, ButtonComponent, BadgeComponent, EmptyStateComponent, @@ -55,6 +53,10 @@ export class ProductsPage { readonly formOpen = signal(false); readonly editing = signal(null); + /** File chosen in the form, uploaded after the item itself is saved. */ + readonly pendingImage = signal(null); + readonly imagePreview = signal(null); + readonly search = signal(''); readonly selected = signal>({ status: [] }); readonly sort = createSort<'name' | 'sku' | 'status' | 'price'>({ @@ -138,14 +140,44 @@ export class ProductsPage { }); } + imageUrl(item: Item): string | null { + return this.catalogue.imageUrl(item); + } + + pickImage(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0] ?? null; + this.pendingImage.set(file); + + // Show the chosen file straight away rather than after a round trip, so + // the operator can see they picked the right picture. + const previous = this.imagePreview(); + if (previous?.startsWith('blob:')) { + URL.revokeObjectURL(previous); + } + this.imagePreview.set(file ? URL.createObjectURL(file) : null); + } + + private clearPendingImage(): void { + const preview = this.imagePreview(); + if (preview?.startsWith('blob:')) { + URL.revokeObjectURL(preview); + } + this.pendingImage.set(null); + this.imagePreview.set(null); + } + openCreate(): void { this.editing.set(null); + this.clearPendingImage(); this.form.reset({ status: 'active', currency: 'EUR', amount: 0 }); this.formOpen.set(true); } openEdit(item: Item): void { this.editing.set(item); + this.clearPendingImage(); + this.imagePreview.set(this.catalogue.imageUrl(item)); this.form.reset({ sku: item.sku, name: item.name, @@ -192,10 +224,26 @@ export class ProductsPage { } as Partial); request.subscribe({ - next: () => { - this.saving.set(false); - this.formOpen.set(false); - this.load(); + next: (saved) => { + const image = this.pendingImage(); + if (!image) { + this.finishSave(); + return; + } + // The image can only be attached once the item exists, so it follows + // the save rather than riding along with it. + this.catalogue.uploadImage(saved.uuid, image).subscribe({ + next: () => this.finishSave(), + error: (err) => { + this.saving.set(false); + this.formOpen.set(false); + this.load(); + // The product itself saved; say so, rather than implying it did not. + this.error.set( + this.message(err, 'The product was saved, but the image could not be uploaded.'), + ); + }, + }); }, error: (err) => { this.saving.set(false); @@ -204,6 +252,13 @@ export class ProductsPage { }); } + private finishSave(): void { + this.saving.set(false); + this.formOpen.set(false); + this.clearPendingImage(); + this.load(); + } + remove(item: Item): void { if (!confirm(`Delete “${item.name}”? Customers will no longer see it.`)) { return; diff --git a/src/app/features/returns/returns.page.html b/src/app/features/returns/returns.page.html index 2c5b6ce..6f93e81 100644 --- a/src/app/features/returns/returns.page.html +++ b/src/app/features/returns/returns.page.html @@ -1,8 +1,3 @@ - - @if (error(); as message) {
{{ message }} diff --git a/src/app/features/returns/returns.page.ts b/src/app/features/returns/returns.page.ts index ffe9a65..dabbd8f 100644 --- a/src/app/features/returns/returns.page.ts +++ b/src/app/features/returns/returns.page.ts @@ -4,13 +4,7 @@ import { FormsModule } from '@angular/forms'; import { ReturnsService } from '../../core/api/returns.service'; import { ReturnStatus } from '../../core/models/api.models'; -import { - AlertComponent, - ButtonComponent, - CardComponent, - ModalComponent, - PageHeaderComponent, -} from '../../shared/ui'; +import { AlertComponent, ButtonComponent, CardComponent, ModalComponent } from '../../shared/ui'; /** * Return (RMA) handling. @@ -29,7 +23,6 @@ import { imports: [ CommonModule, FormsModule, - PageHeaderComponent, ButtonComponent, CardComponent, AlertComponent, diff --git a/src/app/features/users/users.page.html b/src/app/features/users/users.page.html index 6a50d0c..5c14f2d 100644 --- a/src/app/features/users/users.page.html +++ b/src/app/features/users/users.page.html @@ -1,10 +1,3 @@ - - Refresh - - @if (error(); as message) {
{{ message }} @@ -27,7 +20,9 @@ [selected]="selected()" (selectionChange)="selected.set($event)" (cleared)="clearFilters()" - /> + > + Refresh +
diff --git a/src/app/features/users/users.page.ts b/src/app/features/users/users.page.ts index 31d627c..d71cfcf 100644 --- a/src/app/features/users/users.page.ts +++ b/src/app/features/users/users.page.ts @@ -11,7 +11,6 @@ import { ButtonComponent, EmptyStateComponent, ModalComponent, - PageHeaderComponent, SpinnerComponent, } from '../../shared/ui'; @@ -32,7 +31,6 @@ type PendingAction = 'promote' | 'demote' | 'delete'; standalone: true, imports: [ CommonModule, - PageHeaderComponent, ButtonComponent, BadgeComponent, EmptyStateComponent, diff --git a/src/app/shared/filters/filter-bar.ts b/src/app/shared/filters/filter-bar.ts index 04af88b..b7c81ca 100644 --- a/src/app/shared/filters/filter-bar.ts +++ b/src/app/shared/filters/filter-bar.ts @@ -91,6 +91,12 @@ export interface FilterOption { Clear all } + + +
+ +
@if (chips.length > 0) { diff --git a/src/app/shared/ui/index.ts b/src/app/shared/ui/index.ts index 575d791..02b31d7 100644 --- a/src/app/shared/ui/index.ts +++ b/src/app/shared/ui/index.ts @@ -5,5 +5,4 @@ export * from './card'; export * from './empty-state'; export * from './modal'; export * from './money.pipe'; -export * from './page-header'; export * from './spinner'; diff --git a/src/app/shared/ui/page-header.ts b/src/app/shared/ui/page-header.ts deleted file mode 100644 index f818840..0000000 --- a/src/app/shared/ui/page-header.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Component, Input } from '@angular/core'; - -/** - * The title block at the top of every screen. - * - * Exists so page titles share one rhythm rather than each screen choosing its - * own heading size and spacing. Actions are projected on the right. - */ -@Component({ - selector: 'ot-page-header', - standalone: true, - template: ` -
-
-

{{ title }}

- @if (description) { -

{{ description }}

- } -
-
- -
-
- `, -}) -export class PageHeaderComponent { - @Input({ required: true }) title!: string; - @Input() description?: string; -}