feat : compression des images

This commit is contained in:
styve Lioumba
2025-12-22 17:09:15 +01:00
parent 52422724b8
commit 4bca6112bb
25 changed files with 192 additions and 194 deletions

20
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "trouve-ton-profile", "name": "trouve-ton-profile",
"version": "1.1.0", "version": "1.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trouve-ton-profile", "name": "trouve-ton-profile",
"version": "1.1.0", "version": "1.3.1",
"dependencies": { "dependencies": {
"@angular/animations": "^17.0.0", "@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0", "@angular/common": "^17.0.0",
@@ -24,6 +24,7 @@
"@fortawesome/free-regular-svg-icons": "^6.4.2", "@fortawesome/free-regular-svg-icons": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^6.4.2",
"@ngneat/until-destroy": "^10.0.0", "@ngneat/until-destroy": "^10.0.0",
"browser-image-compression": "^2.0.2",
"express": "^4.18.2", "express": "^4.18.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"ng2-pdf-viewer": "^10.3.3", "ng2-pdf-viewer": "^10.3.3",
@@ -8641,6 +8642,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/browser-image-compression": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz",
"integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==",
"license": "MIT",
"dependencies": {
"uzip": "0.20201231.0"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.27.0", "version": "4.27.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz",
@@ -19814,6 +19824,12 @@
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
}, },
"node_modules/uzip": {
"version": "0.20201231.0",
"resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz",
"integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==",
"license": "MIT"
},
"node_modules/v8-to-istanbul": { "node_modules/v8-to-istanbul": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",

View File

@@ -41,6 +41,7 @@
"@fortawesome/free-regular-svg-icons": "^6.4.2", "@fortawesome/free-regular-svg-icons": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^6.4.2",
"@ngneat/until-destroy": "^10.0.0", "@ngneat/until-destroy": "^10.0.0",
"browser-image-compression": "^2.0.2",
"express": "^4.18.2", "express": "^4.18.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"ng2-pdf-viewer": "^10.3.3", "ng2-pdf-viewer": "^10.3.3",

View File

@@ -5,6 +5,6 @@ export interface Project {
nom: string; nom: string;
lien: string; lien: string;
description: string; description: string;
fichier: string[]; fichier: string;
utilisateur: string; utilisateur: string;
} }

View File

@@ -1,4 +1,4 @@
@if (profile != undefined) { @if (profile(); as profile) {
<section <section
class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800" class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800"
> >
@@ -33,7 +33,7 @@
</button> </button>
<!-- Badge vérifié --> <!-- Badge vérifié -->
@if (profile().estVerifier) { @if (profile.estVerifier) {
<div <div
class="flex items-center gap-2 bg-purple-500/20 backdrop-blur-md px-3 py-2 rounded-full animate-pulse-slow" class="flex items-center gap-2 bg-purple-500/20 backdrop-blur-md px-3 py-2 rounded-full animate-pulse-slow"
> >
@@ -60,33 +60,17 @@
<div class="relative -mt-16 md:-mt-22 px-4 md:px-8 pb-6"> <div class="relative -mt-16 md:-mt-22 px-4 md:px-8 pb-6">
<div class="flex flex-col md:flex-row items-center md:items-end gap-4 md:gap-6"> <div class="flex flex-col md:flex-row items-center md:items-end gap-4 md:gap-6">
<!-- Avatar avec indicateur de visibilité --> <!-- Avatar avec indicateur de visibilité -->
@if (user() != undefined) { @if (user(); as user) {
<div class="relative group animate-slide-up"> <div class="relative group animate-slide-up">
<div <div
class="w-28 h-28 md:w-36 md:h-36 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 p-1 shadow-2xl group-hover:scale-105 transition-transform duration-300" class="w-28 h-28 md:w-36 md:h-36 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 p-1 shadow-2xl group-hover:scale-105 transition-transform duration-300"
> >
<div class="w-full h-full rounded-full overflow-hidden bg-white"> <div class="w-full h-full rounded-full overflow-hidden bg-white">
@if (user()!.avatar) {
<img <img
alt="{{ user()!.name }}" alt="{{ user.name }}"
class="object-cover w-full h-full" class="object-cover w-full h-full"
src="{{ environment.baseUrl }}/api/files/users/{{ user()!.id }}/{{ src="{{ user.avatar }}"
user()!.avatar
}}"
/> />
} @else {
<img
alt="{{ user()!.name }}"
class="object-cover w-full h-full"
src="https://api.dicebear.com/9.x/initials/svg?seed={{
user().name
? user().name
: user().username
? user().username
: user().email
}}"
/>
}
</div> </div>
</div> </div>
@@ -141,22 +125,16 @@
} }
<!-- Nom et titre --> <!-- Nom et titre -->
@if (user() != undefined) { @if (user(); as user) {
<div <div
class="flex-1 text-center md:text-left mb-4 mt-8 md:mt-4 md:mb-0 animate-slide-up animation-delay-100" class="flex-1 text-center md:text-left mb-4 mt-8 md:mt-4 md:mb-0 animate-slide-up animation-delay-100"
> >
@if (user()!.name) {
<h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1"> <h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1">
{{ user()!.name }} {{ user.name }}
</h1> </h1>
} @else {
<h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1">
{{ user()!.email }}
</h1>
}
<p class="text-lg md:text-xl text-indigo-600 dark:text-indigo-400 font-semibold"> <p class="text-lg md:text-xl text-indigo-600 dark:text-indigo-400 font-semibold">
{{ profile().profession | uppercase }} {{ profile.profession }}
</p> </p>
</div> </div>
} }
@@ -195,7 +173,7 @@
<!-- Sidebar - Informations --> <!-- Sidebar - Informations -->
<aside class="lg:col-span-1 space-y-6 animate-slide-up animation-delay-200"> <aside class="lg:col-span-1 space-y-6 animate-slide-up animation-delay-200">
<!-- Card Apropos --> <!-- Card Apropos -->
@if (profile().apropos) { @if (profile.apropos) {
<div <div
class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 hover:shadow-xl transition-shadow duration-300" class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 hover:shadow-xl transition-shadow duration-300"
> >
@@ -217,7 +195,7 @@
À propos À propos
</h3> </h3>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed text-base"> <p class="text-gray-700 dark:text-gray-300 leading-relaxed text-base">
{{ profile().apropos }} {{ profile.apropos }}
</p> </p>
</div> </div>
} }
@@ -243,20 +221,13 @@
</svg> </svg>
Biographie Biographie
</h3> </h3>
@if (profile().bio) {
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed"> <p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
{{ profile().bio }} {{ profile.bio }}
</p> </p>
} @else {
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
Je suis sur la plateforme Trouve Ton Profile pour partager mon expertise et mes
compétences.
</p>
}
</div> </div>
<!-- Card Secteur --> <!-- Card Secteur -->
@if (profile().secteur) { @if (profile.secteur) {
<div <div
class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 hover:shadow-xl transition-shadow duration-300" class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 hover:shadow-xl transition-shadow duration-300"
> >
@@ -275,12 +246,12 @@
</svg> </svg>
Secteur Secteur
</h3> </h3>
<app-chips [sectorId]="profile().secteur" /> <app-chips [sectorId]="profile.secteur" />
</div> </div>
} }
<!-- Card Réseaux --> <!-- Card Réseaux -->
@if (profile().reseaux) { @if (profile.reseaux) {
<div <div
class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 hover:shadow-xl transition-shadow duration-300" class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 hover:shadow-xl transition-shadow duration-300"
> >
@@ -302,7 +273,7 @@
</svg> </svg>
Réseaux Réseaux
</h3> </h3>
<app-reseaux [reseaux]="profile().reseaux" /> <app-reseaux [reseaux]="profile.reseaux" />
</div> </div>
} }
</aside> </aside>

View File

@@ -27,7 +27,6 @@ import { MyProfileMapComponent } from '@app/shared/features/my-profile-map/my-pr
RouterOutlet, RouterOutlet,
MyProfileUpdateFormComponent, MyProfileUpdateFormComponent,
PdfViewerComponent, PdfViewerComponent,
UpperCasePipe,
LoadingComponent, LoadingComponent,
SettingsComponent, SettingsComponent,
NgTemplateOutlet, NgTemplateOutlet,

View File

@@ -91,21 +91,12 @@
class="w-28 h-28 md:w-36 md:h-36 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 p-1 shadow-2xl group-hover:scale-105 transition-transform duration-300" class="w-28 h-28 md:w-36 md:h-36 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 p-1 shadow-2xl group-hover:scale-105 transition-transform duration-300"
> >
<div class="w-full h-full rounded-full overflow-hidden bg-white"> <div class="w-full h-full rounded-full overflow-hidden bg-white">
@if (user().avatar) {
<img <img
alt="{{ user().name }}" alt="{{ user().name }}"
class="object-cover w-full h-full" class="object-cover w-full h-full"
src="{{ profile().avatarUrl }}" src="{{ profile().avatarUrl }}"
loading="lazy" loading="lazy"
/> />
} @else {
<img
alt="{{ user().name }}"
class="object-cover w-full h-full"
src="https://api.dicebear.com/9.x/initials/svg?seed={{ user().name }}"
loading="lazy"
/>
}
</div> </div>
</div> </div>
<!-- Indicateur en ligne (optionnel) --> <!-- Indicateur en ligne (optionnel) -->
@@ -118,18 +109,12 @@
<div <div
class="flex-1 text-center md:text-left mb-4 md:mb-0 animate-slide-up animation-delay-100" class="flex-1 text-center md:text-left mb-4 md:mb-0 animate-slide-up animation-delay-100"
> >
@if (user().name) {
<h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1"> <h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1">
{{ user().name }} {{ user().name }}
</h1> </h1>
} @else {
<h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1">
{{ user().email }}
</h1>
}
<p class="text-lg md:text-xl text-indigo-600 dark:text-indigo-400 font-semibold"> <p class="text-lg md:text-xl text-indigo-600 dark:text-indigo-400 font-semibold">
{{ profile()!.profession | uppercase }} {{ profile()!.profession }}
</p> </p>
</div> </div>
</div> </div>
@@ -161,17 +146,9 @@
</svg> </svg>
Biographie Biographie
</h3> </h3>
@if (profile()!.bio) {
<p class="text-gray-600 dark:text-gray-300 text-sm leading-relaxed"> <p class="text-gray-600 dark:text-gray-300 text-sm leading-relaxed">
{{ profile()!.bio }} {{ profile()!.bio }}
</p> </p>
} @else {
<p class="text-gray-600 dark:text-gray-300 text-sm leading-relaxed">
Je suis sur la plateforme Trouve Ton Profile pour partager mon expertise et mes
compétences. N'hésitez pas à me contacter pour en savoir plus sur mon parcours et
mes domaines d'intervention.
</p>
}
</div> </div>
<!-- Card Secteur --> <!-- Card Secteur -->

View File

@@ -4,22 +4,13 @@
> >
<!-- Image du projet --> <!-- Image du projet -->
<div class="relative h-48 overflow-hidden bg-gray-100 dark:bg-gray-700"> <div class="relative h-48 overflow-hidden bg-gray-100 dark:bg-gray-700">
@if (project.fichier) {
<img <img
alt="{{ project.nom }}" alt="{{ project.nom }}"
class="w-full h-full object-cover object-center transition-transform duration-500 group-hover:scale-110" class="w-full h-full object-cover object-center transition-transform duration-500 group-hover:scale-110"
src="{{ environment.baseUrl }}/api/files/projets/{{ project.id }}/{{ project.fichier }}" ngSrc="{{ project.fichier }}"
loading="lazy" loading="lazy"
fill
/> />
} @else {
<img
alt="{{ project.nom }}"
class="w-full h-full object-cover object-center transition-transform duration-500 group-hover:scale-110 opacity-60"
src="https://api.dicebear.com/9.x/shapes/svg?seed={{ project.nom }}"
loading="lazy"
/>
}
<!-- Overlay gradient au hover --> <!-- Overlay gradient au hover -->
<div <div
class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"
@@ -37,7 +28,7 @@
<!-- Description --> <!-- Description -->
<p class="text-sm text-gray-600 dark:text-gray-300 line-clamp-3"> <p class="text-sm text-gray-600 dark:text-gray-300 line-clamp-3">
{{ project.description || 'Aucune description disponible.' }} {{ project.description }}
</p> </p>
<!-- Lien vers le projet --> <!-- Lien vers le projet -->

View File

@@ -1,11 +1,12 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { environment } from '@env/environment'; import { environment } from '@env/environment';
import { ProjectViewModel } from '@app/ui/projects/project.presenter.model'; import { ProjectViewModel } from '@app/ui/projects/project.presenter.model';
import { NgOptimizedImage } from '@angular/common';
@Component({ @Component({
selector: 'app-project-item', selector: 'app-project-item',
standalone: true, standalone: true,
imports: [], imports: [NgOptimizedImage],
templateUrl: './project-item.component.html', templateUrl: './project-item.component.html',
styleUrl: './project-item.component.scss', styleUrl: './project-item.component.scss',
}) })

View File

@@ -20,8 +20,9 @@
<img <img
[alt]="project()?.nom || 'nouveau-projet'" [alt]="project()?.nom || 'nouveau-projet'"
class="object-cover object-center h-full w-full" class="object-cover object-center h-full w-full"
[src]="currentImageUrl" ngSrc="{{ currentImageUrl }}"
loading="lazy" loading="lazy"
fill
/> />
</div> </div>

View File

@@ -63,7 +63,7 @@ describe('ProjectPictureFormComponent', () => {
it("devrait afficher l'image du serveur si pas de preview", () => { it("devrait afficher l'image du serveur si pas de preview", () => {
// mockProject a 'image-serveur.jpg' // mockProject a 'image-serveur.jpg'
expect(component.currentImageUrl).toContain( expect(component.currentImageUrl).toContain(
'http://localhost:8090/api/files/projets/1/portfolio-preview.png,scene-setup.glb' 'http://localhost:8090/api/files/projets/1/portfolio-preview.png'
); );
}); });

View File

@@ -1,5 +1,5 @@
import { Component, effect, inject, input, output, signal } from '@angular/core'; import { Component, effect, inject, input, output, signal } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common'; import { NgOptimizedImage, NgTemplateOutlet } from '@angular/common';
import { environment } from '@env/environment'; import { environment } from '@env/environment';
import { ProjectViewModel } from '@app/ui/projects/project.presenter.model'; import { ProjectViewModel } from '@app/ui/projects/project.presenter.model';
import { ProjectFacade } from '@app/ui/projects/project.facade'; import { ProjectFacade } from '@app/ui/projects/project.facade';
@@ -10,7 +10,7 @@ import { FileManagerService } from '@app/ui/shared/services/file-manager.service
@Component({ @Component({
selector: 'app-project-picture-form', selector: 'app-project-picture-form',
standalone: true, standalone: true,
imports: [LoadingComponent, NgTemplateOutlet], imports: [LoadingComponent, NgTemplateOutlet, NgOptimizedImage],
templateUrl: './project-picture-form.component.html', templateUrl: './project-picture-form.component.html',
styleUrl: './project-picture-form.component.scss', styleUrl: './project-picture-form.component.scss',
}) })
@@ -73,11 +73,11 @@ export class ProjectPictureFormComponent {
return this.imagePreviewUrl(); return this.imagePreviewUrl();
} }
if (this.project()!.fichier) { if (this.project()?.fichier) {
return `${this.environment.baseUrl}/api/files/projets/${this.project()!.id}/${this.project()!.fichier}`; return `${this.environment.baseUrl}/api/files/projets/${this.project()!.id}/${this.project()!.fichier}?thumb=320x240`;
} }
if (this.project()!.nom) { if (this.project()?.nom) {
return `https://api.dicebear.com/9.x/shapes/svg?seed=${this.project()!.nom}`; return `https://api.dicebear.com/9.x/shapes/svg?seed=${this.project()!.nom}`;
} }

View File

@@ -14,15 +14,16 @@
<!-- Avatar avec preview --> <!-- Avatar avec preview -->
<div class="flex flex-col items-center space-y-4"> <div class="flex flex-col items-center space-y-4">
<div class="relative"> <div class="relative">
@if (user() !== undefined) { @if (user(); as user) {
<div <div
class="w-40 h-40 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 flex items-center justify-center shadow-lg ring-4 ring-gray-100 dark:ring-gray-600" class="w-40 h-40 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 flex items-center justify-center shadow-lg ring-4 ring-gray-100 dark:ring-gray-600"
> >
<img <img
[alt]="user()!.username || 'Avatar'" [alt]="user.username || 'Avatar'"
class="object-cover object-center h-full w-full" class="object-cover object-center h-full w-full"
[src]="currentAvatarUrl" ngSrc="{{ currentAvatarUrl }}"
loading="lazy" loading="lazy"
fill
/> />
</div> </div>
} }

View File

@@ -2,7 +2,7 @@ import { Component, effect, inject, input, signal } from '@angular/core';
import { User } from '@app/domain/users/user.model'; import { User } from '@app/domain/users/user.model';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
import { environment } from '@env/environment'; import { environment } from '@env/environment';
import { NgTemplateOutlet } from '@angular/common'; import { NgOptimizedImage, NgTemplateOutlet } from '@angular/common';
import { UserFacade } from '@app/ui/users/user.facade'; import { UserFacade } from '@app/ui/users/user.facade';
import { ActionType } from '@app/domain/action-type.util'; import { ActionType } from '@app/domain/action-type.util';
import { LoadingComponent } from '@app/shared/components/loading/loading.component'; import { LoadingComponent } from '@app/shared/components/loading/loading.component';
@@ -13,7 +13,7 @@ import { FileManagerService } from '@app/ui/shared/services/file-manager.service
@Component({ @Component({
selector: 'app-user-avatar-form', selector: 'app-user-avatar-form',
standalone: true, standalone: true,
imports: [ReactiveFormsModule, NgTemplateOutlet, LoadingComponent], imports: [ReactiveFormsModule, NgTemplateOutlet, LoadingComponent, NgOptimizedImage],
templateUrl: './user-avatar-form.component.html', templateUrl: './user-avatar-form.component.html',
styleUrl: './user-avatar-form.component.scss', styleUrl: './user-avatar-form.component.scss',
}) })
@@ -80,7 +80,7 @@ export class UserAvatarFormComponent {
} }
if (currentUser?.avatar) { if (currentUser?.avatar) {
return `${this.environment.baseUrl}/api/files/users/${currentUser.id}/${currentUser.avatar}`; return `${this.environment.baseUrl}/api/files/users/${currentUser.id}/${currentUser.avatar}?thumb=320x240`;
} }
if (currentUser) { if (currentUser) {

View File

@@ -1,7 +1,7 @@
@if (user() !== undefined) { @if (user(); as user) {
<a <a
[routerLink]="[profile()!.slug!]" [routerLink]="[profile()!.slug!]"
[state]="{ user: user(), profile: profile() }" [state]="{ user: user, profile: profile() }"
class="block group" class="block group"
> >
<!-- Card du profil --> <!-- Card du profil -->
@@ -33,44 +33,33 @@
<!-- Avatar avec bordure gradient --> <!-- Avatar avec bordure gradient -->
<div class="relative inline-block mb-4"> <div class="relative inline-block mb-4">
<div class="w-32 h-32 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 p-1"> <div class="w-32 h-32 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 p-1">
@if (profile().avatarUrl) {
<img <img
class="w-full h-full rounded-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500 group-hover:scale-105" class="w-full h-full rounded-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500 group-hover:scale-105"
src="{{ profile().avatarUrl }}" src="{{ profile().avatarUrl }}"
alt="{{ user().username }}" alt="{{ user.username }}"
loading="lazy" loading="lazy"
/> />
} @else {
<img
class="w-full h-full rounded-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500 group-hover:scale-105"
src="https://api.dicebear.com/9.x/initials/svg?seed={{ user().name }}"
alt="{{ user().username }}"
loading="lazy"
/>
}
</div> </div>
</div> </div>
<!-- Nom --> <!-- Nom -->
@if (user().name) { @if (user.name) {
<h3 <h3
class="text-lg font-bold text-gray-900 dark:text-white mb-2 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors" class="text-lg font-bold text-gray-900 dark:text-white mb-2 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors"
> >
{{ user().firstName }} {{ user().lastName }} {{ user.firstName }} {{ user.lastName }}
</h3> </h3>
} @else if (user().username) { } @else if (user.username) {
<h3 <h3
class="text-lg font-bold text-gray-900 dark:text-white mb-2 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors" class="text-lg font-bold text-gray-900 dark:text-white mb-2 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors"
> >
{{ user().username }} {{ user.username }}
</h3> </h3>
} @else {
<h3 class="text-lg font-bold text-gray-500 dark:text-gray-400 mb-2">Non renseigné</h3>
} }
<!-- Profession --> <!-- Profession -->
<p class="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2"> <p class="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">
{{ profile().profession || 'Profession non renseignée' }} {{ profile().profession }}
</p> </p>
</div> </div>

View File

@@ -1,4 +1,4 @@
@if (user != undefined) { @if (user) {
<div class="space-y-6"> <div class="space-y-6">
<!-- Section Avatar --> <!-- Section Avatar -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 animate-fade-in"> <div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 animate-fade-in">

View File

@@ -10,7 +10,7 @@ export class FakeProjectRepository implements ProjectRepository {
create(projectDto: CreateProjectDto): Observable<Project> { create(projectDto: CreateProjectDto): Observable<Project> {
const newProject: Project = { const newProject: Project = {
...projectDto, ...projectDto,
fichier: [], fichier: '',
id: (this.projects.length + 1).toString(), id: (this.projects.length + 1).toString(),
created: new Date().toISOString(), created: new Date().toISOString(),
updated: new Date().toISOString(), updated: new Date().toISOString(),

View File

@@ -47,7 +47,7 @@ describe('PbProjectRepository', () => {
id: '123', id: '123',
created: '2025-01-01T00:00:00Z', created: '2025-01-01T00:00:00Z',
updated: '2025-01-01T00:00:00Z', updated: '2025-01-01T00:00:00Z',
fichier: [], fichier: '',
...dto, ...dto,
}; };
@@ -89,7 +89,7 @@ describe('PbProjectRepository', () => {
nom: 'Test', nom: 'Test',
lien: '', lien: '',
description: '', description: '',
fichier: [], fichier: '',
utilisateur: 'user_001', utilisateur: 'user_001',
}; };
@@ -115,7 +115,7 @@ describe('PbProjectRepository', () => {
nom: data.nom, nom: data.nom,
lien: '', lien: '',
description: '', description: '',
fichier: [], fichier: '',
utilisateur: 'user_001', utilisateur: 'user_001',
}; };

View File

@@ -10,7 +10,7 @@ export const mockProjects: Project[] = [
lien: 'https://portfolio-3d.example.com', lien: 'https://portfolio-3d.example.com',
description: description:
'Un site web interactif utilisant Three.js et Angular 17 pour présenter un portfolio 3D.', 'Un site web interactif utilisant Three.js et Angular 17 pour présenter un portfolio 3D.',
fichier: ['portfolio-preview.png', 'scene-setup.glb'], fichier: 'portfolio-preview.png',
utilisateur: 'user_001', utilisateur: 'user_001',
}, },
{ {
@@ -21,7 +21,7 @@ export const mockProjects: Project[] = [
lien: 'https://budget-app.example.com', lien: 'https://budget-app.example.com',
description: description:
'Une application mobile multiplateforme développée avec Angular et Capacitor pour suivre les dépenses quotidiennes.', 'Une application mobile multiplateforme développée avec Angular et Capacitor pour suivre les dépenses quotidiennes.',
fichier: ['budget-app-icon.png', 'screenshot-dashboard.png'], fichier: 'budget-app-icon.png',
utilisateur: 'user_002', utilisateur: 'user_002',
}, },
{ {
@@ -32,7 +32,7 @@ export const mockProjects: Project[] = [
lien: 'https://presence-system.example.com', lien: 'https://presence-system.example.com',
description: description:
'Un projet SaaS développé avec Angular et Spring Boot pour gérer la présence des étudiants dans les écoles.', 'Un projet SaaS développé avec Angular et Spring Boot pour gérer la présence des étudiants dans les écoles.',
fichier: ['attendance-report.pdf'], fichier: 'attendance-report.pdf',
utilisateur: 'user_001', utilisateur: 'user_001',
}, },
{ {
@@ -43,7 +43,7 @@ export const mockProjects: Project[] = [
lien: 'https://crypto-dashboard.example.com', lien: 'https://crypto-dashboard.example.com',
description: description:
'Un tableau de bord en temps réel affichant les cours et tendances des crypto-monnaies avec RxJS et WebSocket.', 'Un tableau de bord en temps réel affichant les cours et tendances des crypto-monnaies avec RxJS et WebSocket.',
fichier: ['crypto-dashboard-chart.png'], fichier: 'crypto-dashboard-chart.png',
utilisateur: 'user_003', utilisateur: 'user_003',
}, },
{ {
@@ -54,7 +54,7 @@ export const mockProjects: Project[] = [
lien: 'https://api-project-manager.example.com', lien: 'https://api-project-manager.example.com',
description: description:
'Backend RESTful pour la gestion des projets, conçu avec Node.js, Express et PostgreSQL.', 'Backend RESTful pour la gestion des projets, conçu avec Node.js, Express et PostgreSQL.',
fichier: ['openapi-schema.yaml'], fichier: 'openapi-schema.yaml',
utilisateur: 'user_001', utilisateur: 'user_001',
}, },
]; ];

View File

@@ -16,7 +16,7 @@ describe('ProfilePresenter', () => {
isVerifiedLabel: '✅ Vérifié', isVerifiedLabel: '✅ Vérifié',
estVerifier: true, estVerifier: true,
coordonnees: { latitude: 0, longitude: 0 } as Coordinates, coordonnees: { latitude: 0, longitude: 0 } as Coordinates,
profession: 'Développeur Web', profession: 'Développeur Web'.toUpperCase(),
reseaux: { linkedin: 'https://linkedin.com/in/test' }, reseaux: { linkedin: 'https://linkedin.com/in/test' },
secteur: 'Informatique', secteur: 'Informatique',
utilisateur: 'user_abc', utilisateur: 'user_abc',

View File

@@ -1,6 +1,20 @@
import { FileManagerService } from '@app/ui/shared/services/file-manager.service'; import { FileManagerService } from '@app/ui/shared/services/file-manager.service';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
// 1. Mock de la librairie externe
// Cela empêche Jest d'essayer de compresser réellement (ce qui échouerait dans l'environnement de test)
jest.mock('browser-image-compression', () => {
return {
__esModule: true,
// On simule une fonction qui renvoie une Promesse résolue avec un fichier "compressé"
default: jest
.fn()
.mockImplementation((file) =>
Promise.resolve(new File([file], 'compressed.jpg', { type: 'image/jpeg' }))
),
};
});
describe('FileManagerService', () => { describe('FileManagerService', () => {
let service: FileManagerService; let service: FileManagerService;
@@ -16,46 +30,40 @@ describe('FileManagerService', () => {
}); });
describe('Validation du fichier', () => { describe('Validation du fichier', () => {
it('devrait accepter une image JPEG valide de moins de 5Mo', () => { // 2. AJOUT DE 'async' ICI
it('devrait accepter et compresser une image JPEG valide', async () => {
const validFile = new File(['test content'], 'test.jpg', { type: 'image/jpeg' }); const validFile = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
// Hack pour définir la taille car la propriété size est readonly Object.defineProperty(validFile, 'size', { value: 1024 * 1024 }); // 1MB
Object.defineProperty(validFile, 'size', { value: 1024 * 1024 });
const event = { target: { files: [validFile] } } as unknown as Event; const event = { target: { files: [validFile], value: 'test.jpg' } } as unknown as Event;
service.onPictureChange(event); await service.onPictureChange(event);
expect(service.file()).toBe(validFile); // 4. VÉRIFICATION
const currentFile = service.file();
expect(currentFile).not.toBeNull();
// On s'attend à recevoir le fichier compressé par le Mock (nommé 'compressed.jpg')
// et non le fichier original 'test.jpg'
expect(currentFile?.name).toBe('compressed.jpg');
expect(service.fileError()).toBeNull(); expect(service.fileError()).toBeNull();
}); });
it('devrait rejeter un fichier supérieur à 5Mo', () => { it("devrait rejeter un fichier qui n'est pas une image", async () => {
const largeFile = new File([''], 'large.jpg', { type: 'image/jpeg' });
Object.defineProperty(largeFile, 'size', { value: 6 * 1024 * 1024 }); // 6MB
const event = { target: { files: [largeFile] } } as unknown as Event;
service.onPictureChange(event);
expect(service.file()).toBeNull();
expect(service.fileError()).toContain('trop volumineuse');
});
it("devrait rejeter un fichier qui n'est pas une image", () => {
const invalidFile = new File([''], 'test.pdf', { type: 'application/pdf' }); const invalidFile = new File([''], 'test.pdf', { type: 'application/pdf' });
const event = { target: { files: [invalidFile] } } as unknown as Event; const event = { target: { files: [invalidFile], value: '' } } as unknown as Event;
service.onPictureChange(event); await service.onPictureChange(event);
expect(service.file()).toBeNull(); expect(service.file()).toBeNull();
expect(service.fileError()).toContain('Le fichier doit être une image'); expect(service.fileError()).toContain('Le fichier doit être une image');
}); });
it('devrait rejeter une extension non autorisée (ex: gif)', () => { it('devrait rejeter une extension non autorisée (ex: gif)', async () => {
const invalidFile = new File([''], 'test.gif', { type: 'image/gif' }); const invalidFile = new File([''], 'test.gif', { type: 'image/gif' });
const event = { target: { files: [invalidFile] } } as unknown as Event; const event = { target: { files: [invalidFile], value: '' } } as unknown as Event;
service.onPictureChange(event); await service.onPictureChange(event);
expect(service.file()).toBeNull(); expect(service.file()).toBeNull();
expect(service.fileError()).toContain('Le fichier doit être une image (JPEG, PNG ou WebP)'); expect(service.fileError()).toContain('Le fichier doit être une image (JPEG, PNG ou WebP)');

View File

@@ -10,6 +10,8 @@ import { UserPresenter } from '@app/ui/users/user.presenter';
export class ProfilePresenter { export class ProfilePresenter {
private readonly userPresenter = new UserPresenter(); private readonly userPresenter = new UserPresenter();
private DEFAULT_BIO = `Je suis sur la plateforme Trouve Ton Profil pour partager mon expertise et mes compétences.
N'hésitez pas à me contacter pour en savoir plus sur mon parcours et mes domaines d'intervention.`;
toViewModelPaginated(profilePaginated: ProfilePaginated): ProfileViewModelPaginated { toViewModelPaginated(profilePaginated: ProfilePaginated): ProfileViewModelPaginated {
return { ...profilePaginated, items: this.toViewModels(profilePaginated.items) }; return { ...profilePaginated, items: this.toViewModels(profilePaginated.items) };
@@ -25,6 +27,8 @@ export class ProfilePresenter {
isProfilePublic: profile.estVisible ?? false, isProfilePublic: profile.estVisible ?? false,
}; };
const bio = profile.bio ? profile.bio : this.DEFAULT_BIO;
let profileViewModel: ProfileViewModel = { let profileViewModel: ProfileViewModel = {
id: profile.id, id: profile.id,
fullName: '', // ❗ exemple volontaire fullName: '', // ❗ exemple volontaire
@@ -33,13 +37,13 @@ export class ProfilePresenter {
avatarUrl: ``, avatarUrl: ``,
estVerifier: profile.estVerifier, estVerifier: profile.estVerifier,
utilisateur: profile.utilisateur, utilisateur: profile.utilisateur,
profession: profile.profession, profession: profile.profession.toUpperCase() ?? 'Profession non renseignée'.toUpperCase(),
secteur: profile.secteur, secteur: profile.secteur,
reseaux: profile.reseaux, reseaux: profile.reseaux,
apropos: profile.apropos, apropos: profile.apropos,
projets: profile.projets, projets: profile.projets,
cv: profile.cv ? `${environment.baseUrl}/api/files/profiles/${profile.id}/${profile.cv}` : '', cv: profile.cv ? `${environment.baseUrl}/api/files/profiles/${profile.id}/${profile.cv}` : '',
bio: profile.bio, bio,
coordonnees: profile.coordonnees coordonnees: profile.coordonnees
? { latitude: profile!.coordonnees!.lat!, longitude: profile!.coordonnees!.lon! } ? { latitude: profile!.coordonnees!.lat!, longitude: profile!.coordonnees!.lon! }
: undefined, : undefined,
@@ -56,13 +60,16 @@ export class ProfilePresenter {
const userSlug = userViewModel.slug ?? ''; const userSlug = userViewModel.slug ?? '';
const profileId = profile.id ? profile.id : ''; const profileId = profile.id ? profile.id : '';
const slug = userSlug === '' ? profileId : userSlug.concat('-', profileId); const slug = userSlug === '' ? profileId : userSlug.concat('-', profileId);
const avatarUrl = userExpand.avatar
? `${environment.baseUrl}/api/files/users/${profile.utilisateur}/${userExpand.avatar}?thumb=320x240`
: `https://api.dicebear.com/9.x/initials/svg?seed=${userExpand.name}}`;
profileViewModel = { profileViewModel = {
...profileViewModel, ...profileViewModel,
userViewModel, userViewModel,
slug, slug,
fullName: userExpand.name, fullName: userExpand.name,
avatarUrl: `${environment.baseUrl}/api/files/users/${profile.utilisateur}/${userExpand.avatar}`, avatarUrl,
}; };
} }

View File

@@ -5,6 +5,6 @@ export interface ProjectViewModel {
nom: string; nom: string;
lien: string; lien: string;
description: string; description: string;
fichier: string[]; fichier: string;
utilisateur: string; utilisateur: string;
} }

View File

@@ -1,16 +1,20 @@
import { ProjectViewModel } from '@app/ui/projects/project.presenter.model'; import { ProjectViewModel } from '@app/ui/projects/project.presenter.model';
import { Project } from '@app/domain/projects/project.model'; import { Project } from '@app/domain/projects/project.model';
import { environment } from '@env/environment';
export class ProjectPresenter { export class ProjectPresenter {
toViewModel(project: Project): ProjectViewModel { toViewModel(project: Project): ProjectViewModel {
const fichier = project.fichier
? `${environment.baseUrl}/api/files/projets/${project.id}/${project.fichier}`
: `https://api.dicebear.com/9.x/shapes/svg?seed=${project.nom}`;
return { return {
id: project.id, id: project.id,
created: project.created, created: project.created,
updated: project.updated, updated: project.updated,
nom: project.nom, nom: project.nom,
lien: project.lien, lien: project.lien,
description: project.description, description: project.description ?? 'Aucune description disponible.',
fichier: project.fichier, fichier,
utilisateur: project.utilisateur, utilisateur: project.utilisateur,
}; };
} }
@@ -18,4 +22,6 @@ export class ProjectPresenter {
toViewModels(projects: Project[]): ProjectViewModel[] { toViewModels(projects: Project[]): ProjectViewModel[] {
return projects.map(this.toViewModel); return projects.map(this.toViewModel);
} }
private formatFichier(project: Project) {}
} }

View File

@@ -1,10 +1,12 @@
import { Injectable, signal } from '@angular/core'; import { Injectable, signal } from '@angular/core';
import imageCompression from 'browser-image-compression';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class FileManagerService { export class FileManagerService {
file = signal<File | null>(null); file = signal<File | null>(null);
imagePreviewUrl = signal<string | null>(null); imagePreviewUrl = signal<string | null>(null);
fileError = signal<string | null>(null); fileError = signal<string | null>(null);
isCompressing = signal<boolean>(false);
private readonly MAX_FILE_SIZE = 5 * 1024 * 1024; // 5Mo en bytes private readonly MAX_FILE_SIZE = 5 * 1024 * 1024; // 5Mo en bytes
private readonly ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; private readonly ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
@@ -20,7 +22,7 @@ export class FileManagerService {
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
} }
onPictureChange($event: Event): void { async onPictureChange($event: Event): Promise<void> {
const target = $event.target as HTMLInputElement; const target = $event.target as HTMLInputElement;
const selectedFile = target?.files?.[0]; const selectedFile = target?.files?.[0];
@@ -58,8 +60,30 @@ export class FileManagerService {
return; return;
} }
this.file.set(selectedFile); // Configuration de la compression
this.readFile(selectedFile); const options = {
maxSizeMB: 1, // On vise une taille max de 1MB
maxWidthOrHeight: 1920, // On redimensionne si l'image est immense (4K+)
useWebWorker: true, // Ne pas bloquer l'interface
fileType: 'image/webp', // (Optionnel) Convertir en WebP pour le web (plus léger)
};
try {
this.isCompressing.set(true);
// Compression
const compressedFile = await imageCompression(selectedFile, options);
// Mise à jour des signaux avec le fichier optimisé
this.file.set(compressedFile);
this.readFile(compressedFile);
} catch (error) {
this.fileError.set("Impossible de compresser l'image.");
this.resetFile();
} finally {
this.isCompressing.set(false);
target.value = '';
}
} }
onFileChange($event: Event): void { onFileChange($event: Event): void {

View File

@@ -1,20 +1,26 @@
import { UserViewModel } from '@app/ui/users/user.presenter.model'; import { UserViewModel } from '@app/ui/users/user.presenter.model';
import { User } from '@app/domain/users/user.model'; import { User } from '@app/domain/users/user.model';
import { environment } from '@env/environment';
export class UserPresenter { export class UserPresenter {
private DEFAULT_VALUE = 'Non renséigné';
toViewModel(user: User): UserViewModel { toViewModel(user: User): UserViewModel {
const slug = user.name const slug = user.name
? this.generateProfileSlug(user.name, user.id) ? this.generateProfileSlug(user.name, user.id)
: this.generateProfileSlug('Non renséigné'); : this.generateProfileSlug(this.DEFAULT_VALUE);
const avatar = user.avatar
? `${environment.baseUrl}/api/files/users/${user.id}/${user.avatar}?thumb=320x240`
: `https://api.dicebear.com/9.x/initials/svg?seed=${user.name ? user.name : user.username ? user.username : this.DEFAULT_VALUE}`;
let userViewModel: UserViewModel = { let userViewModel: UserViewModel = {
id: user.id, id: user.id,
username: user.username, username: user.username ?? this.DEFAULT_VALUE,
verified: user.verified, verified: user.verified,
emailVisibility: user.emailVisibility, emailVisibility: user.emailVisibility,
email: user.email, email: user.email,
name: user.name, name: user.name ? user.name : this.DEFAULT_VALUE,
avatar: user.avatar, avatar,
slug, slug,
isUserVisible: this.isUserVisible(user), isUserVisible: this.isUserVisible(user),
missingFields: this.missingFields(user), missingFields: this.missingFields(user),