Compare commits
18 Commits
1.2.0
...
0c269050a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c269050a7 | |||
|
|
b90e78e1b7 | ||
|
|
bbd555d61e | ||
|
|
4bca6112bb | ||
|
|
52422724b8 | ||
|
|
b7007c7ce3 | ||
|
|
dfda229a03 | ||
| a4fb20dd68 | |||
|
|
911546924c | ||
|
|
1ad97ff89d | ||
|
|
de34c91f09 | ||
|
|
e4f5cb8938 | ||
|
|
b6d4584dd9 | ||
|
|
604ff76cdc | ||
| 29628bd0eb | |||
|
|
71fed31254 | ||
|
|
950f9046d8 | ||
|
|
8a0c4b305f |
@@ -22,6 +22,11 @@ jobs:
|
||||
with:
|
||||
node-version: '20' # Spécifie la version de Node.js
|
||||
|
||||
- name: Install system dependencies for Canvas
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
|
||||
|
||||
# Version node installer
|
||||
- name: Show Node version (debug)
|
||||
run: node -v
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
name: Trigger Deploy Repo
|
||||
run-name: Trigger deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*' # Se déclenche sur n'importe quel tag
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
# ------- À ADAPTER -------
|
||||
CODE_REF: ${{ gitea.ref }} # branche/tag/sha à récupérer
|
||||
GITEA_SERVER_URL: https://git.prod.k3s.technostrea.fr # URL du serveur Gitea
|
||||
# --------------------------
|
||||
steps:
|
||||
- name: Dispatch Signal to ttp-deploy
|
||||
run: |
|
||||
# Récupération de la version sans le "refs/tags/"
|
||||
VERSION=${{ env.CODE_REF }}
|
||||
|
||||
echo "Déclenchement du déploiement pour la version $VERSION sur l'instance ${{ env.GITEA_SERVER_URL }}"
|
||||
|
||||
# Appel API Gitea.
|
||||
# Notez la structure de l'URL : /api/v1/repos/{owner}/{repo}/dispatches
|
||||
curl -X POST "${{ env.GITEA_SERVER_URL }}/api/v1/repos/technostrea/trouvetonprofile-deployment/dispatches" \
|
||||
-H "Authorization: token ${{ secrets.ROBOT_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"event_type\": \"build-release\", \"client_payload\": {\"version\": \"$VERSION\"}}"
|
||||
2
CMD.md
2
CMD.md
@@ -1,6 +1,6 @@
|
||||
# commande Tree
|
||||
```bash
|
||||
tree -a -I 'node_modules|coverage|logs|.vscode|.git|dist|.git|.venv|__pycache__'
|
||||
tree -a -I 'node_modules|coverage|logs|.vscode|.git|dist|.git|.venv|__pycache__|.angular'
|
||||
```
|
||||
# Utilisation de `act` avec des workflows Gitea
|
||||
```bash
|
||||
|
||||
14
angular.json
14
angular.json
@@ -18,7 +18,8 @@
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"allowedCommonJsDependencies": [
|
||||
"pocketbase"
|
||||
"pocketbase",
|
||||
"leaflet"
|
||||
],
|
||||
"outputPath": "dist/",
|
||||
"index": "src/index.html",
|
||||
@@ -30,9 +31,15 @@
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
"src/assets",
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/leaflet/dist/images",
|
||||
"output": "assets/leaflet"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"node_modules/leaflet/dist/leaflet.css",
|
||||
"node_modules/primeng/resources/themes/lara-light-blue/theme.css",
|
||||
"node_modules/primeng/resources/primeng.min.css",
|
||||
"node_modules/primeicons/primeicons.css",
|
||||
@@ -76,6 +83,9 @@
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "TrouveTonProfile:build:production"
|
||||
|
||||
45
package-lock.json
generated
45
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trouve-ton-profile",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trouve-ton-profile",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.1",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.0.0",
|
||||
"@angular/common": "^17.0.0",
|
||||
@@ -24,7 +24,9 @@
|
||||
"@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",
|
||||
"ngx-toastr": "^17.0.2",
|
||||
"pocketbase": "^0.21.5",
|
||||
@@ -44,6 +46,7 @@
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^18.18.0",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"angular-eslint": "20.4.0",
|
||||
@@ -6384,6 +6387,13 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/graceful-fs": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
||||
@@ -6475,6 +6485,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
@@ -8622,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",
|
||||
@@ -14478,6 +14507,12 @@
|
||||
"shell-quote": "^1.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/less": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz",
|
||||
@@ -19789,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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trouve-ton-profile",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.1",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "bash replace-prod-env.sh src/environments/environment.development.ts $ENV_URL && ng serve",
|
||||
@@ -14,7 +14,8 @@
|
||||
"format": "bash replace-prod-env.sh src/environments/environment.development.ts http://localhost:8090 && npm run prettier && npm run lint:fix",
|
||||
"lint": "ng lint",
|
||||
"lint:fix": "ng lint --fix",
|
||||
"clean:imports": "ts-unused-exports tsconfig.json --excludePathsFromReport=\"src/main.ts;src/environments;server.ts\" && npm run lint:fix","fix:all": "npm run format && npm run tsc",
|
||||
"clean:imports": "ts-unused-exports tsconfig.json --excludePathsFromReport=\"src/main.ts;src/environments;server.ts\" && npm run lint:fix",
|
||||
"fix:all": "npm run format && npm run tsc",
|
||||
"check:all": "npm run format && npm run tsc && npm run lint && npm run test",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -40,7 +41,9 @@
|
||||
"@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",
|
||||
"ngx-toastr": "^17.0.2",
|
||||
"pocketbase": "^0.21.5",
|
||||
@@ -60,6 +63,7 @@
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^18.18.0",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"angular-eslint": "20.4.0",
|
||||
|
||||
8
proxy.conf.json
Normal file
8
proxy.conf.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "https://pb-dev.prod.k3s.technostrea.fr",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug"
|
||||
}
|
||||
}
|
||||
195
src/app/adapters/authentification/auth.facade.ts
Normal file
195
src/app/adapters/authentification/auth.facade.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { AUTH_REPOSITORY_TOKEN } from '@app/infrastructure/authentification/auth-repository.token';
|
||||
import { LoaderAction } from '@app/domain/loader-action.util';
|
||||
import { ActionType } from '@app/domain/action-type.util';
|
||||
import { ErrorResponse } from '@app/domain/error-response.util';
|
||||
import { LoginUseCase } from '../../application/authentification/login.usecase';
|
||||
import { RegisterUseCase } from '../../application/authentification/register.usecase';
|
||||
import { LoginDto } from '@app/domain/authentification/dto/login-dto';
|
||||
import { RegisterDto } from '@app/domain/authentification/dto/register-dto';
|
||||
import { User } from '@app/domain/users/user.model';
|
||||
import { AuthResponse } from '@app/domain/authentification/auth.repository';
|
||||
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
|
||||
import { ProfileFacade } from '../profiles/profile.facade';
|
||||
import { SendVerificationEmailUsecase } from '../../application/authentification/send-verification-email.usecase';
|
||||
import { LogoutUseCase } from '../../application/authentification/logout.usecase';
|
||||
import { VerifyAuthenticatedUsecase } from '../../application/authentification/verify-authenticated.usecase';
|
||||
import { VerifyEmailUseCase } from '../../application/authentification/verify-email.usecase';
|
||||
import { GetCurrentUserUseCase } from '../../application/authentification/get-current-user.usecase';
|
||||
import { SendRequestPasswordResetUsecase } from '../../application/authentification/send-request-password-reset.usecase';
|
||||
import { FeedbackService } from '../shared/services/feedback.service';
|
||||
import { first } from 'rxjs';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthFacade {
|
||||
private readonly feedbackService = inject(FeedbackService);
|
||||
private readonly authRepository = inject(AUTH_REPOSITORY_TOKEN);
|
||||
|
||||
private readonly profileFacade = new ProfileFacade();
|
||||
|
||||
private readonly loginUseCase = new LoginUseCase(this.authRepository);
|
||||
private readonly registerUseCase = new RegisterUseCase(this.authRepository);
|
||||
private readonly logoutUseCase = new LogoutUseCase(this.authRepository);
|
||||
private readonly getUserUseCase = new GetCurrentUserUseCase(this.authRepository);
|
||||
private readonly sendVerificationEmailUseCase = new SendVerificationEmailUsecase(
|
||||
this.authRepository
|
||||
);
|
||||
private readonly verifyAuthenticatedUseCase = new VerifyAuthenticatedUsecase(this.authRepository);
|
||||
private readonly verifyEmailUseCase = new VerifyEmailUseCase(this.authRepository);
|
||||
|
||||
private readonly senRequestPasswordResetUseCase = new SendRequestPasswordResetUsecase(
|
||||
this.authRepository
|
||||
);
|
||||
|
||||
readonly isAuthenticated = signal<boolean>(false);
|
||||
readonly isEmailVerified = signal<boolean>(false);
|
||||
readonly isVerificationEmailSent = signal<boolean>(false);
|
||||
readonly isRequestPasswordSent = signal<boolean>(false);
|
||||
|
||||
readonly user = signal<User | undefined>(undefined);
|
||||
readonly authResponse = signal<AuthResponse | undefined>(undefined);
|
||||
|
||||
readonly loading = signal<LoaderAction>({ isLoading: false, action: ActionType.NONE });
|
||||
readonly error = signal<ErrorResponse>({
|
||||
action: ActionType.NONE,
|
||||
hasError: false,
|
||||
message: null,
|
||||
});
|
||||
|
||||
login(loginDto: LoginDto) {
|
||||
this.handleError(ActionType.READ, false, null, true);
|
||||
let message = '';
|
||||
|
||||
this.loginUseCase
|
||||
.execute(loginDto)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: async (res: AuthResponse) => {
|
||||
this.authResponse.set(res);
|
||||
this.getCurrentUser();
|
||||
this.handleError(ActionType.READ, false, null, false);
|
||||
|
||||
if (!this.authResponse()!.isValid && !this.authResponse()?.record.verified) {
|
||||
message = `Vous ne pouvez pas vous connecter sans valider la verification envoyé à cet adresse ${this.authResponse()?.record.email!}`;
|
||||
this.feedbackService.notify(ActionType.READ, `${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
message = `Bienvenue parmi nous ${loginDto.email}!`;
|
||||
this.feedbackService.notify(ActionType.READ, `${message}`);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.READ, true, err.message, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
register(registerDto: RegisterDto) {
|
||||
this.handleError(ActionType.CREATE, false, null, true);
|
||||
let message = '';
|
||||
|
||||
this.registerUseCase
|
||||
.execute(registerDto)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (user) => {
|
||||
this.getCurrentUser();
|
||||
this.sendVerificationEmail(registerDto.email);
|
||||
this.createDefaultProfile(user.id);
|
||||
|
||||
message = `Votre compte a bien été crée avec succès !\n Un mail vous a été envoyé à l'adresse ${registerDto.email} pour confirmer votre inscription.`;
|
||||
this.feedbackService.notify(ActionType.CREATE, `${message}`);
|
||||
|
||||
this.handleError(ActionType.CREATE, false, null, false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.CREATE, true, err.message, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.logoutUseCase.execute();
|
||||
this.isAuthenticated.set(false);
|
||||
this.getCurrentUser();
|
||||
}
|
||||
|
||||
verifyAuthenticatedUser() {
|
||||
this.isAuthenticated.set(this.verifyAuthenticatedUseCase.execute());
|
||||
}
|
||||
|
||||
verifyEmail() {
|
||||
this.isEmailVerified.set(this.verifyEmailUseCase.execute());
|
||||
}
|
||||
|
||||
getCurrentUser() {
|
||||
this.user.set(this.getUserUseCase.execute());
|
||||
}
|
||||
|
||||
sendRequestPasswordReset(email: string) {
|
||||
this.handleError(ActionType.CREATE, false, null, true);
|
||||
|
||||
let message = '';
|
||||
this.senRequestPasswordResetUseCase
|
||||
.execute(email)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.isRequestPasswordSent.set(res);
|
||||
this.handleError(ActionType.CREATE, false, null, false);
|
||||
message = `Un mail de réinitialisation vous a été envoyé à cette adresse mail : ${email}`;
|
||||
this.feedbackService.notify(ActionType.CREATE, `${message}`);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.CREATE, true, err.message, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private sendVerificationEmail(email: string) {
|
||||
this.handleError(ActionType.CREATE, false, null, true);
|
||||
this.sendVerificationEmailUseCase
|
||||
.execute(email)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.isVerificationEmailSent.set(res);
|
||||
this.handleError(ActionType.CREATE, false, null, false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.CREATE, true, err.message, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private createDefaultProfile(userId: string) {
|
||||
const profileDto: ProfileDTO = {
|
||||
profession: 'Profession non renseignée',
|
||||
utilisateur: userId,
|
||||
reseaux: {
|
||||
facebook: '',
|
||||
github: '',
|
||||
instagram: '',
|
||||
linkedIn: '',
|
||||
web: '',
|
||||
x: '',
|
||||
youTube: '',
|
||||
},
|
||||
};
|
||||
|
||||
this.profileFacade.create(profileDto);
|
||||
}
|
||||
|
||||
private handleError(
|
||||
action: ActionType = ActionType.NONE,
|
||||
hasError: boolean,
|
||||
message: string | null = null,
|
||||
isLoading = false
|
||||
) {
|
||||
this.error.set({ action, hasError, message });
|
||||
this.loading.set({ action, isLoading });
|
||||
if (hasError) {
|
||||
this.feedbackService.notify(ActionType.READ, message!, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/app/adapters/location/location.facade.ts
Normal file
54
src/app/adapters/location/location.facade.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { GetCurrentLocationUseCase } from '../../application/location/get-current-location.use-case';
|
||||
import { Coordinates } from '@app/domain/localisation/coordinates.model';
|
||||
|
||||
export interface LocationState {
|
||||
coordinates: Coordinates | null;
|
||||
isLocationEnabled: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LocationFacade {
|
||||
private readonly getCurrentLocationUseCase = inject(GetCurrentLocationUseCase);
|
||||
|
||||
readonly locationState = signal<LocationState>({
|
||||
coordinates: null,
|
||||
isLocationEnabled: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
getCurrentLocation(): void {
|
||||
this.setLoading(true);
|
||||
this.clearError();
|
||||
|
||||
this.getCurrentLocationUseCase.execute().subscribe({
|
||||
next: (coordinates) => {
|
||||
this.updateState({ coordinates });
|
||||
this.setLoading(false);
|
||||
},
|
||||
error: (error) => {
|
||||
this.setError(error.message || 'Erreur lors de la récupération de la position');
|
||||
this.setLoading(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private updateState(partial: Partial<LocationState>): void {
|
||||
this.locationState.update((state) => ({ ...state, ...partial }));
|
||||
}
|
||||
|
||||
private setLoading(isLoading: boolean): void {
|
||||
this.updateState({ isLoading });
|
||||
}
|
||||
|
||||
private setError(error: string): void {
|
||||
this.updateState({ error });
|
||||
}
|
||||
|
||||
private clearError(): void {
|
||||
this.updateState({ error: null });
|
||||
}
|
||||
}
|
||||
219
src/app/adapters/profiles/profile.facade.ts
Normal file
219
src/app/adapters/profiles/profile.facade.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { ListProfilesUseCase } from '../../application/profiles/list-profiles.usecase';
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
|
||||
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||
import { ProfilePresenter } from '../profiles/profile.presenter';
|
||||
import { ProfileViewModel, ProfileViewModelPaginated } from '../profiles/profile.presenter.model';
|
||||
import { LoaderAction } from '@app/domain/loader-action.util';
|
||||
import { ActionType } from '@app/domain/action-type.util';
|
||||
import { ErrorResponse } from '@app/domain/error-response.util';
|
||||
import { CreateProfileUseCase } from '../../application/profiles/create-profile.usecase';
|
||||
import { UpdateProfileUseCase } from '../../application/profiles/update-profile.usecase';
|
||||
import { GetProfileUseCase } from '../../application/profiles/get-profile.usecase';
|
||||
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
|
||||
import { SearchFilters } from '@app/domain/search/search-filters';
|
||||
import { SearchService } from '@app/infrastructure/search/search.service';
|
||||
import { UpdateCoordinateProfileUseCase } from '../../application/profiles/update-coordinate-profile.usecase';
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
import { UpdateSettingsProfileUseCase } from '../../application/profiles/update-settings-profile.usecase';
|
||||
import { FeedbackService } from '../shared/services/feedback.service';
|
||||
import { first, Subscription } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ProfileFacade {
|
||||
private readonly feedbackService = inject(FeedbackService);
|
||||
private readonly profileRepository = inject(PROFILE_REPOSITORY_TOKEN);
|
||||
private readonly searchService = inject(SearchService);
|
||||
private readonly profilePresenter = new ProfilePresenter();
|
||||
|
||||
private listUseCase = new ListProfilesUseCase(this.profileRepository);
|
||||
private createUseCase = new CreateProfileUseCase(this.profileRepository);
|
||||
private updateUseCase = new UpdateProfileUseCase(this.profileRepository);
|
||||
private updateCoordinateUseCase = new UpdateCoordinateProfileUseCase(this.profileRepository);
|
||||
private updateSettingsUseCase = new UpdateSettingsProfileUseCase(this.profileRepository);
|
||||
private getUseCase = new GetProfileUseCase(this.profileRepository);
|
||||
|
||||
readonly searchFilters = this.searchService.getFilters();
|
||||
readonly profiles = signal<ProfileViewModel[]>([]);
|
||||
readonly profilePaginated = signal<ProfileViewModelPaginated>({} as ProfileViewModelPaginated);
|
||||
readonly profile = signal<ProfileViewModel>({} as ProfileViewModel);
|
||||
readonly loading = signal<LoaderAction>({
|
||||
isLoading: false,
|
||||
action: ActionType.NONE,
|
||||
isDone: false,
|
||||
});
|
||||
readonly error = signal<ErrorResponse>({
|
||||
action: ActionType.NONE,
|
||||
hasError: false,
|
||||
message: null,
|
||||
});
|
||||
|
||||
private searchSubscription: Subscription | null = null;
|
||||
|
||||
load(search?: SearchFilters) {
|
||||
if (this.searchSubscription) {
|
||||
this.searchSubscription.unsubscribe();
|
||||
this.searchSubscription = null;
|
||||
}
|
||||
|
||||
this.handleError(ActionType.READ, false, null, true);
|
||||
|
||||
if (search === undefined || search === null) {
|
||||
search = this.searchFilters();
|
||||
}
|
||||
|
||||
this.searchSubscription = this.listUseCase.execute(search).subscribe({
|
||||
next: (profilePaginated: ProfilePaginated) => {
|
||||
const filters = {
|
||||
...this.searchFilters(),
|
||||
page: profilePaginated.page,
|
||||
perPage: profilePaginated.perPage,
|
||||
totalItems: profilePaginated.totalItems,
|
||||
totalPages: profilePaginated.totalPages,
|
||||
};
|
||||
|
||||
this.searchService.setFilters(filters);
|
||||
this.searchFilters.set(filters);
|
||||
|
||||
const profileViewModelPaginated =
|
||||
this.profilePresenter.toViewModelPaginated(profilePaginated);
|
||||
|
||||
this.profilePaginated.set(profileViewModelPaginated);
|
||||
this.profiles.set(profileViewModelPaginated.items);
|
||||
this.handleError(ActionType.READ, false, null, false);
|
||||
|
||||
this.searchSubscription = null;
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.READ, false, err, false);
|
||||
this.searchSubscription = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
loadOne(profileId: string) {
|
||||
this.handleError(ActionType.READ, false, null, true);
|
||||
this.getUseCase
|
||||
.execute(profileId)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (profile: Profile) => {
|
||||
this.profile.set(this.profilePresenter.toViewModel(profile));
|
||||
this.handleError(ActionType.READ, false, null, false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.READ, false, err, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
loadOneByUserId(userId: string) {
|
||||
this.handleError(ActionType.READ, false, null, true);
|
||||
this.getUseCase
|
||||
.executeByUserId(userId)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (profile: Profile) => {
|
||||
this.profile.set(this.profilePresenter.toViewModel(profile));
|
||||
this.handleError(ActionType.READ, false, null, false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.READ, false, err, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
create(profileDto: ProfileDTO) {
|
||||
this.handleError(ActionType.CREATE, false, null, true);
|
||||
let message = null;
|
||||
|
||||
this.createUseCase
|
||||
.execute(profileDto)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (profile: Profile) => {
|
||||
this.profile.set(this.profilePresenter.toViewModel(profile));
|
||||
this.handleError(ActionType.CREATE, false, null, false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.CREATE, false, err, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
update(profileId: string, profile: Partial<Profile>) {
|
||||
this.handleError(ActionType.UPDATE, false, null, true);
|
||||
|
||||
this.updateUseCase
|
||||
.execute(profileId, profile)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (profile: Profile) => {
|
||||
this.profile.set(this.profilePresenter.toViewModel(profile));
|
||||
this.handleError(ActionType.UPDATE, false, null, false);
|
||||
|
||||
const message = `Vos informations personnelles ont bien été modifier !`;
|
||||
this.feedbackService.notify(ActionType.UPDATE, message);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.UPDATE, false, err, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateCoordinate(profileId: string, latitude: number, longitude: number) {
|
||||
this.handleError(ActionType.UPDATE, false, null, true);
|
||||
|
||||
this.updateCoordinateUseCase
|
||||
.execute(profileId, latitude, longitude)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (profile: Profile) => {
|
||||
this.profile.set(this.profilePresenter.toViewModel(profile));
|
||||
this.handleError(ActionType.UPDATE, false, null, false);
|
||||
|
||||
const message = `Vos coordonnées géographique ont été enregistrés.`;
|
||||
this.feedbackService.notify(ActionType.UPDATE, message);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.UPDATE, false, err, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateSettings(profileId: string, settings: SettingsProfileDto) {
|
||||
this.handleError(ActionType.UPDATE, false, null, true);
|
||||
|
||||
this.updateSettingsUseCase
|
||||
.execute(profileId, settings)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (profile: Profile) => {
|
||||
this.profile.set(this.profilePresenter.toViewModel(profile));
|
||||
this.handleError(ActionType.UPDATE, false, null, false, true);
|
||||
|
||||
const message = `Vos paramètres ont été enregistrés.`;
|
||||
this.feedbackService.notify(ActionType.UPDATE, message);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.UPDATE, false, err, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private handleError(
|
||||
action: ActionType = ActionType.NONE,
|
||||
hasError: boolean,
|
||||
message: string | null = null,
|
||||
isLoading = false,
|
||||
isDone = false
|
||||
) {
|
||||
this.error.set({ action, hasError, message });
|
||||
this.loading.set({ action, isLoading, isDone });
|
||||
if (hasError) {
|
||||
this.feedbackService.notify(ActionType.READ, message!, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/app/adapters/profiles/profile.presenter.model.ts
Normal file
34
src/app/adapters/profiles/profile.presenter.model.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Coordinates } from '@app/domain/localisation/coordinates.model';
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
import { UserViewModel } from '../users/user.presenter.model';
|
||||
|
||||
export interface ProfileViewModel {
|
||||
id: string;
|
||||
fullName: string;
|
||||
isVerifiedLabel: string;
|
||||
createdAtFormatted: string;
|
||||
avatarUrl?: string;
|
||||
estVerifier: boolean;
|
||||
utilisateur: string;
|
||||
profession: string;
|
||||
secteur: string;
|
||||
reseaux: any;
|
||||
apropos: string;
|
||||
bio: string;
|
||||
cv: string;
|
||||
projets: string[];
|
||||
isProfileVisible?: boolean;
|
||||
missingFields?: string[];
|
||||
coordonnees?: Coordinates;
|
||||
settings?: SettingsProfileDto;
|
||||
slug?: string;
|
||||
userViewModel?: UserViewModel;
|
||||
}
|
||||
|
||||
export interface ProfileViewModelPaginated {
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
items: ProfileViewModel[];
|
||||
}
|
||||
107
src/app/adapters/profiles/profile.presenter.ts
Normal file
107
src/app/adapters/profiles/profile.presenter.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { ProfileViewModel, ProfileViewModelPaginated } from '../profiles/profile.presenter.model';
|
||||
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
import { User } from '@app/domain/users/user.model';
|
||||
import { environment } from '@env/environment';
|
||||
import { UserPresenter } from '../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) };
|
||||
}
|
||||
|
||||
toViewModel(profile: Profile): ProfileViewModel {
|
||||
const isProfileVisible = this.isProfileVisible(profile);
|
||||
const missingFields = this.missingFields(profile);
|
||||
const settings: SettingsProfileDto = {
|
||||
showEmail: profile.partageMail ?? false,
|
||||
showPhone: profile.partagePhone ?? false,
|
||||
allowGeolocation: profile.estGeolocaliser ?? false,
|
||||
isProfilePublic: profile.estVisible ?? false,
|
||||
};
|
||||
|
||||
const bio = profile.bio ? profile.bio : this.DEFAULT_BIO;
|
||||
|
||||
let profileViewModel: ProfileViewModel = {
|
||||
id: profile.id,
|
||||
fullName: '', // ❗ exemple volontaire
|
||||
isVerifiedLabel: profile.estVerifier ? '✅ Vérifié' : '❌ Non vérifié',
|
||||
createdAtFormatted: new Date(profile.created).toLocaleDateString(),
|
||||
avatarUrl: ``,
|
||||
estVerifier: profile.estVerifier,
|
||||
utilisateur: profile.utilisateur,
|
||||
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,
|
||||
coordonnees: profile.coordonnees
|
||||
? { latitude: profile!.coordonnees!.lat!, longitude: profile!.coordonnees!.lon! }
|
||||
: undefined,
|
||||
settings,
|
||||
isProfileVisible,
|
||||
missingFields,
|
||||
};
|
||||
|
||||
const profileExpand = (profile as any) ? (profile as any).expand : { utilisateur: {} as User };
|
||||
const userExpand = profileExpand ? (profileExpand.utilisateur as User) : undefined;
|
||||
if (userExpand !== undefined) {
|
||||
const userViewModel = this.userPresenter.toViewModel(userExpand);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
return profileViewModel;
|
||||
}
|
||||
|
||||
toViewModels(profiles: Profile[]): ProfileViewModel[] {
|
||||
return profiles.map((profile) => this.toViewModel(profile));
|
||||
}
|
||||
|
||||
private isProfileVisible(currentProfile: Profile) {
|
||||
if (!currentProfile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasProfession = currentProfile.profession
|
||||
? currentProfile.profession.toLowerCase() !== 'profession non renseignée'
|
||||
: false;
|
||||
const hasSector = !!currentProfile.secteur;
|
||||
|
||||
return hasProfession && hasSector;
|
||||
}
|
||||
|
||||
private missingFields(currentProfile: Profile) {
|
||||
const missing: string[] = [];
|
||||
|
||||
if (
|
||||
!currentProfile?.profession ||
|
||||
currentProfile.profession.toLowerCase() === 'profession non renseignée'
|
||||
) {
|
||||
missing.push('profession');
|
||||
}
|
||||
if (!currentProfile?.secteur) {
|
||||
missing.push("secteur d'activité");
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
}
|
||||
133
src/app/adapters/projects/project.facade.ts
Normal file
133
src/app/adapters/projects/project.facade.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { PROJECT_REPOSITORY_TOKEN } from '@app/infrastructure/projects/project-repository.token';
|
||||
import { CreateProjectUseCase } from '../../application/projects/create-project.usecase';
|
||||
import { ListProjectUseCase } from '../../application/projects/list-project.usecase';
|
||||
import { GetProjectUseCase } from '../../application/projects/get-project.usecase';
|
||||
import { UpdateProjectUseCase } from '../../application/projects/update-project.usecase';
|
||||
import { Project } from '@app/domain/projects/project.model';
|
||||
import { ProjectViewModel } from '../projects/project.presenter.model';
|
||||
import { ProjectPresenter } from '../projects/project.presenter';
|
||||
import { CreateProjectDto } from '@app/domain/projects/dto/create-project.dto';
|
||||
import { ErrorResponse } from '@app/domain/error-response.util';
|
||||
import { ActionType } from '@app/domain/action-type.util';
|
||||
import { LoaderAction } from '@app/domain/loader-action.util';
|
||||
import { first, Subscription } from 'rxjs';
|
||||
import { FeedbackService } from '../shared/services/feedback.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ProjectFacade {
|
||||
private readonly feedbackService = inject(FeedbackService);
|
||||
private readonly projectRepo = inject(PROJECT_REPOSITORY_TOKEN);
|
||||
|
||||
private readonly createUseCase = new CreateProjectUseCase(this.projectRepo);
|
||||
private readonly listUseCase = new ListProjectUseCase(this.projectRepo);
|
||||
private readonly getUseCase = new GetProjectUseCase(this.projectRepo);
|
||||
private readonly UpdateUseCase = new UpdateProjectUseCase(this.projectRepo);
|
||||
|
||||
readonly projects = signal<ProjectViewModel[]>([]);
|
||||
readonly project = signal<ProjectViewModel>({} as ProjectViewModel);
|
||||
readonly loading = signal<LoaderAction>({ isLoading: false, action: ActionType.NONE });
|
||||
readonly error = signal<ErrorResponse>({
|
||||
action: ActionType.NONE,
|
||||
hasError: false,
|
||||
message: null,
|
||||
});
|
||||
|
||||
private readonly projectPresenter = new ProjectPresenter();
|
||||
private projectSubscription: Subscription | null = null;
|
||||
|
||||
load(userId: string) {
|
||||
if (this.projectSubscription) {
|
||||
this.projectSubscription.unsubscribe();
|
||||
this.projectSubscription = null;
|
||||
}
|
||||
|
||||
this.handleError(ActionType.READ, false, null, true);
|
||||
|
||||
this.projectSubscription = this.listUseCase.execute(userId).subscribe({
|
||||
next: (projects: Project[]) => {
|
||||
this.projects.set(this.projectPresenter.toViewModels(projects));
|
||||
this.handleError(ActionType.READ, false, null, false);
|
||||
|
||||
this.projectSubscription = null;
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.READ, false, err, false);
|
||||
this.projectSubscription = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
loadOne(projectId: string) {
|
||||
this.handleError(ActionType.READ, false, null, true);
|
||||
|
||||
this.getUseCase
|
||||
.execute(projectId)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (project: Project) => {
|
||||
this.project.set(this.projectPresenter.toViewModel(project));
|
||||
this.handleError(ActionType.READ, false, null, false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.READ, false, err, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
create(projectDto: CreateProjectDto) {
|
||||
this.handleError(ActionType.CREATE, false, null, true);
|
||||
|
||||
this.createUseCase
|
||||
.execute(projectDto)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (project: Project) => {
|
||||
this.project.set(this.projectPresenter.toViewModel(project));
|
||||
this.projects.update((prev) => [...prev, this.projectPresenter.toViewModel(project)]);
|
||||
this.handleError(ActionType.CREATE, false, null, false);
|
||||
|
||||
const message = `Le projet ${project.nom} a bien été créer !`;
|
||||
this.feedbackService.notify(ActionType.UPDATE, message);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.CREATE, false, err, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
update(userId: string, data: any) {
|
||||
this.handleError(ActionType.UPDATE, false, null, true);
|
||||
|
||||
this.UpdateUseCase.execute(userId, data)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (project: Project) => {
|
||||
this.project.set(this.projectPresenter.toViewModel(project));
|
||||
this.handleError(ActionType.UPDATE, false, null, false);
|
||||
|
||||
const message = `Les informations du projet ${project.nom} ont bien été modifier !`;
|
||||
this.feedbackService.notify(ActionType.UPDATE, message);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.UPDATE, false, err, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private handleError(
|
||||
action: ActionType = ActionType.NONE,
|
||||
hasError: boolean,
|
||||
message: string | null = null,
|
||||
isLoading = false
|
||||
) {
|
||||
this.error.set({ action, hasError, message });
|
||||
this.loading.set({ action, isLoading });
|
||||
|
||||
if (hasError) {
|
||||
this.feedbackService.notify(ActionType.READ, message!, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { ProjectViewModel } from '../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,16 +1,19 @@
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { SECTOR_REPOSITORY_TOKEN } from '@app/infrastructure/sectors/sector-repository.token';
|
||||
import { ListSectorUsecase } from '@app/usecase/sectors/list-sector.usecase';
|
||||
import { GetSectorUseCase } from '@app/usecase/sectors/get-sector.usecase';
|
||||
import { ListSectorUsecase } from '../../application/sectors/list-sector.usecase';
|
||||
import { GetSectorUseCase } from '../../application/sectors/get-sector.usecase';
|
||||
import { ActionType } from '@app/domain/action-type.util';
|
||||
import { LoaderAction } from '@app/domain/loader-action.util';
|
||||
import { ErrorResponse } from '@app/domain/error-response.util';
|
||||
import { SectorPresenterModel } from '@app/ui/sectors/sector.presenter.model';
|
||||
import { SectorPresenterModel } from '../sectors/sector.presenter.model';
|
||||
import { Sector } from '@app/domain/sectors/sector.model';
|
||||
import { SectorPresenter } from '@app/ui/sectors/sector.presenter';
|
||||
import { SectorPresenter } from '../sectors/sector.presenter';
|
||||
import { first, Subscription } from 'rxjs';
|
||||
import { FeedbackService } from '../shared/services/feedback.service';
|
||||
|
||||
@Injectable()
|
||||
export class SectorFacade {
|
||||
private readonly feedbackService = inject(FeedbackService);
|
||||
private readonly sectorRepo = inject(SECTOR_REPOSITORY_TOKEN);
|
||||
|
||||
private readonly listSectorUseCase = new ListSectorUsecase(this.sectorRepo);
|
||||
@@ -27,31 +30,42 @@ export class SectorFacade {
|
||||
});
|
||||
|
||||
private readonly sectorPresenter = new SectorPresenter();
|
||||
private sectorSubscription: Subscription | null = null;
|
||||
|
||||
load() {
|
||||
if (this.sectorSubscription) {
|
||||
this.sectorSubscription.unsubscribe();
|
||||
this.sectorSubscription = null;
|
||||
}
|
||||
|
||||
this.handleError(ActionType.READ, false, null, true);
|
||||
this.listSectorUseCase.execute().subscribe({
|
||||
this.sectorSubscription = this.listSectorUseCase.execute().subscribe({
|
||||
next: (sectors: Sector[]) => {
|
||||
this.sectors.set(this.sectorPresenter.toViewModels(sectors));
|
||||
this.handleError(ActionType.READ, false, null, false);
|
||||
this.sectorSubscription = null;
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.READ, false, err, false);
|
||||
this.sectorSubscription = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
loadOne(sectorId: string) {
|
||||
this.handleError(ActionType.READ, false, null, true);
|
||||
this.getSectorUseCase.execute(sectorId).subscribe({
|
||||
next: (sector: Sector) => {
|
||||
this.sector.set(this.sectorPresenter.toViewModel(sector));
|
||||
this.handleError(ActionType.READ, false, null, false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.READ, false, err, false);
|
||||
},
|
||||
});
|
||||
this.getSectorUseCase
|
||||
.execute(sectorId)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (sector: Sector) => {
|
||||
this.sector.set(this.sectorPresenter.toViewModel(sector));
|
||||
this.handleError(ActionType.READ, false, null, false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.READ, false, err, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private handleError(
|
||||
@@ -62,5 +76,9 @@ export class SectorFacade {
|
||||
) {
|
||||
this.error.set({ action, hasError, message });
|
||||
this.loading.set({ action, isLoading });
|
||||
|
||||
if (hasError) {
|
||||
this.feedbackService.notify(ActionType.READ, message!, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SectorPresenterModel } from '@app/ui/sectors/sector.presenter.model';
|
||||
import { SectorPresenterModel } from '../sectors/sector.presenter.model';
|
||||
import { Sector } from '@app/domain/sectors/sector.model';
|
||||
|
||||
export class SectorPresenter {
|
||||
69
src/app/adapters/settings/settings.facade.ts
Normal file
69
src/app/adapters/settings/settings.facade.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { ThemeType, UserSettings } from '@app/domain/settings/setting.model';
|
||||
import { GetSettingsUseCase } from '../../application/settings/get-settings.usecase';
|
||||
import { UpdateSettingsUseCase } from '../../application/settings/update-settings.usecase';
|
||||
import { SETTING_REPOSITORY_TOKEN } from '@app/infrastructure/settings/setting-repository.token';
|
||||
import { ApplyThemeUsecase } from '../../application/settings/apply-theme.usecase';
|
||||
import { GetDefaultSystemThemeUsecase } from '../../application/settings/get-default-system-theme.usecase';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SettingsFacade {
|
||||
private readonly settingRepository = inject(SETTING_REPOSITORY_TOKEN);
|
||||
|
||||
private readonly getSettingsUseCase = new GetSettingsUseCase(this.settingRepository);
|
||||
private readonly updateSettingsUseCase = new UpdateSettingsUseCase(this.settingRepository);
|
||||
private readonly applyThemeUseCase = new ApplyThemeUsecase(this.settingRepository);
|
||||
private readonly getDefaultSystemThemeUseCase = new GetDefaultSystemThemeUsecase(
|
||||
this.settingRepository
|
||||
);
|
||||
|
||||
readonly settings = signal<UserSettings>(this.getSettingsUseCase.execute());
|
||||
|
||||
constructor() {
|
||||
const initialSettings = {
|
||||
...this.settings(),
|
||||
theme: this.themeMode(),
|
||||
};
|
||||
this.applyThemeSettings(initialSettings);
|
||||
}
|
||||
|
||||
applyThemeSettings(initialSettings: UserSettings) {
|
||||
this.applyThemeUseCase.execute(initialSettings);
|
||||
}
|
||||
|
||||
loadSettings(): void {
|
||||
this.getSettingsUseCase.execute();
|
||||
}
|
||||
|
||||
updateSettings(settings: UserSettings): void {
|
||||
const settingReceived = {
|
||||
...this.settings(),
|
||||
theme: settings.theme,
|
||||
privacy: {
|
||||
isProfilePublic: settings.privacy.isProfilePublic,
|
||||
showEmail: settings.privacy.showEmail,
|
||||
showPhone: settings.privacy.showPhone,
|
||||
allowGeolocation: settings.privacy.allowGeolocation,
|
||||
},
|
||||
};
|
||||
this.updateSettingsUseCase.execute(settingReceived);
|
||||
this.applyThemeSettings(settingReceived);
|
||||
}
|
||||
|
||||
private themeMode(): ThemeType {
|
||||
let theme = '';
|
||||
|
||||
switch (this.settings().theme) {
|
||||
case 'light':
|
||||
theme = 'light';
|
||||
break;
|
||||
case 'dark':
|
||||
theme = 'dark';
|
||||
break;
|
||||
case 'system':
|
||||
theme = this.getDefaultSystemThemeUseCase.execute() ? 'dark' : 'light';
|
||||
break;
|
||||
}
|
||||
return theme as ThemeType;
|
||||
}
|
||||
}
|
||||
82
src/app/adapters/shared/services/feedback.service.ts
Normal file
82
src/app/adapters/shared/services/feedback.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ActionType } from '@app/domain/action-type.util';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class FeedbackService {
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
private readonly TOAST_CONFIG = {
|
||||
closeButton: true,
|
||||
progressAnimation: 'decreasing' as const,
|
||||
progressBar: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param action Le type d'action (UPDATE, CREATE...). Passer null pour une info générique.
|
||||
* @param message Le message à afficher.
|
||||
* @param hasError Si true, affiche une erreur générique.
|
||||
*/
|
||||
notify(action: ActionType | null, message: string, hasError: boolean = false): void {
|
||||
if (hasError) {
|
||||
const userFriendlyMessage = this.handleErrorMessage(message);
|
||||
this.handleError(userFriendlyMessage);
|
||||
} else {
|
||||
this.handleSuccess(action, message);
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(message: string): void {
|
||||
this.toastr.error(`${message}`, `Erreur`, {
|
||||
...this.TOAST_CONFIG,
|
||||
disableTimeOut: true,
|
||||
progressBar: false,
|
||||
});
|
||||
}
|
||||
|
||||
private handleSuccess(action: ActionType | null, message: string): void {
|
||||
let title = 'Succès';
|
||||
|
||||
switch (action) {
|
||||
case ActionType.UPDATE:
|
||||
title = 'Mise à jour';
|
||||
break;
|
||||
case ActionType.CREATE:
|
||||
title = 'Création';
|
||||
break;
|
||||
case ActionType.DELETE:
|
||||
title = 'Suppression';
|
||||
break;
|
||||
}
|
||||
|
||||
this.toastr.success(message, title, this.TOAST_CONFIG);
|
||||
}
|
||||
|
||||
private handleErrorMessage(messageError: string): string {
|
||||
const lowerMessage = messageError.toLowerCase();
|
||||
|
||||
// Mapping des erreurs PocketBase courantes vers messages utilisateurs
|
||||
const errorMessages: Record<string, string> = {
|
||||
'failed to authenticate': `Votre email n'est pas valide ou votre mot de passe est incorrect`,
|
||||
'failed to auth': `Échec de l'authentification. Vérifiez vos identifiants.`,
|
||||
'invalid email': `L'adresse email n'est pas valide.`,
|
||||
'invalid password': `Le mot de passe ne respecte pas les critères de sécurité.`,
|
||||
'passwords do not match': `Les mots de passe saisis ne correspondent pas.`,
|
||||
'record not found': `La ressource demandée n'existe pas.`,
|
||||
'validation error': `Données invalides. Vérifiez les champs saisis.`,
|
||||
'network error': `Problème de connexion. Vérifiez votre réseau.`,
|
||||
};
|
||||
|
||||
// Recherche du premier match
|
||||
for (const [errorKey, userMessage] of Object.entries(errorMessages)) {
|
||||
if (lowerMessage.includes(errorKey)) {
|
||||
return userMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// Message par défaut
|
||||
return `Une erreur s'est produite, veuillez réessayer ultérieurement`;
|
||||
}
|
||||
}
|
||||
142
src/app/adapters/shared/services/file-manager.service.ts
Normal file
142
src/app/adapters/shared/services/file-manager.service.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
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'];
|
||||
private readonly ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
|
||||
|
||||
formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Octets';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Octets', 'Ko', 'Mo', 'Go'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
async onPictureChange($event: Event): Promise<void> {
|
||||
const target = $event.target as HTMLInputElement;
|
||||
const selectedFile = target?.files?.[0];
|
||||
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Réinitialiser l'erreur
|
||||
this.fileError.set(null);
|
||||
|
||||
// Vérifier le type de fichier
|
||||
if (!this.ALLOWED_IMAGE_TYPES.includes(selectedFile.type)) {
|
||||
this.resetFile();
|
||||
this.fileError.set('Le fichier doit être une image (JPEG, PNG ou WebP)');
|
||||
target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier l'extension du fichier (sécurité supplémentaire)
|
||||
const fileExtension = selectedFile.name.split('.').pop()?.toLowerCase();
|
||||
if (!fileExtension || !this.ALLOWED_EXTENSIONS.includes(fileExtension)) {
|
||||
this.resetFile();
|
||||
this.fileError.set('Extension de fichier non autorisée');
|
||||
target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier la taille du fichier
|
||||
if (selectedFile.size > this.MAX_FILE_SIZE) {
|
||||
this.resetFile();
|
||||
this.fileError.set(
|
||||
`L'image est trop volumineuse (${this.formatFileSize(selectedFile.size)}). Taille maximale autorisée : 5 Mo`
|
||||
);
|
||||
target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const target = $event.target as HTMLInputElement;
|
||||
const selectedFile = target?.files?.[0];
|
||||
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Réinitialiser l'erreur
|
||||
this.fileError.set(null);
|
||||
|
||||
// Vérifier le type de fichier
|
||||
if (selectedFile.type !== 'application/pdf') {
|
||||
this.fileError.set('Le fichier doit être au format PDF');
|
||||
this.file.set(null);
|
||||
target.value = ''; // Réinitialiser l'input
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier la taille du fichier
|
||||
if (selectedFile.size > this.MAX_FILE_SIZE) {
|
||||
this.fileError.set(
|
||||
`Le fichier est trop volumineux (${this.formatFileSize(selectedFile.size)}). Taille maximale autorisée : 5 Mo`
|
||||
);
|
||||
this.file.set(null);
|
||||
target.value = ''; // Réinitialiser l'input
|
||||
return;
|
||||
}
|
||||
|
||||
this.file.set(selectedFile);
|
||||
}
|
||||
|
||||
removeFile(): void {
|
||||
this.resetFile();
|
||||
}
|
||||
|
||||
resetFile(): void {
|
||||
this.file.set(null);
|
||||
this.imagePreviewUrl.set(null);
|
||||
this.fileError.set(null);
|
||||
}
|
||||
|
||||
private readFile(file: File): void {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.imagePreviewUrl.set(e.target?.result as string);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
this.fileError.set('Erreur lors de la lecture du fichier');
|
||||
this.resetFile();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
80
src/app/adapters/users/user.facade.ts
Normal file
80
src/app/adapters/users/user.facade.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { USER_REPOSITORY_TOKEN } from '@app/infrastructure/users/user-repository.token';
|
||||
import { GetUserUseCase } from '../../application/users/get-user.usecase';
|
||||
import { UpdateUserUseCase } from '../../application/users/update-user.usecase';
|
||||
import { LoaderAction } from '@app/domain/loader-action.util';
|
||||
import { ActionType } from '@app/domain/action-type.util';
|
||||
import { ErrorResponse } from '@app/domain/error-response.util';
|
||||
import { UserViewModel } from '../users/user.presenter.model';
|
||||
import { UserPresenter } from '../users/user.presenter';
|
||||
import { first } from 'rxjs';
|
||||
import { FeedbackService } from '../shared/services/feedback.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserFacade {
|
||||
private readonly feedbackService = inject(FeedbackService);
|
||||
private readonly userRepository = inject(USER_REPOSITORY_TOKEN);
|
||||
|
||||
private readonly getUseCase = new GetUserUseCase(this.userRepository);
|
||||
private readonly updateUseCase = new UpdateUserUseCase(this.userRepository);
|
||||
|
||||
readonly user = signal<UserViewModel>({} as UserViewModel);
|
||||
readonly users = signal<UserViewModel[]>([]);
|
||||
readonly loading = signal<LoaderAction>({ isLoading: false, action: ActionType.NONE });
|
||||
readonly error = signal<ErrorResponse>({
|
||||
action: ActionType.NONE,
|
||||
hasError: false,
|
||||
message: null,
|
||||
});
|
||||
|
||||
private readonly userPresenter = new UserPresenter();
|
||||
|
||||
loadOne(userId: string) {
|
||||
this.handleError(ActionType.READ, false, null, true);
|
||||
this.getUseCase
|
||||
.execute(userId)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (user) => {
|
||||
this.user.set(this.userPresenter.toViewModel(user));
|
||||
this.handleError(ActionType.READ, false, null, false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.READ, false, err, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
update(userId: string, user: Partial<UserViewModel>) {
|
||||
this.handleError(ActionType.UPDATE, false, null, true);
|
||||
this.updateUseCase
|
||||
.execute(userId, user)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (user) => {
|
||||
this.user.set(this.userPresenter.toViewModel(user));
|
||||
this.handleError(ActionType.UPDATE, false, null, false);
|
||||
|
||||
const message = `Votre profile a bien été modifier !`;
|
||||
this.feedbackService.notify(ActionType.UPDATE, message);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.UPDATE, false, err, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private handleError(
|
||||
action: ActionType = ActionType.NONE,
|
||||
hasError: boolean,
|
||||
message: string | null = null,
|
||||
isLoading = false
|
||||
) {
|
||||
this.error.set({ action, hasError, message });
|
||||
this.loading.set({ action, isLoading });
|
||||
|
||||
if (hasError) {
|
||||
this.feedbackService.notify(ActionType.READ, message!, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,6 @@ export interface UserViewModel {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
slug?: string;
|
||||
isUserVisible?: boolean;
|
||||
missingFields?: string[];
|
||||
}
|
||||
@@ -1,21 +1,29 @@
|
||||
import { UserViewModel } from '@app/ui/users/user.presenter.model';
|
||||
import { UserViewModel } from '../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),
|
||||
};
|
||||
|
||||
if (user.name) {
|
||||
@@ -28,7 +36,7 @@ export class UserPresenter {
|
||||
}
|
||||
|
||||
toViewModels(users: User[]): UserViewModel[] {
|
||||
return users.map(this.toViewModel);
|
||||
return users.map((user) => this.toViewModel(user));
|
||||
}
|
||||
|
||||
private generateProfileSlug(name: string, id?: string): string {
|
||||
@@ -42,4 +50,18 @@ export class UserPresenter {
|
||||
.replace(/\s+/g, '-') // Remplace les espaces par des tirets
|
||||
.replace(/-+/g, '-'); // Évite les tirets multiples
|
||||
}
|
||||
|
||||
private isUserVisible(currentUser: User) {
|
||||
return !!currentUser.name;
|
||||
}
|
||||
|
||||
private missingFields(currentUser: User) {
|
||||
const missing: string[] = [];
|
||||
|
||||
if (!currentUser?.name) {
|
||||
missing.push('nom');
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
<div
|
||||
class="app-container min-h-screen flex flex-col bg-white dark:bg-gray-900"
|
||||
[ngClass]="themeService.darkModeSignal()"
|
||||
>
|
||||
<div class="app-container min-h-screen flex flex-col bg-white dark:bg-gray-900">
|
||||
<!-- Navigation fixe -->
|
||||
<app-nav-bar class="flex-shrink-0" />
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ import { AuthRepository } from '@app/domain/authentification/auth.repository';
|
||||
import { AUTH_REPOSITORY_TOKEN } from '@app/infrastructure/authentification/auth-repository.token';
|
||||
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
|
||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
||||
import { SettingRepository } from '@app/domain/settings/setting.repository';
|
||||
import { mockSettingRepo } from '@app/testing/setting.mock';
|
||||
import { SETTING_REPOSITORY_TOKEN } from '@app/infrastructure/settings/setting-repository.token';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { mockToastR } from '@app/testing/toastr.mock';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
let component: AppComponent;
|
||||
@@ -12,6 +17,7 @@ describe('AppComponent', () => {
|
||||
|
||||
let mockAuthRepository: jest.Mocked<Partial<AuthRepository>>;
|
||||
let mockProfileRepo: jest.Mocked<Partial<ProfileRepository>>;
|
||||
let mockSettingRepository: jest.Mocked<Partial<SettingRepository>> = mockSettingRepo;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockAuthRepository = {
|
||||
@@ -37,8 +43,10 @@ describe('AppComponent', () => {
|
||||
imports: [AppComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: ToastrService, useValue: mockToastR },
|
||||
{ provide: AUTH_REPOSITORY_TOKEN, useValue: mockAuthRepository },
|
||||
{ provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProfileRepo },
|
||||
{ provide: SETTING_REPOSITORY_TOKEN, useValue: mockSettingRepository },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -54,10 +62,4 @@ describe('AppComponent', () => {
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have the 'TrouveTonProfil' title`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('TrouveTonProfil');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { NavBarComponent } from '@app/shared/components/nav-bar/nav-bar.component';
|
||||
import { FooterComponent } from '@app/shared/components/footer/footer.component';
|
||||
import { ThemeService } from '@app/core/services/theme/theme.service';
|
||||
import { SettingsFacade } from './adapters/settings/settings.facade';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -12,7 +12,10 @@ import { ThemeService } from '@app/core/services/theme/theme.service';
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
})
|
||||
export class AppComponent {
|
||||
title = 'TrouveTonProfil';
|
||||
themeService = inject(ThemeService);
|
||||
export class AppComponent implements OnInit {
|
||||
private readonly settingsFacade = inject(SettingsFacade);
|
||||
|
||||
ngOnInit() {
|
||||
this.settingsFacade.loadSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApplicationConfig } from '@angular/core';
|
||||
import { ApplicationConfig, ErrorHandler } from '@angular/core';
|
||||
import {
|
||||
PreloadAllModules,
|
||||
provideRouter,
|
||||
@@ -24,6 +24,9 @@ import { AUTH_REPOSITORY_TOKEN } from '@app/infrastructure/authentification/auth
|
||||
import { PbAuthRepository } from '@app/infrastructure/authentification/pb-auth.repository';
|
||||
import { WEB_SHARE_SERVICE_TOKEN } from '@app/infrastructure/shareData/web-share.service.token';
|
||||
import { WebShareService } from '@app/infrastructure/shareData/web-share.service';
|
||||
import { SETTING_REPOSITORY_TOKEN } from '@app/infrastructure/settings/setting-repository.token';
|
||||
import { LocalSettingRepository } from '@app/infrastructure/settings/local-setting.repository';
|
||||
import { GlobalErrorHandler } from '@app/infrastructure/handlers/global-error-handler';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
@@ -45,6 +48,8 @@ export const appConfig: ApplicationConfig = {
|
||||
{ provide: USER_REPOSITORY_TOKEN, useExisting: PbUserRepository },
|
||||
{ provide: AUTH_REPOSITORY_TOKEN, useExisting: PbAuthRepository },
|
||||
{ provide: WEB_SHARE_SERVICE_TOKEN, useExisting: WebShareService },
|
||||
{ provide: SETTING_REPOSITORY_TOKEN, useClass: LocalSettingRepository },
|
||||
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
|
||||
provideToastr({
|
||||
timeOut: 10000,
|
||||
positionClass: 'toast-top-right',
|
||||
|
||||
@@ -5,23 +5,23 @@ export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
title: 'Accueil',
|
||||
loadChildren: () => import('@app/routes/home/home.module').then((m) => m.HomeModule),
|
||||
loadChildren: () => import('./views/home/home.module').then((m) => m.HomeModule),
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
title: 'Accueil',
|
||||
loadChildren: () => import('@app/routes/home/home.module').then((m) => m.HomeModule),
|
||||
loadChildren: () => import('./views/home/home.module').then((m) => m.HomeModule),
|
||||
},
|
||||
{
|
||||
path: 'profiles',
|
||||
title: 'Liste des profiles',
|
||||
loadChildren: () => import('@app/routes/profile/profile.module').then((m) => m.ProfileModule),
|
||||
loadChildren: () => import('./views/profile/profile.module').then((m) => m.ProfileModule),
|
||||
},
|
||||
{
|
||||
path: 'auth',
|
||||
title: 'Authentification',
|
||||
loadChildren: () =>
|
||||
import('@app/routes/authentification/authentification.module').then(
|
||||
import('./views/authentification/authentification.module').then(
|
||||
(m) => m.AuthentificationModule
|
||||
),
|
||||
},
|
||||
@@ -30,13 +30,12 @@ export const routes: Routes = [
|
||||
title: 'Mon profile',
|
||||
canActivate: [authGuard],
|
||||
loadChildren: () =>
|
||||
import('@app/routes/my-profile/my-profile.module').then((m) => m.MyProfileModule),
|
||||
import('./views/my-profile/my-profile.module').then((m) => m.MyProfileModule),
|
||||
},
|
||||
{
|
||||
path: 'not-found',
|
||||
title: 'Page non trouvée',
|
||||
loadChildren: () =>
|
||||
import('@app/routes/not-found/not-found.module').then((m) => m.NotFoundModule),
|
||||
loadChildren: () => import('./views/not-found/not-found.module').then((m) => m.NotFoundModule),
|
||||
},
|
||||
{ path: '', redirectTo: '/', pathMatch: 'full' },
|
||||
{ path: '**', redirectTo: '/not-found' },
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Coordinates } from '@app/domain/localisation/coordinates.model';
|
||||
import { from, Observable } from 'rxjs';
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class GetCurrentLocationUseCase {
|
||||
execute(): Observable<Coordinates> {
|
||||
return from(
|
||||
new Promise<Coordinates>((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject({
|
||||
code: 0,
|
||||
message: "La géolocalisation n'est pas supportée par votre navigateur",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
resolve({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
let message = 'Erreur de géolocalisation';
|
||||
|
||||
switch (error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
message = 'Permission de géolocalisation refusée';
|
||||
break;
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
message = 'Position indisponible';
|
||||
break;
|
||||
case error.TIMEOUT:
|
||||
message = 'Délai de géolocalisation dépassé';
|
||||
break;
|
||||
}
|
||||
|
||||
reject({
|
||||
code: error.code,
|
||||
message,
|
||||
});
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 0,
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,8 @@ export class GetProfileUseCase {
|
||||
execute(profileId: string): Observable<Profile> {
|
||||
return this.repo.getById(profileId);
|
||||
}
|
||||
|
||||
executeByUserId(userId: string): Observable<Profile> {
|
||||
return this.repo.getByUserId(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
||||
import { Profile } from '@app/domain/profiles/profile.model';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export class UpdateCoordinateProfileUseCase {
|
||||
constructor(private readonly repo: ProfileRepository) {}
|
||||
|
||||
execute(profileId: string, latitude: number, longitude: number): Observable<Profile> {
|
||||
return this.repo.updateCoordinates(profileId, latitude, longitude);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
||||
import { Profile } from '@app/domain/profiles/profile.model';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
|
||||
export class UpdateSettingsProfileUseCase {
|
||||
constructor(private readonly repo: ProfileRepository) {}
|
||||
|
||||
execute(profileId: string, settingsDto: SettingsProfileDto): Observable<Profile> {
|
||||
return this.repo.updateSettings(profileId, settingsDto);
|
||||
}
|
||||
}
|
||||
10
src/app/application/settings/apply-theme.usecase.ts
Normal file
10
src/app/application/settings/apply-theme.usecase.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { SettingRepository } from '@app/domain/settings/setting.repository';
|
||||
import { UserSettings } from '@app/domain/settings/setting.model';
|
||||
|
||||
export class ApplyThemeUsecase {
|
||||
constructor(private settingRepository: SettingRepository) {}
|
||||
|
||||
execute(settings: UserSettings): void {
|
||||
return this.settingRepository.applyTheme(settings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SettingRepository } from '@app/domain/settings/setting.repository';
|
||||
import { ThemeType } from '@app/domain/settings/setting.model';
|
||||
|
||||
export class GetDefaultSystemThemeUsecase {
|
||||
constructor(private settingRepository: SettingRepository) {}
|
||||
|
||||
execute(): ThemeType {
|
||||
return this.settingRepository.getDefaultSystemTheme();
|
||||
}
|
||||
}
|
||||
10
src/app/application/settings/get-settings.usecase.ts
Normal file
10
src/app/application/settings/get-settings.usecase.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { SettingRepository } from '@app/domain/settings/setting.repository';
|
||||
import { UserSettings } from '@app/domain/settings/setting.model';
|
||||
|
||||
export class GetSettingsUseCase {
|
||||
constructor(private settingRepository: SettingRepository) {}
|
||||
|
||||
execute(): UserSettings {
|
||||
return this.settingRepository.getSettings();
|
||||
}
|
||||
}
|
||||
10
src/app/application/settings/update-settings.usecase.ts
Normal file
10
src/app/application/settings/update-settings.usecase.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { SettingRepository } from '@app/domain/settings/setting.repository';
|
||||
import { UserSettings } from '@app/domain/settings/setting.model';
|
||||
|
||||
export class UpdateSettingsUseCase {
|
||||
constructor(private settingRepository: SettingRepository) {}
|
||||
|
||||
execute(settings: UserSettings): void {
|
||||
return this.settingRepository.saveSettings(settings);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { authGuard } from './auth.guard';
|
||||
import { CanActivateFn, Router, UrlTree } from '@angular/router';
|
||||
import { AuthFacade } from '@app/ui/authentification/auth.facade';
|
||||
import { AuthFacade } from '../../../adapters/authentification/auth.facade';
|
||||
|
||||
describe('authGuard', () => {
|
||||
// 1. Définition des variables pour les Mocks
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { inject } from '@angular/core';
|
||||
import { AuthFacade } from '@app/ui/authentification/auth.facade';
|
||||
import { AuthFacade } from '../../../adapters/authentification/auth.facade';
|
||||
|
||||
export const authGuard: CanActivateFn = (route, state) => {
|
||||
const authFacade = inject(AuthFacade);
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ThemeService } from './theme.service';
|
||||
|
||||
describe('ThemeService', () => {
|
||||
let service: ThemeService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [ThemeService],
|
||||
});
|
||||
service = TestBed.inject(ThemeService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
// Test de la valeur initiale
|
||||
it('should have initial value "null"', () => {
|
||||
// Avec les Signals, on accède à la valeur en exécutant la fonction : signal()
|
||||
expect(service.darkModeSignal()).toBe('null');
|
||||
});
|
||||
|
||||
// Test du basculement vers Dark
|
||||
it('should switch to "dark" when updateDarkMode is called and current is "null"', () => {
|
||||
// Action
|
||||
service.updateDarkMode();
|
||||
|
||||
// Vérification
|
||||
expect(service.darkModeSignal()).toBe('dark');
|
||||
});
|
||||
|
||||
// Test du basculement inverse (Dark vers Null)
|
||||
it('should switch back to "null" when updateDarkMode is called and current is "dark"', () => {
|
||||
// 1. Préparation : On force l'état à 'dark' pour tester ce cas précis
|
||||
service.darkModeSignal.set('dark');
|
||||
|
||||
// 2. Action
|
||||
service.updateDarkMode();
|
||||
|
||||
// 3. Vérification
|
||||
expect(service.darkModeSignal()).toBe('null');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ThemeService {
|
||||
darkModeSignal = signal<string>('null');
|
||||
|
||||
updateDarkMode() {
|
||||
this.darkModeSignal.update((value) => (value === 'dark' ? 'null' : 'dark'));
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,5 @@ import { ActionType } from '@app/domain/action-type.util';
|
||||
export interface LoaderAction {
|
||||
action: ActionType;
|
||||
isLoading: boolean;
|
||||
isDone?: boolean;
|
||||
}
|
||||
|
||||
21
src/app/domain/localisation/coordinates.model.ts
Normal file
21
src/app/domain/localisation/coordinates.model.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface Coordinates {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export class CoordinatesValidator {
|
||||
static isValid(coords: Coordinates | null | undefined): boolean {
|
||||
if (!coords) return false;
|
||||
|
||||
return (
|
||||
coords.latitude >= -90 &&
|
||||
coords.latitude <= 90 &&
|
||||
coords.longitude >= -180 &&
|
||||
coords.longitude <= 180
|
||||
);
|
||||
}
|
||||
|
||||
static format(coords: Coordinates): string {
|
||||
return `${coords.latitude.toFixed(6)}, ${coords.longitude.toFixed(6)}`;
|
||||
}
|
||||
}
|
||||
7
src/app/domain/log-level.ts
Normal file
7
src/app/domain/log-level.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARN = 2,
|
||||
ERROR = 3,
|
||||
OFF = 4,
|
||||
}
|
||||
6
src/app/domain/profiles/dto/settings-profile.dto.ts
Normal file
6
src/app/domain/profiles/dto/settings-profile.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface SettingsProfileDto {
|
||||
isProfilePublic: boolean;
|
||||
showEmail: boolean;
|
||||
showPhone: boolean;
|
||||
allowGeolocation: boolean;
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export interface Profile {
|
||||
cv: string;
|
||||
projets: string[];
|
||||
apropos: string;
|
||||
coordonnees?: { lat: number; lon: number };
|
||||
estVisible?: boolean;
|
||||
estGeolocaliser?: boolean;
|
||||
partageMail?: boolean;
|
||||
partagePhone?: boolean;
|
||||
}
|
||||
|
||||
export interface ProfilePaginated {
|
||||
@@ -18,5 +23,5 @@ export interface ProfilePaginated {
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
items: any[];
|
||||
items: Profile[];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SearchFilters } from '@app/domain/search/search-filters';
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
|
||||
export interface ProfileRepository {
|
||||
list(params?: SearchFilters): Observable<ProfilePaginated>;
|
||||
getById(profileId: string): Observable<Profile>;
|
||||
getByUserId(userId: string): Observable<Profile>;
|
||||
create(profile: Profile): Observable<Profile>;
|
||||
update(profileId: string, profile: Partial<Profile>): Observable<Profile>;
|
||||
updateCoordinates(profileId: string, latitude: number, longitude: number): Observable<Profile>;
|
||||
updateSettings(profileId: string, settings: SettingsProfileDto): Observable<Profile>;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,6 @@ export interface Project {
|
||||
nom: string;
|
||||
lien: string;
|
||||
description: string;
|
||||
fichier: string[];
|
||||
fichier: string;
|
||||
utilisateur: string;
|
||||
}
|
||||
|
||||
23
src/app/domain/settings/setting.model.ts
Normal file
23
src/app/domain/settings/setting.model.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
|
||||
export enum ThemeType {
|
||||
LIGHT = 'light',
|
||||
DARK = 'dark',
|
||||
SYSTEM = 'system',
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
theme: ThemeType;
|
||||
privacy: SettingsProfileDto;
|
||||
}
|
||||
|
||||
// Valeurs par défaut pour éviter les nulls
|
||||
export const DEFAULT_SETTINGS: UserSettings = {
|
||||
theme: ThemeType.SYSTEM,
|
||||
privacy: {
|
||||
isProfilePublic: false,
|
||||
showEmail: false,
|
||||
showPhone: false,
|
||||
allowGeolocation: false,
|
||||
},
|
||||
};
|
||||
8
src/app/domain/settings/setting.repository.ts
Normal file
8
src/app/domain/settings/setting.repository.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ThemeType, UserSettings } from '@app/domain/settings/setting.model';
|
||||
|
||||
export abstract class SettingRepository {
|
||||
abstract getSettings(): UserSettings;
|
||||
abstract saveSettings(settings: UserSettings): void;
|
||||
abstract applyTheme(settings: UserSettings): void;
|
||||
abstract getDefaultSystemTheme(): ThemeType;
|
||||
}
|
||||
31
src/app/infrastructure/handlers/global-error-handler.ts
Normal file
31
src/app/infrastructure/handlers/global-error-handler.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ErrorHandler, inject, Injectable } from '@angular/core';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { LoggerService } from '@app/infrastructure/shared/logger.service';
|
||||
|
||||
@Injectable()
|
||||
export class GlobalErrorHandler implements ErrorHandler {
|
||||
// On utilise inject() pour éviter les soucis de dépendance circulaire dans le constructeur
|
||||
private logger = inject(LoggerService);
|
||||
|
||||
handleError(error: any): void {
|
||||
let message = '';
|
||||
let stackTrace = '';
|
||||
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
// Erreur serveur
|
||||
message = `Erreur Serveur: ${error.status} ${error.message}`;
|
||||
} else if (error instanceof Error) {
|
||||
// Erreur Client (JavaScript)
|
||||
message = `Erreur Client: ${error.message}`;
|
||||
stackTrace = error.stack || '';
|
||||
} else {
|
||||
message = error;
|
||||
}
|
||||
|
||||
// On loggue l'erreur via notre service
|
||||
this.logger.error(message, stackTrace);
|
||||
|
||||
// Optionnel : Relancer l'erreur pour qu'elle apparaisse quand même dans la console du navigateur en DEV
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
||||
import { from, Observable } from 'rxjs';
|
||||
import { from, map, Observable } from 'rxjs';
|
||||
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||
import { Injectable } from '@angular/core';
|
||||
import PocketBase from 'pocketbase';
|
||||
import PocketBase, { ListResult } from 'pocketbase';
|
||||
import { environment } from '@env/environment';
|
||||
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
|
||||
import { SearchFilters } from '@app/domain/search/search-filters';
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PbProfileRepository implements ProfileRepository {
|
||||
@@ -23,15 +24,21 @@ export class PbProfileRepository implements ProfileRepository {
|
||||
};
|
||||
|
||||
return from(
|
||||
this.pb
|
||||
.collection('profiles')
|
||||
.getList<ProfilePaginated>(params?.page, params?.perPage, requestOptions)
|
||||
this.pb.collection('profiles').getList<Profile>(params?.page, params?.perPage, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
getById(userId: string): Observable<Profile> {
|
||||
getById(profileId: string): Observable<Profile> {
|
||||
return from(
|
||||
this.pb.collection('profiles').getOne<Profile>(`${userId}`, { expand: 'utilisateur' })
|
||||
this.pb.collection('profiles').getOne<Profile>(`${profileId}`, { expand: 'utilisateur' })
|
||||
);
|
||||
}
|
||||
|
||||
getByUserId(userId: string): Observable<Profile> {
|
||||
return from(
|
||||
this.pb
|
||||
.collection('profiles')
|
||||
.getFirstListItem<Profile>(`utilisateur.id="${userId}"`, { expand: 'utilisateur' })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +50,23 @@ export class PbProfileRepository implements ProfileRepository {
|
||||
return from(this.pb.collection('profiles').update<Profile>(id, data));
|
||||
}
|
||||
|
||||
updateCoordinates(profileId: string, latitude: number, longitude: number): Observable<Profile> {
|
||||
const coordinates = { lat: latitude, lon: longitude };
|
||||
return from(
|
||||
this.pb.collection('profiles').update<Profile>(profileId, { coordonnees: coordinates })
|
||||
);
|
||||
}
|
||||
|
||||
updateSettings(profileId: string, settings: SettingsProfileDto): Observable<Profile> {
|
||||
const settingsData = {
|
||||
estVisible: settings.isProfilePublic,
|
||||
estGeolocaliser: settings.allowGeolocation,
|
||||
partageMail: settings.showEmail,
|
||||
partagePhone: settings.showPhone,
|
||||
};
|
||||
return from(this.pb.collection('profiles').update<Profile>(profileId, settingsData));
|
||||
}
|
||||
|
||||
private onFilterSetting(params?: SearchFilters): string[] {
|
||||
const filters: string[] = [
|
||||
'utilisateur.verified = true',
|
||||
|
||||
37
src/app/infrastructure/settings/local-setting.repository.ts
Normal file
37
src/app/infrastructure/settings/local-setting.repository.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { SettingRepository } from '@app/domain/settings/setting.repository';
|
||||
import { DEFAULT_SETTINGS, ThemeType, UserSettings } from '@app/domain/settings/setting.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LocalSettingRepository implements SettingRepository {
|
||||
private readonly STORAGE_KEY = 'app_user_settings';
|
||||
|
||||
getSettings(): UserSettings {
|
||||
const data = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
saveSettings(settings: UserSettings): void {
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
applyTheme(settings: UserSettings): void {
|
||||
// Application immédiate de l'effet de bord du thème (Optionnel ici ou dans la Facade)
|
||||
if (settings.theme === ThemeType.DARK) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else if (settings.theme === ThemeType.SYSTEM) {
|
||||
document.documentElement.classList.add(this.getDefaultSystemTheme());
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultSystemTheme(): ThemeType {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? ThemeType.DARK
|
||||
: ThemeType.LIGHT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import { SettingRepository } from '@app/domain/settings/setting.repository';
|
||||
|
||||
export const SETTING_REPOSITORY_TOKEN = new InjectionToken<SettingRepository>('SettingRepository');
|
||||
47
src/app/infrastructure/shared/logger.service.ts
Normal file
47
src/app/infrastructure/shared/logger.service.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { environment } from '@env/environment';
|
||||
import { LogLevel } from '@app/domain/log-level';
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LoggerService {
|
||||
private level: LogLevel = environment.production ? LogLevel.ERROR : LogLevel.DEBUG;
|
||||
|
||||
debug(msg: string, ...optionalParams: any[]) {
|
||||
this.log(LogLevel.DEBUG, msg, optionalParams);
|
||||
}
|
||||
|
||||
info(msg: string, ...optionalParams: any[]) {
|
||||
this.log(LogLevel.INFO, msg, optionalParams);
|
||||
}
|
||||
|
||||
warn(msg: string, ...optionalParams: any[]) {
|
||||
this.log(LogLevel.WARN, msg, optionalParams);
|
||||
}
|
||||
|
||||
error(msg: string, ...optionalParams: any[]) {
|
||||
this.log(LogLevel.ERROR, msg, optionalParams);
|
||||
// TODO: Ici, vous pourriez envoyer l'erreur vers votre backend PocketBase ou Sentry
|
||||
// this.sendToBackend(msg, optionalParams);
|
||||
}
|
||||
|
||||
private log(level: LogLevel, msg: string, params: any[]) {
|
||||
if (level >= this.level) {
|
||||
switch (level) {
|
||||
case LogLevel.DEBUG:
|
||||
console.debug('%c[DEBUG]:', 'color: blue', msg, ...params);
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
console.info('%c[INFO]:', 'color: green', msg, ...params);
|
||||
break;
|
||||
case LogLevel.WARN:
|
||||
console.warn('%c[WARN]:', 'color: orange', msg, ...params);
|
||||
break;
|
||||
case LogLevel.ERROR:
|
||||
console.error('%c[ERROR]:', 'color: red', msg, ...params);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
@if (profile != undefined) {
|
||||
<section
|
||||
class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800"
|
||||
>
|
||||
<div class="container mx-auto px-4 py-6 md:py-12">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- Header avec bannière -->
|
||||
<div class="relative rounded-2xl overflow-hidden shadow-xl mb-8 animate-fade-in">
|
||||
<div class="h-48 md:h-64 bg-cover bg-center bg-auth relative">
|
||||
<!-- Overlay -->
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-black/30"></div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="relative z-10 p-4 flex justify-between items-start">
|
||||
<!-- Bouton retour -->
|
||||
<button
|
||||
(click)="location.back()"
|
||||
class="group flex items-center justify-center w-10 h-10 md:w-12 md:h-12 bg-white/20 backdrop-blur-md rounded-full hover:bg-white/30 transition-all duration-300 hover:scale-110"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-6 h-6 text-white group-hover:-translate-x-1 transition-transform"
|
||||
>
|
||||
<title>Retour</title>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-4.28 9.22a.75.75 0 0 0 0 1.06l3 3a.75.75 0 1 0 1.06-1.06l-1.72-1.72h5.69a.75.75 0 0 0 0-1.5h-5.69l1.72-1.72a.75.75 0 0 0-1.06-1.06l-3 3Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Badge vérifié -->
|
||||
@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"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5 md:w-6 md:h-6 text-purple-300"
|
||||
>
|
||||
<title>Profil vérifié</title>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.603 3.799A4.49 4.49 0 0 1 12 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 0 1 3.498 1.307 4.491 4.491 0 0 1 1.307 3.497A4.49 4.49 0 0 1 21.75 12a4.49 4.49 0 0 1-1.549 3.397 4.491 4.491 0 0 1-1.307 3.497 4.491 4.491 0 0 1-3.497 1.307A4.49 4.49 0 0 1 12 21.75a4.49 4.49 0 0 1-3.397-1.549 4.49 4.49 0 0 1-3.498-1.306 4.491 4.491 0 0 1-1.307-3.498A4.49 4.49 0 0 1 2.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 0 1 1.307-3.497 4.49 4.49 0 0 1 3.497-1.307Zm7.007 6.387a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-white text-sm font-medium hidden md:inline">Vérifié</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avatar et info principale -->
|
||||
<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 bouton edit -->
|
||||
@if (user() != undefined) {
|
||||
<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()!.username }}"
|
||||
class="object-cover w-full h-full"
|
||||
src="{{ environment.baseUrl }}/api/files/users/{{ user()!.id }}/{{
|
||||
user()!.avatar
|
||||
}}"
|
||||
/>
|
||||
} @else {
|
||||
<img
|
||||
alt="{{ user()!.username }}"
|
||||
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>
|
||||
}
|
||||
|
||||
<!-- Nom et titre -->
|
||||
@if (user() != undefined) {
|
||||
<div
|
||||
class="flex-1 text-center md:text-left mb-4 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 if (user()!.username) {
|
||||
<h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
{{ user()!.username }}
|
||||
</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">
|
||||
{{ profile().profession | uppercase }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contenu principal -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<!-- Sidebar - Informations -->
|
||||
<aside class="lg:col-span-1 space-y-6 animate-slide-up animation-delay-200">
|
||||
<!-- Card 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"
|
||||
>
|
||||
<h3
|
||||
class="text-lg font-bold text-gray-900 dark:text-white mb-3 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-indigo-500"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
À propos
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed text-base">
|
||||
{{ profile().apropos }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Card Bio -->
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 hover:shadow-xl transition-shadow duration-300"
|
||||
>
|
||||
<h3
|
||||
class="text-lg font-bold text-gray-900 dark:text-white mb-3 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-indigo-500"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-6-3a2 2 0 11-4 0 2 2 0 014 0zm-2 4a5 5 0 00-4.546 2.916A5.986 5.986 0 0010 16a5.986 5.986 0 004.546-2.084A5 5 0 0010 11z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</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>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Card 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"
|
||||
>
|
||||
<h3
|
||||
class="text-lg font-bold text-gray-900 dark:text-white mb-3 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-indigo-500"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"
|
||||
/>
|
||||
</svg>
|
||||
Secteur
|
||||
</h3>
|
||||
<app-chips [sectorId]="profile().secteur" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Card Réseaux -->
|
||||
@if (profile().reseaux) {
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 hover:shadow-xl transition-shadow duration-300"
|
||||
>
|
||||
<h3
|
||||
class="text-lg font-bold text-gray-900 dark:text-white mb-3 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-indigo-500"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z"
|
||||
/>
|
||||
<path
|
||||
d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767c.28.149.599.233.938.233h2l3 3v-3h2a2 2 0 002-2V9a2 2 0 00-2-2h-1z"
|
||||
/>
|
||||
</svg>
|
||||
Réseaux
|
||||
</h3>
|
||||
<app-reseaux [reseaux]="profile().reseaux" />
|
||||
</div>
|
||||
}
|
||||
</aside>
|
||||
|
||||
<!-- Contenu principal avec onglets -->
|
||||
<main class="lg:col-span-3 animate-slide-up animation-delay-300">
|
||||
<!-- Navigation par onglets -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-4 mb-6">
|
||||
<nav class="flex flex-wrap gap-2">
|
||||
<button
|
||||
(click)="menu.set('home')"
|
||||
[class.active-tab]="menu() === 'home'"
|
||||
class="tab-button flex items-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Mon profil</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="menu.set('projects')"
|
||||
[class.active-tab]="menu() === 'projects'"
|
||||
class="tab-button flex items-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23-.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Mes projets</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="menu.set('update')"
|
||||
[class.active-tab]="menu() === 'update'"
|
||||
class="tab-button flex items-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Mes informations</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="menu.set('cv')"
|
||||
[class.active-tab]="menu() === 'cv'"
|
||||
class="tab-button flex items-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Lecteur PDF</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Contenu des onglets -->
|
||||
<div class="tab-content">
|
||||
@switch (menu().toLowerCase()) {
|
||||
@case ('home') {
|
||||
@if (!userLoading().isLoading) {
|
||||
<app-update-user [user]="user()" />
|
||||
} @else {
|
||||
<app-loading message="Chargement encours..." />
|
||||
}
|
||||
}
|
||||
@case ('projects') {
|
||||
<app-my-profile-project-list
|
||||
[projectIds]="profile().projets"
|
||||
[userId]="user().id"
|
||||
/>
|
||||
<router-outlet />
|
||||
}
|
||||
@case ('update') {
|
||||
<app-my-profile-update-form [profile]="profile()" />
|
||||
}
|
||||
@case ('cv') {
|
||||
<app-pdf-viewer [profile]="profile()" />
|
||||
}
|
||||
@default {
|
||||
@if (!userLoading().isLoading) {
|
||||
<app-update-user [user]="user()" />
|
||||
} @else {
|
||||
<app-loading message="Chargement encours..." />
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<section
|
||||
class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 pb-16"
|
||||
>
|
||||
<!-- Hero Section avec recherche -->
|
||||
<div class="relative overflow-hidden">
|
||||
<!-- Décorations de fond -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div
|
||||
class="absolute -top-40 -right-40 w-80 h-80 bg-indigo-400 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -bottom-40 -left-40 w-80 h-80 bg-purple-400 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 bg-pink-400 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-4000"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8 pt-24 pb-12">
|
||||
<!-- Titre de la page -->
|
||||
<div class="text-center mb-8 animate-fade-in">
|
||||
<h1
|
||||
class="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-gray-900 dark:text-white mb-4"
|
||||
>
|
||||
Découvrez les
|
||||
<span
|
||||
class="text-transparent bg-clip-text bg-gradient-to-r from-indigo-600 to-purple-600"
|
||||
>
|
||||
meilleurs profils
|
||||
</span>
|
||||
</h1>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
Explorez notre communauté de talents et trouvez le profil parfait pour votre projet
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Barre de recherche améliorée -->
|
||||
<div class="max-w-3xl mx-auto animate-slide-up animation-delay-200">
|
||||
<div
|
||||
class="flex flex-col space-y-8 bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-4 sm:p-6 hover:shadow-2xl transition-shadow duration-300"
|
||||
>
|
||||
<div>
|
||||
<app-search (onSearchChange)="showNewQuery($event)" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<app-filter (onFilterChange)="onFilterChange($event)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des profils -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-8">
|
||||
@if (loading().isLoading) {
|
||||
<app-loading message="Chargement des profils..." />
|
||||
} @else {
|
||||
<!-- Titre de section -->
|
||||
<div class="mb-6 flex items-center justify-between animate-fade-in">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Tous les profils</h2>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ profilePaginated().items.length }} profil(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des profils avec animation d'apparition -->
|
||||
<div class="animate-slide-up animation-delay-100">
|
||||
<app-vertical-profile-list [profiles]="profilePaginated().items" />
|
||||
</div>
|
||||
|
||||
<div class="animate-slide-up animation-delay-100">
|
||||
<app-pagination [filters]="searchFilters()" (onPageChange)="onPageChange($event)" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, Input, output } from '@angular/core';
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-btn-loading',
|
||||
|
||||
@@ -5,6 +5,8 @@ import { provideRouter } from '@angular/router';
|
||||
import { SECTOR_REPOSITORY_TOKEN } from '@app/infrastructure/sectors/sector-repository.token';
|
||||
import { SectorRepository } from '@app/domain/sectors/sector.repository';
|
||||
import { mockSectorRepo } from '@app/testing/sector.mock';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { mockToastR } from '@app/testing/toastr.mock';
|
||||
|
||||
describe('ChipsComponent', () => {
|
||||
let component: ChipsComponent;
|
||||
@@ -18,6 +20,7 @@ describe('ChipsComponent', () => {
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: SECTOR_REPOSITORY_TOKEN, useValue: mockSectorRepository },
|
||||
{ provide: ToastrService, useValue: mockToastR },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, inject, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { Component, inject, input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { TitleCasePipe } from '@angular/common';
|
||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||
import { SectorFacade } from '@app/ui/sectors/sector.facade';
|
||||
import { SectorFacade } from '@app/adapters/sectors/sector.facade';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chips',
|
||||
@@ -13,7 +13,7 @@ import { SectorFacade } from '@app/ui/sectors/sector.facade';
|
||||
})
|
||||
@UntilDestroy()
|
||||
export class ChipsComponent implements OnChanges {
|
||||
@Input({ required: true }) sectorId: string | null = null;
|
||||
sectorId = input.required<string>();
|
||||
|
||||
private readonly sectorFacade = inject(SectorFacade);
|
||||
protected sector = this.sectorFacade.sector;
|
||||
@@ -21,8 +21,8 @@ export class ChipsComponent implements OnChanges {
|
||||
protected readonly error = this.sectorFacade.error;
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (this.sectorId && !this.loading().isLoading) {
|
||||
this.sectorFacade.loadOne(this.sectorId);
|
||||
if (this.sectorId() && !this.loading().isLoading) {
|
||||
this.sectorFacade.loadOne(this.sectorId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
src/app/shared/components/map/map.component.html
Normal file
7
src/app/shared/components/map/map.component.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="map-container" [style.height]="height">
|
||||
<div id="map" class="map"></div>
|
||||
|
||||
@if (!mapReady()) {
|
||||
<app-loading message="Chargement de la carte..." />
|
||||
}
|
||||
</div>
|
||||
76
src/app/shared/components/map/map.component.scss
Normal file
76
src/app/shared/components/map/map.component.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
// src/app/ui/shared/components/map/map.component.scss
|
||||
|
||||
.map-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.map-loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
z-index: 1000;
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f4f6;
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 16px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Styles globaux pour les popups Leaflet
|
||||
:host ::ng-deep {
|
||||
.leaflet-popup-content {
|
||||
.map-popup {
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
22
src/app/shared/components/map/map.component.spec.ts
Normal file
22
src/app/shared/components/map/map.component.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MapComponent } from './map.component';
|
||||
|
||||
describe('MapComponent', () => {
|
||||
let component: MapComponent;
|
||||
let fixture: ComponentFixture<MapComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MapComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MapComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
305
src/app/shared/components/map/map.component.ts
Normal file
305
src/app/shared/components/map/map.component.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
effect,
|
||||
EventEmitter,
|
||||
input,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { Coordinates } from '@app/domain/localisation/coordinates.model';
|
||||
import * as L from 'leaflet';
|
||||
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
|
||||
import { ProfileViewModel } from '@app/adapters/profiles/profile.presenter.model';
|
||||
|
||||
export interface MapMarker {
|
||||
id: string;
|
||||
coordinates: Coordinates;
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
profile?: ProfileViewModel;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-map',
|
||||
standalone: true,
|
||||
imports: [LoadingComponent],
|
||||
templateUrl: './map.component.html',
|
||||
styleUrl: './map.component.scss',
|
||||
})
|
||||
export class MapComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
@Input() center: Coordinates = { latitude: 48.8566, longitude: 2.3522 }; // Paris par défaut
|
||||
@Input() zoom: number = 13;
|
||||
@Input() interactive: boolean = true;
|
||||
@Input() showUserLocation: boolean = false;
|
||||
@Input() height: string = '500px';
|
||||
|
||||
@Output() onLocationSelected = new EventEmitter<Coordinates>();
|
||||
@Output() onMarkerClick = new EventEmitter<MapMarker>();
|
||||
|
||||
markers = input<MapMarker[]>([]);
|
||||
|
||||
private map: L.Map | null = null;
|
||||
private markersLayer: L.LayerGroup | null = null;
|
||||
private userMarker: L.Marker | null = null;
|
||||
|
||||
protected mapReady = signal<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const isReady = this.mapReady();
|
||||
if (isReady && this.map) {
|
||||
this.updateMarkers();
|
||||
}
|
||||
});
|
||||
|
||||
this.fixLeafletIconPath();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Fix pour les icônes Leaflet
|
||||
this.fixLeafletIconPath();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
setTimeout(() => this.initMap(), 100);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private initMap(): void {
|
||||
// Initialiser la carte
|
||||
this.map = L.map('map', {
|
||||
center: [this.center.latitude, this.center.longitude],
|
||||
zoom: this.zoom,
|
||||
zoomControl: true,
|
||||
dragging: this.interactive,
|
||||
scrollWheelZoom: this.interactive,
|
||||
});
|
||||
|
||||
// Ajouter le layer de tuiles
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(this.map);
|
||||
|
||||
// Créer le layer pour les markers
|
||||
this.markersLayer = L.layerGroup().addTo(this.map);
|
||||
|
||||
// Ajouter l'événement de clic sur la carte si interactive
|
||||
if (this.interactive) {
|
||||
this.map.on('click', (e: L.LeafletMouseEvent) => {
|
||||
this.onMapClick(e);
|
||||
});
|
||||
}
|
||||
|
||||
this.mapReady.set(true);
|
||||
this.updateMarkers();
|
||||
}
|
||||
|
||||
private onMapClick(event: L.LeafletMouseEvent): void {
|
||||
const coords: Coordinates = {
|
||||
latitude: event.latlng.lat,
|
||||
longitude: event.latlng.lng,
|
||||
};
|
||||
|
||||
this.onLocationSelected.emit(coords);
|
||||
|
||||
// Ajouter un marker temporaire à la position cliquée
|
||||
if (this.showUserLocation) {
|
||||
this.setUserLocation(coords);
|
||||
}
|
||||
}
|
||||
|
||||
/*private updateMarkers(): void {
|
||||
if (!this.markersLayer) return;
|
||||
|
||||
// Nettoyer les markers existants
|
||||
this.markersLayer.clearLayers();
|
||||
|
||||
// Ajouter les nouveaux markers
|
||||
this.markers().forEach((marker) => {
|
||||
const leafletMarker = L.marker([marker.coordinates.latitude, marker.coordinates.longitude], {
|
||||
icon: this.createCustomIcon(marker.icon),
|
||||
});
|
||||
|
||||
if (marker.title || marker.description) {
|
||||
leafletMarker.bindPopup(
|
||||
`<div class="map-popup">
|
||||
${marker.title ? `<strong>${marker.title}</strong>` : ''}
|
||||
${marker.description ? `<p>${marker.description}</p>` : ''}
|
||||
</div>`
|
||||
);
|
||||
}
|
||||
|
||||
leafletMarker.on('click', () => {
|
||||
this.onMarkerClick.emit(marker);
|
||||
});
|
||||
|
||||
this.markersLayer!.addLayer(leafletMarker);
|
||||
});
|
||||
|
||||
// Ajuster la vue pour montrer tous les markers
|
||||
if (this.markers().length > 0) {
|
||||
const bounds = L.latLngBounds(
|
||||
this.markers().map((m) => [m.coordinates.latitude, m.coordinates.longitude])
|
||||
);
|
||||
this.map?.fitBounds(bounds, { padding: [50, 50] });
|
||||
}
|
||||
}*/
|
||||
|
||||
private updateMarkers(): void {
|
||||
if (!this.markersLayer) return;
|
||||
|
||||
this.markersLayer.clearLayers();
|
||||
|
||||
this.markers().forEach((marker) => {
|
||||
// 1. Création du Marker
|
||||
const leafletMarker = L.marker([marker.coordinates.latitude, marker.coordinates.longitude], {
|
||||
icon: this.createCustomIcon(),
|
||||
});
|
||||
|
||||
// --- A. GESTION DU SURVOL (HOVER) ---
|
||||
// On utilise un Tooltip pour l'affichage au survol
|
||||
if (marker.profile!.fullName) {
|
||||
leafletMarker.bindTooltip(marker.profile!.fullName!, {
|
||||
permanent: false, // false = n'apparaît qu'au survol
|
||||
direction: 'top', // s'affiche au-dessus du marker
|
||||
className: 'custom-map-tooltip', // Pour le style CSS si besoin
|
||||
offset: [0, -35], // Décalage pour ne pas chevaucher le marker
|
||||
});
|
||||
}
|
||||
|
||||
// --- B. GESTION DU CLIC (POPUP) ---
|
||||
// On construit la Card complète pour le clic
|
||||
const popupContainer = document.createElement('div');
|
||||
popupContainer.className =
|
||||
'custom-popup-card flex flex-col items-center gap-3 min-w-[160px] text-center p-1';
|
||||
|
||||
// Image (Avatar)
|
||||
if (marker.profile!.avatarUrl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = marker.profile!.avatarUrl.replace('320x240', '100x100');
|
||||
img.className = 'w-16 h-16 rounded-full object-cover border-2 border-indigo-500 shadow-sm';
|
||||
popupContainer.appendChild(img);
|
||||
} else {
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className =
|
||||
'w-16 h-16 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-500 font-bold text-xl';
|
||||
placeholder.innerText = marker.profile!.fullName
|
||||
? marker.profile!.fullName.charAt(0).toUpperCase()
|
||||
: '?';
|
||||
popupContainer.appendChild(placeholder);
|
||||
}
|
||||
|
||||
// Nom (Titre)
|
||||
if (marker.profile!.fullName) {
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'font-bold text-gray-800 text-sm m-0';
|
||||
title.innerText = marker.profile!.fullName;
|
||||
popupContainer.appendChild(title);
|
||||
}
|
||||
|
||||
// Bouton "Voir le profil"
|
||||
const btn = document.createElement('button');
|
||||
btn.className =
|
||||
'bg-indigo-600 text-white text-xs px-4 py-1.5 rounded-full hover:bg-indigo-700 transition-colors w-full mt-1';
|
||||
btn.innerText = 'Voir le profil';
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
this.onMarkerClick.emit(marker);
|
||||
});
|
||||
popupContainer.appendChild(btn);
|
||||
|
||||
// On lie la popup au marker (Par défaut, bindPopup s'active au clic)
|
||||
leafletMarker.bindPopup(popupContainer, {
|
||||
closeButton: true, // On remet la croix pour pouvoir fermer si besoin
|
||||
offset: [0, -30],
|
||||
className: 'custom-leaflet-popup',
|
||||
});
|
||||
|
||||
this.markersLayer!.addLayer(leafletMarker);
|
||||
});
|
||||
|
||||
// Recentrage automatique (optionnel, selon votre goût)
|
||||
if (this.markers().length > 0 && this.map) {
|
||||
const bounds = L.latLngBounds(
|
||||
this.markers().map((m) => [m.coordinates.latitude, m.coordinates.longitude])
|
||||
);
|
||||
this.map.fitBounds(bounds, { padding: [50, 50] });
|
||||
}
|
||||
}
|
||||
|
||||
setUserLocation(coordinates: Coordinates): void {
|
||||
if (!this.map) return;
|
||||
|
||||
// Supprimer l'ancien marker utilisateur
|
||||
if (this.userMarker) {
|
||||
this.userMarker.remove();
|
||||
}
|
||||
|
||||
// Créer un nouveau marker utilisateur
|
||||
this.userMarker = L.marker([coordinates.latitude, coordinates.longitude], {
|
||||
icon: this.createUserIcon(),
|
||||
}).addTo(this.map);
|
||||
|
||||
this.userMarker.bindPopup('Votre position').openPopup();
|
||||
|
||||
// Centrer la carte sur la position
|
||||
this.map.setView([coordinates.latitude, coordinates.longitude], this.zoom);
|
||||
}
|
||||
|
||||
recenterMap(): void {
|
||||
if (this.map) {
|
||||
this.map.setView([this.center.latitude, this.center.longitude], this.zoom);
|
||||
}
|
||||
}
|
||||
|
||||
private createCustomIcon(iconUrl?: string): L.Icon {
|
||||
return L.icon({
|
||||
iconUrl: iconUrl || 'assets/leaflet/marker-icon.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowUrl: 'assets/leaflet/marker-shadow.png',
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
}
|
||||
|
||||
private createUserIcon(): L.Icon {
|
||||
return L.icon({
|
||||
iconUrl: 'assets/leaflet/marker-icon.png',
|
||||
iconRetinaUrl: 'assets/leaflet/marker-icon-2x.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
tooltipAnchor: [16, -28],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
}
|
||||
|
||||
private fixLeafletIconPath(): void {
|
||||
const iconRetinaUrl = 'assets/leaflet/marker-icon-2x.png';
|
||||
const iconUrl = 'assets/leaflet/marker-icon.png';
|
||||
const shadowUrl = 'assets/leaflet/marker-shadow.png';
|
||||
const iconDefault = L.icon({
|
||||
iconRetinaUrl,
|
||||
iconUrl,
|
||||
shadowUrl,
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
tooltipAnchor: [16, -28],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
L.Marker.prototype.options.icon = iconDefault;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MyProfileProjectItemComponent } from './my-profile-project-item.component';
|
||||
import { of } from 'rxjs';
|
||||
import { Project } from '@app/domain/projects/project.model';
|
||||
import { ProjectRepository } from '@app/domain/projects/project.repository';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { PROJECT_REPOSITORY_TOKEN } from '@app/infrastructure/projects/project-repository.token';
|
||||
import { mockProjectRepo } from '@app/testing/project.mock';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { mockToastR } from '@app/testing/toastr.mock';
|
||||
|
||||
describe('MyProfileProjectItemComponent', () => {
|
||||
let component: MyProfileProjectItemComponent;
|
||||
@@ -20,11 +20,15 @@ describe('MyProfileProjectItemComponent', () => {
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: PROJECT_REPOSITORY_TOKEN, useValue: mockProjectRepository },
|
||||
{ provide: ToastrService, useValue: mockToastR },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MyProfileProjectItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
fixture.componentRef.setInput('projectId', 'fakeId');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
await fixture.whenStable();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Component, input, OnInit } from '@angular/core';
|
||||
import { environment } from '@env/environment';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ProjectFacade } from '@app/ui/projects/project.facade';
|
||||
import { ProjectFacade } from '@app/adapters/projects/project.facade';
|
||||
|
||||
@Component({
|
||||
selector: 'app-my-profile-project-item',
|
||||
@@ -12,12 +12,12 @@ import { ProjectFacade } from '@app/ui/projects/project.facade';
|
||||
})
|
||||
export class MyProfileProjectItemComponent implements OnInit {
|
||||
protected readonly environment = environment;
|
||||
@Input({ required: true }) projectId = '';
|
||||
projectId = input.required<string>();
|
||||
|
||||
private readonly projectFacade = new ProjectFacade();
|
||||
protected project = this.projectFacade.project;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.projectFacade.loadOne(this.projectId);
|
||||
this.projectFacade.loadOne(this.projectId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { ProjectRepository } from '@app/domain/projects/project.repository';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { PROJECT_REPOSITORY_TOKEN } from '@app/infrastructure/projects/project-repository.token';
|
||||
import { mockProfileRepo } from '@app/testing/profile.mock';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { mockToastR } from '@app/testing/toastr.mock';
|
||||
|
||||
describe('MyProfileProjectListComponent', () => {
|
||||
let component: MyProfileProjectListComponent;
|
||||
@@ -17,6 +19,7 @@ describe('MyProfileProjectListComponent', () => {
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: PROJECT_REPOSITORY_TOKEN, useValue: mockProjectRepository },
|
||||
{ provide: ToastrService, useValue: mockToastR },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Component, Input, OnInit, signal } from '@angular/core';
|
||||
import { Component, input, OnInit, signal } from '@angular/core';
|
||||
import { PaginatorModule } from 'primeng/paginator';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||
import { MyProfileUpdateProjectFormComponent } from '@app/shared/components/my-profile-update-project-form/my-profile-update-project-form.component';
|
||||
import { ProjectFacade } from '@app/ui/projects/project.facade';
|
||||
import { ProjectFacade } from '@app/adapters/projects/project.facade';
|
||||
|
||||
@Component({
|
||||
selector: 'app-my-profile-project-list',
|
||||
@@ -14,8 +14,8 @@ import { ProjectFacade } from '@app/ui/projects/project.facade';
|
||||
})
|
||||
@UntilDestroy()
|
||||
export class MyProfileProjectListComponent implements OnInit {
|
||||
@Input({ required: true }) projectIds: string[] = [];
|
||||
@Input({ required: true }) userId = '';
|
||||
projectIds = input.required<string[]>();
|
||||
userId = input<string>('');
|
||||
|
||||
protected projectIdSelected = signal<string | null>(null);
|
||||
|
||||
@@ -23,7 +23,7 @@ export class MyProfileProjectListComponent implements OnInit {
|
||||
protected projects = this.projectFacade.projects;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.projectFacade.load(this.userId);
|
||||
this.projectFacade.load(this.userId());
|
||||
}
|
||||
|
||||
onProjectFormSubmitted($event: string | null) {
|
||||
|
||||
@@ -1,64 +1,115 @@
|
||||
<div class="flex max-sm:flex-col flex-row max-w-sm:space-y-2 space-x-2 justify-around items-center">
|
||||
@if (file != null) {
|
||||
<div class="flex-col flex space-y-2 justify-center items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5 cursor-pointer text-red-600"
|
||||
(click)="file = null"
|
||||
>
|
||||
<title>Supprimer le fichier</title>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.5 4.478v.227a48.816 48.816 0 0 1 3.878.512.75.75 0 1 1-.256 1.478l-.209-.035-1.005 13.07a3 3 0 0 1-2.991 2.77H8.084a3 3 0 0 1-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 0 1-.256-1.478A48.567 48.567 0 0 1 7.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 0 1 3.369 0c1.603.051 2.815 1.387 2.815 2.951Zm-6.136-1.452a51.196 51.196 0 0 1 3.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 0 0-6 0v-.113c0-.794.609-1.428 1.364-1.452Zm-.355 5.945a.75.75 0 1 0-1.5.058l.347 9a.75.75 0 1 0 1.499-.058l-.346-9Zm5.48.058a.75.75 0 1 0-1.498-.058l-.347 9a.75.75 0 0 0 1.5.058l.345-9Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<img src="assets/images/pdf.svg" alt="pdf" class="max-w-sm max-h-16" />
|
||||
|
||||
<small>{{ file.name }}</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
<label
|
||||
for="uploadFile1"
|
||||
class="flex justify-center items-center space-x-2 bg-gray-800 hover:bg-gray-700 text-white text-base px-3 py-1 outline-none rounded w-max cursor-pointer font-[sans-serif]"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 mr-2 fill-white inline"
|
||||
viewBox="0 0 32 32"
|
||||
>
|
||||
<path
|
||||
d="M23.75 11.044a7.99 7.99 0 0 0-15.5-.009A8 8 0 0 0 9 27h3a1 1 0 0 0 0-2H9a6 6 0 0 1-.035-12 1.038 1.038 0 0 0 1.1-.854 5.991 5.991 0 0 1 11.862 0A1.08 1.08 0 0 0 23 13a6 6 0 0 1 0 12h-3a1 1 0 0 0 0 2h3a8 8 0 0 0 .75-15.956z"
|
||||
data-original="#000000"
|
||||
/>
|
||||
<path
|
||||
d="M20.293 19.707a1 1 0 0 0 1.414-1.414l-5-5a1 1 0 0 0-1.414 0l-5 5a1 1 0 0 0 1.414 1.414L15 16.414V29a1 1 0 0 0 2 0V16.414z"
|
||||
data-original="#000000"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<small class="text-xs">Selectionner un fichier .pdf</small>
|
||||
<input
|
||||
type="file"
|
||||
id="uploadFile1"
|
||||
class="hidden"
|
||||
accept="application/pdf"
|
||||
(change)="onFileChange($event)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (file != null) {
|
||||
<button
|
||||
type="button"
|
||||
[ngClass]="{ 'bg-purple-600': file != null }"
|
||||
class="!mt-2 px-6 py-2 w-full bg-[#333] hover:bg-[#444] text-sm text-white mx-auto block"
|
||||
(click)="onSubmit()"
|
||||
>
|
||||
Mettre à jour mon CV
|
||||
</button>
|
||||
@if (loading().isLoading) {
|
||||
<app-loading message="Téléchargement du CV" />
|
||||
} @else {
|
||||
<ng-container *ngTemplateOutlet="cvForm" />
|
||||
}
|
||||
|
||||
<ng-template #cvForm>
|
||||
<div class="space-y-4 max-w-md mx-auto">
|
||||
<!-- Zone de sélection/affichage du fichier -->
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:space-x-4 space-y-4 sm:space-y-0 items-center justify-center"
|
||||
>
|
||||
<!-- Aperçu du fichier sélectionné -->
|
||||
@if (file(); as file) {
|
||||
<div
|
||||
class="flex flex-col space-y-2 items-center p-4 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800"
|
||||
>
|
||||
<div class="relative">
|
||||
<img src="assets/images/pdf.svg" alt="PDF" class="w-16 h-16" />
|
||||
<button
|
||||
type="button"
|
||||
(click)="fileManagerService.removeFile()"
|
||||
class="absolute -top-2 -right-2 p-1 bg-red-600 hover:bg-red-700 rounded-full text-white transition-colors"
|
||||
aria-label="Supprimer le fichier"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
class="text-sm text-gray-700 dark:text-gray-300 text-center break-all max-w-[200px]"
|
||||
>
|
||||
{{ file.name }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ fileManagerService.formatFileSize(file.size) }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Bouton de sélection -->
|
||||
<label
|
||||
for="uploadFile1"
|
||||
class="flex flex-col sm:flex-row items-center justify-center space-y-2 sm:space-y-0 sm:space-x-2 bg-gray-800 hover:bg-gray-700 text-white px-6 py-3 rounded-lg cursor-pointer transition-colors w-full sm:w-auto"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 fill-white" viewBox="0 0 32 32">
|
||||
<path
|
||||
d="M23.75 11.044a7.99 7.99 0 0 0-15.5-.009A8 8 0 0 0 9 27h3a1 1 0 0 0 0-2H9a6 6 0 0 1-.035-12 1.038 1.038 0 0 0 1.1-.854 5.991 5.991 0 0 1 11.862 0A1.08 1.08 0 0 0 23 13a6 6 0 0 1 0 12h-3a1 1 0 0 0 0 2h3a8 8 0 0 0 .75-15.956z"
|
||||
/>
|
||||
<path
|
||||
d="M20.293 19.707a1 1 0 0 0 1.414-1.414l-5-5a1 1 0 0 0-1.414 0l-5 5a1 1 0 0 0 1.414 1.414L15 16.414V29a1 1 0 0 0 2 0V16.414z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm text-center">
|
||||
{{ file() != null ? 'Changer le fichier' : 'Sélectionner un fichier PDF' }}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
id="uploadFile1"
|
||||
class="hidden"
|
||||
accept="application/pdf"
|
||||
(change)="fileManagerService.onFileChange($event)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Message d'erreur -->
|
||||
@if (fileError()) {
|
||||
<div
|
||||
class="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm text-red-800 dark:text-red-200">{{ fileError() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Information sur la taille maximale -->
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||
Format accepté : PDF | Taille maximale : 5 Mo
|
||||
</p>
|
||||
|
||||
<!-- Bouton de soumission -->
|
||||
@if (file() != null && !fileError()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="onSubmit()"
|
||||
[disabled]="!canSubmit"
|
||||
class="w-full px-6 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Mettre à jour mon CV
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -1,38 +1,128 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MyProfileUpdateCvFormComponent } from './my-profile-update-cv-form.component';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
|
||||
import { ProjectRepository } from '@app/domain/projects/project.repository';
|
||||
import { mockProfileRepo } from '@app/testing/profile.mock';
|
||||
import { mockToastR } from '@app/testing/toastr.mock';
|
||||
import { mockFileManagerSvc } from '@app/testing/file-manager.service.mock';
|
||||
import { ActionType } from '@app/domain/action-type.util';
|
||||
import { ProfileFacade } from '../../../adapters/profiles/profile.facade';
|
||||
import { FileManagerService } from '../../../adapters/shared/services/file-manager.service';
|
||||
import { mockProfileFac } from '@app/testing/adapters/profiles/profile.facade.mock';
|
||||
import { mockProfiles } from '@app/testing/profile.mock';
|
||||
|
||||
describe('MyProfileUpdateCvFormComponent', () => {
|
||||
let component: MyProfileUpdateCvFormComponent;
|
||||
let fixture: ComponentFixture<MyProfileUpdateCvFormComponent>;
|
||||
|
||||
let mockToastrService: jest.Mocked<Partial<ToastrService>> = mockToastR;
|
||||
let mockProjectRepository: jest.Mocked<Partial<ProjectRepository>> = mockProfileRepo;
|
||||
// 1. Mock FileManagerService
|
||||
// On reprend la logique des tests précédents pour ce service
|
||||
let mockFileManagerService = mockFileManagerSvc;
|
||||
|
||||
// 2. Mock ProfileFacade
|
||||
const mockProfileFacade = mockProfileFac;
|
||||
|
||||
// Donnée de test
|
||||
const mockProfile = mockProfiles[0];
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset des mocks
|
||||
mockFileManagerService.file.set(null);
|
||||
mockFileManagerService.fileError.set(null);
|
||||
mockFileManagerService.resetFile.mockClear();
|
||||
|
||||
mockProfileFacade.update.mockClear();
|
||||
mockProfileFacade.loading.set({ isLoading: false, action: ActionType.NONE });
|
||||
mockProfileFacade.error.set({ hasError: false });
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MyProfileUpdateCvFormComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProjectRepository },
|
||||
{ provide: ToastrService, useValue: mockToastrService },
|
||||
// On injecte nos mocks à la place des vrais services
|
||||
{ provide: FileManagerService, useValue: mockFileManagerService },
|
||||
{ provide: ProfileFacade, useValue: mockProfileFacade },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MyProfileUpdateCvFormComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
await fixture.whenStable();
|
||||
// Initialisation de l'input requis
|
||||
fixture.componentRef.setInput('profile', mockProfile);
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
it('devrait créer le composant', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('Validation (canSubmit)', () => {
|
||||
it('devrait être false par défaut (pas de fichier)', () => {
|
||||
expect(component.canSubmit).toBe(false);
|
||||
});
|
||||
|
||||
it('devrait être true si un fichier est valide', () => {
|
||||
mockFileManagerService.file.set(new File([''], 'cv.pdf'));
|
||||
expect(component.canSubmit).toBe(true);
|
||||
});
|
||||
|
||||
it('devrait être false si une erreur de fichier existe', () => {
|
||||
mockFileManagerService.file.set(new File([''], 'cv.exe')); // Fichier présent...
|
||||
mockFileManagerService.fileError.set('Type invalide'); // ...mais erreur
|
||||
|
||||
expect(component.canSubmit).toBe(false);
|
||||
});
|
||||
|
||||
it('devrait être false si un chargement est en cours', () => {
|
||||
mockFileManagerService.file.set(new File([''], 'cv.pdf'));
|
||||
mockProfileFacade.loading.set({ isLoading: true, action: ActionType.UPDATE });
|
||||
|
||||
expect(component.canSubmit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Soumission (onSubmit)', () => {
|
||||
it('devrait appeler facade.update avec FormData', () => {
|
||||
const validFile = new File(['dummy content'], 'cv.pdf', { type: 'application/pdf' });
|
||||
mockFileManagerService.file.set(validFile);
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(mockProfileFacade.update).toHaveBeenCalledTimes(1);
|
||||
|
||||
const args = mockProfileFacade.update.mock.calls[0];
|
||||
expect(args[0]).toBe('1'); // ID du profil
|
||||
expect(args[1]).toBeInstanceOf(FormData);
|
||||
expect(args[1].get('cv')).toBe(validFile);
|
||||
});
|
||||
|
||||
it('ne devrait rien faire si pas de fichier', () => {
|
||||
mockFileManagerService.file.set(null);
|
||||
component.onSubmit();
|
||||
expect(mockProfileFacade.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Effets (Reset après succès)', () => {
|
||||
it('devrait réinitialiser le fichier quand le chargement se termine sans erreur', () => {
|
||||
// 1. État initial : chargement en cours
|
||||
mockProfileFacade.loading.set({ isLoading: true, action: ActionType.UPDATE });
|
||||
fixture.detectChanges();
|
||||
|
||||
// 2. État final : chargement terminé (isLoading: false)
|
||||
mockProfileFacade.loading.set({ isLoading: false, action: ActionType.UPDATE });
|
||||
mockProfileFacade.error.set({ hasError: false }); // Pas d'erreur
|
||||
|
||||
fixture.detectChanges(); // Déclenche l'effect
|
||||
|
||||
expect(mockFileManagerService.resetFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ne devrait PAS réinitialiser si il y a une erreur', () => {
|
||||
mockProfileFacade.loading.set({ isLoading: false, action: ActionType.UPDATE });
|
||||
mockProfileFacade.error.set({ hasError: true }); // Erreur présente
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockFileManagerService.resetFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,58 +1,59 @@
|
||||
import { Component, effect, inject, Input } from '@angular/core';
|
||||
import { NgClass } from '@angular/common';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
|
||||
import { ProfileFacade } from '@app/ui/profiles/profile.facade';
|
||||
import { Component, effect, inject, input } from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { ProfileViewModel } from '@app/adapters/profiles/profile.presenter.model';
|
||||
import { ProfileFacade } from '@app/adapters/profiles/profile.facade';
|
||||
import { ActionType } from '@app/domain/action-type.util';
|
||||
import { Profile } from '@app/domain/profiles/profile.model';
|
||||
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
|
||||
import { FileManagerService } from '@app/adapters/shared/services/file-manager.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-my-profile-update-cv-form',
|
||||
standalone: true,
|
||||
imports: [NgClass],
|
||||
imports: [LoadingComponent, NgTemplateOutlet],
|
||||
templateUrl: './my-profile-update-cv-form.component.html',
|
||||
styleUrl: './my-profile-update-cv-form.component.scss',
|
||||
})
|
||||
export class MyProfileUpdateCvFormComponent {
|
||||
@Input({ required: true }) profile: ProfileViewModel | undefined = undefined;
|
||||
readonly fileManagerService = inject(FileManagerService);
|
||||
profile = input.required<ProfileViewModel>();
|
||||
|
||||
private readonly toastrService = inject(ToastrService);
|
||||
file = this.fileManagerService.file; // Variable to store file
|
||||
fileError = this.fileManagerService.fileError;
|
||||
|
||||
file: File | null = null; // Variable to store file
|
||||
private readonly profileFacade = inject(ProfileFacade);
|
||||
|
||||
private readonly profileFacade = new ProfileFacade();
|
||||
protected readonly loading = this.profileFacade.loading;
|
||||
protected readonly error = this.profileFacade.error;
|
||||
readonly loading = this.profileFacade.loading;
|
||||
readonly error = this.profileFacade.error;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
switch (this.loading().action) {
|
||||
case ActionType.UPDATE:
|
||||
if (!this.loading() && !this.error().hasError) {
|
||||
//this.authService.updateUser();
|
||||
|
||||
this.toastrService.success(` Votre CV a bien été modifier !`, `Mise à jour`, {
|
||||
closeButton: true,
|
||||
progressAnimation: 'decreasing',
|
||||
progressBar: true,
|
||||
});
|
||||
if (!this.loading().isLoading && !this.error().hasError) {
|
||||
this.fileManagerService.resetFile();
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (this.file != null) {
|
||||
onSubmit(): void {
|
||||
const file = this.file();
|
||||
|
||||
if (file && !this.fileError()) {
|
||||
const formData = new FormData();
|
||||
formData.append('cv', this.file); // "avatar" est le nom du champ dans PocketBase
|
||||
this.profileFacade.update(this.profile?.id!, formData as Partial<Profile>);
|
||||
formData.append('cv', file);
|
||||
this.profileFacade.update(this.profile()?.id!, formData as Partial<Profile>);
|
||||
}
|
||||
}
|
||||
|
||||
onFileChange($event: Event) {
|
||||
const target: HTMLInputElement = $event.target as HTMLInputElement;
|
||||
if (target?.files?.[0]) {
|
||||
this.file = target.files[0];
|
||||
}
|
||||
get canSubmit(): boolean {
|
||||
return (
|
||||
this.file() != null &&
|
||||
!this.fileError() &&
|
||||
!this.error().hasError &&
|
||||
!this.loading().isLoading
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,12 @@
|
||||
id="profession"
|
||||
type="text"
|
||||
formControlName="profession"
|
||||
[ngClass]="{
|
||||
'border-red-500':
|
||||
profileForm.get('profession')?.invalid ||
|
||||
profileForm.getRawValue().profession.toLowerCase() ===
|
||||
'Profession non renseignée'.toLowerCase(),
|
||||
}"
|
||||
placeholder="Ex: Développeur Web, Designer..."
|
||||
class="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
/>
|
||||
@@ -188,6 +194,10 @@
|
||||
<select
|
||||
id="secteur"
|
||||
formControlName="secteur"
|
||||
[ngClass]="{
|
||||
'border-red-500':
|
||||
profileForm.get('secteur')?.invalid || profileForm.getRawValue().secteur === null,
|
||||
}"
|
||||
class="w-full pl-10 pr-10 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all appearance-none cursor-pointer"
|
||||
>
|
||||
<option [ngValue]="null" disabled>Sélectionnez votre secteur</option>
|
||||
|
||||
@@ -8,13 +8,12 @@ import {
|
||||
} from '@angular/forms';
|
||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||
import { MyProfileUpdateCvFormComponent } from '@app/shared/components/my-profile-update-cv-form/my-profile-update-cv-form.component';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
|
||||
import { ProfileFacade } from '@app/ui/profiles/profile.facade';
|
||||
import { ProfileViewModel } from '@app/adapters/profiles/profile.presenter.model';
|
||||
import { ProfileFacade } from '@app/adapters/profiles/profile.facade';
|
||||
import { ActionType } from '@app/domain/action-type.util';
|
||||
import { Profile } from '@app/domain/profiles/profile.model';
|
||||
import { SectorFacade } from '@app/ui/sectors/sector.facade';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { SectorFacade } from '@app/adapters/sectors/sector.facade';
|
||||
import { NgClass, NgTemplateOutlet } from '@angular/common';
|
||||
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
|
||||
|
||||
@Component({
|
||||
@@ -25,13 +24,13 @@ import { LoadingComponent } from '@app/shared/components/loading/loading.compone
|
||||
MyProfileUpdateCvFormComponent,
|
||||
NgTemplateOutlet,
|
||||
LoadingComponent,
|
||||
NgClass,
|
||||
],
|
||||
templateUrl: './my-profile-update-form.component.html',
|
||||
styleUrl: './my-profile-update-form.component.scss',
|
||||
})
|
||||
@UntilDestroy()
|
||||
export class MyProfileUpdateFormComponent implements OnInit {
|
||||
private readonly toastrService = inject(ToastrService);
|
||||
protected readonly ActionType = ActionType;
|
||||
|
||||
@Input({ required: true }) profile: ProfileViewModel = {} as ProfileViewModel;
|
||||
@@ -49,14 +48,10 @@ export class MyProfileUpdateFormComponent implements OnInit {
|
||||
protected readonly sectorError = this.sectorFacade.error;
|
||||
|
||||
constructor() {
|
||||
let message = '';
|
||||
|
||||
effect(() => {
|
||||
if (!this.loading().isLoading) {
|
||||
switch (this.loading().action) {
|
||||
case ActionType.UPDATE:
|
||||
message = `Vos informations personnelles ont bien été modifier !`;
|
||||
this.customToast(ActionType.UPDATE, message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -126,29 +121,4 @@ export class MyProfileUpdateFormComponent implements OnInit {
|
||||
|
||||
this.profileFacade.update(this.profile.id, data);
|
||||
}
|
||||
|
||||
private customToast(action: ActionType, message: string): void {
|
||||
if (this.error().hasError) {
|
||||
this.toastrService.error(
|
||||
`Une erreur s'est produite, veuillez réessayer ulterieurement`,
|
||||
`Erreur`,
|
||||
{
|
||||
closeButton: true,
|
||||
progressAnimation: 'decreasing',
|
||||
progressBar: true,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.toastrService.success(
|
||||
`${message}`,
|
||||
`${action === ActionType.UPDATE ? 'Mise à jour' : ''}`,
|
||||
{
|
||||
closeButton: true,
|
||||
progressAnimation: 'decreasing',
|
||||
progressBar: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@if (projectId) {
|
||||
@if (projectId()) {
|
||||
<div class="space-y-6">
|
||||
<!-- Section Image du projet -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 animate-fade-in">
|
||||
@@ -22,7 +22,7 @@
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">Image du projet</h3>
|
||||
</div>
|
||||
|
||||
@if (projectId === 'add'.toLowerCase()) {
|
||||
@if (projectId() === 'add'.toLowerCase()) {
|
||||
<app-project-picture-form [project]="undefined" />
|
||||
} @else {
|
||||
<app-project-picture-form [project]="project()" />
|
||||
|
||||
@@ -5,8 +5,6 @@ import { ToastrService } from 'ngx-toastr';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { PROJECT_REPOSITORY_TOKEN } from '@app/infrastructure/projects/project-repository.token';
|
||||
import { ProjectRepository } from '@app/domain/projects/project.repository';
|
||||
import { of } from 'rxjs';
|
||||
import { Project } from '@app/domain/projects/project.model';
|
||||
import { AUTH_REPOSITORY_TOKEN } from '@app/infrastructure/authentification/auth-repository.token';
|
||||
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
|
||||
import { AuthRepository } from '@app/domain/authentification/auth.repository';
|
||||
@@ -40,6 +38,8 @@ describe('MyProfileUpdateProjectFormComponent', () => {
|
||||
|
||||
fixture = TestBed.createComponent(MyProfileUpdateProjectFormComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
fixture.componentRef.setInput('projectId', 'fakeId');
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
output,
|
||||
SimpleChanges,
|
||||
} from '@angular/core';
|
||||
import { Component, effect, inject, input, output } from '@angular/core';
|
||||
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { PaginatorModule } from 'primeng/paginator';
|
||||
import { ProjectPictureFormComponent } from '@app/shared/components/project-picture-form/project-picture-form.component';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ProjectFacade } from '@app/ui/projects/project.facade';
|
||||
import { ProjectFacade } from '@app/adapters/projects/project.facade';
|
||||
import { CreateProjectDto } from '@app/domain/projects/dto/create-project.dto';
|
||||
import { ActionType } from '@app/domain/action-type.util';
|
||||
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
|
||||
import { AuthFacade } from '@app/ui/authentification/auth.facade';
|
||||
import { AuthFacade } from '@app/adapters/authentification/auth.facade';
|
||||
|
||||
@Component({
|
||||
selector: 'app-my-profile-update-project-form',
|
||||
@@ -25,10 +15,8 @@ import { AuthFacade } from '@app/ui/authentification/auth.facade';
|
||||
templateUrl: './my-profile-update-project-form.component.html',
|
||||
styleUrl: './my-profile-update-project-form.component.scss',
|
||||
})
|
||||
export class MyProfileUpdateProjectFormComponent implements OnInit, OnChanges {
|
||||
@Input({ required: true }) projectId: string | null = null;
|
||||
|
||||
private readonly toastrService = inject(ToastrService);
|
||||
export class MyProfileUpdateProjectFormComponent {
|
||||
projectId = input.required<string | null>();
|
||||
|
||||
private readonly projectFacade = new ProjectFacade();
|
||||
protected readonly ActionType = ActionType;
|
||||
@@ -50,17 +38,12 @@ export class MyProfileUpdateProjectFormComponent implements OnInit, OnChanges {
|
||||
formIsUpdated = output<string | null>();
|
||||
|
||||
constructor() {
|
||||
let message = '';
|
||||
effect(() => {
|
||||
if (!this.loading().isLoading) {
|
||||
switch (this.loading().action) {
|
||||
case ActionType.CREATE:
|
||||
message = `Le projet ${this.projectForm.getRawValue().nom} a bien été créer !`;
|
||||
this.customToast(ActionType.CREATE, message);
|
||||
break;
|
||||
case ActionType.UPDATE:
|
||||
message = `Les informations du projet ${this.projectForm.getRawValue().nom} ont bien été modifier !`;
|
||||
this.customToast(ActionType.UPDATE, message);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -73,20 +56,26 @@ export class MyProfileUpdateProjectFormComponent implements OnInit, OnChanges {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.projectId == 'add'.toLowerCase()) {
|
||||
this.projectForm.setValue({
|
||||
nom: '',
|
||||
description: '',
|
||||
lien: '',
|
||||
});
|
||||
}
|
||||
|
||||
if (this.projectId != null && this.projectId != 'add'.toLowerCase()) {
|
||||
this.projectFacade.loadOne(this.projectId);
|
||||
}
|
||||
effect(
|
||||
() => {
|
||||
const id = this.projectId();
|
||||
if (id) {
|
||||
if (id.toLowerCase() === 'add') {
|
||||
// Mode Création : On vide le formulaire
|
||||
this.projectForm.reset({
|
||||
nom: '',
|
||||
description: '',
|
||||
lien: '',
|
||||
});
|
||||
} else {
|
||||
// Mode Édition : On charge les données
|
||||
this.projectFacade.loadOne(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
@@ -94,7 +83,7 @@ export class MyProfileUpdateProjectFormComponent implements OnInit, OnChanges {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.projectId != null && this.projectId != 'add'.toLowerCase()) {
|
||||
if (this.projectId() != null && this.projectId() != 'add'.toLowerCase()) {
|
||||
// Update
|
||||
this.projectFacade.update(this.project()!.id, this.projectForm.getRawValue());
|
||||
} else {
|
||||
@@ -107,35 +96,4 @@ export class MyProfileUpdateProjectFormComponent implements OnInit, OnChanges {
|
||||
this.projectFacade.create(projectDto);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
this.projectId = changes['projectId'].currentValue;
|
||||
this.ngOnInit();
|
||||
}
|
||||
|
||||
private customToast(action: ActionType, message: string): void {
|
||||
if (this.error().hasError) {
|
||||
this.toastrService.error(
|
||||
`Une erreur s'est produite, veuillez réessayer ulterieurement`,
|
||||
`Erreur`,
|
||||
{
|
||||
closeButton: true,
|
||||
progressAnimation: 'decreasing',
|
||||
progressBar: true,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.formIsUpdated.emit(this.project()!.id);
|
||||
this.toastrService.success(
|
||||
`${message}`,
|
||||
`${action === ActionType.UPDATE ? 'Mise à jour' : 'Nouveau projet'}`,
|
||||
{
|
||||
closeButton: true,
|
||||
progressAnimation: 'decreasing',
|
||||
progressBar: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,46 +18,6 @@
|
||||
|
||||
<!-- Actions utilisateur -->
|
||||
<div class="flex items-center gap-2 sm:gap-4">
|
||||
<!-- Toggle thème -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleDarkMode()"
|
||||
class="p-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
[attr.aria-label]="
|
||||
themeService.darkModeSignal() === 'dark'
|
||||
? 'Passer en mode clair'
|
||||
: 'Passer en mode sombre'
|
||||
"
|
||||
>
|
||||
@if (themeService.darkModeSignal() === 'dark') {
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
|
||||
/>
|
||||
</svg>
|
||||
} @else {
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 -960 960 960"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
d="M480-120q-150 0-255-105T120-480q0-150 105-255t255-105q14 0 27.5 1t26.5 3q-41 29-65.5 75.5T444-660q0 90 63 153t153 63q55 0 101-24.5t75-65.5q2 13 3 26.5t1 27.5q0 150-105 255T480-120Zm0-80q88 0 158-48.5T740-375q-20 5-40 8t-40 3q-123 0-209.5-86.5T364-660q0-20 3-40t8-40q-78 32-126.5 102T200-480q0 116 82 198t198 82Zm-10-270Z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
|
||||
@if (isAuthenticated() && isEmailVerified()) {
|
||||
<!-- Menu utilisateur connecté -->
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { NavBarComponent } from './nav-bar.component';
|
||||
import { ThemeService } from '@app/core/services/theme/theme.service';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { User } from '@app/domain/users/user.model';
|
||||
import { AUTH_REPOSITORY_TOKEN } from '@app/infrastructure/authentification/auth-repository.token';
|
||||
@@ -10,12 +9,12 @@ import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-r
|
||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
||||
import { mockProfileRepo } from '@app/testing/profile.mock';
|
||||
import { mockAuthRepo } from '@app/testing/auth.mock';
|
||||
import { mockThemeService } from '@app/testing/theme.mock';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { mockToastR } from '@app/testing/toastr.mock';
|
||||
|
||||
describe('NavBarComponent', () => {
|
||||
let component: NavBarComponent;
|
||||
let fixture: ComponentFixture<NavBarComponent>;
|
||||
let mockTheme: jest.Mocked<Partial<ThemeService>> = mockThemeService;
|
||||
|
||||
let mockAuthRepository: jest.Mocked<Partial<AuthRepository>> = mockAuthRepo;
|
||||
let mockProfileRepository: jest.Mocked<Partial<ProfileRepository>> = mockProfileRepo;
|
||||
@@ -37,7 +36,7 @@ describe('NavBarComponent', () => {
|
||||
imports: [NavBarComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: ThemeService, useValue: mockTheme },
|
||||
{ provide: ToastrService, useValue: mockToastR },
|
||||
{ provide: AUTH_REPOSITORY_TOKEN, useValue: mockAuthRepository },
|
||||
{ provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProfileRepository },
|
||||
],
|
||||
@@ -51,9 +50,4 @@ describe('NavBarComponent', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call themeService.updateDarkMode when toggleDarkMode called', () => {
|
||||
component.toggleDarkMode();
|
||||
expect(mockThemeService.updateDarkMode).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ThemeService } from '@app/core/services/theme/theme.service';
|
||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||
import { environment } from '@env/environment';
|
||||
import { AuthFacade } from '@app/ui/authentification/auth.facade';
|
||||
import { AuthFacade } from '@app/adapters/authentification/auth.facade';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-bar',
|
||||
@@ -14,7 +13,6 @@ import { AuthFacade } from '@app/ui/authentification/auth.facade';
|
||||
})
|
||||
@UntilDestroy()
|
||||
export class NavBarComponent implements OnInit {
|
||||
protected themeService: ThemeService = inject(ThemeService);
|
||||
protected readonly environment = environment;
|
||||
|
||||
protected authFacade = inject(AuthFacade);
|
||||
@@ -23,10 +21,6 @@ export class NavBarComponent implements OnInit {
|
||||
readonly isEmailVerified = this.authFacade.isEmailVerified;
|
||||
readonly user = this.authFacade.user;
|
||||
|
||||
toggleDarkMode() {
|
||||
this.themeService.updateDarkMode();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.authFacade.verifyEmail();
|
||||
this.authFacade.verifyAuthenticatedUser();
|
||||
|
||||
@@ -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 { ProjectViewModel } from '@app/adapters/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',
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user