Compare commits

..

20 Commits

Author SHA1 Message Date
b79068623f Update jenkinsfile 2025-12-05 14:27:59 +00:00
Vincent Guillet
3eed3d251f Refactor CORS configuration to use allowed origins and enhance header handling 2025-12-05 15:14:16 +01:00
7dcc85ac95 Update api/src/main/java/fr/gameovergne/api/controller/auth/AuthController.java 2025-12-05 13:57:42 +00:00
ec9eb0dc7d Update api/src/main/java/fr/gameovergne/api/config/SecurityConfig.java 2025-12-05 13:40:48 +00:00
01cafd5904 Update docker-compose.prod.yml 2025-12-05 13:35:48 +00:00
321e2fd546 Update jenkinsfile 2025-12-05 13:27:16 +00:00
3026f0a13f Update jenkinsfile 2025-12-05 13:23:58 +00:00
52d17e5ad8 Update jenkinsfile 2025-12-05 12:56:15 +00:00
2803e910bd Add docker-compose.prod.yml 2025-12-05 12:54:54 +00:00
653ce83c33 Add docker-compose.dev.yml 2025-12-05 12:54:08 +00:00
ce618deecf Update docker-compose.yml.OLD 2025-12-05 12:53:06 +00:00
5331ce7866 Update docker-compose.yml 2025-12-04 09:19:07 +00:00
Vincent Guillet
6f6d033be3 Center align items in main navbar container for improved layout 2025-12-03 22:48:26 +01:00
Vincent Guillet
ff8536b448 Update SecurityConfig to require authentication for /api/app/** endpoints 2025-12-03 22:47:14 +01:00
Vincent Guillet
60593f6c11 Update base URL in index.html for proper routing in Game Over'gne app 2025-12-03 22:46:34 +01:00
Vincent Guillet
1708c1bead Update base URL in index.html for proper routing in Game Over'gne app 2025-12-03 22:11:33 +01:00
Vincent Guillet
dc33d762a1 Refactor app configuration to use hrefBase for base URL and improve provider imports 2025-12-03 21:59:42 +01:00
Vincent Guillet
e04cac3345 Enhance main navbar styles for better overflow handling and safe area support 2025-12-03 21:49:52 +01:00
Vincent Guillet
00f45ae6c7 Add loading indicators to product CRUD and dialog components 2025-12-03 21:46:18 +01:00
Vincent Guillet
1a5d3a570a Add image deletion functionality to product dialog carousel 2025-12-03 21:21:50 +01:00
19 changed files with 826 additions and 264 deletions

View File

@@ -46,7 +46,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // autoriser les preflight .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // autoriser les preflight
.requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/users/**").authenticated() .requestMatchers("/api/users/**").authenticated()
.requestMatchers("/api/app/**").permitAll() .requestMatchers("/api/app/**").authenticated()
.anyRequest().permitAll() .anyRequest().permitAll()
) )
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
@@ -61,16 +61,26 @@ public class SecurityConfig {
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration(); CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(Arrays.asList(
// IMPORTANT : origins explicites, sans path
config.setAllowedOrigins(Arrays.asList(
"http://localhost:4200", "http://localhost:4200",
"http://127.0.0.1:4200", "http://127.0.0.1:4200",
"https://dev.vincent-guillet.fr" "https://dev.vincent-guillet.fr",
"https://projets.vincent-guillet.fr"
)); ));
config.setAllowedMethods(Arrays.asList("GET","POST","PUT","DELETE","OPTIONS"));
config.setAllowedHeaders(Arrays.asList("Authorization","Content-Type","Accept"));
config.setExposedHeaders(Arrays.asList("Authorization"));
config.setAllowCredentials(true); config.setAllowCredentials(true);
// Autoriser tous les headers côté requête (plus robuste)
config.setAllowedHeaders(Arrays.asList("*"));
// Autoriser les méthodes classiques
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
// Headers que le client *voit* dans la réponse
config.setExposedHeaders(Arrays.asList("Authorization", "Content-Type"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config); source.registerCorsConfiguration("/**", config);
return source; return source;

View File

@@ -17,12 +17,6 @@ import java.util.Arrays;
@RestController @RestController
@RequestMapping("/api/auth") @RequestMapping("/api/auth")
@CrossOrigin(
origins = "https://dev.vincent-guillet.fr",
allowCredentials = "true",
allowedHeaders = "*",
methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.OPTIONS}
)
public class AuthController { public class AuthController {
private final AuthService authService; private final AuthService authService;

View File

@@ -1,32 +1,41 @@
import {APP_INITIALIZER, ApplicationConfig, inject, provideZoneChangeDetection} from '@angular/core'; import {
APP_INITIALIZER,
ApplicationConfig,
inject,
provideZoneChangeDetection,
importProvidersFrom
} from '@angular/core';
import {provideRouter} from '@angular/router'; import {provideRouter} from '@angular/router';
import {BrowserModule} from '@angular/platform-browser';
import {APP_BASE_HREF} from '@angular/common';
import {routes} from './app.routes'; import {routes} from './app.routes';
import {provideHttpClient, withInterceptors} from '@angular/common/http'; import {provideHttpClient, withInterceptors} from '@angular/common/http';
import {provideAnimationsAsync} from '@angular/platform-browser/animations/async'; import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
import {authTokenInterceptor} from './interceptors/auth-token.interceptor'; import {authTokenInterceptor} from './interceptors/auth-token.interceptor';
import {AuthService} from './services/auth.service'; import {AuthService} from './services/auth.service';
import {catchError, firstValueFrom, of} from 'rxjs'; import {catchError, firstValueFrom, of} from 'rxjs';
import {environment} from '../environments/environment';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideZoneChangeDetection({eventCoalescing: true}), provideZoneChangeDetection({eventCoalescing: true}),
importProvidersFrom(BrowserModule),
{provide: APP_BASE_HREF, useValue: environment.hrefBase},
provideRouter(routes), provideRouter(routes),
provideAnimationsAsync(), provideAnimationsAsync(),
provideHttpClient(withInterceptors([ provideHttpClient(withInterceptors([authTokenInterceptor])),
authTokenInterceptor
])
),
{ {
provide: APP_INITIALIZER, provide: APP_INITIALIZER,
multi: true, multi: true,
useFactory: () => { useFactory: () => {
const auth = inject(AuthService); const auth = inject(AuthService);
return () => firstValueFrom(auth.bootstrapSession().pipe( return () =>
catchError(err => of(null)) firstValueFrom(
auth.bootstrapSession().pipe(
catchError(() => of(null))
) )
); );
} }
}, provideAnimationsAsync() }
] ]
}; };

View File

@@ -5,7 +5,7 @@ import {LoginComponent} from './pages/auth/login/login.component';
import {ProfileComponent} from './pages/profile/profile.component'; import {ProfileComponent} from './pages/profile/profile.component';
import {guestOnlyCanActivate, guestOnlyCanMatch} from './guards/guest-only.guard'; import {guestOnlyCanActivate, guestOnlyCanMatch} from './guards/guest-only.guard';
import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard'; import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard';
import {authOnlyCanMatch} from './guards/auth-only.guard'; import {authOnlyCanActivate, authOnlyCanMatch} from './guards/auth-only.guard';
import {PsAdminComponent} from './pages/admin/ps-admin/ps-admin.component'; import {PsAdminComponent} from './pages/admin/ps-admin/ps-admin.component';
import {ProductsComponent} from './pages/products/products.component'; import {ProductsComponent} from './pages/products/products.component';
@@ -40,13 +40,13 @@ export const routes: Routes = [
path: 'profile', path: 'profile',
component: ProfileComponent, component: ProfileComponent,
canMatch: [authOnlyCanMatch], canMatch: [authOnlyCanMatch],
canActivate: [authOnlyCanMatch] canActivate: [authOnlyCanActivate]
}, },
{ {
path: 'products', path: 'products',
component: ProductsComponent, component: ProductsComponent,
canMatch: [authOnlyCanMatch], canMatch: [adminOnlyCanMatch],
canActivate: [authOnlyCanMatch] canActivate: [adminOnlyCanActivate]
}, },
{ {
path: 'admin', path: 'admin',

View File

@@ -1,3 +1,16 @@
/* src/app/components/main-navbar/main-navbar.component.css */
/* Ajout prise en charge safe-area et meilleure gestion des overflow */
.mat-toolbar {
/* protège contre les zones sensibles (notch / status bar) */
padding-top: constant(safe-area-inset-top);
padding-top: env(safe-area-inset-top);
position: relative;
z-index: 1000;
box-sizing: border-box;
}
/* wrapper principal */
.container { .container {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
@@ -8,19 +21,22 @@
gap: 12px; gap: 12px;
padding: 0 12px; padding: 0 12px;
box-sizing: border-box; box-sizing: border-box;
min-height: 56px; /* assure une hauteur minimale utile sur mobile */
} }
/* marque / titre */
.brand { .brand {
font-weight: bold; font-weight: bold;
font-size: 1.2rem; font-size: 1.2rem;
cursor: pointer; cursor: pointer;
min-width: 0; min-width: 0; /* autorise le shrink */
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
flex: 1 1 auto; flex: 1 1 auto;
} }
/* actions (boutons, menu utilisateur) */
.nav-actions { .nav-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -28,16 +44,47 @@
flex: 0 1 auto; flex: 0 1 auto;
justify-content: flex-end; justify-content: flex-end;
flex-wrap: nowrap; flex-wrap: nowrap;
min-width: 0; /* important pour permettre la réduction des enfants */
overflow: visible;
} }
/* icône dans mat-menu */
.mat-menu-item mat-icon { .mat-menu-item mat-icon {
margin-right: 8px; margin-right: 8px;
} }
/* Empêcher les boutons de dépasser et couper le texte avec ellipsis */
.nav-actions button {
min-width: 0;
max-width: 100%;
display: inline-flex;
align-items: center;
gap: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Angular Material place le texte dans .mat-button-wrapper — on le tronque proprement */
.nav-actions button .mat-button-wrapper {
display: inline-block;
max-width: calc(100% - 56px); /* espace pour icônes + padding */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
/* Ajustements spécifiques pour petits écrans */
@media (max-width: 720px) { @media (max-width: 720px) {
.mat-toolbar {
padding-top: env(safe-area-inset-top);
}
.container { .container {
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: center;
padding: 8px; padding: 8px;
} }
@@ -55,6 +102,7 @@
gap: 8px; gap: 8px;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
padding-bottom: 4px; /* espace pour le scroll horizontal */
} }
.nav-actions button { .nav-actions button {
@@ -62,6 +110,11 @@
font-size: 0.95rem; font-size: 0.95rem;
min-width: 0; min-width: 0;
white-space: nowrap; white-space: nowrap;
max-width: 100%;
}
.nav-actions button .mat-button-wrapper {
max-width: calc(100% - 40px);
} }
.nav-actions mat-icon { .nav-actions mat-icon {

View File

@@ -47,6 +47,21 @@ th, td {
flex-shrink: 0; flex-shrink: 0;
} }
.product-list-root {
position: relative;
}
.product-list-loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
pointer-events: all;
}
mat-paginator { mat-paginator {
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;

View File

@@ -1,16 +1,27 @@
<section class="crud"> <section class="crud">
<div class="toolbar"> <div class="toolbar">
<button mat-raised-button color="primary" (click)="create()"> <button mat-raised-button
color="primary"
(click)="create()"
[disabled]="isLoading">
<mat-icon>add</mat-icon>&nbsp;Nouveau produit <mat-icon>add</mat-icon>&nbsp;Nouveau produit
</button> </button>
<mat-form-field appearance="outline" class="filter"> <mat-form-field appearance="outline" class="filter">
<mat-label>Filtrer</mat-label> <mat-label>Filtrer</mat-label>
<input matInput [formControl]="filterCtrl" placeholder="Nom, ID, catégorie, marque, fournisseur…"> <input matInput
[formControl]="filterCtrl"
placeholder="Nom, ID, catégorie, marque, fournisseur…"
[disabled]="isLoading">
</mat-form-field> </mat-form-field>
</div> </div>
<div class="mat-elevation-z2"> <div class="mat-elevation-z2 product-list-root">
<!-- Overlay de chargement -->
<div class="product-list-loading-overlay" *ngIf="isLoading">
<mat-spinner diameter="48"></mat-spinner>
</div>
<table mat-table [dataSource]="dataSource" matSort> <table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="id"> <ng-container matColumnDef="id">
@@ -51,8 +62,19 @@
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th> <th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let el"> <td mat-cell *matCellDef="let el">
<button mat-icon-button (click)="edit(el)" aria-label="edit"><mat-icon>edit</mat-icon></button> <button mat-icon-button
<button mat-icon-button color="warn" (click)="remove(el)" aria-label="delete"><mat-icon>delete</mat-icon></button> aria-label="edit"
(click)="edit(el)"
[disabled]="isLoading">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button
color="warn"
aria-label="delete"
(click)="remove(el)"
[disabled]="isLoading">
<mat-icon>delete</mat-icon>
</button>
</td> </td>
</ng-container> </ng-container>
@@ -60,10 +82,17 @@
<tr mat-row *matRowDef="let row; columns: displayed;"></tr> <tr mat-row *matRowDef="let row; columns: displayed;"></tr>
<tr class="mat-row" *matNoDataRow> <tr class="mat-row" *matNoDataRow>
<td class="mat-cell" [attr.colspan]="displayed.length">Aucune donnée.</td> <td class="mat-cell" [attr.colspan]="displayed.length">
Aucune donnée.
</td>
</tr> </tr>
</table> </table>
<mat-paginator [pageSizeOptions]="[5,10,25,100]" [pageSize]="10" aria-label="Pagination"></mat-paginator> <mat-paginator
[pageSizeOptions]="[5,10,25,100]"
[pageSize]="10"
aria-label="Pagination"
[disabled]="isLoading">
</mat-paginator>
</div> </div>
</section> </section>

View File

@@ -13,7 +13,8 @@ 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 {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import {forkJoin, finalize} 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';
@@ -32,7 +33,8 @@ import {ProductDialogData, PsProductDialogComponent} from '../ps-product-dialog/
MatSortModule, MatPaginatorModule, MatSortModule, MatPaginatorModule,
MatFormField, MatLabel, MatInput, MatFormField, MatLabel, MatInput,
MatButton, MatIconButton, MatIcon, MatButton, MatIconButton, MatIcon,
MatDialogModule MatDialogModule,
MatProgressSpinnerModule
] ]
}) })
export class PsProductCrudComponent implements OnInit { export class PsProductCrudComponent implements OnInit {
@@ -40,26 +42,24 @@ export class PsProductCrudComponent implements OnInit {
private readonly ps = inject(PrestashopService); private readonly ps = inject(PrestashopService);
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
// référentiels
categories: PsItem[] = []; categories: PsItem[] = [];
manufacturers: PsItem[] = []; manufacturers: PsItem[] = [];
suppliers: PsItem[] = []; suppliers: PsItem[] = [];
// maps daffichage
private catMap = new Map<number, string>(); private catMap = new Map<number, string>();
private manMap = new Map<number, string>(); private manMap = new Map<number, string>();
private supMap = new Map<number, string>(); private supMap = new Map<number, string>();
// table
displayed: string[] = ['id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', 'quantity', 'actions']; displayed: string[] = ['id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', 'quantity', 'actions'];
dataSource = new MatTableDataSource<any>([]); dataSource = new MatTableDataSource<any>([]);
@ViewChild(MatPaginator) paginator!: MatPaginator; @ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort; @ViewChild(MatSort) sort!: MatSort;
@ViewChild(MatTable) table!: MatTable<any>; @ViewChild(MatTable) table!: MatTable<any>;
// filtre
filterCtrl = this.fb.control<string>(''); filterCtrl = this.fb.control<string>('');
isLoading = false;
ngOnInit(): void { ngOnInit(): void {
forkJoin({ forkJoin({
cats: this.ps.list('categories'), cats: this.ps.list('categories'),
@@ -73,6 +73,7 @@ export class PsProductCrudComponent implements OnInit {
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 => { error: err => {
@@ -80,7 +81,6 @@ export class PsProductCrudComponent implements OnInit {
} }
}); });
// filtre client
this.filterCtrl.valueChanges.subscribe(v => { this.filterCtrl.valueChanges.subscribe(v => {
this.dataSource.filter = (v ?? '').toString().trim().toLowerCase(); this.dataSource.filter = (v ?? '').toString().trim().toLowerCase();
if (this.paginator) this.paginator.firstPage(); if (this.paginator) this.paginator.firstPage();
@@ -133,10 +133,24 @@ export class PsProductCrudComponent implements OnInit {
} }
reload() { reload() {
this.ps.listProducts().subscribe(p => this.bindProducts(p)); this.isLoading = true;
this.ps.listProducts()
.pipe(
finalize(() => {
this.isLoading = false;
})
)
.subscribe({
next: p => this.bindProducts(p),
error: err => {
console.error('Erreur lors du chargement des produits', err);
}
});
} }
create() { create() {
if (this.isLoading) return;
const data: ProductDialogData = { const data: ProductDialogData = {
mode: 'create', mode: 'create',
refs: { refs: {
@@ -145,12 +159,16 @@ export class PsProductCrudComponent implements OnInit {
suppliers: this.suppliers suppliers: this.suppliers
} }
}; };
this.dialog.open(PsProductDialogComponent, {width: '900px', data}).afterClosed().subscribe(ok => { this.dialog.open(PsProductDialogComponent, {width: '900px', data})
.afterClosed()
.subscribe(ok => {
if (ok) this.reload(); if (ok) this.reload();
}); });
} }
edit(row: ProductListItem & { priceHt?: number }) { edit(row: ProductListItem & { priceHt?: number }) {
if (this.isLoading) return;
const data: ProductDialogData = { const data: ProductDialogData = {
mode: 'edit', mode: 'edit',
productRow: row, productRow: row,
@@ -160,16 +178,29 @@ export class PsProductCrudComponent implements OnInit {
suppliers: this.suppliers suppliers: this.suppliers
} }
}; };
this.dialog.open(PsProductDialogComponent, {width: '900px', data}).afterClosed().subscribe(ok => { this.dialog.open(PsProductDialogComponent, {width: '900px', data})
.afterClosed()
.subscribe(ok => {
if (ok) this.reload(); if (ok) this.reload();
}); });
} }
remove(row: ProductListItem) { remove(row: ProductListItem) {
if (this.isLoading) return;
if (!confirm(`Supprimer le produit "${row.name}" (#${row.id}) ?`)) return; if (!confirm(`Supprimer le produit "${row.name}" (#${row.id}) ?`)) return;
this.ps.deleteProduct(row.id).subscribe({
this.isLoading = true;
this.ps.deleteProduct(row.id)
.pipe(
finalize(() => {
})
)
.subscribe({
next: () => this.reload(), next: () => this.reload(),
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e))) error: (e: unknown) => {
this.isLoading = false;
alert('Erreur: ' + (e instanceof Error ? e.message : String(e)));
}
}); });
} }
} }

View File

@@ -76,6 +76,24 @@
font-size: 40px; font-size: 40px;
} }
/* Bouton de suppression (croix rouge) */
.carousel-delete-btn {
position: absolute;
top: 6px;
right: 6px;
background: rgba(255, 255, 255, 0.9);
border-radius: 4px;
padding: 2px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
}
.carousel-delete-btn mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #e53935;
}
/* Bandeau de vignettes */ /* Bandeau de vignettes */
.carousel-thumbs { .carousel-thumbs {
@@ -85,15 +103,42 @@
} }
.thumb-item { .thumb-item {
position: relative;
width: 64px; width: 64px;
height: 64px; height: 64px;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden; /* tu peux laisser comme ça */
border: 2px solid transparent; border: 2px solid transparent;
flex: 0 0 auto; flex: 0 0 auto;
cursor: pointer; cursor: pointer;
} }
/* Bouton de suppression sur les vignettes */
.thumb-delete-btn {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
min-width: 18px;
padding: 0;
line-height: 18px;
background: transparent;
border-radius: 0;
box-shadow: none;
}
.thumb-delete-btn mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #e53935; /* rouge discret mais lisible */
}
.thumb-item.active { .thumb-item.active {
border-color: #1976d2; border-color: #1976d2;
} }
@@ -118,3 +163,19 @@
.thumb-placeholder mat-icon { .thumb-placeholder mat-icon {
font-size: 28px; font-size: 28px;
} }
.dialog-root {
position: relative;
}
/* Overlay plein écran dans le dialog pendant la sauvegarde */
.dialog-loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
pointer-events: all;
}

View File

@@ -1,6 +1,14 @@
<h2 mat-dialog-title>{{ mode === 'create' ? 'Nouveau produit' : 'Modifier le produit' }}</h2> <h2 mat-dialog-title>{{ mode === 'create' ? 'Nouveau produit' : 'Modifier le produit' }}</h2>
<div mat-dialog-content class="grid" [formGroup]="form"> <div class="dialog-root">
<!-- Overlay de chargement -->
@if (isSaving) {
<div class="dialog-loading-overlay">
<mat-spinner diameter="48"></mat-spinner>
</div>
}
<div mat-dialog-content class="grid" [formGroup]="form">
<!-- CARROUSEL IMAGES --> <!-- CARROUSEL IMAGES -->
<div class="col-12 carousel"> <div class="col-12 carousel">
@@ -31,6 +39,23 @@
[disabled]="carouselItems.length <= 1"> [disabled]="carouselItems.length <= 1">
<mat-icon>chevron_right</mat-icon> <mat-icon>chevron_right</mat-icon>
</button> </button>
<!-- Bouton de suppression (croix rouge) -->
@if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
<button mat-icon-button
class="carousel-delete-btn"
(click)="onDeleteCurrentImage()">
<mat-icon>delete</mat-icon>
</button>
}
<!-- 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 --> <!-- Bandeau de vignettes -->
@@ -40,6 +65,14 @@
[class.active]="i === currentIndex" [class.active]="i === currentIndex"
(click)="onThumbClick(i)"> (click)="onThumbClick(i)">
@if (!item.isPlaceholder) { @if (!item.isPlaceholder) {
<!-- Bouton suppression vignette -->
<button mat-icon-button
class="thumb-delete-btn"
(click)="onDeleteThumb(i, $event)">
<mat-icon>close</mat-icon>
</button>
<img class="thumb-img" [src]="item.src" alt="Vignette produit"> <img class="thumb-img" [src]="item.src" alt="Vignette produit">
} @else { } @else {
<div class="thumb-placeholder" (click)="fileInput.click()"> <div class="thumb-placeholder" (click)="fileInput.click()">
@@ -126,12 +159,25 @@
<mat-label>Quantité</mat-label> <mat-label>Quantité</mat-label>
<input matInput type="number" step="1" min="0" formControlName="quantity"> <input matInput type="number" step="1" min="0" formControlName="quantity">
</mat-form-field> </mat-form-field>
</div>
</div> </div>
<!-- Actions --> <!-- Actions -->
<div mat-dialog-actions> <mat-dialog-actions align="end">
<button mat-button (click)="close()">Annuler</button> <button mat-button
<button mat-raised-button color="primary" (click)="save()" [disabled]="form.invalid"> (click)="close()"
{{ mode === 'create' ? 'Créer' : 'Enregistrer' }} [disabled]="isSaving">
Annuler
</button> </button>
</div>
<button mat-raised-button
color="primary"
(click)="save()"
[disabled]="form.invalid || isSaving">
@if (!isSaving) {
Enregistrer
} @else {
Enregistrement...
}
</button>
</mat-dialog-actions>

View File

@@ -1,11 +1,11 @@
import {Component, Inject, OnInit, inject, OnDestroy} 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, MatIconButton } from '@angular/material/button'; import {MatButton, MatIconButton} from '@angular/material/button';
import { import {
MatDialogRef, MatDialogRef,
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
@@ -13,13 +13,14 @@ import {
MatDialogContent, MatDialogContent,
MatDialogTitle MatDialogTitle
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon'; import {MatIcon} from '@angular/material/icon';
import { catchError, forkJoin, of, Observable } from 'rxjs'; import {catchError, forkJoin, of, Observable, finalize} 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';
import { PrestashopService } from '../../services/prestashop.serivce'; import {PrestashopService} from '../../services/prestashop.serivce';
import {MatProgressSpinner} from '@angular/material/progress-spinner';
export type ProductDialogData = { export type ProductDialogData = {
mode: 'create' | 'edit'; mode: 'create' | 'edit';
@@ -38,7 +39,7 @@ type CarouselItem = { src: string; isPlaceholder: boolean };
CommonModule, ReactiveFormsModule, CommonModule, ReactiveFormsModule,
MatFormField, MatLabel, MatInput, MatSelectModule, MatCheckbox, MatFormField, MatLabel, MatInput, MatSelectModule, MatCheckbox,
MatButton, MatDialogActions, MatDialogContent, MatDialogTitle, MatButton, MatDialogActions, MatDialogContent, MatDialogTitle,
MatIcon, MatIconButton MatIcon, MatIconButton, MatProgressSpinner
] ]
}) })
export class PsProductDialogComponent implements OnInit, OnDestroy { export class PsProductDialogComponent implements OnInit, OnDestroy {
@@ -48,7 +49,10 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
constructor( constructor(
@Inject(MAT_DIALOG_DATA) public data: ProductDialogData, @Inject(MAT_DIALOG_DATA) public data: ProductDialogData,
private readonly dialogRef: MatDialogRef<PsProductDialogComponent> private readonly dialogRef: MatDialogRef<PsProductDialogComponent>
) {} ) {
}
isSaving = false;
mode!: 'create' | 'edit'; mode!: 'create' | 'edit';
categories: PsItem[] = []; categories: PsItem[] = [];
@@ -167,11 +171,11 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
const qty$ = this.ps.getProductQuantity(r.id).pipe(catchError(() => of(0))); const qty$ = this.ps.getProductQuantity(r.id).pipe(catchError(() => of(0)));
const imgs$ = this.ps.getProductImageUrls(r.id).pipe(catchError(() => of<string[]>([]))); const imgs$ = this.ps.getProductImageUrls(r.id).pipe(catchError(() => of<string[]>([])));
const flags$ = this.ps.getProductFlags(r.id).pipe( const flags$ = this.ps.getProductFlags(r.id).pipe(
catchError(() => of({ complete: false, hasManual: false, conditionLabel: undefined })) catchError(() => of({complete: false, hasManual: false, conditionLabel: undefined}))
); );
forkJoin({ details: details$, qty: qty$, imgs: imgs$, flags: flags$ }) forkJoin({details: details$, qty: qty$, imgs: imgs$, flags: flags$})
.subscribe(({ details, qty, imgs, flags }) => { .subscribe(({details, qty, imgs, flags}) => {
const ttc = this.toTtc(details.priceHt ?? 0); const ttc = this.toTtc(details.priceHt ?? 0);
const baseDesc = this.cleanForTextarea(details.description ?? ''); const baseDesc = this.cleanForTextarea(details.description ?? '');
this.lastLoadedDescription = baseDesc; this.lastLoadedDescription = baseDesc;
@@ -203,7 +207,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
const fl = (ev.target as HTMLInputElement).files; const fl = (ev.target as HTMLInputElement).files;
// Nettoyage des anciens objectURL // Nettoyage des anciens objectURL
for(let url of this.previewUrls) { for (let url of this.previewUrls) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
this.previewUrls = []; this.previewUrls = [];
@@ -224,12 +228,12 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
private buildCarousel() { private buildCarousel() {
const items: CarouselItem[] = [ const items: CarouselItem[] = [
...this.existingImageUrls.map(u => ({ src: u, isPlaceholder: false })), ...this.existingImageUrls.map(u => ({src: u, isPlaceholder: false})),
...this.previewUrls.map(u => ({ src: u, isPlaceholder: false })) ...this.previewUrls.map(u => ({src: u, isPlaceholder: false}))
]; ];
// placeholder en dernier // placeholder en dernier
items.push({ src: '', isPlaceholder: true }); items.push({src: '', isPlaceholder: true});
this.carouselItems = items; this.carouselItems = items;
if (!this.carouselItems.length) { if (!this.carouselItems.length) {
@@ -261,10 +265,13 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
} }
} }
// -------- Save / close inchangés (à part dto.images) -------- // -------- Save / close --------
save() { save() {
if (this.form.invalid) return; if (this.form.invalid || this.isSaving) return;
this.isSaving = true;
this.dialogRef.disableClose = true;
const v = this.form.getRawValue(); const v = this.form.getRawValue();
const effectiveDescription = (v.description ?? '').trim() || this.lastLoadedDescription; const effectiveDescription = (v.description ?? '').trim() || this.lastLoadedDescription;
@@ -275,7 +282,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
categoryId: +v.categoryId!, categoryId: +v.categoryId!,
manufacturerId: +v.manufacturerId!, manufacturerId: +v.manufacturerId!,
supplierId: +v.supplierId!, supplierId: +v.supplierId!,
images: this.images, // toujours les fichiers sélectionnés images: this.images,
complete: !!v.complete, complete: !!v.complete,
hasManual: !!v.hasManual, hasManual: !!v.hasManual,
conditionLabel: v.conditionLabel || undefined, conditionLabel: v.conditionLabel || undefined,
@@ -291,13 +298,99 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
op$ = this.ps.updateProduct(this.productRow.id, dto) as Observable<unknown>; op$ = this.ps.updateProduct(this.productRow.id, dto) as Observable<unknown>;
} }
op$.subscribe({ op$
.pipe(
finalize(() => {
// si la boîte de dialogue est encore ouverte, on réactive tout
this.isSaving = false;
this.dialogRef.disableClose = false;
})
)
.subscribe({
next: () => this.dialogRef.close(true), next: () => this.dialogRef.close(true),
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e))) error: (e: unknown) =>
alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
}); });
} }
/** Extrait l'id_image depuis une URL FO Presta (.../img/p/.../<id>.jpg) */
private extractImageIdFromUrl(url: string): number | null {
const m = /\/(\d+)\.(?:jpg|jpeg|png|gif)$/i.exec(url);
if (!m) return null;
const id = Number(m[1]);
return Number.isFinite(id) ? id : null;
}
/** Suppression générique d'une image à l'index donné (carrousel + vignettes) */
private deleteImageAtIndex(idx: number) {
if (!this.carouselItems.length) return;
const item = this.carouselItems[idx];
if (!item || item.isPlaceholder) return;
const existingCount = this.existingImageUrls.length;
// --- Cas 1 : image existante (déjà chez Presta) ---
if (idx < existingCount) {
if (!this.productRow) return; // sécurité
const url = this.existingImageUrls[idx];
const imageId = this.extractImageIdFromUrl(url);
if (!imageId) {
alert('Impossible de déterminer lID de limage à supprimer.');
return;
}
if (!confirm('Supprimer cette image du produit ?')) return;
this.ps.deleteProductImage(this.productRow.id, imageId).subscribe({
next: () => {
// On la retire du tableau local et on reconstruit le carrousel
this.existingImageUrls.splice(idx, 1);
this.buildCarousel();
// Repositionnement de lindex si nécessaire
if (this.currentIndex >= this.carouselItems.length - 1) {
this.currentIndex = Math.max(0, this.carouselItems.length - 2);
}
},
error: (e: unknown) => {
alert('Erreur lors de la suppression de limage : ' + (e instanceof Error ? e.message : String(e)));
}
});
return;
}
// --- Cas 2 : image locale (nouvelle) ---
const localIdx = idx - existingCount;
if (localIdx >= 0 && localIdx < this.previewUrls.length) {
if (!confirm('Retirer cette image de la sélection ?')) return;
this.previewUrls.splice(localIdx, 1);
this.images.splice(localIdx, 1);
this.buildCarousel();
if (this.currentIndex >= this.carouselItems.length - 1) {
this.currentIndex = Math.max(0, this.carouselItems.length - 2);
}
}
}
// utilisée par la grande image
onDeleteCurrentImage() {
if (!this.carouselItems.length) return;
this.deleteImageAtIndex(this.currentIndex);
}
// utilisée par la croix sur une vignette
onDeleteThumb(index: number, event: MouseEvent) {
event.stopPropagation();
this.deleteImageAtIndex(index);
}
close() { close() {
if (this.isSaving) return;
this.dialogRef.close(false); this.dialogRef.close(false);
} }
} }

View File

@@ -516,6 +516,16 @@ export class PrestashopService {
); );
} }
deleteProductImage(productId: number, imageId: number) {
// Presta : DELETE /images/products/{id_product}/{id_image}
return this.http.delete(
`${this.base}/images/products/${productId}/${imageId}`,
{ responseType: 'text' }
).pipe(
map(() => true)
);
}
// -------- Stock (quantité) — gestion fine via stock_availables // -------- Stock (quantité) — gestion fine via stock_availables
getProductQuantity(productId: number) { getProductQuantity(productId: number) {

View File

@@ -2,4 +2,5 @@ export const environment = {
production: true, production: true,
apiUrl: '/gameovergne-api/api', apiUrl: '/gameovergne-api/api',
psUrl: '/gameovergne-api/api/ps', psUrl: '/gameovergne-api/api/ps',
hrefBase: '/gameovergne/',
}; };

View File

@@ -2,4 +2,5 @@ export const environment = {
production: false, production: false,
apiUrl: 'http://localhost:3000/api', apiUrl: 'http://localhost:3000/api',
psUrl: '/ps', psUrl: '/ps',
hrefBase: '/',
}; };

View File

@@ -10,6 +10,6 @@
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head> </head>
<body class="mat-typography"> <body class="mat-typography">
<app-root></app-root> <app-root></app-root>
</body> </body>
</html> </html>

View File

@@ -1,5 +1,4 @@
version: "3.9" # docker-compose.dev.yml
services: services:
mysql: mysql:
image: mysql:8.4 image: mysql:8.4
@@ -9,11 +8,10 @@ services:
MYSQL_DATABASE: gameovergne_app MYSQL_DATABASE: gameovergne_app
MYSQL_USER: gameovergne MYSQL_USER: gameovergne
MYSQL_PASSWORD: gameovergne MYSQL_PASSWORD: gameovergne
# 🔒 Persistant + accessible depuis l'extérieur
volumes: volumes:
- ./mysql-data:/var/lib/mysql - ./mysql-data:/var/lib/mysql
ports: ports:
- "3366:3306" # pour te connecter depuis ton Mac / un client SQL - "3366:3306"
networks: networks:
- gameovergne - gameovergne
restart: unless-stopped restart: unless-stopped
@@ -35,16 +33,14 @@ services:
SPRING_DATASOURCE_PASSWORD: gameovergne SPRING_DATASOURCE_PASSWORD: gameovergne
PRESTASHOP_API_KEY: 2AQPG13MJ8X117U6FJ5NGHPS93HE34AB PRESTASHOP_API_KEY: 2AQPG13MJ8X117U6FJ5NGHPS93HE34AB
SERVER_PORT: 3000 SERVER_PORT: 3000
# pense bien à avoir prestashop.base-url / prestashop.basic-auth dans application.properties ou via env si besoin
networks: networks:
- gameovergne
- traefik - traefik
- gameovergne
restart: unless-stopped restart: unless-stopped
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network=traefik - traefik.docker.network=traefik
# API sous /gameovergne-api
- traefik.http.routers.gameovergne-api.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne-api`) - traefik.http.routers.gameovergne-api.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne-api`)
- traefik.http.routers.gameovergne-api.entrypoints=edge - traefik.http.routers.gameovergne-api.entrypoints=edge
- traefik.http.routers.gameovergne-api.service=gameovergne-api - traefik.http.routers.gameovergne-api.service=gameovergne-api
@@ -65,24 +61,19 @@ services:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network=traefik - traefik.docker.network=traefik
# FRONT sous /gameovergne (avec et sans slash final)
- traefik.http.routers.gameovergne-client.rule=Host(`dev.vincent-guillet.fr`) && (Path(`/gameovergne`) || PathPrefix(`/gameovergne/`)) - traefik.http.routers.gameovergne-client.rule=Host(`dev.vincent-guillet.fr`) && (Path(`/gameovergne`) || PathPrefix(`/gameovergne/`))
- traefik.http.routers.gameovergne-client.entrypoints=edge - traefik.http.routers.gameovergne-client.entrypoints=edge
- traefik.http.routers.gameovergne-client.service=gameovergne-client - traefik.http.routers.gameovergne-client.service=gameovergne-client
- traefik.http.routers.gameovergne-client.middlewares=gameovergne-slash,gameovergne-client-stripprefix - traefik.http.routers.gameovergne-client.middlewares=gameovergne-slash,gameovergne-client-stripprefix
# Redirige /gameovergne vers /gameovergne/
- traefik.http.middlewares.gameovergne-slash.redirectregex.regex=^https?://([^/]+)/gameovergne$$ - traefik.http.middlewares.gameovergne-slash.redirectregex.regex=^https?://([^/]+)/gameovergne$$
- traefik.http.middlewares.gameovergne-slash.redirectregex.replacement=https://$${1}/gameovergne/ - traefik.http.middlewares.gameovergne-slash.redirectregex.replacement=https://$${1}/gameovergne/
- traefik.http.middlewares.gameovergne-slash.redirectregex.permanent=true - traefik.http.middlewares.gameovergne-slash.redirectregex.permanent=true
# Enlève /gameovergne avant d'envoyer vers Nginx (le conteneur Angular)
- traefik.http.middlewares.gameovergne-client-stripprefix.stripprefix.prefixes=/gameovergne - traefik.http.middlewares.gameovergne-client-stripprefix.stripprefix.prefixes=/gameovergne
# Service vers Nginx (port 80 dans le conteneur)
- traefik.http.services.gameovergne-client.loadbalancer.server.port=80 - traefik.http.services.gameovergne-client.loadbalancer.server.port=80
# Proxy Presta via /gameovergne/ps -> même service, même StripPrefix
- traefik.http.routers.gameovergne-ps.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne/ps`) - traefik.http.routers.gameovergne-ps.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne/ps`)
- traefik.http.routers.gameovergne-ps.entrypoints=edge - traefik.http.routers.gameovergne-ps.entrypoints=edge
- traefik.http.routers.gameovergne-ps.service=gameovergne-client - traefik.http.routers.gameovergne-ps.service=gameovergne-client
@@ -92,4 +83,4 @@ networks:
traefik: traefik:
external: true external: true
gameovergne: gameovergne:
driver: bridge external: true

86
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,86 @@
# docker-compose.prod.yml
services:
mysql:
image: mysql:8.4
container_name: gameovergne-mysql-prod
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: gameovergne_app
MYSQL_USER: gameovergne
MYSQL_PASSWORD: gameovergne
volumes:
- ./mysql-data-prod:/var/lib/mysql
ports:
- "3366:3306"
networks:
- gameovergne
restart: unless-stopped
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-proot"]
interval: 10s
timeout: 5s
retries: 10
spring:
image: registry.vincent-guillet.fr/gameovergne-api:prod-latest
container_name: gameovergne-api-prod
depends_on:
mysql:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/gameovergne_app?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
SPRING_DATASOURCE_USERNAME: gameovergne
SPRING_DATASOURCE_PASSWORD: gameovergne
PRESTASHOP_API_KEY: 2AQPG13MJ8X117U6FJ5NGHPS93HE34AB
SERVER_PORT: 3000
networks:
- traefik
- gameovergne
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- traefik.http.routers.gameovergne-api.rule=Host(`projets.vincent-guillet.fr`) && PathPrefix(`/gameovergne-api`)
- traefik.http.routers.gameovergne-api.entrypoints=edge
- traefik.http.routers.gameovergne-api.service=gameovergne-api
- traefik.http.services.gameovergne-api.loadbalancer.server.port=3000
- traefik.http.routers.gameovergne-api.middlewares=gameovergne-api-stripprefix
- traefik.http.middlewares.gameovergne-api-stripprefix.stripprefix.prefixes=/gameovergne-api
angular:
image: registry.vincent-guillet.fr/gameovergne-client:prod-latest
container_name: gameovergne-client-prod
depends_on:
- spring
networks:
- gameovergne
- traefik
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- traefik.http.routers.gameovergne-client.rule=Host(`projets.vincent-guillet.fr`) && (Path(`/gameovergne`) || PathPrefix(`/gameovergne/`))
- traefik.http.routers.gameovergne-client.entrypoints=edge
- traefik.http.routers.gameovergne-client.service=gameovergne-client
- traefik.http.routers.gameovergne-client.middlewares=gameovergne-slash,gameovergne-client-stripprefix
- traefik.http.middlewares.gameovergne-slash.redirectregex.regex=^https?://([^/]+)/gameovergne$$
- traefik.http.middlewares.gameovergne-slash.redirectregex.replacement=https://$${1}/gameovergne/
- traefik.http.middlewares.gameovergne-slash.redirectregex.permanent=true
- traefik.http.middlewares.gameovergne-client-stripprefix.stripprefix.prefixes=/gameovergne
- traefik.http.services.gameovergne-client.loadbalancer.server.port=80
- traefik.http.routers.gameovergne-ps.rule=Host(`projets.vincent-guillet.fr`) && PathPrefix(`/gameovergne/ps`)
- traefik.http.routers.gameovergne-ps.entrypoints=edge
- traefik.http.routers.gameovergne-ps.service=gameovergne-client
- traefik.http.routers.gameovergne-ps.middlewares=gameovergne-client-stripprefix
networks:
traefik:
external: true
gameovergne:
external: true

85
docker-compose.yml.OLD Normal file
View File

@@ -0,0 +1,85 @@
services:
mysql:
image: mysql:8.4
container_name: gameovergne-mysql
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: gameovergne_app
MYSQL_USER: gameovergne
MYSQL_PASSWORD: gameovergne
volumes:
- ./mysql-data:/var/lib/mysql
ports:
- "3366:3306"
networks:
- gameovergne
restart: unless-stopped
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-proot"]
interval: 10s
timeout: 5s
retries: 10
spring:
image: registry.vincent-guillet.fr/gameovergne-api:dev-latest
container_name: gameovergne-api
depends_on:
mysql:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/gameovergne_app?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
SPRING_DATASOURCE_USERNAME: gameovergne
SPRING_DATASOURCE_PASSWORD: gameovergne
PRESTASHOP_API_KEY: 2AQPG13MJ8X117U6FJ5NGHPS93HE34AB
SERVER_PORT: 3000
networks:
- traefik
- gameovergne
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- traefik.http.routers.gameovergne-api.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne-api`)
- traefik.http.routers.gameovergne-api.entrypoints=edge
- traefik.http.routers.gameovergne-api.service=gameovergne-api
- traefik.http.services.gameovergne-api.loadbalancer.server.port=3000
- traefik.http.routers.gameovergne-api.middlewares=gameovergne-api-stripprefix
- traefik.http.middlewares.gameovergne-api-stripprefix.stripprefix.prefixes=/gameovergne-api
angular:
image: registry.vincent-guillet.fr/gameovergne-client:dev-latest
container_name: gameovergne-client
depends_on:
- spring
networks:
- gameovergne
- traefik
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- traefik.http.routers.gameovergne-client.rule=Host(`dev.vincent-guillet.fr`) && (Path(`/gameovergne`) || PathPrefix(`/gameovergne/`))
- traefik.http.routers.gameovergne-client.entrypoints=edge
- traefik.http.routers.gameovergne-client.service=gameovergne-client
- traefik.http.routers.gameovergne-client.middlewares=gameovergne-slash,gameovergne-client-stripprefix
- traefik.http.middlewares.gameovergne-slash.redirectregex.regex=^https?://([^/]+)/gameovergne$$
- traefik.http.middlewares.gameovergne-slash.redirectregex.replacement=https://$${1}/gameovergne/
- traefik.http.middlewares.gameovergne-slash.redirectregex.permanent=true
- traefik.http.middlewares.gameovergne-client-stripprefix.stripprefix.prefixes=/gameovergne
- traefik.http.services.gameovergne-client.loadbalancer.server.port=80
- traefik.http.routers.gameovergne-ps.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne/ps`)
- traefik.http.routers.gameovergne-ps.entrypoints=edge
- traefik.http.routers.gameovergne-ps.service=gameovergne-client
- traefik.http.routers.gameovergne-ps.middlewares=gameovergne-client-stripprefix
networks:
traefik:
external: true
gameovergne:
external: true

View File

@@ -1,72 +1,119 @@
pipeline { pipeline {
agent any agent none
tools {
maven 'mvn'
nodejs 'npm'
}
environment { environment {
JAVA_HOME = '/opt/java/openjdk' REGISTRY = 'registry.vincent-guillet.fr'
PATH = "${JAVA_HOME}/bin:${env.PATH}" API_IMAGE_DEV = "${REGISTRY}/gameovergne-api:dev-latest"
SPRING_IMAGE_NAME = 'spring-jenkins' CLIENT_IMAGE_DEV = "${REGISTRY}/gameovergne-client:dev-latest"
ANGULAR_IMAGE_NAME = 'angular-jenkins' API_IMAGE_PROD = "${REGISTRY}/gameovergne-api:prod-latest"
IMAGE_TAG = 'latest' CLIENT_IMAGE_PROD = "${REGISTRY}/gameovergne-client:prod-latest"
COMPOSE_PROJECT = 'gameovergne-app' COMPOSE_PROJECT = 'gameovergne-app'
} }
stages { stages {
stage('Checkout sur la branche dev') {
// Build & push images (toujours sur ct-home-dev)
stage('Build & Push Docker Images') {
agent { label 'ct-home-dev' }
steps { steps {
git branch: 'dev', url: 'https://gitea.vincent-guillet.fr/vincentguillet/gameovergne-app.git' // Multi-branch friendly : Jenkins fait le checkout de la branche courante
checkout scm
script {
// Choix des tags selon la branche
if (env.BRANCH_NAME == 'main') {
env.API_IMAGE = API_IMAGE_PROD
env.CLIENT_IMAGE = CLIENT_IMAGE_PROD
} else {
env.API_IMAGE = API_IMAGE_DEV
env.CLIENT_IMAGE = CLIENT_IMAGE_DEV
} }
} }
stage('Maven Build') { // ----- Build API -----
steps {
dir('api') { dir('api') {
sh 'mvn clean package -DskipTests' sh """
} echo "=== Build image API ${API_IMAGE} ==="
} docker build -t ${API_IMAGE} .
"""
} }
stage('Angular Build') { // ----- Build Client -----
steps {
dir('client') { dir('client') {
sh 'npm install' sh """
sh 'npm run build' echo "=== Build image CLIENT ${CLIENT_IMAGE} ==="
docker build -t ${CLIENT_IMAGE} .
"""
} }
// ----- Push vers registry -----
sh """
echo "=== Push images vers ${REGISTRY} ==="
docker push ${API_IMAGE}
docker push ${CLIENT_IMAGE}
"""
} }
} }
stage('Spring Docker Build') { // Déploiement DEV (ct-home-dev, branche dev)
steps { stage('Deploy DEV') {
sh 'docker build -t registry.vincent-guillet.fr/gameovergne-api:dev-latest ./api' when {
} branch 'dev'
} }
agent { label 'ct-home-dev' }
stage('Angular Docker Build') {
steps { steps {
sh 'docker build -t registry.vincent-guillet.fr/gameovergne-client:dev-latest ./client' checkout scm
}
}
stage('Deployment') {
steps {
withEnv([ withEnv([
"DOCKER_HOST=unix:///var/run/docker.sock", "DOCKER_HOST=unix:///var/run/docker.sock",
"COMPOSE_PROJECT_NAME=${env.COMPOSE_PROJECT}" "COMPOSE_PROJECT_NAME=${env.COMPOSE_PROJECT}"
]) { ]) {
sh ''' sh """
echo "=== Nettoyage des anciens conteneurs nommés ===" echo "=== [DEV] Nettoyage anciens conteneurs ==="
docker rm -f gameovergne-api gameovergne-client 2>/dev/null || true docker rm -f gameovergne-api gameovergne-client 2>/dev/null || true
echo "=== docker-compose down sur le projet courant ===" echo "=== [DEV] docker compose down ==="
docker-compose down -v || true docker compose -f docker-compose.dev.yml down -v || true
echo "=== (Re)création de la stack MySQL + Spring + Angular ===" echo "=== [DEV] docker compose pull ==="
docker-compose up -d mysql spring angular docker compose -f docker-compose.dev.yml pull
'''
echo "=== [DEV] docker compose up (force recreate) ==="
docker compose -f docker-compose.dev.yml up -d --force-recreate mysql spring angular
"""
}
}
}
// Déploiement PROD (ct-home-projets, branche main)
stage('Deploy PROD') {
when {
branch 'main'
}
agent { label 'ct-home-projets' }
steps {
checkout scm
withEnv([
"DOCKER_HOST=unix:///var/run/docker.sock",
"COMPOSE_PROJECT_NAME=${env.COMPOSE_PROJECT}"
]) {
sh """
echo "=== [PROD] Nettoyage anciens conteneurs ==="
docker rm -f gameovergne-api-prod gameovergne-client-prod 2>/dev/null || true
echo "=== [PROD] docker compose down ==="
docker compose -f docker-compose.prod.yml down || true
echo "=== [PROD] docker compose pull ==="
docker compose -f docker-compose.prod.yml pull
echo "=== [PROD] docker compose up (force recreate) ==="
docker compose -f docker-compose.prod.yml up -d --force-recreate mysql spring angular
"""
} }
} }
} }