feat : #3 carte interactive avec les profils

This commit is contained in:
styve Lioumba
2025-12-15 16:14:53 +01:00
parent de34c91f09
commit 1ad97ff89d
19 changed files with 667 additions and 44 deletions

View File

@@ -82,6 +82,9 @@
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "TrouveTonProfile:build:production"

8
proxy.conf.json Normal file
View File

@@ -0,0 +1,8 @@
{
"/api": {
"target": "https://pb-dev.prod.k3s.technostrea.fr",
"secure": false,
"changeOrigin": true,
"logLevel": "debug"
}
}

View File

@@ -23,5 +23,5 @@ export interface ProfilePaginated {
perPage: number;
totalPages: number;
totalItems: number;
items: any[];
items: Profile[];
}

View File

@@ -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<ProfilePaginated>(params?.page, params?.perPage, requestOptions)
this.pb.collection('profiles').getList<Profile>(params?.page, params?.perPage, requestOptions)
);
}

View File

@@ -51,37 +51,100 @@
</div>
</div>
<!-- Liste des profils -->
<!-- Toggle Vue Liste / Carte -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-8">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Tous les profils</h2>
<!-- Boutons de switch de vue -->
<div class="view-switcher">
<button
type="button"
class="view-btn"
[class.active]="viewMode() === 'list'"
(click)="switchToListView()"
>
<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="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z"
/>
</svg>
Liste
</button>
<button
type="button"
class="view-btn"
[class.active]="viewMode() === 'map'"
(click)="switchToMapView()"
>
<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="M9 6.75V15m6-6v8.25m.503 3.498l4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 00-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0z"
/>
</svg>
Carte
</button>
</div>
</div>
@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>
<!-- Vue Liste (existante) -->
@if (viewMode() === 'list') {
<div class="mb-6 flex items-center justify-between animate-fade-in">
<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>
</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-vertical-profile-list [profiles]="profilePaginated().items" />
</div>
<div class="animate-slide-up animation-delay-100">
<app-pagination [filters]="searchFilters()" (onPageChange)="onPageChange($event)" />
</div>
<div class="animate-slide-up animation-delay-100">
<app-pagination [filters]="searchFilters()" (onPageChange)="onPageChange($event)" />
</div>
}
<!-- Vue Carte (nouvelle) -->
@if (viewMode() === 'map') {
<div class="animate-fade-in">
@if (loading().isLoading) {
<app-loading message="Chargement de la carte..." />
} @else {
<app-profile-list-map [profiles]="profilePaginated().items" />
}
</div>
}
}
</div>
</section>

View File

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

View File

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

View File

@@ -53,7 +53,7 @@
<!-- PDF Viewer -->
<div class="p-4 bg-gray-100 dark:bg-gray-900">
<pdf-viewer
[src]="cv_link()"
[src]="cv_link()!"
[zoom]="1"
[rotation]="0"
[original-size]="false"

View File

@@ -20,7 +20,7 @@ describe('PdfViewerComponent', () => {
fixture = TestBed.createComponent(PdfViewerComponent);
component = fixture.componentInstance;
component.profile = mockProfile;
fixture.componentRef.setInput('profile', mockProfile);
fixture.detectChanges();

View File

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

View File

@@ -0,0 +1,57 @@
<div class="my-profile-map-container">
<!-- Message d'information -->
<div class="info-message">
<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="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg>
<p>Vous pouvez cliquer sur les profils présent sur la carte pour en savoir plus .</p>
</div>
<!-- Boutons d'action -->
<div class="actions">
<button type="button" (click)="onGetCurrentLocation()" class="btn btn-secondary">
<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="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
/>
</svg>
Me géolocaliser
</button>
</div>
<!-- Carte interactive -->
<div class="map-wrapper">
<app-map
[center]="locationState().coordinates || { latitude: 48.8566, longitude: 2.3522 }"
[markers]="markers()"
[interactive]="true"
[showUserLocation]="true"
[height]="'400px'"
/>
</div>
</div>

View File

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

View File

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

View File

@@ -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<ProfileViewModel[]>();
private readonly locationFacade = inject(LocationFacade);
protected readonly locationState = this.locationFacade.locationState;
protected readonly markers = signal<MapMarker[]>([]);
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,
};
});
}
}

View File

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

View File

@@ -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<ProfileViewModel[]>([]);
readonly profilePaginated = signal<ProfilePaginated>({} as ProfilePaginated);
readonly profilePaginated = signal<ProfileViewModelPaginated>({} as ProfileViewModelPaginated);
readonly profile = signal<ProfileViewModel>({} as ProfileViewModel);
readonly loading = signal<LoaderAction>({
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) => {

View File

@@ -21,3 +21,11 @@ export interface ProfileViewModel {
coordonnees?: Coordinates;
settings?: SettingsProfileDto;
}
export interface ProfileViewModelPaginated {
page: number;
perPage: number;
totalPages: number;
totalItems: number;
items: ProfileViewModel[];
}

View File

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

View File

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