feat : #3 carte interactive avec les profils
This commit is contained in:
@@ -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
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;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
items: any[];
|
||||
items: Profile[];
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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% {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('PdfViewerComponent', () => {
|
||||
fixture = TestBed.createComponent(PdfViewerComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
component.profile = mockProfile;
|
||||
fixture.componentRef.setInput('profile', mockProfile);
|
||||
|
||||
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 { 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}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
effect(() => {
|
||||
const userSettings = this.profile()!.settings
|
||||
const userSettings = this.profile()!.settings!
|
||||
? this.profile()!.settings!
|
||||
: this.settings().privacy;
|
||||
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, 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) => {
|
||||
|
||||
@@ -21,3 +21,11 @@ export interface ProfileViewModel {
|
||||
coordonnees?: Coordinates;
|
||||
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 { 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);
|
||||
|
||||
@@ -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')({
|
||||
|
||||
Reference in New Issue
Block a user