feat : #3 carte interactive avec les profils
This commit is contained in:
@@ -82,6 +82,9 @@
|
|||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"options": {
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "TrouveTonProfile:build:production"
|
"buildTarget": "TrouveTonProfile:build:production"
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,5 +23,5 @@ export interface ProfilePaginated {
|
|||||||
perPage: number;
|
perPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
items: any[];
|
items: Profile[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
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 { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import PocketBase from 'pocketbase';
|
import PocketBase, { ListResult } from 'pocketbase';
|
||||||
import { environment } from '@env/environment';
|
import { environment } from '@env/environment';
|
||||||
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
|
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
|
||||||
import { SearchFilters } from '@app/domain/search/search-filters';
|
import { SearchFilters } from '@app/domain/search/search-filters';
|
||||||
@@ -24,9 +24,7 @@ export class PbProfileRepository implements ProfileRepository {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return from(
|
return from(
|
||||||
this.pb
|
this.pb.collection('profiles').getList<Profile>(params?.page, params?.perPage, requestOptions)
|
||||||
.collection('profiles')
|
|
||||||
.getList<ProfilePaginated>(params?.page, params?.perPage, requestOptions)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,37 +51,100 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="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) {
|
@if (loading().isLoading) {
|
||||||
<app-loading message="Chargement des profils..." />
|
<app-loading message="Chargement des profils..." />
|
||||||
} @else {
|
} @else {
|
||||||
<!-- Titre de section -->
|
<!-- Vue Liste (existante) -->
|
||||||
<div class="mb-6 flex items-center justify-between animate-fade-in">
|
@if (viewMode() === 'list') {
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Tous les profils</h2>
|
<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">
|
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-5 w-5"
|
class="h-5 w-5"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
<path
|
<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"
|
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>
|
</svg>
|
||||||
<span>{{ profilePaginated().items.length }} profil(s)</span>
|
<span>{{ profilePaginated().items.length }} profil(s)</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Liste des profils avec animation d'apparition -->
|
<div class="animate-slide-up animation-delay-100">
|
||||||
<div class="animate-slide-up animation-delay-100">
|
<app-vertical-profile-list [profiles]="profilePaginated().items" />
|
||||||
<app-vertical-profile-list [profiles]="profilePaginated().items" />
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="animate-slide-up animation-delay-100">
|
<div class="animate-slide-up animation-delay-100">
|
||||||
<app-pagination [filters]="searchFilters()" (onPageChange)="onPageChange($event)" />
|
<app-pagination [filters]="searchFilters()" (onPageChange)="onPageChange($event)" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,4 +1,60 @@
|
|||||||
/* Animations blob pour le fond */
|
/* 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 {
|
@keyframes blob {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
|
|||||||
@@ -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 { SearchComponent } from '@app/shared/features/search/search.component';
|
||||||
import { VerticalProfileListComponent } from '@app/shared/components/vertical-profile-list/vertical-profile-list.component';
|
import { VerticalProfileListComponent } from '@app/shared/components/vertical-profile-list/vertical-profile-list.component';
|
||||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||||
@@ -8,6 +8,10 @@ import { Router } from '@angular/router';
|
|||||||
import { SearchFilters } from '@app/domain/search/search-filters';
|
import { SearchFilters } from '@app/domain/search/search-filters';
|
||||||
import { FilterComponent } from '@app/shared/features/filter/filter.component';
|
import { FilterComponent } from '@app/shared/features/filter/filter.component';
|
||||||
import { PaginationComponent } from '@app/shared/features/pagination/pagination.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({
|
@Component({
|
||||||
selector: 'app-profile-list',
|
selector: 'app-profile-list',
|
||||||
@@ -18,6 +22,7 @@ import { PaginationComponent } from '@app/shared/features/pagination/pagination.
|
|||||||
LoadingComponent,
|
LoadingComponent,
|
||||||
FilterComponent,
|
FilterComponent,
|
||||||
PaginationComponent,
|
PaginationComponent,
|
||||||
|
ProfileListMapComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './profile-list.component.html',
|
templateUrl: './profile-list.component.html',
|
||||||
styleUrl: './profile-list.component.scss',
|
styleUrl: './profile-list.component.scss',
|
||||||
@@ -32,6 +37,8 @@ export class ProfileListComponent {
|
|||||||
protected readonly loading = this.facade.loading;
|
protected readonly loading = this.facade.loading;
|
||||||
protected readonly error = this.facade.error;
|
protected readonly error = this.facade.error;
|
||||||
|
|
||||||
|
protected readonly viewMode = signal<ViewMode>('list');
|
||||||
|
|
||||||
showNewQuery(filters: SearchFilters) {
|
showNewQuery(filters: SearchFilters) {
|
||||||
this.facade.load(filters);
|
this.facade.load(filters);
|
||||||
this.router.navigate(['/profiles'], { queryParams: { search: filters.search } });
|
this.router.navigate(['/profiles'], { queryParams: { search: filters.search } });
|
||||||
@@ -44,4 +51,19 @@ export class ProfileListComponent {
|
|||||||
onPageChange(filters: SearchFilters) {
|
onPageChange(filters: SearchFilters) {
|
||||||
this.facade.load(filters);
|
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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
<!-- PDF Viewer -->
|
<!-- PDF Viewer -->
|
||||||
<div class="p-4 bg-gray-100 dark:bg-gray-900">
|
<div class="p-4 bg-gray-100 dark:bg-gray-900">
|
||||||
<pdf-viewer
|
<pdf-viewer
|
||||||
[src]="cv_link()"
|
[src]="cv_link()!"
|
||||||
[zoom]="1"
|
[zoom]="1"
|
||||||
[rotation]="0"
|
[rotation]="0"
|
||||||
[original-size]="false"
|
[original-size]="false"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe('PdfViewerComponent', () => {
|
|||||||
fixture = TestBed.createComponent(PdfViewerComponent);
|
fixture = TestBed.createComponent(PdfViewerComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
|
|
||||||
component.profile = mockProfile;
|
fixture.componentRef.setInput('profile', mockProfile);
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
|||||||
@@ -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 { PdfViewerModule } from 'ng2-pdf-viewer';
|
||||||
import { environment } from '@env/environment';
|
import { environment } from '@env/environment';
|
||||||
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
|
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',
|
styleUrl: './pdf-viewer.component.scss',
|
||||||
})
|
})
|
||||||
export class PdfViewerComponent {
|
export class PdfViewerComponent {
|
||||||
@Input({ required: true }) profile: ProfileViewModel | undefined = undefined;
|
profile = input.required<ProfileViewModel>();
|
||||||
protected readonly environment = environment;
|
protected readonly environment = environment;
|
||||||
protected readonly cv_link = computed(() => {
|
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}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ export class SettingsComponent implements OnInit {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const userSettings = this.profile()!.settings
|
const userSettings = this.profile()!.settings!
|
||||||
? this.profile()!.settings!
|
? this.profile()!.settings!
|
||||||
: this.settings().privacy;
|
: this.settings().privacy;
|
||||||
if (userSettings) {
|
if (userSettings) {
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { inject, Injectable, signal } from '@angular/core';
|
|||||||
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
|
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
|
||||||
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||||
import { ProfilePresenter } from '@app/ui/profiles/profile.presenter';
|
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 { LoaderAction } from '@app/domain/loader-action.util';
|
||||||
import { ActionType } from '@app/domain/action-type.util';
|
import { ActionType } from '@app/domain/action-type.util';
|
||||||
import { ErrorResponse } from '@app/domain/error-response.util';
|
import { ErrorResponse } from '@app/domain/error-response.util';
|
||||||
@@ -34,7 +37,7 @@ export class ProfileFacade {
|
|||||||
|
|
||||||
readonly searchFilters = this.searchService.getFilters();
|
readonly searchFilters = this.searchService.getFilters();
|
||||||
readonly profiles = signal<ProfileViewModel[]>([]);
|
readonly profiles = signal<ProfileViewModel[]>([]);
|
||||||
readonly profilePaginated = signal<ProfilePaginated>({} as ProfilePaginated);
|
readonly profilePaginated = signal<ProfileViewModelPaginated>({} as ProfileViewModelPaginated);
|
||||||
readonly profile = signal<ProfileViewModel>({} as ProfileViewModel);
|
readonly profile = signal<ProfileViewModel>({} as ProfileViewModel);
|
||||||
readonly loading = signal<LoaderAction>({
|
readonly loading = signal<LoaderAction>({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -67,8 +70,11 @@ export class ProfileFacade {
|
|||||||
this.searchService.setFilters(filters);
|
this.searchService.setFilters(filters);
|
||||||
this.searchFilters.set(filters);
|
this.searchFilters.set(filters);
|
||||||
|
|
||||||
this.profilePaginated.set(profilePaginated);
|
const profileViewModelPaginated =
|
||||||
this.profiles.set(this.profilePresenter.toViewModels(profilePaginated.items as Profile[]));
|
this.profilePresenter.toViewModelPaginated(profilePaginated);
|
||||||
|
|
||||||
|
this.profilePaginated.set(profileViewModelPaginated);
|
||||||
|
this.profiles.set(profileViewModelPaginated.items);
|
||||||
this.handleError(ActionType.READ, false, null, false);
|
this.handleError(ActionType.READ, false, null, false);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
|
|||||||
@@ -21,3 +21,11 @@ export interface ProfileViewModel {
|
|||||||
coordonnees?: Coordinates;
|
coordonnees?: Coordinates;
|
||||||
settings?: SettingsProfileDto;
|
settings?: SettingsProfileDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProfileViewModelPaginated {
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalItems: number;
|
||||||
|
items: ProfileViewModel[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
|
import {
|
||||||
import { Profile } from '@app/domain/profiles/profile.model';
|
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';
|
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||||
|
|
||||||
export class ProfilePresenter {
|
export class ProfilePresenter {
|
||||||
|
toViewModelPaginated(profilePaginated: ProfilePaginated): ProfileViewModelPaginated {
|
||||||
|
return { ...profilePaginated, items: this.toViewModels(profilePaginated.items) };
|
||||||
|
}
|
||||||
|
|
||||||
toViewModel(profile: Profile): ProfileViewModel {
|
toViewModel(profile: Profile): ProfileViewModel {
|
||||||
const isProfileVisible = this.isProfileVisible(profile);
|
const isProfileVisible = this.isProfileVisible(profile);
|
||||||
const missingFields = this.missingFields(profile);
|
const missingFields = this.missingFields(profile);
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
// Plugin pour line-clamp (limiter le nombre de lignes)
|
// Plugin pour line-clamp (limiter le nombre de lignes)
|
||||||
require('@tailwindcss/line-clamp'),
|
// require('@tailwindcss/line-clamp'),
|
||||||
|
|
||||||
// Plugin pour les formulaires
|
// Plugin pour les formulaires
|
||||||
require('@tailwindcss/forms')({
|
require('@tailwindcss/forms')({
|
||||||
|
|||||||
Reference in New Issue
Block a user