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",
"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",

View File

@@ -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",

View File

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

View File

@@ -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>

View File

@@ -27,7 +27,6 @@ import { MyProfileMapComponent } from '@app/shared/features/my-profile-map/my-pr
RouterOutlet,
MyProfileUpdateFormComponent,
PdfViewerComponent,
UpperCasePipe,
LoadingComponent,
SettingsComponent,
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"
>
<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 -->

View File

@@ -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 -->

View File

@@ -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',
})

View File

@@ -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>

View File

@@ -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'
);
});

View File

@@ -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}`;
}

View File

@@ -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>
}

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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">

View File

@@ -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(),

View File

@@ -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',
};

View File

@@ -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',
},
];

View File

@@ -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',

View File

@@ -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)');

View File

@@ -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,
};
}

View File

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

View File

@@ -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) {}
}

View File

@@ -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 {

View File

@@ -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),