feat : compression des images
This commit is contained in:
20
package-lock.json
generated
20
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trouve-ton-profile",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trouve-ton-profile",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.1",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.0.0",
|
||||
"@angular/common": "^17.0.0",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@ngneat/until-destroy": "^10.0.0",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"express": "^4.18.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"ng2-pdf-viewer": "^10.3.3",
|
||||
@@ -8641,6 +8642,15 @@
|
||||
"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": {
|
||||
"version": "4.27.0",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz",
|
||||
@@ -19814,6 +19824,12 @@
|
||||
"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": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@ngneat/until-destroy": "^10.0.0",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"express": "^4.18.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"ng2-pdf-viewer": "^10.3.3",
|
||||
|
||||
@@ -5,6 +5,6 @@ export interface Project {
|
||||
nom: string;
|
||||
lien: string;
|
||||
description: string;
|
||||
fichier: string[];
|
||||
fichier: string;
|
||||
utilisateur: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@if (profile != undefined) {
|
||||
@if (profile(); as profile) {
|
||||
<section
|
||||
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>
|
||||
|
||||
<!-- Badge vérifié -->
|
||||
@if (profile().estVerifier) {
|
||||
@if (profile.estVerifier) {
|
||||
<div
|
||||
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="flex flex-col md:flex-row items-center md:items-end gap-4 md:gap-6">
|
||||
<!-- Avatar avec indicateur de visibilité -->
|
||||
@if (user() != undefined) {
|
||||
@if (user(); as user) {
|
||||
<div class="relative group animate-slide-up">
|
||||
<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"
|
||||
>
|
||||
<div class="w-full h-full rounded-full overflow-hidden bg-white">
|
||||
@if (user()!.avatar) {
|
||||
<img
|
||||
alt="{{ user()!.name }}"
|
||||
class="object-cover w-full h-full"
|
||||
src="{{ environment.baseUrl }}/api/files/users/{{ user()!.id }}/{{
|
||||
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
|
||||
}}"
|
||||
/>
|
||||
}
|
||||
<img
|
||||
alt="{{ user.name }}"
|
||||
class="object-cover w-full h-full"
|
||||
src="{{ user.avatar }}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,22 +125,16 @@
|
||||
}
|
||||
|
||||
<!-- Nom et titre -->
|
||||
@if (user() != undefined) {
|
||||
@if (user(); as user) {
|
||||
<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"
|
||||
>
|
||||
@if (user()!.name) {
|
||||
<h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
{{ user()!.name }}
|
||||
</h1>
|
||||
} @else {
|
||||
<h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
{{ user()!.email }}
|
||||
</h1>
|
||||
}
|
||||
<h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
{{ user.name }}
|
||||
</h1>
|
||||
|
||||
<p class="text-lg md:text-xl text-indigo-600 dark:text-indigo-400 font-semibold">
|
||||
{{ profile().profession | uppercase }}
|
||||
{{ profile.profession }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@@ -195,7 +173,7 @@
|
||||
<!-- Sidebar - Informations -->
|
||||
<aside class="lg:col-span-1 space-y-6 animate-slide-up animation-delay-200">
|
||||
<!-- Card Apropos -->
|
||||
@if (profile().apropos) {
|
||||
@if (profile.apropos) {
|
||||
<div
|
||||
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
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed text-base">
|
||||
{{ profile().apropos }}
|
||||
{{ profile.apropos }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@@ -243,20 +221,13 @@
|
||||
</svg>
|
||||
Biographie
|
||||
</h3>
|
||||
@if (profile().bio) {
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{{ profile().bio }}
|
||||
</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>
|
||||
}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{{ profile.bio }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card Secteur -->
|
||||
@if (profile().secteur) {
|
||||
@if (profile.secteur) {
|
||||
<div
|
||||
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>
|
||||
Secteur
|
||||
</h3>
|
||||
<app-chips [sectorId]="profile().secteur" />
|
||||
<app-chips [sectorId]="profile.secteur" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Card Réseaux -->
|
||||
@if (profile().reseaux) {
|
||||
@if (profile.reseaux) {
|
||||
<div
|
||||
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>
|
||||
Réseaux
|
||||
</h3>
|
||||
<app-reseaux [reseaux]="profile().reseaux" />
|
||||
<app-reseaux [reseaux]="profile.reseaux" />
|
||||
</div>
|
||||
}
|
||||
</aside>
|
||||
|
||||
@@ -27,7 +27,6 @@ import { MyProfileMapComponent } from '@app/shared/features/my-profile-map/my-pr
|
||||
RouterOutlet,
|
||||
MyProfileUpdateFormComponent,
|
||||
PdfViewerComponent,
|
||||
UpperCasePipe,
|
||||
LoadingComponent,
|
||||
SettingsComponent,
|
||||
NgTemplateOutlet,
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div class="w-full h-full rounded-full overflow-hidden bg-white">
|
||||
@if (user().avatar) {
|
||||
<img
|
||||
alt="{{ user().name }}"
|
||||
class="object-cover w-full h-full"
|
||||
src="{{ profile().avatarUrl }}"
|
||||
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"
|
||||
/>
|
||||
}
|
||||
<img
|
||||
alt="{{ user().name }}"
|
||||
class="object-cover w-full h-full"
|
||||
src="{{ profile().avatarUrl }}"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Indicateur en ligne (optionnel) -->
|
||||
@@ -118,18 +109,12 @@
|
||||
<div
|
||||
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">
|
||||
{{ user().name }}
|
||||
</h1>
|
||||
} @else {
|
||||
<h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
{{ user().email }}
|
||||
</h1>
|
||||
}
|
||||
<h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
{{ user().name }}
|
||||
</h1>
|
||||
|
||||
<p class="text-lg md:text-xl text-indigo-600 dark:text-indigo-400 font-semibold">
|
||||
{{ profile()!.profession | uppercase }}
|
||||
{{ profile()!.profession }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,17 +146,9 @@
|
||||
</svg>
|
||||
Biographie
|
||||
</h3>
|
||||
@if (profile()!.bio) {
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm leading-relaxed">
|
||||
{{ profile()!.bio }}
|
||||
</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>
|
||||
}
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm leading-relaxed">
|
||||
{{ profile()!.bio }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card Secteur -->
|
||||
|
||||
@@ -4,22 +4,13 @@
|
||||
>
|
||||
<!-- Image du projet -->
|
||||
<div class="relative h-48 overflow-hidden bg-gray-100 dark:bg-gray-700">
|
||||
@if (project.fichier) {
|
||||
<img
|
||||
alt="{{ project.nom }}"
|
||||
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 }}"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @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"
|
||||
/>
|
||||
}
|
||||
|
||||
<img
|
||||
alt="{{ project.nom }}"
|
||||
class="w-full h-full object-cover object-center transition-transform duration-500 group-hover:scale-110"
|
||||
ngSrc="{{ project.fichier }}"
|
||||
loading="lazy"
|
||||
fill
|
||||
/>
|
||||
<!-- Overlay gradient au hover -->
|
||||
<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"
|
||||
@@ -37,7 +28,7 @@
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 line-clamp-3">
|
||||
{{ project.description || 'Aucune description disponible.' }}
|
||||
{{ project.description }}
|
||||
</p>
|
||||
|
||||
<!-- Lien vers le projet -->
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { environment } from '@env/environment';
|
||||
import { ProjectViewModel } from '@app/ui/projects/project.presenter.model';
|
||||
import { NgOptimizedImage } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-item',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [NgOptimizedImage],
|
||||
templateUrl: './project-item.component.html',
|
||||
styleUrl: './project-item.component.scss',
|
||||
})
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
<img
|
||||
[alt]="project()?.nom || 'nouveau-projet'"
|
||||
class="object-cover object-center h-full w-full"
|
||||
[src]="currentImageUrl"
|
||||
ngSrc="{{ currentImageUrl }}"
|
||||
loading="lazy"
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('ProjectPictureFormComponent', () => {
|
||||
it("devrait afficher l'image du serveur si pas de preview", () => {
|
||||
// mockProject a 'image-serveur.jpg'
|
||||
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'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { ProjectViewModel } from '@app/ui/projects/project.presenter.model';
|
||||
import { ProjectFacade } from '@app/ui/projects/project.facade';
|
||||
@@ -10,7 +10,7 @@ import { FileManagerService } from '@app/ui/shared/services/file-manager.service
|
||||
@Component({
|
||||
selector: 'app-project-picture-form',
|
||||
standalone: true,
|
||||
imports: [LoadingComponent, NgTemplateOutlet],
|
||||
imports: [LoadingComponent, NgTemplateOutlet, NgOptimizedImage],
|
||||
templateUrl: './project-picture-form.component.html',
|
||||
styleUrl: './project-picture-form.component.scss',
|
||||
})
|
||||
@@ -73,11 +73,11 @@ export class ProjectPictureFormComponent {
|
||||
return this.imagePreviewUrl();
|
||||
}
|
||||
|
||||
if (this.project()!.fichier) {
|
||||
return `${this.environment.baseUrl}/api/files/projets/${this.project()!.id}/${this.project()!.fichier}`;
|
||||
if (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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,15 +14,16 @@
|
||||
<!-- Avatar avec preview -->
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="relative">
|
||||
@if (user() !== undefined) {
|
||||
@if (user(); as user) {
|
||||
<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"
|
||||
>
|
||||
<img
|
||||
[alt]="user()!.username || 'Avatar'"
|
||||
[alt]="user.username || 'Avatar'"
|
||||
class="object-cover object-center h-full w-full"
|
||||
[src]="currentAvatarUrl"
|
||||
ngSrc="{{ currentAvatarUrl }}"
|
||||
loading="lazy"
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Component, effect, inject, input, signal } from '@angular/core';
|
||||
import { User } from '@app/domain/users/user.model';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
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 { ActionType } from '@app/domain/action-type.util';
|
||||
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
|
||||
@@ -13,7 +13,7 @@ import { FileManagerService } from '@app/ui/shared/services/file-manager.service
|
||||
@Component({
|
||||
selector: 'app-user-avatar-form',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, NgTemplateOutlet, LoadingComponent],
|
||||
imports: [ReactiveFormsModule, NgTemplateOutlet, LoadingComponent, NgOptimizedImage],
|
||||
templateUrl: './user-avatar-form.component.html',
|
||||
styleUrl: './user-avatar-form.component.scss',
|
||||
})
|
||||
@@ -80,7 +80,7 @@ export class UserAvatarFormComponent {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@if (user() !== undefined) {
|
||||
@if (user(); as user) {
|
||||
<a
|
||||
[routerLink]="[profile()!.slug!]"
|
||||
[state]="{ user: user(), profile: profile() }"
|
||||
[state]="{ user: user, profile: profile() }"
|
||||
class="block group"
|
||||
>
|
||||
<!-- Card du profil -->
|
||||
@@ -33,44 +33,33 @@
|
||||
<!-- Avatar avec bordure gradient -->
|
||||
<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">
|
||||
@if (profile().avatarUrl) {
|
||||
<img
|
||||
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 }}"
|
||||
alt="{{ user().username }}"
|
||||
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"
|
||||
/>
|
||||
}
|
||||
<img
|
||||
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 }}"
|
||||
alt="{{ user.username }}"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nom -->
|
||||
@if (user().name) {
|
||||
@if (user.name) {
|
||||
<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"
|
||||
>
|
||||
{{ user().firstName }} {{ user().lastName }}
|
||||
{{ user.firstName }} {{ user.lastName }}
|
||||
</h3>
|
||||
} @else if (user().username) {
|
||||
} @else if (user.username) {
|
||||
<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"
|
||||
>
|
||||
{{ user().username }}
|
||||
{{ user.username }}
|
||||
</h3>
|
||||
} @else {
|
||||
<h3 class="text-lg font-bold text-gray-500 dark:text-gray-400 mb-2">Non renseigné</h3>
|
||||
}
|
||||
|
||||
<!-- Profession -->
|
||||
<p class="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">
|
||||
{{ profile().profession || 'Profession non renseignée' }}
|
||||
{{ profile().profession }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@if (user != undefined) {
|
||||
@if (user) {
|
||||
<div class="space-y-6">
|
||||
<!-- Section Avatar -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 animate-fade-in">
|
||||
|
||||
@@ -10,7 +10,7 @@ export class FakeProjectRepository implements ProjectRepository {
|
||||
create(projectDto: CreateProjectDto): Observable<Project> {
|
||||
const newProject: Project = {
|
||||
...projectDto,
|
||||
fichier: [],
|
||||
fichier: '',
|
||||
id: (this.projects.length + 1).toString(),
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('PbProjectRepository', () => {
|
||||
id: '123',
|
||||
created: '2025-01-01T00:00:00Z',
|
||||
updated: '2025-01-01T00:00:00Z',
|
||||
fichier: [],
|
||||
fichier: '',
|
||||
...dto,
|
||||
};
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('PbProjectRepository', () => {
|
||||
nom: 'Test',
|
||||
lien: '',
|
||||
description: '',
|
||||
fichier: [],
|
||||
fichier: '',
|
||||
utilisateur: 'user_001',
|
||||
};
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('PbProjectRepository', () => {
|
||||
nom: data.nom,
|
||||
lien: '',
|
||||
description: '',
|
||||
fichier: [],
|
||||
fichier: '',
|
||||
utilisateur: 'user_001',
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export const mockProjects: Project[] = [
|
||||
lien: 'https://portfolio-3d.example.com',
|
||||
description:
|
||||
'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',
|
||||
},
|
||||
{
|
||||
@@ -21,7 +21,7 @@ export const mockProjects: Project[] = [
|
||||
lien: 'https://budget-app.example.com',
|
||||
description:
|
||||
'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',
|
||||
},
|
||||
{
|
||||
@@ -32,7 +32,7 @@ export const mockProjects: Project[] = [
|
||||
lien: 'https://presence-system.example.com',
|
||||
description:
|
||||
'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',
|
||||
},
|
||||
{
|
||||
@@ -43,7 +43,7 @@ export const mockProjects: Project[] = [
|
||||
lien: 'https://crypto-dashboard.example.com',
|
||||
description:
|
||||
'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',
|
||||
},
|
||||
{
|
||||
@@ -54,7 +54,7 @@ export const mockProjects: Project[] = [
|
||||
lien: 'https://api-project-manager.example.com',
|
||||
description:
|
||||
'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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('ProfilePresenter', () => {
|
||||
isVerifiedLabel: '✅ Vérifié',
|
||||
estVerifier: true,
|
||||
coordonnees: { latitude: 0, longitude: 0 } as Coordinates,
|
||||
profession: 'Développeur Web',
|
||||
profession: 'Développeur Web'.toUpperCase(),
|
||||
reseaux: { linkedin: 'https://linkedin.com/in/test' },
|
||||
secteur: 'Informatique',
|
||||
utilisateur: 'user_abc',
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { FileManagerService } from '@app/ui/shared/services/file-manager.service';
|
||||
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', () => {
|
||||
let service: FileManagerService;
|
||||
|
||||
@@ -16,46 +30,40 @@ describe('FileManagerService', () => {
|
||||
});
|
||||
|
||||
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' });
|
||||
// Hack pour définir la taille car la propriété size est readonly
|
||||
Object.defineProperty(validFile, 'size', { value: 1024 * 1024 });
|
||||
Object.defineProperty(validFile, 'size', { value: 1024 * 1024 }); // 1MB
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('devrait rejeter un fichier supérieur à 5Mo', () => {
|
||||
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", () => {
|
||||
it("devrait rejeter un fichier qui n'est pas une image", async () => {
|
||||
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.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 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.fileError()).toContain('Le fichier doit être une image (JPEG, PNG ou WebP)');
|
||||
|
||||
@@ -10,6 +10,8 @@ import { UserPresenter } from '@app/ui/users/user.presenter';
|
||||
|
||||
export class ProfilePresenter {
|
||||
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 {
|
||||
return { ...profilePaginated, items: this.toViewModels(profilePaginated.items) };
|
||||
@@ -25,6 +27,8 @@ export class ProfilePresenter {
|
||||
isProfilePublic: profile.estVisible ?? false,
|
||||
};
|
||||
|
||||
const bio = profile.bio ? profile.bio : this.DEFAULT_BIO;
|
||||
|
||||
let profileViewModel: ProfileViewModel = {
|
||||
id: profile.id,
|
||||
fullName: '', // ❗ exemple volontaire
|
||||
@@ -33,13 +37,13 @@ export class ProfilePresenter {
|
||||
avatarUrl: ``,
|
||||
estVerifier: profile.estVerifier,
|
||||
utilisateur: profile.utilisateur,
|
||||
profession: profile.profession,
|
||||
profession: profile.profession.toUpperCase() ?? 'Profession non renseignée'.toUpperCase(),
|
||||
secteur: profile.secteur,
|
||||
reseaux: profile.reseaux,
|
||||
apropos: profile.apropos,
|
||||
projets: profile.projets,
|
||||
cv: profile.cv ? `${environment.baseUrl}/api/files/profiles/${profile.id}/${profile.cv}` : '',
|
||||
bio: profile.bio,
|
||||
bio,
|
||||
coordonnees: profile.coordonnees
|
||||
? { latitude: profile!.coordonnees!.lat!, longitude: profile!.coordonnees!.lon! }
|
||||
: undefined,
|
||||
@@ -56,13 +60,16 @@ export class ProfilePresenter {
|
||||
const userSlug = userViewModel.slug ?? '';
|
||||
const profileId = profile.id ? profile.id : '';
|
||||
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,
|
||||
userViewModel,
|
||||
slug,
|
||||
fullName: userExpand.name,
|
||||
avatarUrl: `${environment.baseUrl}/api/files/users/${profile.utilisateur}/${userExpand.avatar}`,
|
||||
avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,6 @@ export interface ProjectViewModel {
|
||||
nom: string;
|
||||
lien: string;
|
||||
description: string;
|
||||
fichier: string[];
|
||||
fichier: string;
|
||||
utilisateur: string;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { ProjectViewModel } from '@app/ui/projects/project.presenter.model';
|
||||
import { Project } from '@app/domain/projects/project.model';
|
||||
import { environment } from '@env/environment';
|
||||
|
||||
export class ProjectPresenter {
|
||||
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 {
|
||||
id: project.id,
|
||||
created: project.created,
|
||||
updated: project.updated,
|
||||
nom: project.nom,
|
||||
lien: project.lien,
|
||||
description: project.description,
|
||||
fichier: project.fichier,
|
||||
description: project.description ?? 'Aucune description disponible.',
|
||||
fichier,
|
||||
utilisateur: project.utilisateur,
|
||||
};
|
||||
}
|
||||
@@ -18,4 +22,6 @@ export class ProjectPresenter {
|
||||
toViewModels(projects: Project[]): ProjectViewModel[] {
|
||||
return projects.map(this.toViewModel);
|
||||
}
|
||||
|
||||
private formatFichier(project: Project) {}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import imageCompression from 'browser-image-compression';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FileManagerService {
|
||||
file = signal<File | null>(null);
|
||||
imagePreviewUrl = 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 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];
|
||||
}
|
||||
|
||||
onPictureChange($event: Event): void {
|
||||
async onPictureChange($event: Event): Promise<void> {
|
||||
const target = $event.target as HTMLInputElement;
|
||||
const selectedFile = target?.files?.[0];
|
||||
|
||||
@@ -58,8 +60,30 @@ export class FileManagerService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.file.set(selectedFile);
|
||||
this.readFile(selectedFile);
|
||||
// Configuration de la compression
|
||||
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 {
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { UserViewModel } from '@app/ui/users/user.presenter.model';
|
||||
import { User } from '@app/domain/users/user.model';
|
||||
import { environment } from '@env/environment';
|
||||
|
||||
export class UserPresenter {
|
||||
private DEFAULT_VALUE = 'Non renséigné';
|
||||
toViewModel(user: User): UserViewModel {
|
||||
const slug = user.name
|
||||
? 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 = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
username: user.username ?? this.DEFAULT_VALUE,
|
||||
verified: user.verified,
|
||||
emailVisibility: user.emailVisibility,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
avatar: user.avatar,
|
||||
name: user.name ? user.name : this.DEFAULT_VALUE,
|
||||
avatar,
|
||||
slug,
|
||||
isUserVisible: this.isUserVisible(user),
|
||||
missingFields: this.missingFields(user),
|
||||
|
||||
Reference in New Issue
Block a user