feat: implement image carousel in product dialog; enhance image upload handling and preview functionality

This commit is contained in:
Vincent Guillet
2025-11-18 16:32:29 +01:00
parent d4ffcf0562
commit b756c9fa2d
7 changed files with 302 additions and 58 deletions

View File

@@ -17,3 +17,17 @@
table { table {
width: 100%; width: 100%;
} }
.prod-cell {
display: flex;
align-items: center;
gap: 8px;
}
.prod-thumb {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}

View File

@@ -2,8 +2,8 @@ import {Component, inject, OnInit, ViewChild} from '@angular/core';
import {CommonModule} from '@angular/common'; import {CommonModule} from '@angular/common';
import { import {
MatCell, MatCellDef, MatColumnDef, MatHeaderCell, MatHeaderCellDef, MatCell, MatCellDef, MatColumnDef, MatHeaderCell, MatHeaderCellDef,
MatHeaderRow, MatHeaderRowDef, MatNoDataRow, MatRow, MatRowDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef,
MatTable, MatTableDataSource MatNoDataRow, MatTable, MatTableDataSource
} from '@angular/material/table'; } from '@angular/material/table';
import {MatPaginator, MatPaginatorModule} from '@angular/material/paginator'; import {MatPaginator, MatPaginatorModule} from '@angular/material/paginator';
import {MatSort, MatSortModule} from '@angular/material/sort'; import {MatSort, MatSortModule} from '@angular/material/sort';
@@ -13,6 +13,7 @@ import {MatButton, MatIconButton} from '@angular/material/button';
import {MatIcon} from '@angular/material/icon'; import {MatIcon} from '@angular/material/icon';
import {FormBuilder, ReactiveFormsModule} from '@angular/forms'; import {FormBuilder, ReactiveFormsModule} from '@angular/forms';
import {MatDialog, MatDialogModule} from '@angular/material/dialog'; import {MatDialog, MatDialogModule} from '@angular/material/dialog';
import {forkJoin} from 'rxjs';
import {PsItem} from '../../interfaces/ps-item'; import {PsItem} from '../../interfaces/ps-item';
import {ProductListItem} from '../../interfaces/product-list-item'; import {ProductListItem} from '../../interfaces/product-list-item';
@@ -60,19 +61,23 @@ export class PsProductCrudComponent implements OnInit {
filterCtrl = this.fb.control<string>(''); filterCtrl = this.fb.control<string>('');
ngOnInit(): void { ngOnInit(): void {
// charger référentiels en parallèle forkJoin({
Promise.all([ cats: this.ps.list('categories'),
this.ps.list('categories').toPromise(), mans: this.ps.list('manufacturers'),
this.ps.list('manufacturers').toPromise(), sups: this.ps.list('suppliers')
this.ps.list('suppliers').toPromise() }).subscribe({
]).then(([cats, mans, sups]) => { next: ({cats, mans, sups}) => {
this.categories = cats ?? []; this.categories = cats ?? [];
this.catMap = new Map(this.categories.map(x => [x.id, x.name])); this.catMap = new Map(this.categories.map(x => [x.id, x.name]));
this.manufacturers = mans ?? []; this.manufacturers = mans ?? [];
this.manMap = new Map(this.manufacturers.map(x => [x.id, x.name])); this.manMap = new Map(this.manufacturers.map(x => [x.id, x.name]));
this.suppliers = sups ?? []; this.suppliers = sups ?? [];
this.supMap = new Map(this.suppliers.map(x => [x.id, x.name])); this.supMap = new Map(this.suppliers.map(x => [x.id, x.name]));
this.reload(); this.reload();
},
error: err => {
console.error('Erreur lors du chargement des référentiels', err);
}
}); });
// filtre client // filtre client
@@ -115,7 +120,7 @@ export class PsProductCrudComponent implements OnInit {
} }
private bindProducts(p: (ProductListItem & { priceHt?: number })[]) { private bindProducts(p: (ProductListItem & { priceHt?: number })[]) {
const vat = 0.20; // valeur fixe utilisée pour calcul TTC en liste const vat = 0.2;
this.dataSource.data = p.map(x => ({ this.dataSource.data = p.map(x => ({
...x, ...x,
categoryName: x.id_category_default ? (this.catMap.get(x.id_category_default) ?? '') : '', categoryName: x.id_category_default ? (this.catMap.get(x.id_category_default) ?? '') : '',

View File

@@ -23,14 +23,98 @@
align-items: center; align-items: center;
} }
.thumbs { /* ===== Nouveau : carrousel ===== */
display: flex;
.carousel {
grid-column: span 12;
display: grid;
gap: 8px; gap: 8px;
flex-wrap: wrap;
} }
.thumbs img { .carousel-main {
position: relative;
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 8px;
overflow: hidden;
}
.carousel-main img {
max-width: 100%;
max-height: 280px;
object-fit: contain;
}
.carousel-nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.carousel-nav-btn.left {
left: 4px;
}
.carousel-nav-btn.right {
right: 4px;
}
.carousel-placeholder {
text-align: center;
color: #757575;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
}
.carousel-placeholder mat-icon {
font-size: 40px;
}
/* Bandeau de vignettes */
.carousel-thumbs {
display: flex;
gap: 8px;
overflow-x: auto;
}
.thumb-item {
width: 64px;
height: 64px; height: 64px;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, .2); overflow: hidden;
border: 2px solid transparent;
flex: 0 0 auto;
cursor: pointer;
}
.thumb-item.active {
border-color: #1976d2;
}
.thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #eeeeee;
color: #575656;
border: 1px dashed #bdbdbd;
}
.thumb-placeholder mat-icon {
font-size: 28px;
} }

View File

@@ -2,22 +2,57 @@
<div mat-dialog-content class="grid" [formGroup]="form"> <div mat-dialog-content class="grid" [formGroup]="form">
<!-- Input pour l'upload des images --> <!-- CARROUSEL IMAGES -->
<div class="col-12"> <div class="col-12 carousel">
<label for="fileInput">Images du produit</label> <div class="carousel-main">
<input type="file" multiple (change)="onFiles($event)">
</div>
<!-- Affichage des vignettes des images existantes en mode édition --> <!-- Bouton précédent -->
@if (mode==='edit' && existingImageUrls.length) { <button mat-icon-button
<div class="col-12"> class="carousel-nav-btn left"
<div class="thumbs"> (click)="prev()"
@for (url of existingImageUrls; track url) { [disabled]="carouselItems.length <= 1">
<img [src]="url" alt="Produit"> <mat-icon>chevron_left</mat-icon>
} </button>
</div>
<!-- Image principale ou placeholder -->
@if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
<img [src]="carouselItems[currentIndex].src" alt="Produit">
} @else {
<div class="carousel-placeholder" (click)="fileInput.click()">
<mat-icon>add_photo_alternate</mat-icon>
<span>Ajouter des images</span>
</div>
}
<!-- Bouton suivant -->
<button mat-icon-button
class="carousel-nav-btn right"
(click)="next()"
[disabled]="carouselItems.length <= 1">
<mat-icon>chevron_right</mat-icon>
</button>
</div> </div>
}
<!-- Bandeau de vignettes -->
<div class="carousel-thumbs">
@for (item of carouselItems; let i = $index; track item) {
<div class="thumb-item"
[class.active]="i === currentIndex"
(click)="onThumbClick(i)">
@if (!item.isPlaceholder) {
<img class="thumb-img" [src]="item.src" alt="Vignette produit">
} @else {
<div class="thumb-placeholder" (click)="fileInput.click()">
<mat-icon>add</mat-icon>
</div>
}
</div>
}
</div>
<!-- Input réel, caché -->
<input #fileInput type="file" multiple hidden (change)="onFiles($event)">
</div>
<!-- Input pour le nom du produit --> <!-- Input pour le nom du produit -->
<mat-form-field class="col-12"> <mat-form-field class="col-12">
@@ -93,6 +128,7 @@
</mat-form-field> </mat-form-field>
</div> </div>
<!-- Actions -->
<div mat-dialog-actions> <div mat-dialog-actions>
<button mat-button (click)="close()">Annuler</button> <button mat-button (click)="close()">Annuler</button>
<button mat-raised-button color="primary" (click)="save()" [disabled]="form.invalid"> <button mat-raised-button color="primary" (click)="save()" [disabled]="form.invalid">

View File

@@ -1,11 +1,11 @@
import { Component, Inject, OnInit, inject } from '@angular/core'; import {Component, Inject, OnInit, inject, OnDestroy} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatFormField, MatLabel } from '@angular/material/form-field'; import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input'; import { MatInput } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { MatButton } from '@angular/material/button'; import { MatButton, MatIconButton } from '@angular/material/button';
import { import {
MatDialogRef, MatDialogRef,
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
@@ -13,6 +13,7 @@ import {
MatDialogContent, MatDialogContent,
MatDialogTitle MatDialogTitle
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { catchError, forkJoin, of, Observable } from 'rxjs'; import { catchError, forkJoin, of, Observable } from 'rxjs';
@@ -26,6 +27,8 @@ export type ProductDialogData = {
productRow?: ProductListItem & { priceHt?: number }; productRow?: ProductListItem & { priceHt?: number };
}; };
type CarouselItem = { src: string; isPlaceholder: boolean };
@Component({ @Component({
selector: 'app-ps-product-dialog', selector: 'app-ps-product-dialog',
standalone: true, standalone: true,
@@ -34,10 +37,11 @@ export type ProductDialogData = {
imports: [ imports: [
CommonModule, ReactiveFormsModule, CommonModule, ReactiveFormsModule,
MatFormField, MatLabel, MatInput, MatSelectModule, MatCheckbox, MatFormField, MatLabel, MatInput, MatSelectModule, MatCheckbox,
MatButton, MatDialogActions, MatDialogContent, MatDialogTitle MatButton, MatDialogActions, MatDialogContent, MatDialogTitle,
MatIcon, MatIconButton
] ]
}) })
export class PsProductDialogComponent implements OnInit { export class PsProductDialogComponent implements OnInit, OnDestroy {
private readonly fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
private readonly ps = inject(PrestashopService); private readonly ps = inject(PrestashopService);
@@ -55,6 +59,13 @@ export class PsProductDialogComponent implements OnInit {
images: File[] = []; images: File[] = [];
existingImageUrls: string[] = []; existingImageUrls: string[] = [];
// Previews des fichiers nouvellement sélectionnés
previewUrls: string[] = [];
// items du carrousel (existants + nouveaux + placeholder)
carouselItems: CarouselItem[] = [];
currentIndex = 0;
// options possibles pour l'état (Neuf, Très bon état, etc.) // options possibles pour l'état (Neuf, Très bon état, etc.)
conditionOptions: string[] = []; conditionOptions: string[] = [];
@@ -178,15 +189,80 @@ export class PsProductDialogComponent implements OnInit {
}); });
this.existingImageUrls = imgs; this.existingImageUrls = imgs;
this.buildCarousel();
}); });
} else {
// mode création : uniquement placeholder au début
this.buildCarousel();
} }
} }
// -------- Carrousel / gestion des fichiers --------
onFiles(ev: Event) { onFiles(ev: Event) {
const fl = (ev.target as HTMLInputElement).files; const fl = (ev.target as HTMLInputElement).files;
this.images = fl ? Array.from(fl) : [];
// Nettoyage des anciens objectURL
for(let url of this.previewUrls) {
URL.revokeObjectURL(url);
}
this.previewUrls = [];
this.images = [];
if (fl) {
this.images = Array.from(fl);
this.previewUrls = this.images.map(f => URL.createObjectURL(f));
}
this.buildCarousel();
// si on a ajouté des images, se placer sur la première nouvelle
if (this.images.length && this.existingImageUrls.length + this.previewUrls.length > 0) {
this.currentIndex = this.existingImageUrls.length; // première nouvelle
}
} }
private buildCarousel() {
const items: CarouselItem[] = [
...this.existingImageUrls.map(u => ({ src: u, isPlaceholder: false })),
...this.previewUrls.map(u => ({ src: u, isPlaceholder: false }))
];
// placeholder en dernier
items.push({ src: '', isPlaceholder: true });
this.carouselItems = items;
if (!this.carouselItems.length) {
this.currentIndex = 0;
} else if (this.currentIndex >= this.carouselItems.length) {
this.currentIndex = 0;
}
}
prev() {
if (!this.carouselItems.length) return;
this.currentIndex = (this.currentIndex - 1 + this.carouselItems.length) % this.carouselItems.length;
}
next() {
if (!this.carouselItems.length) return;
this.currentIndex = (this.currentIndex + 1) % this.carouselItems.length;
}
onThumbClick(index: number) {
if (index < 0 || index >= this.carouselItems.length) return;
this.currentIndex = index;
}
ngOnDestroy() {
// Nettoyage des objectURL
for (let url of this.previewUrls) {
URL.revokeObjectURL(url);
}
}
// -------- Save / close inchangés (à part dto.images) --------
save() { save() {
if (this.form.invalid) return; if (this.form.invalid) return;
@@ -199,7 +275,7 @@ export class PsProductDialogComponent implements OnInit {
categoryId: +v.categoryId!, categoryId: +v.categoryId!,
manufacturerId: +v.manufacturerId!, manufacturerId: +v.manufacturerId!,
supplierId: +v.supplierId!, supplierId: +v.supplierId!,
images: this.images, images: this.images, // toujours les fichiers sélectionnés
complete: !!v.complete, complete: !!v.complete,
hasManual: !!v.hasManual, hasManual: !!v.hasManual,
conditionLabel: v.conditionLabel || undefined, conditionLabel: v.conditionLabel || undefined,

View File

@@ -12,4 +12,5 @@ export interface PsProduct {
priceTtc: number; // saisi côté UI (TTC) priceTtc: number; // saisi côté UI (TTC)
vatRate?: number; // ex: 0.20 (20%). Défaut: 0.20 si non fourni vatRate?: number; // ex: 0.20 (20%). Défaut: 0.20 si non fourni
quantity: number; // stock souhaité (pour id_product_attribute = 0) quantity: number; // stock souhaité (pour id_product_attribute = 0)
thumbUrl?: string | null;
} }

View File

@@ -37,6 +37,7 @@ const UPDATE_CFG: Record<Resource, {
export class PrestashopService { export class PrestashopService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly base = '/ps'; private readonly base = '/ps';
private readonly frontBase = 'https://shop.gameovergne.fr'
// -------- Utils // -------- Utils
private readonly headersXml = new HttpHeaders({ private readonly headersXml = new HttpHeaders({
@@ -465,20 +466,34 @@ export class PrestashopService {
// -------- Images // -------- Images
private getProductImageIds(productId: number) { /** Retourne les URLs publiques des images du produit (FO Presta, pas lAPI) */
return this.http.get<any>(`${this.base}/images/products/${productId}`, { getProductImageUrls(productId: number) {
responseType: 'json' as any const params = new HttpParams()
}).pipe( .set('output_format', 'JSON')
.set('display', 'full');
return this.http.get<any>(`${this.base}/products/${productId}`, { params }).pipe(
map(r => { map(r => {
const arr = (r?.image ?? r?.images ?? []) as Array<any>; // même logique que pour les autres méthodes : format 1 ou 2
return Array.isArray(arr) ? arr.map(x => +x.id) : []; const p = r?.product ?? (Array.isArray(r?.products) ? r.products[0] : r);
const rawAssoc = p?.associations ?? {};
const rawImages = rawAssoc?.images?.image ?? rawAssoc?.images ?? [];
const arr: any[] = Array.isArray(rawImages) ? rawImages : (rawImages ? [rawImages] : []);
const ids = arr
.map(img => +img.id)
.filter(id => Number.isFinite(id) && id > 0);
return ids.map(id => this.buildFrontImageUrl(id));
}) })
); );
} }
getProductImageUrls(productId: number) { /** Retourne la première URL d'image (miniature) du produit, ou null s'il n'y en a pas */
return this.getProductImageIds(productId).pipe( getProductThumbnailUrl(productId: number) {
map(ids => ids.map(idImg => `${this.base}/images/products/${productId}/${idImg}`)) return this.getProductImageUrls(productId).pipe(
map(urls => urls.length ? urls[0] : null),
catchError(() => of(null))
); );
} }
@@ -810,8 +825,6 @@ export class PrestashopService {
); );
} }
// -------- Helpers internes (produit complet pour update)
// -------- Helpers internes (produit complet pour update) // -------- Helpers internes (produit complet pour update)
private getProductForUpdate(id: number) { private getProductForUpdate(id: number) {
const params = new HttpParams().set('output_format', 'JSON').set('display', 'full'); const params = new HttpParams().set('output_format', 'JSON').set('display', 'full');
@@ -890,8 +903,14 @@ export class PrestashopService {
); );
} }
// -------- Description (désormais: uniquement la description libre) /** Construit lURL publique dune image produit Presta à partir de son id_image */
private buildFrontImageUrl(imageId: number): string {
const idStr = String(imageId);
const path = idStr.split('').join('/'); // "123" -> "1/2/3"
return `${this.frontBase}/img/p/${path}/${idStr}.jpg`;
}
// -------- Description (désormais: uniquement la description libre)
private buildAugmentedDescription(dto: PsProduct): string { private buildAugmentedDescription(dto: PsProduct): string {
return dto.description?.trim() || ''; return dto.description?.trim() || '';
} }
@@ -1040,11 +1059,20 @@ export class PrestashopService {
return this.http.put(`${this.base}/products/${id}`, xml, { return this.http.put(`${this.base}/products/${id}`, xml, {
headers: this.headersXml, headers: this.headersXml,
responseType: 'text' responseType: 'text'
}).pipe( });
switchMap(() => this.setProductQuantity(id, dto.quantity)), }),
map(() => true) switchMap(() => {
); const ops: Array<Observable<unknown>> = [];
})
if (dto.images?.length) {
ops.push(forkJoin(dto.images.map(f => this.uploadProductImage(id, f))));
}
ops.push(this.setProductQuantity(id, dto.quantity));
return ops.length ? forkJoin(ops) : of(true);
}),
map(() => true)
); );
} }