From 1ad97ff89d3086ff0f70fe949f641523c90a2a1b Mon Sep 17 00:00:00 2001 From: styve Lioumba Date: Mon, 15 Dec 2025 16:14:53 +0100 Subject: [PATCH] feat : #3 carte interactive avec les profils --- angular.json | 3 + proxy.conf.json | 8 + src/app/domain/profiles/profile.model.ts | 2 +- .../profiles/pb-profile.repository.ts | 8 +- .../profile-list/profile-list.component.html | 111 +++++-- .../profile-list/profile-list.component.scss | 56 ++++ .../profile-list/profile-list.component.ts | 24 +- .../pdf-viewer/pdf-viewer.component.html | 2 +- .../pdf-viewer/pdf-viewer.component.spec.ts | 2 +- .../pdf-viewer/pdf-viewer.component.ts | 12 +- .../profile-list-map.component.html | 57 ++++ .../profile-list-map.component.scss | 293 ++++++++++++++++++ .../profile-list-map.component.spec.ts | 31 ++ .../profile-list-map.component.ts | 65 ++++ .../features/settings/settings.component.ts | 2 +- src/app/ui/profiles/profile.facade.ts | 14 +- .../ui/profiles/profile.presenter.model.ts | 8 + src/app/ui/profiles/profile.presenter.ts | 11 +- tailwind.config.js | 2 +- 19 files changed, 667 insertions(+), 44 deletions(-) create mode 100644 proxy.conf.json create mode 100644 src/app/shared/features/profile-list-map/profile-list-map.component.html create mode 100644 src/app/shared/features/profile-list-map/profile-list-map.component.scss create mode 100644 src/app/shared/features/profile-list-map/profile-list-map.component.spec.ts create mode 100644 src/app/shared/features/profile-list-map/profile-list-map.component.ts diff --git a/angular.json b/angular.json index 31e7ddc..4fc3a69 100644 --- a/angular.json +++ b/angular.json @@ -82,6 +82,9 @@ }, "serve": { "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "proxyConfig": "proxy.conf.json" + }, "configurations": { "production": { "buildTarget": "TrouveTonProfile:build:production" diff --git a/proxy.conf.json b/proxy.conf.json new file mode 100644 index 0000000..fa4b618 --- /dev/null +++ b/proxy.conf.json @@ -0,0 +1,8 @@ +{ + "/api": { + "target": "https://pb-dev.prod.k3s.technostrea.fr", + "secure": false, + "changeOrigin": true, + "logLevel": "debug" + } +} diff --git a/src/app/domain/profiles/profile.model.ts b/src/app/domain/profiles/profile.model.ts index cf3ebb3..f8b7df6 100644 --- a/src/app/domain/profiles/profile.model.ts +++ b/src/app/domain/profiles/profile.model.ts @@ -23,5 +23,5 @@ export interface ProfilePaginated { perPage: number; totalPages: number; totalItems: number; - items: any[]; + items: Profile[]; } diff --git a/src/app/infrastructure/profiles/pb-profile.repository.ts b/src/app/infrastructure/profiles/pb-profile.repository.ts index 4458646..22e14be 100644 --- a/src/app/infrastructure/profiles/pb-profile.repository.ts +++ b/src/app/infrastructure/profiles/pb-profile.repository.ts @@ -1,8 +1,8 @@ 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'; @@ -24,9 +24,7 @@ export class PbProfileRepository implements ProfileRepository { }; return from( - this.pb - .collection('profiles') - .getList(params?.page, params?.perPage, requestOptions) + this.pb.collection('profiles').getList(params?.page, params?.perPage, requestOptions) ); } diff --git a/src/app/routes/profile/profile-list/profile-list.component.html b/src/app/routes/profile/profile-list/profile-list.component.html index f41b10f..4f05152 100644 --- a/src/app/routes/profile/profile-list/profile-list.component.html +++ b/src/app/routes/profile/profile-list/profile-list.component.html @@ -51,37 +51,100 @@ - +
+
+

Tous les profils

+ + +
+ + +
+
+ @if (loading().isLoading) { } @else { - -
-

Tous les profils

-
- - - - {{ profilePaginated().items.length }} profil(s) + + @if (viewMode() === 'list') { +
+
+ + + + {{ profilePaginated().items.length }} profil(s) +
-
- -
- -
+
+ +
-
- -
+
+ +
+ } + + + @if (viewMode() === 'map') { +
+ @if (loading().isLoading) { + + } @else { + + } +
+ } }
diff --git a/src/app/routes/profile/profile-list/profile-list.component.scss b/src/app/routes/profile/profile-list/profile-list.component.scss index 39603ae..62d18f7 100644 --- a/src/app/routes/profile/profile-list/profile-list.component.scss +++ b/src/app/routes/profile/profile-list/profile-list.component.scss @@ -1,4 +1,60 @@ /* Animations blob pour le fond */ +.view-switcher { + display: flex; + gap: 8px; + padding: 4px; + background: #f3f4f6; + border-radius: 10px; + + .view-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + color: #6b7280; + background: transparent; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + + svg { + width: 20px; + height: 20px; + } + + &:hover { + color: #374151; + background: rgba(255, 255, 255, 0.5); + } + + &.active { + color: #6366f1; + background: white; + box-shadow: + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); + } + } +} + +// Responsive +@media (max-width: 768px) { + .view-switcher { + .view-btn { + padding: 6px 12px; + font-size: 13px; + + svg { + width: 18px; + height: 18px; + } + } + } +} + @keyframes blob { 0%, 100% { diff --git a/src/app/routes/profile/profile-list/profile-list.component.ts b/src/app/routes/profile/profile-list/profile-list.component.ts index 9e9ba17..43c753f 100644 --- a/src/app/routes/profile/profile-list/profile-list.component.ts +++ b/src/app/routes/profile/profile-list/profile-list.component.ts @@ -1,4 +1,4 @@ -import { Component, inject } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { SearchComponent } from '@app/shared/features/search/search.component'; import { VerticalProfileListComponent } from '@app/shared/components/vertical-profile-list/vertical-profile-list.component'; import { UntilDestroy } from '@ngneat/until-destroy'; @@ -8,6 +8,10 @@ import { Router } from '@angular/router'; import { SearchFilters } from '@app/domain/search/search-filters'; import { FilterComponent } from '@app/shared/features/filter/filter.component'; import { PaginationComponent } from '@app/shared/features/pagination/pagination.component'; +import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model'; +import { ProfileListMapComponent } from '@app/shared/features/profile-list-map/profile-list-map.component'; + +type ViewMode = 'list' | 'map'; @Component({ selector: 'app-profile-list', @@ -18,6 +22,7 @@ import { PaginationComponent } from '@app/shared/features/pagination/pagination. LoadingComponent, FilterComponent, PaginationComponent, + ProfileListMapComponent, ], templateUrl: './profile-list.component.html', styleUrl: './profile-list.component.scss', @@ -32,6 +37,8 @@ export class ProfileListComponent { protected readonly loading = this.facade.loading; protected readonly error = this.facade.error; + protected readonly viewMode = signal('list'); + showNewQuery(filters: SearchFilters) { this.facade.load(filters); this.router.navigate(['/profiles'], { queryParams: { search: filters.search } }); @@ -44,4 +51,19 @@ export class ProfileListComponent { onPageChange(filters: SearchFilters) { this.facade.load(filters); } + + // Nouvelles méthodes + switchToListView(): void { + this.viewMode.set('list'); + } + + switchToMapView(): void { + this.viewMode.set('map'); + // Recharger les profils avec localisation si nécessaire + } + + onProfileClickFromMap(profile: ProfileViewModel): void { + // Navigation vers le profil + this.router.navigate(['/profiles', profile.id]); + } } diff --git a/src/app/shared/features/pdf-viewer/pdf-viewer.component.html b/src/app/shared/features/pdf-viewer/pdf-viewer.component.html index 9f03c21..df2b2f2 100644 --- a/src/app/shared/features/pdf-viewer/pdf-viewer.component.html +++ b/src/app/shared/features/pdf-viewer/pdf-viewer.component.html @@ -53,7 +53,7 @@
{ fixture = TestBed.createComponent(PdfViewerComponent); component = fixture.componentInstance; - component.profile = mockProfile; + fixture.componentRef.setInput('profile', mockProfile); fixture.detectChanges(); diff --git a/src/app/shared/features/pdf-viewer/pdf-viewer.component.ts b/src/app/shared/features/pdf-viewer/pdf-viewer.component.ts index 0181203..c44240b 100644 --- a/src/app/shared/features/pdf-viewer/pdf-viewer.component.ts +++ b/src/app/shared/features/pdf-viewer/pdf-viewer.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, Input } from '@angular/core'; +import { Component, computed, input } from '@angular/core'; import { PdfViewerModule } from 'ng2-pdf-viewer'; import { environment } from '@env/environment'; import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model'; @@ -11,9 +11,15 @@ import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model'; styleUrl: './pdf-viewer.component.scss', }) export class PdfViewerComponent { - @Input({ required: true }) profile: ProfileViewModel | undefined = undefined; + profile = input.required(); protected readonly environment = environment; protected readonly cv_link = computed(() => { - return `${environment.baseUrl}/api/files/profiles/${this.profile!.id}/${this.profile!.cv}`; + const currentProfile = this.profile(); + + if (!currentProfile || !currentProfile.cv) { + return null; + } + + return `${environment.baseUrl}/api/files/profiles/${currentProfile.id}/${currentProfile.cv}`; }); } diff --git a/src/app/shared/features/profile-list-map/profile-list-map.component.html b/src/app/shared/features/profile-list-map/profile-list-map.component.html new file mode 100644 index 0000000..0dec287 --- /dev/null +++ b/src/app/shared/features/profile-list-map/profile-list-map.component.html @@ -0,0 +1,57 @@ +
+ +
+ + + +

Vous pouvez cliquer sur les profils présent sur la carte pour en savoir plus .

+
+ + +
+ +
+ + +
+ +
+
diff --git a/src/app/shared/features/profile-list-map/profile-list-map.component.scss b/src/app/shared/features/profile-list-map/profile-list-map.component.scss new file mode 100644 index 0000000..cec059d --- /dev/null +++ b/src/app/shared/features/profile-list-map/profile-list-map.component.scss @@ -0,0 +1,293 @@ +// src/app/ui/shared/components/my-profile-map/my-profile-map.component.scss + +.my-profile-map-container { + display: flex; + flex-direction: column; + gap: 24px; + padding: 24px; + background: white; + border-radius: 16px; + box-shadow: + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); + + .header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + + .title { + font-size: 24px; + font-weight: 700; + color: #111827; + margin: 0; + } + + .subtitle { + font-size: 14px; + color: #6b7280; + margin: 4px 0 0 0; + } + + .visibility-toggle { + display: flex; + align-items: center; + gap: 12px; + + .toggle-label { + position: relative; + display: inline-block; + width: 48px; + height: 24px; + cursor: pointer; + + .toggle-input { + opacity: 0; + width: 0; + height: 0; + + &:checked + .toggle-slider { + background-color: #6366f1; + + &::before { + transform: translateX(24px); + } + } + } + + .toggle-slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #d1d5db; + border-radius: 24px; + transition: background-color 0.2s; + + &::before { + content: ''; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + border-radius: 50%; + transition: transform 0.2s; + } + } + } + + .toggle-text { + font-size: 14px; + font-weight: 500; + color: #374151; + } + } + } + + .coordinates-display { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background: #f9fafb; + border-radius: 12px; + border: 1px solid #e5e7eb; + + .coordinates-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: #eef2ff; + border-radius: 10px; + color: #6366f1; + } + + .coordinates-label { + font-size: 12px; + font-weight: 500; + color: #6b7280; + margin: 0 0 4px 0; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .coordinates-value { + font-size: 16px; + font-weight: 600; + color: #111827; + margin: 0; + font-family: monospace; + } + } + + .alert { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-radius: 8px; + font-size: 14px; + + svg { + flex-shrink: 0; + } + + &.alert-error { + background: #fef2f2; + color: #991b1b; + border: 1px solid #fecaca; + } + + &.alert-info { + background: #eff6ff; + color: #1e40af; + border: 1px solid #bfdbfe; + } + } + + .actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + } + + .map-wrapper { + width: 100%; + } + + .save-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding-top: 16px; + border-top: 1px solid #e5e7eb; + } + + .info-message { + display: flex; + gap: 12px; + padding: 16px; + background: #f0f9ff; + border-radius: 8px; + border: 1px solid #bae6fd; + + svg { + flex-shrink: 0; + color: #0284c7; + } + + p { + margin: 0; + font-size: 14px; + color: #0c4a6e; + line-height: 1.5; + } + } +} + +// Boutons +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 20px; + font-size: 14px; + font-weight: 500; + border-radius: 8px; + border: none; + cursor: pointer; + transition: all 0.2s; + + svg { + width: 20px; + height: 20px; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &.btn-primary { + background: #6366f1; + color: white; + + &:hover:not(:disabled) { + background: #4f46e5; + } + } + + &.btn-secondary { + background: #f3f4f6; + color: #374151; + + &:hover:not(:disabled) { + background: #e5e7eb; + } + } + + &.btn-danger { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; + + &:hover:not(:disabled) { + background: #fee2e2; + } + } + + &.btn-outline { + background: transparent; + color: #6b7280; + border: 1px solid #d1d5db; + + &:hover:not(:disabled) { + background: #f9fafb; + } + } +} + +.spinner-small { + width: 16px; + height: 16px; + border: 2px solid #e5e7eb; + border-top-color: #6366f1; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +// Responsive +@media (max-width: 768px) { + .my-profile-map-container { + padding: 16px; + + .header { + flex-direction: column; + + .visibility-toggle { + align-self: flex-start; + } + } + + .actions { + flex-direction: column; + + .btn { + width: 100%; + } + } + } +} diff --git a/src/app/shared/features/profile-list-map/profile-list-map.component.spec.ts b/src/app/shared/features/profile-list-map/profile-list-map.component.spec.ts new file mode 100644 index 0000000..4b8dbc1 --- /dev/null +++ b/src/app/shared/features/profile-list-map/profile-list-map.component.spec.ts @@ -0,0 +1,31 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProfileListMapComponent } from './profile-list-map.component'; +import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token'; +import { mockProfileRepo } from '@app/testing/profile.mock'; +import { ToastrService } from 'ngx-toastr'; +import { mockToastR } from '@app/testing/toastr.mock'; + +describe('ProfileListMapComponent', () => { + let component: ProfileListMapComponent; + let fixture: ComponentFixture; + let mockProfileRepository = mockProfileRepo; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProfileListMapComponent], + providers: [ + { provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProfileRepository }, + { provide: ToastrService, useValue: mockToastR }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ProfileListMapComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/features/profile-list-map/profile-list-map.component.ts b/src/app/shared/features/profile-list-map/profile-list-map.component.ts new file mode 100644 index 0000000..908cc85 --- /dev/null +++ b/src/app/shared/features/profile-list-map/profile-list-map.component.ts @@ -0,0 +1,65 @@ +import { Component, effect, inject, input, OnInit, signal } from '@angular/core'; +import { MapComponent, MapMarker } from '@app/shared/components/map/map.component'; +import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model'; +import { LocationFacade } from '@app/ui/location/location.facade'; + +@Component({ + selector: 'app-profile-list-map', + standalone: true, + imports: [MapComponent], + providers: [LocationFacade], + templateUrl: './profile-list-map.component.html', + styleUrl: './profile-list-map.component.scss', +}) +export class ProfileListMapComponent implements OnInit { + readonly profiles = input(); + private readonly locationFacade = inject(LocationFacade); + + protected readonly locationState = this.locationFacade.locationState; + protected readonly markers = signal([]); + + constructor() { + effect( + () => { + const state = this.locationState(); + const profiles = this.profiles(); + if (profiles) { + this.markers.set(this.toMapMarker(profiles)); + } + + // Centrer la carte par rapport à sa position lorsque les coordonnées changent + if (state.coordinates) { + this.locationState.set({ ...this.locationState(), coordinates: state.coordinates }); + } + }, + { allowSignalWrites: true } + ); + } + + ngOnInit() {} + + onGetCurrentLocation(): void { + this.locationFacade.getCurrentLocation(); + } + + private toMapMarker(profiles: ProfileViewModel[]): MapMarker[] { + return profiles + .filter((profile: ProfileViewModel) => { + return ( + profile.settings!.allowGeolocation && + profile.coordonnees && + profile.coordonnees!.longitude != 0 && + profile.coordonnees!.latitude != 0 + ); + }) + .map((currentProfile: ProfileViewModel) => { + return { + id: currentProfile.id, + coordinates: currentProfile.coordonnees!, + title: currentProfile.fullName, + description: currentProfile.bio, + icon: currentProfile.avatarUrl, + }; + }); + } +} diff --git a/src/app/shared/features/settings/settings.component.ts b/src/app/shared/features/settings/settings.component.ts index 3f1b5f8..962e61f 100644 --- a/src/app/shared/features/settings/settings.component.ts +++ b/src/app/shared/features/settings/settings.component.ts @@ -41,7 +41,7 @@ export class SettingsComponent implements OnInit { constructor() { effect(() => { - const userSettings = this.profile()!.settings + const userSettings = this.profile()!.settings! ? this.profile()!.settings! : this.settings().privacy; if (userSettings) { diff --git a/src/app/ui/profiles/profile.facade.ts b/src/app/ui/profiles/profile.facade.ts index 0253585..2f787f9 100644 --- a/src/app/ui/profiles/profile.facade.ts +++ b/src/app/ui/profiles/profile.facade.ts @@ -3,7 +3,10 @@ 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 '@app/ui/profiles/profile.presenter'; -import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model'; +import { + ProfileViewModel, + ProfileViewModelPaginated, +} from '@app/ui/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'; @@ -34,7 +37,7 @@ export class ProfileFacade { readonly searchFilters = this.searchService.getFilters(); readonly profiles = signal([]); - readonly profilePaginated = signal({} as ProfilePaginated); + readonly profilePaginated = signal({} as ProfileViewModelPaginated); readonly profile = signal({} as ProfileViewModel); readonly loading = signal({ isLoading: false, @@ -67,8 +70,11 @@ export class ProfileFacade { this.searchService.setFilters(filters); this.searchFilters.set(filters); - this.profilePaginated.set(profilePaginated); - this.profiles.set(this.profilePresenter.toViewModels(profilePaginated.items as Profile[])); + const profileViewModelPaginated = + this.profilePresenter.toViewModelPaginated(profilePaginated); + + this.profilePaginated.set(profileViewModelPaginated); + this.profiles.set(profileViewModelPaginated.items); this.handleError(ActionType.READ, false, null, false); }, error: (err) => { diff --git a/src/app/ui/profiles/profile.presenter.model.ts b/src/app/ui/profiles/profile.presenter.model.ts index 695e1cf..a933b39 100644 --- a/src/app/ui/profiles/profile.presenter.model.ts +++ b/src/app/ui/profiles/profile.presenter.model.ts @@ -21,3 +21,11 @@ export interface ProfileViewModel { coordonnees?: Coordinates; settings?: SettingsProfileDto; } + +export interface ProfileViewModelPaginated { + page: number; + perPage: number; + totalPages: number; + totalItems: number; + items: ProfileViewModel[]; +} diff --git a/src/app/ui/profiles/profile.presenter.ts b/src/app/ui/profiles/profile.presenter.ts index e628d94..9cc3527 100644 --- a/src/app/ui/profiles/profile.presenter.ts +++ b/src/app/ui/profiles/profile.presenter.ts @@ -1,8 +1,15 @@ -import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model'; -import { Profile } from '@app/domain/profiles/profile.model'; +import { + ProfileViewModel, + ProfileViewModelPaginated, +} from '@app/ui/profiles/profile.presenter.model'; +import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model'; import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto'; export class ProfilePresenter { + 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); diff --git a/tailwind.config.js b/tailwind.config.js index add51cd..0b96353 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -84,7 +84,7 @@ module.exports = { }, plugins: [ // Plugin pour line-clamp (limiter le nombre de lignes) - require('@tailwindcss/line-clamp'), + // require('@tailwindcss/line-clamp'), // Plugin pour les formulaires require('@tailwindcss/forms')({