Compare commits

..

No commits in common. 'master' and 'show-pins' have entirely different histories.

@ -1,45 +0,0 @@
kind: pipeline
type: docker
name: Frontend CI/CD
trigger:
event:
- push
steps:
- name: code-analysis
image: hub.codefirst.iut.uca.fr/alix.jeudi--lemoine/codefirst-dronesonarplugin-node:latest
commands:
- npm install
- /opt/sonar-scanner/bin/sonar-scanner
-Dsonar.login=$PLUGIN_SONAR_TOKEN
-Dsonar.projectKey=SAE3A_MemoryMap-front
-Dsonar.sources=src
-Dsonar.exclusions=**/*.spec.ts,**/node_modules/**
settings:
sonar_token:
from_secret: SECRET_SONAR_TOKEN
- name: docker-build-and-push
image: plugins/docker
settings:
dockerfile: docker/Dockerfile
context: .
registry: hub.codefirst.iut.uca.fr
mirror: https://proxy.iut.uca.fr:8443
repo: hub.codefirst.iut.uca.fr/alix.jeudi--lemoine/memorymap_front
username:
from_secret: SECRET_REGISTRY_USERNAME
password:
from_secret: SECRET_REGISTRY_PASSWORD
depends_on: [ code-analysis ]
- name: deploy-frontend
image: hub.codefirst.iut.uca.fr/thomas.bellembois/codefirst-dockerproxy-clientdrone:latest
environment:
IMAGENAME: hub.codefirst.iut.uca.fr/alix.jeudi--lemoine/memorymap_front:latest
CONTAINERNAME: frontend
COMMAND: create
OVERWRITE: true
ADMINS: alixjeudi--lemoine,alexisferon,mathisframit,maxencejouannet
depends_on: [ docker-build-and-push ]

@ -1,5 +1,7 @@
# Memory Map Front
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.0.6.
## Development server
To start a local development server, run:

@ -3,7 +3,7 @@
"version": 1,
"newProjectRoot": "projects",
"projects": {
"front": {
"frontv2": {
"projectType": "application",
"schematics": {},
"root": "",
@ -13,34 +13,21 @@
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/front",
"outputPath": "dist/frontv2",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"allowedCommonJsDependencies": ["leaflet"],
"assets": [
{
"glob": "**/*",
"input": "public"
},
{
"glob": "**/*",
"input": "node_modules/leaflet/dist/images/",
"output": "assets/images/"
}
],
"styles": [
"src/styles.css",
"node_modules/leaflet/dist/leaflet.css",
"node_modules/intro.js/introjs.css",
"node_modules/leaflet.markercluster/dist/MarkerCluster.css",
"node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css",
"src/introjs-modern.css"],
"styles": ["src/styles.css"],
"scripts": [
"node_modules/flowbite/dist/flowbite.min.js",
"node_modules/leaflet/dist/leaflet.js",
"node_modules/intro.js/intro.js"
"node_modules/leaflet/dist/leaflet.js"
]
},
"configurations": {
@ -48,23 +35,27 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "5MB"
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all",
"serviceWorker": "ngsw-config.json"
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": []
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
},
"defaultConfiguration": "production"
@ -73,10 +64,10 @@
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "front:build:production"
"buildTarget": "frontv2:build:production"
},
"development": {
"buildTarget": "front:build:development"
"buildTarget": "frontv2:build:development"
}
},
"defaultConfiguration": "development"
@ -93,11 +84,6 @@
{
"glob": "**/*",
"input": "public"
},
{
"glob": "**/*",
"input": "./node_modules/leaflet/dist/images",
"output": "./assets"
}
],
"styles": ["src/styles.css"],

@ -1,17 +0,0 @@
# Official Node.js image
FROM node:22-slim
# Set workdir
WORKDIR /app
# Copy the Angular app folder in the container
COPY . .
# Install dependencies
RUN npm install
# Expose port
EXPOSE 80
# Start the application
CMD [ "npm", "run", "production" ]

@ -1,30 +0,0 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.csr.html",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}

75
package-lock.json generated

@ -16,14 +16,8 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@angular/service-worker": "^19.0.5",
"@types/intro.js": "^5.1.5",
"exifr": "^7.1.3",
"flowbite": "^2.5.2",
"intro.js": "^7.2.0",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"ngx-cookie-service": "^19.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
@ -34,7 +28,6 @@
"@angular/compiler-cli": "^19.0.0",
"@types/jasmine": "~5.1.0",
"@types/leaflet": "^1.9.15",
"@types/leaflet.markercluster": "^1.5.5",
"autoprefixer": "^10.4.20",
"jasmine-core": "~5.4.0",
"karma": "~6.4.0",
@ -548,24 +541,6 @@
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/service-worker": {
"version": "19.0.5",
"resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-19.0.5.tgz",
"integrity": "sha512-qU5lgx1WJ+feCOV/EhkN9m20xFdIslpEQcSZZC+VJnEwcG6VTbofg1dRaHWZ9HAjS1uP7bFoK0HUYu4el0bHGA==",
"dependencies": {
"tslib": "^2.3.0"
},
"bin": {
"ngsw-config": "ngsw-config.js"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/common": "19.0.5",
"@angular/core": "19.0.5"
}
},
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@ -4647,12 +4622,6 @@
"@types/node": "*"
}
},
"node_modules/@types/intro.js": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/@types/intro.js/-/intro.js-5.1.5.tgz",
"integrity": "sha512-TT1d8ayz07svlBcoqh26sNpQaU6bBpdFcCC+IMZHp46NNX2mYAHAVefM3wCmQSd4UWhhObeMjFByw2IaPKOXlw==",
"license": "MIT"
},
"node_modules/@types/jasmine": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.5.tgz",
@ -4674,16 +4643,6 @@
"@types/geojson": "*"
}
},
"node_modules/@types/leaflet.markercluster": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.5.tgz",
"integrity": "sha512-TkWOhSHDM1ANxmLi+uK0PjsVcjIKBr8CLV2WoF16dIdeFmC0Cj5P5axkI3C1Xsi4+ht6EU8+BfEbbqEF9icPrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/leaflet": "*"
}
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@ -6931,12 +6890,6 @@
"node": ">=0.8.x"
}
},
"node_modules/exifr": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
"license": "MIT"
},
"node_modules/exponential-backoff": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz",
@ -7942,12 +7895,6 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/intro.js": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/intro.js/-/intro.js-7.2.0.tgz",
"integrity": "sha512-qbMfaB70rOXVBceIWNYnYTpVTiZsvQh/MIkfdQbpA9di9VBfj1GigUPfcCv3aOfsbrtPcri8vTLTA4FcEDcHSQ==",
"license": "AGPL-3.0"
},
"node_modules/ip-address": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
@ -8745,15 +8692,6 @@
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="
},
"node_modules/leaflet.markercluster": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
"license": "MIT",
"peerDependencies": {
"leaflet": "^1.3.1"
}
},
"node_modules/less": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz",
@ -9805,19 +9743,6 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true
},
"node_modules/ngx-cookie-service": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/ngx-cookie-service/-/ngx-cookie-service-19.0.0.tgz",
"integrity": "sha512-itxGY1BlIRoEjEtDsSsRKnJuiQteTMLKPNHrykiH06tjUQ1bi3orE7YKU1D210VBqVy1jNrB7hKuGOOIQtQJDA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.0"
},
"peerDependencies": {
"@angular/common": "^19.0.0",
"@angular/core": "^19.0.0"
}
},
"node_modules/node-addon-api": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",

@ -6,8 +6,7 @@
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"production": "ng serve --host 0.0.0.0 --port 80 --configuration=production"
"test": "ng test"
},
"private": true,
"dependencies": {
@ -19,14 +18,8 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@angular/service-worker": "^19.0.5",
"@types/intro.js": "^5.1.5",
"exifr": "^7.1.3",
"flowbite": "^2.5.2",
"intro.js": "^7.2.0",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"ngx-cookie-service": "^19.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
@ -37,7 +30,6 @@
"@angular/compiler-cli": "^19.0.0",
"@types/jasmine": "~5.1.0",
"@types/leaflet": "^1.9.15",
"@types/leaflet.markercluster": "^1.5.5",
"autoprefixer": "^10.4.20",
"jasmine-core": "~5.4.0",
"karma": "~6.4.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 244 KiB

@ -1,67 +0,0 @@
{
"name": "Memory Map",
"short_name": "Memory Map",
"theme_color": "#111827",
"background_color": "#FFFFFF",
"display": "standalone",
"scope": "./",
"start_url": "./",
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any",
"background_color": "transparent"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any",
"background_color": "transparent"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any",
"background_color": "transparent"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any",
"background_color": "transparent"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any",
"background_color": "transparent"
},
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any",
"background_color": "transparent"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any",
"background_color": "transparent"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any",
"background_color": "transparent"
}
]
}

@ -1,6 +1,4 @@
<app-navbar *ngIf="authService.isLoggedIn()"></app-navbar>
<app-home-navbar *ngIf="!authService.isLoggedIn()"></app-home-navbar>
<app-navbar *ngIf="isAuth"></app-navbar>
<app-home-navbar *ngIf="!isAuth"></app-home-navbar>
<router-outlet />
<app-admin-footer *ngIf="authService.isAdmin()"></app-admin-footer>

@ -14,16 +14,16 @@ describe('AppComponent', () => {
expect(app).toBeTruthy();
});
it(`should have the 'front' title`, () => {
it(`should have the 'frontv2' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('front');
expect(app.title).toEqual('frontv2');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, front');
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontv2');
});
});

@ -3,17 +3,21 @@ import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { HomeNavbarComponent } from './components/home-navbar/home-navbar.component';
import { NavbarComponent } from './components/navbar/navbar.component';
import { AdminFooterComponent } from './components/admin-footer/admin-footer.component';
import { AuthService } from './services/auth/auth.service';
import { PinDetailComponent } from './components/pin-detail/pin-detail.component';
@Component({
selector: 'app-root',
imports: [RouterOutlet, NavbarComponent, HomeNavbarComponent, CommonModule, AdminFooterComponent],
imports: [RouterOutlet, NavbarComponent, HomeNavbarComponent, CommonModule],
templateUrl: './app.component.html',
})
export class AppComponent {
title = 'Memory Map';
isAuth: boolean = false;
constructor(protected authService: AuthService) {}
constructor() {}
ngOnInit(): void {
if (localStorage.getItem('auth_token') !== null) {
this.isAuth = true;
}
}
}

@ -1,25 +1,13 @@
import { provideHttpClient } from '@angular/common/http';
import {
ApplicationConfig,
isDevMode,
LOCALE_ID,
provideZoneChangeDetection,
} from '@angular/core';
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideServiceWorker } from '@angular/service-worker';
import { routes } from './app.routes';
import { CookieService } from 'ngx-cookie-service';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(),
{ provide: LOCALE_ID, useValue: 'fr-FR' },
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000',
}),
CookieService,
],
};

@ -1,15 +1,12 @@
import { Routes } from '@angular/router';
import { AuthGuard } from './auth.guard';
import { HomePageComponent } from './components/home-page/home-page.component';
import { LeafletMapComponent } from './components/leaflet-map/leaflet-map.component';
import { LoginPageComponent } from './components/login-page/login-page.component';
import { NotFoundComponent } from './components/not-found/not-found.component';
import { TimelineComponent } from './components/timeline/timeline.component';
import { PinDetailComponent } from './components/pin-detail/pin-detail.component';
export const routes: Routes = [
{ path: '', component: HomePageComponent },
{ path: 'map', component: LeafletMapComponent, canActivate: [AuthGuard] },
{ path: 'timeline', component: TimelineComponent, canActivate: [AuthGuard] },
{ path: 'pin/:id', component: PinDetailComponent, canActivate: [AuthGuard] },
{ path: 'map', component: LeafletMapComponent },
{ path: 'sign', component: LoginPageComponent },
{ path: '**', component: NotFoundComponent },
];

@ -1,49 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { AuthGuard } from './auth.guard';
import { CookiesService } from './services/cookies/cookies.service';
import { ModalService } from './services/modal/modal.service';
describe('AuthGuard', () => {
let guard: AuthGuard;
let cookiesServiceSpy: jasmine.SpyObj<CookiesService>;
let routerSpy: jasmine.SpyObj<Router>;
let loginModalServiceSpy: jasmine.SpyObj<ModalService>;
beforeEach(() => {
cookiesServiceSpy = jasmine.createSpyObj('CookiesService', [
'getToken',
]);
routerSpy = jasmine.createSpyObj('Router', ['navigate']);
loginModalServiceSpy = jasmine.createSpyObj('LoginModalService', [
'openModal',
]);
TestBed.configureTestingModule({
providers: [
AuthGuard,
{ provide: CookiesService, useValue: cookiesServiceSpy },
{ provide: Router, useValue: routerSpy },
{ provide: ModalService, useValue: loginModalServiceSpy },
],
});
guard = TestBed.inject(AuthGuard);
});
it('should allow activation when token exists', () => {
cookiesServiceSpy.getToken.and.returnValue('valid-token');
const result = guard.canActivate();
expect(result).toBeTrue();
});
it('should deny activation and trigger redirect and modal when token is missing', async () => {
cookiesServiceSpy.getToken.and.returnValue(null);
routerSpy.navigate.and.returnValue(Promise.resolve(true));
const result = guard.canActivate();
expect(result).toBeFalse();
expect(routerSpy.navigate).toHaveBeenCalledWith(['/']);
expect(loginModalServiceSpy.openModal).toHaveBeenCalled();
});
});

@ -1,26 +0,0 @@
import { Injectable } from '@angular/core';
import { CanActivate, Router } from '@angular/router';
import { ModalService } from './services/modal/modal.service';
import { AuthService } from './services/auth/auth.service';
@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router,
private loginModalService: ModalService
) {}
canActivate(): boolean {
if (this.authService.isLoggedIn()) {
return true;
} else {
this.router.navigate(['/']).then(() => {
this.loginModalService.openModal('login-modal');
});
return false;
}
}
}

@ -1,29 +1,23 @@
<!-- Fond assombri -->
<div
class="fixed inset-0 bg-gray-900 bg-opacity-50 w-full h-full transition-opacity duration-300 ease-in-out z-40"
[ngClass]="{
'opacity-0 pointer-events-none': !isPinModalOpen,
'opacity-100': isPinModalOpen
}"
(click)="closePinModal()"
></div>
<!-- Modal toggle -->
<button
data-modal-target="authentication-modal"
data-modal-toggle="authentication-modal"
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
type="button"
>
<p *ngIf="!isHomePage">Ajouter un pin</p>
</button>
<!-- Main modal -->
<div
id="pin-modal"
id="authentication-modal"
tabindex="-1"
aria-hidden="true"
[ngClass]="{
'opacity-0 scale-50 pointer-events-none': !isPinModalOpen,
'opacity-100 scale-100': isPinModalOpen
}"
class="fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full h-full transition-transform duration-300 ease-in-out overflow-y-auto"
class="hidden overflow-auto absolute top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full"
>
<div class="relative p-4 w-full max-w-xl max-h-full">
<!-- Modal content -->
<div
class="relative bg-white rounded-lg shadow dark:bg-gray-700 transition-transform duration-300 ease-in-out my-8"
>
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<!-- Modal header -->
<div
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600"
@ -33,8 +27,9 @@
</h3>
<button
type="button"
(click)="closePinModal()"
id="close-modal"
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
data-modal-hide="authentication-modal"
>
<svg
class="w-3 h-3"
@ -54,37 +49,27 @@
<span class="sr-only">Fermer la modal</span>
</button>
</div>
<!-- Modal body -->
<div class="p-4 md:p-5">
<form class="grid gap-6 mb-1 md:grid-cols-2" [formGroup]="form">
<div id="add-pin-modal-title" class="mb-4">
<div>
<label
for="title"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Titre</label
>
<input
type="text"
type="title"
name="title"
id="title"
formControlName="title"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Titre"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
placeholder="Mont Saint-Michel"
required
/>
<div
*ngIf="form.get('title')?.invalid && form.get('title')?.touched"
class="mt-1 text-sm text-red-600 dark:text-red-500"
>
<span *ngIf="form.get('title')?.errors?.['required']"
>Le titre est requis</span
>
<span *ngIf="form.get('title')?.errors?.['minlength']"
>Le titre doit contenir au moins 3 caractères</span
>
</div>
</div>
<div id="add-pin-modal-localisation">
<div>
<label
for="localisation"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
@ -94,21 +79,11 @@
type="text"
id="localisation"
formControlName="location"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Localisation"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
placeholder="Saisir la localisation"
(focus)="onFocus()"
(blur)="onBlur()"
/>
<div
*ngIf="
form.get('location')?.invalid && form.get('location')?.touched
"
class="mt-1 text-sm text-red-600 dark:text-red-500"
>
<span *ngIf="form.get('location')?.errors?.['required']"
>La localisation est requise</span
>
</div>
<ul
*ngIf="suggestions.length > 0 && inputFocused"
class="bg-white dark:bg-gray-700 border border-gray-300 mt-2 rounded shadow absolute z-10 mr-5 max-h-60 overflow-auto"
@ -123,22 +98,12 @@
</ul>
</div>
<div id="add-pin-modal-image">
<label
for="files"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Images</label
>
<div>
<app-drag-drop
*ngIf="isPinModalOpen"
[initialFiles]="getFileNames()"
(filesSelected)="onFilesReceived($event)"
(fileRemoved)="removeFile($event)"
[errorMessage]="uploadError"
></app-drag-drop>
</div>
<div id="add-pin-modal-description" class="mb-4">
<div>
<label
for="description"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
@ -148,77 +113,30 @@
id="description"
rows="4"
formControlName="description"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Description"
class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Décrit ton souvenir..."
></textarea>
<div
*ngIf="
form.get('description')?.invalid &&
form.get('description')?.touched
"
class="mt-1 text-sm text-red-600 dark:text-red-500"
>
<span *ngIf="form.get('description')?.errors?.['required']"
>La description est requise</span
>
<span *ngIf="form.get('description')?.errors?.['minlength']"
>La description doit contenir au moins 3 caractères</span
>
</div>
</div>
<!-- <div *ngIf="files.length > 0">
<div *ngFor="let file of files">
<img
[src]="getImagePreview(file)"
alt="Image preview"
width="100"
/>
</div>
</div> -->
<!-- <div *ngIf="files.length > 0">
<div *ngFor="let file of files">
<img
[src]="getImagePreview(file)"
alt="Image preview"
width="100"
/>
</div>
</div> -->
<div id="add-pin-modal-date">
<label
for="date"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Date (optionnel)</label
<div class="flex justify-between">
<button
type="reset"
data-modal-hide="authentication-modal"
class="w-full text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
>
<input
type="date"
id="date"
formControlName="date"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
/>
Annuler
</button>
</div>
<div class="flex justify-between">
<button
type="submit"
(click)="submitForm()"
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
Valider
</button>
</div>
</form>
<!-- Boutons alignés sous la grille -->
<div class="flex gap-4 mt-6">
<button
type="reset"
(click)="closePinModal()"
class="w-1/2 text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
>
Annuler
</button>
<button
id="add-pin-modal-validate"
type="submit"
(click)="submitForm()"
class="w-1/2 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
Valider
</button>
</div>
</div>
</div>
</div>

@ -8,8 +8,9 @@ describe('AddPinPopupComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AddPinPopupComponent],
}).compileComponents();
imports: [AddPinPopupComponent]
})
.compileComponents();
fixture = TestBed.createComponent(AddPinPopupComponent);
component = fixture.componentInstance;

@ -1,25 +1,20 @@
import { CommonModule } from '@angular/common';
import { Component, Input, OnInit, ViewChild } from '@angular/core';
import { Component, Input, OnInit } from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { forkJoin, of, Subscription } from 'rxjs';
import { of } from 'rxjs';
import {
catchError,
debounceTime,
distinctUntilChanged,
switchMap,
} from 'rxjs/operators';
import { AutocompleteService } from '../../services/auto-complete/auto-complete.service';
import { ExifService } from '../../services/exif/exif.service';
import { ImageService } from '../../services/image/image.service';
import { MapReloadService } from '../../services/map-reload/map-reload.service';
import { ModalService } from '../../services/modal/modal.service';
import { PinService } from '../../services/pin/pin.service';
import { AddPinService } from '../../services/add-pin.service';
import { AutocompleteService } from '../../services/auto-complete.service';
import { DragDropComponent } from '../drag-drop/drag-drop.component';
@Component({
@ -29,40 +24,22 @@ import { DragDropComponent } from '../drag-drop/drag-drop.component';
templateUrl: './add-pin-popup.component.html',
})
export class AddPinPopupComponent implements OnInit {
@ViewChild(DragDropComponent) dragDropComponent!: DragDropComponent;
form: FormGroup;
suggestions: any[] = [];
inputFocused: boolean = false;
@Input() isHomePage: boolean = false;
files: File[] = [];
isPinModalOpen: boolean = false;
modalId: string = 'add-pin-modal';
private modalSub!: Subscription;
uploadError: string = '';
files: any[] = [];
constructor(
private fb: FormBuilder,
private autocompleteService: AutocompleteService,
private pinService: PinService,
private exifService: ExifService,
private modalService: ModalService,
private mapReloadService: MapReloadService,
private imageService: ImageService
private addPinService: AddPinService
) {
this.form = this.fb.group({
title: new FormControl('', [
Validators.required,
Validators.minLength(3),
]),
description: new FormControl('', [
Validators.required,
Validators.minLength(3),
]),
location: new FormControl('', [Validators.required]),
complete_address: new FormControl('', [Validators.required]),
coordinates: new FormControl<number[]>([]),
files: new FormControl([]),
date: new FormControl(''),
title: new FormControl(''),
description: new FormControl(''),
location: new FormControl(''),
files: new FormControl(null),
});
}
@ -77,42 +54,12 @@ export class AddPinPopupComponent implements OnInit {
}
ngOnInit(): void {
this.modalSub = this.modalService
.getModalState(this.modalId)
.subscribe((open) => {
this.isPinModalOpen = open;
if (open) {
const images = this.modalService.getImageFiles().getValue();
if (images && images.length > 0) {
this.files = images;
this.form.patchValue({ files: images });
// Convertir les fichiers en FileList pour le composant drag-drop
const dataTransfer = new DataTransfer();
images.forEach((file) => dataTransfer.items.add(file));
const fileList = dataTransfer.files;
if (this.dragDropComponent) {
this.dragDropComponent.updateFileNamesFromFileList(fileList);
}
}
// Récupérer les données pré-remplies du formulaire
const formData = this.modalService.getFormData().getValue();
if (formData) {
this.form.patchValue(formData);
}
}
});
this.form
.get('location')
?.valueChanges.pipe(
debounceTime(200), // Attendre 200ms après la dernière frappe
debounceTime(300), // Attendre 300ms après la dernière frappe
distinctUntilChanged(), // Ignorer si la nouvelle valeur est la même que la précédente
switchMap((query) => {
if (query === null) {
return of([]);
}
const trimmedQuery = query.trim();
if (trimmedQuery.length > 2) {
return this.autocompleteService.getAddressSuggestions(trimmedQuery);
@ -133,149 +80,37 @@ export class AddPinPopupComponent implements OnInit {
const locationControl = this.form.get('location');
if (locationControl instanceof FormControl) {
locationControl.setValue(suggestion.display_name);
this.form.get('complete_address')?.setValue(suggestion.display_name);
this.form.get('coordinates')?.setValue([suggestion.lat, suggestion.lon]);
}
this.suggestions = [];
}
async onFilesReceived(files: FileList): Promise<void> {
// Ajouter les nouveaux fichiers à la liste existante
this.files = [...this.files, ...Array.from(files)];
this.uploadError = ''; // Réinitialiser l'erreur
if (this.dragDropComponent) {
this.dragDropComponent.updateFileNamesFromFileList(files);
} else {
console.warn('AddPinPopupComponent - dragDropComponent not available');
}
// Ne traiter que la première photo pour les métadonnées EXIF
if (files.length > 0) {
try {
const data = await this.exifService.getLocation(files[0]);
if (data.latitude !== undefined && data.longitude !== undefined) {
this.autocompleteService
.getAddressFromCoordinates(data.latitude, data.longitude)
.subscribe((address) => {
if (address) {
this.form.get('location')?.setValue(address.display_name);
this.form
.get('complete_address')
?.setValue(address.display_name);
this.form
.get('coordinates')
?.setValue([data.latitude, data.longitude]);
}
});
}
} catch (error) {
return;
}
}
}
getFileNames(): string[] {
return this.files.map((file) => file.name);
}
ngOnDestroy() {
this.modalSub.unsubscribe();
onFilesReceived(files: FileList): void {
this.files = Array.from(files);
}
async submitForm(): Promise<void> {
// Marquer tous les champs comme touched pour afficher les erreurs
Object.keys(this.form.controls).forEach((key) => {
const control = this.form.get(key);
control?.markAsTouched();
});
submitForm(): void {
if (this.form.valid) {
const uploadObservables = await Promise.all(
this.files.map(async (file) => {
if (file.size === 0) {
this.uploadError = file.name + ' : ' + 'Image vide';
return of(null);
}
const pictureExifDate = await this.exifService.getDateTime(file);
return this.imageService.postImage(file, pictureExifDate).pipe(
catchError((error) => {
this.uploadError =
file.name + ' : ' + error.error.detail ||
"Erreur lors de l'upload de l'image";
if (this.dragDropComponent) {
this.dragDropComponent.errorMessage = this.uploadError;
}
return of(null);
})
);
})
);
forkJoin(uploadObservables).subscribe((responses) => {
// Vérifier si toutes les réponses sont valides
if (responses.some((response) => response === null)) {
return; // Ne pas continuer si une erreur s'est produite
}
this.files = responses.map((res: any) => res.id);
const coordinates = this.form.get('coordinates')?.value;
const pinData = {
...this.form.value,
files: this.files,
date: this.form.get('date')?.value || null,
location: coordinates || [0, 0], // Utiliser les coordonnées pour location
complete_address:
this.form.get('complete_address')?.value ||
this.form.get('location')?.value,
};
this.files = this.files.map((file) => {
return file.name; //TODO: Mettre le hash du fichier
});
// Supprimer le champ coordinates qui n'est pas dans le modèle Pin
delete pinData.coordinates;
const pinData = {
...this.form.value,
files: this.files,
};
this.pinService.addPin(pinData)?.subscribe(() => {
this.mapReloadService.requestReload(); // Demander le rechargement de la carte
this.closePinModal();
});
this.addPinService.addPin(pinData).subscribe(() => {
this.closeModal();
});
} else {
console.error('Le formulaire est invalide');
}
}
closePinModal() {
this.modalService.closeModal(this.modalId);
this.form.reset();
this.files = [];
this.uploadError = '';
if (this.dragDropComponent) {
this.dragDropComponent.updateFileNamesFromFileList(
new DataTransfer().files
);
this.dragDropComponent.errorMessage = '';
}
}
getImagePreview(file: File): string {
return URL.createObjectURL(file);
}
removeFile(fileName: string): void {
const index = this.files.findIndex((file) => file.name === fileName);
if (index > -1) {
this.files.splice(index, 1);
this.uploadError = ''; // Réinitialiser l'erreur lors de la suppression d'un fichier
if (this.dragDropComponent) {
this.dragDropComponent.errorMessage = '';
}
// Mettre à jour le form control
const dataTransfer = new DataTransfer();
this.files.forEach((file) => dataTransfer.items.add(file));
this.form.patchValue({ files: dataTransfer.files });
private closeModal() {
const modal = document.getElementById('close-modal');
if (modal) {
modal.click();
}
}
}

@ -1,9 +0,0 @@
<div class="fixed bottom-0 left-0 w-full bg-gray-900 dark:bg-gray-900 border-t border-gray-700 dark:border-gray-700">
<div class="container mx-auto px-4 py-2">
<div class="flex justify-center items-center">
<a href="https://administration.memorymap.fr" class="text-m text-gray-300 dark:text-gray-300 hover:text-white dark:hover:text-white transition-colors duration-200">
Accès panneau d'administration
</a>
</div>
</div>
</div>

@ -1,9 +0,0 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-admin-footer',
imports: [CommonModule],
templateUrl: './admin-footer.component.html'
})
export class AdminFooterComponent { }

@ -1,71 +0,0 @@
<div id="confirm-modal-{{pinId}}">
<!-- Fond assombri -->
<div
class="fixed inset-0 bg-gray-900 bg-opacity-50 w-full h-full z-40 transition-opacity duration-300 ease-in-out"
[ngClass]="{
'opacity-0 pointer-events-none': !isOpen,
'opacity-100': isOpen
}"
(click)="cancel()"
></div>
<!-- Contenu principal -->
<div
class="fixed inset-0 z-50 flex justify-center items-center w-full h-full overflow-y-auto"
[ngClass]="{
'opacity-0 scale-50 pointer-events-none': !isOpen,
'opacity-100 scale-100': isOpen
}"
>
<div
class="bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md transition-transform duration-300 ease-in-out my-8"
>
<!-- Modal header -->
<div
class="flex items-center justify-between border-b rounded-t dark:border-gray-600 mb-6 pb-2"
>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Confirmation
</h2>
<button
type="button"
(click)="closeModal()"
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg
class="w-3 h-3"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
<span class="sr-only">Fermer la modal</span>
</button>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-6">{{ message }}</p>
<div class="flex justify-end space-x-4">
<button
class="px-4 py-2 text-white bg-red-600 hover:bg-red-700 rounded"
(click)="confirm()"
>
Supprimer
</button>
<button
class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-800 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500"
(click)="cancel()"
>
Annuler
</button>
</div>
</div>
</div>
</div>

@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ConfirmModalComponent } from './confirm-modal.component';
describe('ConfirmModalComponent', () => {
let component: ConfirmModalComponent;
let fixture: ComponentFixture<ConfirmModalComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ConfirmModalComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ConfirmModalComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -1,73 +0,0 @@
import { CommonModule } from '@angular/common';
import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
} from '@angular/core';
import { Subscription } from 'rxjs';
import { ModalService } from '../../services/modal/modal.service';
@Component({
selector: 'app-confirm-modal',
standalone: true,
imports: [CommonModule],
templateUrl: './confirm-modal.component.html',
})
export class ConfirmModalComponent implements OnInit, OnDestroy {
@Input() message: string = 'Es-tu sûr de vouloir supprimer ?';
@Input() pinId: string = '';
@Input() pinOpened!: EventEmitter<void>;
@Output() confirmed = new EventEmitter<void>();
@Output() cancelled = new EventEmitter<void>();
modalId: string = '';
isOpen = false;
private subscription!: Subscription;
constructor(private modalService: ModalService) {}
ngOnInit() {
this.modalId = 'confirm-modal-' + this.pinId;
this.subscription = this.modalService
.getModalState(this.modalId)
.subscribe((state) => {
this.isOpen = state;
});
this.pinOpened.subscribe(() => {
this.moveModalToBody();
});
}
ngOnDestroy() {
this.subscription?.unsubscribe();
}
confirm() {
this.confirmed.emit();
this.modalService.closeModal(this.modalId);
}
cancel() {
this.cancelled.emit();
this.modalService.closeModal(this.modalId);
}
closeModal() {
this.isOpen = false;
this.modalService.closeModal(this.modalId);
}
private moveModalToBody(): void {
const modal = document.getElementById(this.modalId);
if (modal && modal.parentElement !== document.body) {
document.body.appendChild(modal);
}
}
}

@ -39,11 +39,6 @@
/>
</div>
<!-- Message d'erreur -->
<div *ngIf="errorMessage" class="mt-2 text-sm text-red-600 dark:text-red-400">
{{ errorMessage }}
</div>
<!-- Zone pour afficher les fichiers sélectionnés -->
<div
*ngIf="fileNames.length > 0"
@ -55,7 +50,7 @@
<button
type="button"
class="end-2.5 text-gray-400 bg-transparent hover:text-gray-900 rounded-lg text-sm w-6 h-6 ms-auto inline-flex justify-center items-center dark:hover:text-white"
(click)="removeFile(fileName, $event)"
(click)="removeFile(fileName)"
>
<svg
class="w-2 h-2"

@ -1,36 +1,14 @@
import { CommonModule } from '@angular/common';
import {
Component,
EventEmitter,
Input,
OnChanges,
Output,
SimpleChanges,
} from '@angular/core';
import { Component, EventEmitter, Output } from '@angular/core';
@Component({
selector: 'app-drag-drop',
imports: [CommonModule],
templateUrl: './drag-drop.component.html',
})
export class DragDropComponent implements OnChanges {
@Input() initialFiles: string[] = [];
@Input() errorMessage: string = '';
export class DragDropComponent {
fileNames: string[] = [];
@Output() filesSelected = new EventEmitter<FileList>();
@Output() fileRemoved = new EventEmitter<string>();
ngOnChanges(changes: SimpleChanges) {
if (changes['initialFiles']) {
this.fileNames = [...this.initialFiles];
}
}
ngOnInit() {
if (this.initialFiles && this.initialFiles.length > 0) {
this.fileNames = [...this.initialFiles];
}
}
onFilesSelected(event: Event): void {
const input = event.target as HTMLInputElement;
@ -57,21 +35,14 @@ export class DragDropComponent implements OnChanges {
event.preventDefault();
}
updateFileNamesFromFileList(files: FileList): void {
this.fileNames = Array.from(files).map((file) => file.name);
}
private updateFileNames(files: FileList): void {
const newFileNames = Array.from(files).map(file => file.name);
this.fileNames = [...this.fileNames, ...newFileNames];
for (let i = 0; i < files.length; i++) {
this.fileNames.push(files[i].name);
}
}
removeFile(fileName: string, event: Event): void {
event.stopPropagation(); // Empêcher la propagation du clic
removeFile(fileName: string): void {
const index = this.fileNames.indexOf(fileName);
if (index > -1) {
this.fileNames.splice(index, 1);
this.fileRemoved.emit(fileName);
}
this.fileNames.splice(index, 1);
}
}

@ -1,231 +0,0 @@
<!-- Modal toggle -->
<button
class="p-2 text-blue-500 rounded-full hover:bg-blue-200 focus:outline-none flex items-center shadow-sm transition duration-200"
aria-label="Edit"
(click)="openPinModal()"
>
<svg
class="w-5 h-5 text-gray-800"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.779 17.779 4.36 19.918 6.5 13.5m4.279 4.279 8.364-8.643a3.027 3.027 0 0 0-2.14-5.165 3.03 3.03 0 0 0-2.14.886L6.5 13.5m4.279 4.279L6.499 13.5m2.14 2.14 6.213-6.504M12.75 7.04 17 11.28"
/>
</svg>
</button>
<div id="edit-pin-popup-{{ pinId }}">
<!-- Fond assombri -->
<div
class="fixed inset-0 bg-gray-900 bg-opacity-50 w-full h-full transition-opacity duration-300 ease-in-out z-40"
[ngClass]="{
'opacity-0 pointer-events-none': !isPinModalOpen,
'opacity-100': isPinModalOpen
}"
(click)="closePinModal()"
></div>
<!-- Main modal -->
<div
tabindex="-1"
aria-hidden="true"
[ngClass]="{
'opacity-0 scale-50 pointer-events-none': !isPinModalOpen,
'opacity-100 scale-100': isPinModalOpen
}"
class="fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full h-full transition-transform duration-300 ease-in-out overflow-y-auto"
>
<div class="relative p-4 w-full max-w-xl max-h-full">
<!-- Modal content -->
<div
class="relative bg-white rounded-lg shadow dark:bg-gray-700 transition-transform duration-300 ease-in-out my-8"
>
<!-- Modal header -->
<div
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600"
>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Modifie ton souvenir
</h3>
<button
type="button"
(click)="closePinModal()"
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg
class="w-3 h-3"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
<span class="sr-only">Fermer la modal</span>
</button>
</div>
<!-- Modal body -->
<div class="p-4 md:p-5">
<form class="grid gap-6 mb-1 md:grid-cols-2" [formGroup]="form">
<div>
<label
for="title"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Titre</label
>
<input
type="text"
id="title"
formControlName="title"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
placeholder="Mont Saint-Michel"
required
/>
<div
*ngIf="form.get('title')?.invalid && form.get('title')?.touched"
class="text-red-500 text-sm mt-1"
>
<span *ngIf="form.get('title')?.errors?.['required']"
>Le titre est requis</span
>
<span *ngIf="form.get('title')?.errors?.['minlength']"
>Le titre doit contenir au moins 3 caractères</span
>
</div>
</div>
<div>
<label
for="localisation"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Localisation</label
>
<input
type="text"
id="localisation"
formControlName="location"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
placeholder="Saisir la localisation"
(focus)="onFocus()"
(blur)="onBlur()"
/>
<div
*ngIf="
form.get('location')?.invalid && form.get('location')?.touched
"
class="text-red-500 text-sm mt-1"
>
<span *ngIf="form.get('location')?.errors?.['required']"
>La localisation est requise</span
>
</div>
<ul
*ngIf="suggestions.length > 0 && inputFocused"
class="bg-white dark:bg-gray-700 border border-gray-300 mt-2 rounded shadow absolute z-10 mr-5 max-h-60 overflow-auto"
>
<li
*ngFor="let suggestion of suggestions"
(click)="selectSuggestion(suggestion)"
class="p-2 block mb-2 text-sm font-medium text-gray-900 dark:text-white hover:bg-gray-500 cursor-pointer"
>
{{ suggestion.display_name }}
</li>
</ul>
</div>
<div>
<label
for="files"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Images</label
>
<app-drag-drop
*ngIf="isPinModalOpen"
[initialFiles]="getFileNames()"
(filesSelected)="onFilesReceived($event)"
(fileRemoved)="removeFile($event)"
[errorMessage]="uploadError"
></app-drag-drop>
</div>
<div>
<label
for="description"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Description</label
>
<textarea
id="description"
rows="4"
formControlName="description"
class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Décrit ton souvenir..."
></textarea>
<div
*ngIf="
form.get('description')?.invalid &&
form.get('description')?.touched
"
class="text-red-500 text-sm mt-1"
>
<span *ngIf="form.get('description')?.errors?.['required']"
>La description est requise</span
>
<span *ngIf="form.get('description')?.errors?.['minlength']"
>La description doit contenir au moins 3 caractères</span
>
</div>
</div>
<div>
<label
for="date"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Date (optionnel)</label
>
<input
type="date"
id="date"
formControlName="date"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
/>
</div>
</form>
<!-- Boutons alignés sous la grille -->
<div class="flex gap-4 mt-6">
<button
type="reset"
(click)="closePinModal()"
class="w-1/2 text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
>
Annuler
</button>
<button
type="submit"
(click)="submitForm()"
class="w-1/2 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
Valider
</button>
</div>
</div>
</div>
</div>
</div>
</div>

@ -1,22 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EditPinPopupComponent } from './edit-pin-popup.component';
describe('EditPinPopupComponent', () => {
let component: EditPinPopupComponent;
let fixture: ComponentFixture<EditPinPopupComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EditPinPopupComponent],
}).compileComponents();
fixture = TestBed.createComponent(EditPinPopupComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -1,300 +0,0 @@
import { CommonModule } from '@angular/common';
import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
ViewChild,
} from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { NavigationEnd, Router } from '@angular/router';
import { forkJoin, of, Subscription } from 'rxjs';
import {
catchError,
debounceTime,
distinctUntilChanged,
filter,
switchMap,
take,
} from 'rxjs/operators';
import { Pin } from '../../model/Pin';
import { AutocompleteService } from '../../services/auto-complete/auto-complete.service';
import { ExifService } from '../../services/exif/exif.service';
import { ImageService } from '../../services/image/image.service';
import { MapReloadService } from '../../services/map-reload/map-reload.service';
import { ModalService } from '../../services/modal/modal.service';
import { PinService } from '../../services/pin/pin.service';
import { DragDropComponent } from '../drag-drop/drag-drop.component';
@Component({
selector: 'app-edit-pin-popup',
standalone: true,
imports: [ReactiveFormsModule, CommonModule, DragDropComponent],
templateUrl: './edit-pin-popup.component.html',
})
export class EditPinPopupComponent implements OnInit, OnDestroy {
@Input() isHomePage: boolean = false;
@Input() pin!: Pin;
@Input() pinId!: string;
@Input() pinOpened!: EventEmitter<void>;
@ViewChild(DragDropComponent) dragDropComponent!: DragDropComponent;
private modalOpenSubscription!: Subscription;
form!: FormGroup;
suggestions: any[] = [];
inputFocused: boolean = false;
files: File[] = [];
isPinModalOpen: boolean = false;
uploadError: string = '';
modalId: string = '';
constructor(
private fb: FormBuilder,
private autocompleteService: AutocompleteService,
private pinService: PinService,
private exifService: ExifService,
private modalService: ModalService,
private mapReloadService: MapReloadService,
private imageService: ImageService
) {
// Initialiser le formulaire avec des valeurs par défaut
this.form = this.fb.group({
title: new FormControl('', [Validators.required, Validators.minLength(3)]),
description: new FormControl('', [Validators.required, Validators.minLength(3)]),
location: new FormControl('', [Validators.required]),
complete_address: new FormControl('', [Validators.required]),
coordinates: new FormControl<number[]>([]),
files: new FormControl(null),
date: new FormControl(''),
});
}
onFocus(): void {
this.inputFocused = true;
}
onBlur(): void {
setTimeout(() => {
this.inputFocused = false; // Désactiver le focus après un petit délai pour permettre un clic sur la liste
}, 200);
}
ngOnInit(): void {
this.modalId = 'edit-pin-popup-' + this.pinId;
// S'abonner aux changements d'état du modal
this.modalOpenSubscription = this.modalService
.getModalState(this.modalId)
.subscribe((state) => {
this.isPinModalOpen = state;
});
// S'abonner aux événements de navigation du router
this.pinOpened.subscribe(() => {
this.moveModalToBody();
});
// Configuration de l'autocomplétion pour le champ d'adresse
this.form
.get('location')
?.valueChanges.pipe(
debounceTime(300), // Attendre 300ms après la dernière frappe
distinctUntilChanged(), // Ignorer si la nouvelle valeur est la même que la précédente
switchMap((query) => {
// Vérifier que query est une chaîne de caractères
if (typeof query !== 'string') {
return of([]);
}
const trimmedQuery = query.trim();
if (trimmedQuery.length > 2) {
return this.autocompleteService.getAddressSuggestions(trimmedQuery);
}
return of([]);
}),
catchError((error) => {
console.error('Error fetching suggestions:', error);
return of([]);
})
)
.subscribe((data) => {
this.suggestions = data;
});
}
ngOnDestroy() {
// Nettoyage des abonnements pour éviter les fuites de mémoire
if (this.modalOpenSubscription) {
this.modalOpenSubscription.unsubscribe();
}
}
// Méthode dédiée pour déplacer le modal vers le body
private moveModalToBody(): void {
const modal = document.getElementById(this.modalId);
if (modal && modal.parentElement !== document.body) {
document.body.appendChild(modal);
}
}
selectSuggestion(suggestion: any): void {
const locationControl = this.form.get('location');
if (locationControl instanceof FormControl) {
locationControl.setValue(suggestion.display_name);
this.form.get('complete_address')?.setValue(suggestion.display_name);
this.form.get('coordinates')?.setValue([suggestion.lat, suggestion.lon]);
}
this.suggestions = [];
}
async onFilesReceived(files: FileList): Promise<void> {
// Ajouter les nouveaux fichiers à la liste existante
this.files = [...this.files, ...Array.from(files).map((file) => file)];
this.uploadError = ''; // Réinitialiser l'erreur
if (this.dragDropComponent) {
this.dragDropComponent.updateFileNamesFromFileList(files);
} else {
console.warn('EditPinPopupComponent - dragDropComponent not available');
}
// Ne traiter que la première photo pour les métadonnées EXIF
if (files.length > 0) {
try {
const data = await this.exifService.getLocation(files[0]);
if (data && data.latitude !== undefined && data.longitude !== undefined) {
this.autocompleteService.getAddressFromCoordinates(data.latitude, data.longitude).subscribe((address) => {
if (address) {
this.form.get('location')?.setValue(address.display_name);
this.form.get('complete_address')?.setValue(address.display_name);
this.form.get('coordinates')?.setValue([data.latitude, data.longitude]);
}
});
}
} catch (error) {
console.error(
'EditPinPopupComponent - Error processing EXIF data:',
error
);
}
}
}
async submitForm(): Promise<void> {
// Marquer tous les champs comme touched pour afficher les erreurs
Object.keys(this.form.controls).forEach(key => {
const control = this.form.get(key);
control?.markAsTouched();
});
if (this.form.valid) {
const uploadObservables = await Promise.all(this.files.map(async (file) => {
if(file.size === 0) {
if(file.name.includes("|")) {
return of({id: file.name.split("|")[1]});
} else {
this.uploadError = file.name + ' : ' + 'Image vide';
return of(null);
}
}
let fileDate = await this.exifService.getDateTime(file);
return this.imageService.postImage(file, fileDate).pipe(
catchError(async error => {
this.uploadError = file.name + ' : ' + error.error.detail || 'Erreur lors de l\'upload de l\'image';
if (this.dragDropComponent) {
this.dragDropComponent.errorMessage = this.uploadError;
}
return of(null);
})
)
}));
forkJoin(uploadObservables).subscribe(async (responses) => {
// Vérifier si toutes les réponses sont valides
if (responses.some(response => response === null)) {
return; // Ne pas continuer si une erreur s'est produite
}
this.files = responses.map((res: any) => res.id);
const coordinates = this.form.get('coordinates')?.value;
const pinData = {
...this.form.value,
files: this.files,
date: this.form.get('date')?.value || null,
location: coordinates || [0, 0],
complete_address: this.form.get('complete_address')?.value || this.form.get('location')?.value,
};
delete pinData.coordinates;
this.pinService.updatePin(this.pin.id, pinData)?.subscribe(() => {
this.mapReloadService.requestReload();
this.closePinModal();
});
});
} else {
console.error('Le formulaire est invalide');
}
}
openPinModal() {
// Initialiser le formulaire avec les valeurs de base
this.form.patchValue({
title: this.pin?.title || '',
description: this.pin?.description || '',
location: this.pin?.complete_address || '',
complete_address: this.pin?.complete_address || '',
coordinates: this.pin?.location || [],
files: this.pin?.files || [],
date: this.pin?.date
? new Date(this.pin.date).toISOString().split('T')[0]
: '',
});
this.pin.files.forEach((file) => {
this.imageService.getImageMetadata(file).subscribe((metadata) => {
this.files.push(new File([], metadata.metadata.original_filename + "|" + file.toString(), { type: metadata.metadata.content_type }));
});
});
this.modalService.openModal(this.modalId);
}
closePinModal() {
this.files = [];
this.modalService.closeModal(this.modalId);
}
removeFile(fileName: string): void {
const index = this.files.findIndex((file) => file.name === fileName || file.name.split("|")[0] === fileName);
if (index > -1) {
this.files.splice(index, 1);
this.uploadError = ''; // Réinitialiser l'erreur lors de la suppression d'un fichier
if (this.dragDropComponent) {
this.dragDropComponent.errorMessage = '';
}
// Mettre à jour le form control
const dataTransfer = new DataTransfer();
this.files.forEach((file) => dataTransfer.items.add(file as File));
this.form.patchValue({ files: dataTransfer.files });
}
}
getFileNames(): string[] {
return this.files.map((file) => file.name ? file.name.split("|")[0] : '');
}
}

@ -1,256 +0,0 @@
<!-- Bouton d'ouverture du modal -->
<button
(click)="openFriendModal()"
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
type="button"
>
Amis
</button>
<!-- Fond assombri -->
<div
class="fixed inset-0 bg-gray-900 bg-opacity-50 w-full h-full transition-opacity duration-300 ease-in-out z-40"
[ngClass]="{
'opacity-0 pointer-events-none': !isFriendModalOpen,
'opacity-100': isFriendModalOpen
}"
(click)="closeFriendModal()"
></div>
<!-- Modal principal -->
<div
id="friends-modal"
tabindex="-1"
aria-hidden="true"
[ngClass]="{
'opacity-0 scale-50 pointer-events-none': !isFriendModalOpen,
'opacity-100 scale-100': isFriendModalOpen
}"
class="fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full h-full transition-transform duration-300 ease-in-out overflow-y-auto"
>
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg w-96 max-w-full my-8"
>
<!-- En-tête du modal -->
<div
class="flex items-center justify-between p-4 border-b dark:border-gray-700"
>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Liste d'amis
</h3>
<button
(click)="closeFriendModal()"
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
</button>
</div>
<!-- Barre de recherche -->
<div class="p-4" id="friend-search-bar">
<input
type="text"
id="search-friends"
class="w-full p-2 border rounded-lg dark:bg-gray-700 dark:text-white"
placeholder="Rechercher un ami..."
[(ngModel)]="searchTerm"
(ngModelChange)="onSearchTermChange($event)"
/>
<div *ngIf="listUser" class="text-gray-500 text-sm">
<div
*ngFor="let user of listUser"
class="friend flex items-center justify-between space-x-3 pt-10"
>
<div class="friend flex items-center space-x-3">
<img
class="w-10 h-10 rounded-full"
src="/avatar.png"
alt="Friend 2"
/>
<span class="text-gray-900 dark:text-white">{{
user.username
}}</span>
</div>
<button
(click)="addUser(user.uid)"
class="p-2 bg-green-500 text-white rounded-full"
>
<svg
class="w-6 h-6 text-gray-800 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M9 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm-2 9a4 4 0 0 0-4 4v1a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-1a4 4 0 0 0-4-4H7Zm8-1a1 1 0 0 1 1-1h1v-1a1 1 0 1 1 2 0v1h1a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-1h-1a1 1 0 0 1-1-1Z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
</div>
<div class="p-4 space-y-3" id="friend-list">
<p class="dark:text-white">Amis</p>
<div
*ngIf="hasNoAcceptedFriends()"
class="text-gray-500 dark:text-gray-300"
>
<p>
<small><em>Aucun amis</em></small>
</p>
</div>
<div *ngFor="let friend of listFriend">
<div
*ngIf="friend.status == 'accepted'"
class="flex justify-between items-center w-full space-x-2"
>
<div class="friend flex items-center space-x-3">
<img
class="w-10 h-10 rounded-full"
src="/avatar.png"
alt="Friend 2"
/>
<span class="text-gray-900 dark:text-white">{{
friend.username
}}</span>
</div>
<button
(click)="deleteFriend(friend.id)"
class="p-2 bg-red-500 text-white rounded-full"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6h16zM10 11v6m4-6v6"
/>
</svg>
</button>
</div>
</div>
<div *ngIf="hasPendingApprovalFriend()">
<p class="dark:text-white">Demandes</p>
<hr class="border-gray-300 my-3" />
</div>
<div *ngFor="let friend of listFriend">
<div
*ngIf="friend.status == 'pending_approval'"
class="flex justify-between items-center w-full"
>
<div class="friend flex items-center space-x-3">
<img
class="w-10 h-10 rounded-full"
src="/avatar.png"
alt="Friend 2"
/>
<span class="text-gray-900 dark:text-white">{{
friend.username
}}</span>
</div>
<div class="flex space-x-2">
<button
(click)="onAcceptOrDeny(friend.id, 'accept')"
class="p-2 bg-green-500 text-white rounded-full"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</button>
<button
(click)="onAcceptOrDeny(friend.id, 'deny')"
class="p-2 bg-red-500 text-white rounded-full"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
</div>
</div>
<div *ngIf="hasPendingFriend()">
<p class="dark:text-white">En attente</p>
<hr class="border-gray-300 my-3" />
</div>
<div *ngFor="let friend of listFriend">
<div
*ngIf="friend.status == 'pending'"
class="flex justify-between items-center w-full"
>
<div class="friend flex items-center space-x-3">
<img
class="w-10 h-10 rounded-full"
src="/avatar.png"
alt="Friend 2"
/>
<span class="text-gray-900 dark:text-white">{{
friend.username
}}</span>
</div>
<svg
class="w-6 h-6 text-gray-800 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18.5 4h-13m13 16h-13M8 20v-3.333a2 2 0 0 1 .4-1.2L10 12.6a1 1 0 0 0 0-1.2L8.4 8.533a2 2 0 0 1-.4-1.2V4h8v3.333a2 2 0 0 1-.4 1.2L13.957 11.4a1 1 0 0 0 0 1.2l1.643 2.867a2 2 0 0 1 .4 1.2V20H8Z"
/>
</svg>
</div>
</div>
<!-- Pied du modal -->
<div class="flex justify-end p-4 border-t dark:border-gray-700">
<button
(click)="closeFriendModal()"
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Fermer
</button>
</div>
</div>
</div>
</div>

@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FriendPageComponent } from './friend-page.component';
describe('FriendPageComponent', () => {
let component: FriendPageComponent;
let fixture: ComponentFixture<FriendPageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FriendPageComponent]
})
.compileComponents();
fixture = TestBed.createComponent(FriendPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -1,194 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, Subject, Subscription } from 'rxjs';
import { FriendsService } from '../../services/friends/friends.service';
import { UserService } from '../../services/user/user.service';
import { ModalService } from '../../services/modal/modal.service';
import { AuthService } from '../../services/auth/auth.service';
@Component({
selector: 'app-friend-page',
imports: [CommonModule, FormsModule],
templateUrl: './friend-page.component.html',
})
export class FriendPageComponent implements OnInit, OnDestroy {
protected listFriend: {
username: string;
status: string;
friend_user_id: string;
id: string;
}[] = [];
protected listUser: {
uid: string;
username: string;
}[] = [];
userId: string = '';
status: string = '';
isFriendModalOpen: boolean = false;
searchTerm: string = '';
searchTermChanged = new Subject<string>();
modalId: string = 'friend-modal';
private modalSub!: Subscription;
constructor(
private friendService: FriendsService,
private userService: UserService,
private authService: AuthService,
private modalService: ModalService
) {}
ngOnInit(): void {
this.getFriendData();
this.modalSub = this.modalService.getModalState(this.modalId).subscribe(open => {
this.isFriendModalOpen = open;
});
this.searchTermChanged
.pipe(debounceTime(200), distinctUntilChanged())
.subscribe((username: string) => {
this.searchUser(username);
});
}
ngOnDestroy(): void {
this.modalSub.unsubscribe();
}
protected searchUser(username: string) {
this.searchTerm = username;
if (this.searchTerm) {
this.getUserData(this.searchTerm.trim());
} else {
this.listUser = [];
}
}
onSearchTermChange(username: string) {
this.searchTermChanged.next(username);
}
private getUserData(search: string): void {
const username = this.authService.getUsername();
this.userService
.getUser('^(?!' + username + ')' + search)
.subscribe((data: any[]) => {
if (data.length > 0) {
const existingFriendIds = this.listFriend.map(
(friend) => friend.friend_user_id
);
this.listUser = data.filter(
(user) => !existingFriendIds.includes(user.uid)
);
}
});
}
protected addUser(user_id: string): void {
this.friendService.addFriend(user_id).subscribe((data: any) => {
if (data.id) {
const add_user = this.listUser.find((x) => x.uid == user_id);
if (add_user) {
this.listFriend.push({
username: add_user.username,
status: 'pending',
friend_user_id: add_user.uid,
id: data.id,
});
this.searchTerm = '';
this.listUser = [];
}
}
});
}
private getFriendData(): void {
this.friendService.getFriend().subscribe((data: any[]) => {
if (data.length > 0) {
data.forEach((friend) => {
let status = friend['status'];
let userId = friend['friend_user_id'];
let id = friend['id'];
this.friendService
.getFriendById(userId)
.subscribe((friendData: any) => {
this.listFriend.push({
username: friendData.username,
status: status,
friend_user_id: userId,
id: id,
});
});
});
}
});
}
onAcceptOrDeny(id: string, choice: string) {
if (choice == 'accept') {
this.friendService.acceptFriendById(id).subscribe((data: any) => {
if (data.message == 'Friend request accepted') {
this.listFriend.forEach((friend) => {
if (friend.id == id) {
friend.status = 'accepted';
}
});
}
});
} else {
this.friendService.denyFriendById(id).subscribe((data: any) => {
if (data.message == 'Friend request denied') {
this.listFriend.forEach((friend, index) => {
if (friend.id == id) {
this.listFriend.splice(index, 1);
}
});
}
});
}
}
openFriendModal() {
this.modalService.openModal(this.modalId);
}
closeFriendModal() {
this.modalService.closeModal(this.modalId);
}
deleteFriend(id: string) {
this.friendService.deleteFriend(id).subscribe((data: any) => {
if (data.message == 'Friend deleted') {
this.listFriend.forEach((friend, index) => {
if (friend.id == id) {
this.listFriend.splice(index, 1);
}
});
}
});
}
hasNoAcceptedFriends(): boolean {
return (
this.listFriend.filter((friend) => friend.status === 'accepted')
.length === 0
);
}
hasPendingApprovalFriend(): boolean {
return (
this.listFriend.filter((friend) => friend.status === 'pending_approval')
.length !== 0
);
}
hasPendingFriend(): boolean {
return (
this.listFriend.filter((friend) => friend.status === 'pending').length !==
0
);
}
}

@ -1,92 +1,35 @@
<nav class="bg-white border-gray-200 dark:bg-gray-900">
<app-login-page></app-login-page>
<app-register-page></app-register-page>
<div
class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4"
>
<a routerLink="/" class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="./logo.png" class="h-12" alt="Memory Map Logo" />
<a class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="./logo.png" class="h-10" alt="Memory Map Logo" />
<span
class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white hidden lg:inline"
class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white"
>Memory Map</span
>
</a>
<button
type="button"
(click)="isMenuOpen = !isMenuOpen"
[attr.aria-expanded]="isMenuOpen"
class="lg:hidden text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5 me-1"
>
<svg
*ngIf="!isMenuOpen"
class="w-5 h-5"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 17 14"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M1 1h15M1 7h15M1 13h15"
/>
</svg>
<svg
*ngIf="isMenuOpen"
class="w-5 h-5"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 17 17"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2 2l13 13M2 15L15 2"
/>
</svg>
<span class="sr-only">Open main menu</span>
</button>
<div
[ngClass]="{ hidden: !isMenuOpen, flex: isMenuOpen }"
class="items-center justify-between w-full lg:flex lg:w-auto lg:order-1"
class="items-center justify-between hidden w-full lg:flex lg:w-auto lg:order-1"
>
<ul
class="w-full flex flex-col p-4 lg:p-0 mt-4 font-medium border border-gray-100 rounded-lg bg-gray-50 lg:space-x-8 rtl:space-x-reverse lg:flex-row lg:mt-0 lg:border-0 lg:bg-white dark:bg-gray-800 lg:dark:bg-gray-900 dark:border-gray-700"
class="flex flex-col p-4 lg:p-0 mt-4 font-medium border border-gray-100 rounded-lg bg-gray-50 lg:space-x-8 rtl:space-x-reverse lg:flex-row lg:mt-0 lg:border-0 lg:bg-white dark:bg-gray-800 lg:dark:bg-gray-900 dark:border-gray-700"
>
<li class="flex items-center">
<a
class="block text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
aria-current="page"
>
<span class="space-x-2">
<button
(click)="openLoginModal()"
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
type="button"
>
Connexion
</button>
<span class="space-x-2 py-2">
<app-login-page></app-login-page>
</span>
</a>
</li>
<li class="flex items-center space-x-2">
<a
class="block text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
>
<span>
<button
(click)="openRegisterModal()"
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
type="button"
>
Inscription
</button>
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
><span>
<app-register-page></app-register-page>
</span>
</a>
</li>

@ -1,8 +1,7 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { ModalService } from '../../services/modal/modal.service';
import { LoginPageComponent } from '../login-page/login-page.component';
import { RegisterPageComponent } from '../register-page/register-page.component';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-home-navbar',
@ -10,14 +9,5 @@ import { RegisterPageComponent } from '../register-page/register-page.component'
templateUrl: './home-navbar.component.html',
})
export class HomeNavbarComponent {
isMenuOpen = false;
constructor(private modalService: ModalService) {}
openLoginModal() {
this.modalService.openModal('login-modal');
}
openRegisterModal() {
this.modalService.openModal('register-modal');
}
constructor() {}
}

@ -46,7 +46,7 @@
important.
</p>
<button
(click)="openLogin()"
routerLink="/map"
class="inline-flex items-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:focus:ring-blue-800"
>
Commencez votre aventure
@ -301,7 +301,7 @@
</li>
</ul>
<a
(click)="openLogin()"
href="/map"
class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-200 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-800 dark:hover:bg-blue-900 dark:focus:ring-blue-900"
>Commencez</a
>

@ -1,26 +1,11 @@
import { Component } from '@angular/core';
import { ModalService } from '../../services/modal/modal.service';
import { Router } from '@angular/router';
import { AuthService } from '../../services/auth/auth.service';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-home-page',
imports: [RouterLink],
templateUrl: './home-page.component.html',
})
export class HomePageComponent {
currentYear = new Date().getFullYear();
constructor(
private loginModalService: ModalService,
private router: Router,
private authService: AuthService
) {}
openLogin() {
if (!this.authService.isLoggedIn()) {
this.loginModalService.openModal('login-modal');
} else {
this.router.navigate(['/map']);
}
}
}

@ -1,95 +1,3 @@
<div class="map-container h-[calc(100vh_-_72px)] relative">
<div
id="map"
class="h-full w-full z-0"
(drop)="onDrop($event)"
(dragover)="onDragOver($event)"
></div>
<!-- Bouton vertical d'ouverture des filtres (mobile uniquement, caché si ouvert) -->
<button
*ngIf="!isFiltersVisible && !isDesktop()"
(click)="toggleFilters()"
class="md:hidden absolute top-4 right-0 z-40 h-12 w-8 flex items-center justify-center bg-white dark:bg-gray-900 dark:text-white rounded-l-xl shadow-lg border border-gray-200 dark:border-gray-700 border-r-0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-black dark:text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m15 19-7-7 7-7"
/>
</svg>
</button>
<!-- Filtres : même div pour mobile et desktop -->
<div
*ngIf="isFiltersVisible || isDesktop()"
[ngClass]="{
'absolute top-4 right-2 z-30 p-4': !isDesktop(),
'absolute top-4 right-4 p-3': isDesktop(),
'bg-white absolute top-4 right-2 dark:bg-gray-900 dark:text-white rounded-xl shadow-lg border border-gray-200 dark:border-gray-700': true
}"
class="box-border"
>
<!-- Ligne du haut : Pays + croix à droite (mobile uniquement) -->
<div class="flex flex-row items-center justify-between mb-2">
<label class="flex items-center space-x-2 m-0">
Pays :
<select
[(ngModel)]="selectedCountry"
(change)="onCountryChange(selectedCountry)"
class="bg-white dark:bg-gray-900 dark:text-white ml-2"
>
<option value="__all__">Tous</option>
<option *ngFor="let country of availableCountries" [value]="country">
{{ country }}
</option>
</select>
</label>
<button
*ngIf="!isDesktop()"
(click)="toggleFilters()"
class="text-xl text-gray-700 dark:text-white focus:outline-none p-0 ml-4"
aria-label="Fermer les filtres"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="flex flex-col md:flex-row md:items-center gap-4">
<label class="flex items-center space-x-2">
Amis :
<select
[(ngModel)]="selectedPerson"
(change)="onPersonChange(selectedPerson)"
class="bg-white dark:bg-gray-900 dark:text-white ml-2"
>
<option value="__all__">Tous</option>
<option value="__none__">Aucun</option>
<option *ngFor="let person of availablePersons" [value]="person">
{{ person }}
</option>
</select>
</label>
</div>
</div>
<div class="map-container h-[calc(100vh_-_72px)]">
<div id="map" class="h-full w-full z-0"></div>
</div>

@ -1,395 +1,79 @@
import { NgClass, NgFor, NgIf } from '@angular/common';
import {
Component,
EventEmitter,
OnInit,
Output,
ViewContainerRef,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Component, OnInit, ViewContainerRef } from '@angular/core';
import * as L from 'leaflet';
import 'leaflet.markercluster';
import { Pin } from '../../model/Pin';
import { AuthService } from '../../services/auth/auth.service';
import { AutocompleteService } from '../../services/auto-complete/auto-complete.service';
import { CookiesService } from '../../services/cookies/cookies.service';
import { IntroService } from '../../services/intro/intro.service';
import { MapReloadService } from '../../services/map-reload/map-reload.service';
import { ModalService } from '../../services/modal/modal.service';
import { PinService } from '../../services/pin/pin.service';
import { PinMarkerComponent } from '../pin-marker/pin-marker.component';
import 'leaflet/dist/leaflet.css';
import { Monument } from '../../model/Monument';
import { GetPinService } from '../../services/get-pin.service';
import { MonumentMarkerComponent } from '../monument-marker/monument-marker.component';
@Component({
selector: 'app-leaflet-map',
templateUrl: './leaflet-map.component.html',
standalone: true,
imports: [NgFor, FormsModule, NgIf, NgClass],
})
export class LeafletMapComponent implements OnInit {
private map!: L.Map;
private markersMap: { [key: string]: L.Marker } = {};
private markerClusterGroup!: L.MarkerClusterGroup;
private allPins: Pin[] = [];
private pinCountries: { [pinId: string]: string } = {};
private contextMenu: L.Popup | null = null;
private user_id: string = '';
availableCountries: string[] = [];
availablePersons: string[] = [];
selectedCountry: string = '__all__';
selectedPerson: string = '__all__';
isFiltersVisible: boolean = false;
fileNames: string[] = [];
@Output() filesSelected = new EventEmitter<FileList>();
constructor(
private viewContainerRef: ViewContainerRef,
private pinsService: PinService,
private route: ActivatedRoute,
private router: Router,
private modalService: ModalService,
private mapReloadService: MapReloadService,
private introService: IntroService,
private autocompleteService: AutocompleteService,
private authService: AuthService
) {
this.user_id = this.authService.getUserId();
}
private getPinsService: GetPinService
) {}
ngOnInit(): void {
this.initializeMap();
this.mapReloadService.reload$.subscribe(() => {
this.loadPins(); // recharge les pins quand demandé
});
this.route.params.subscribe((params) => {
if (params['tutorial'] === 'true') {
this.introService.startIntro();
}
});
this.route.queryParams.subscribe((params) => {
const pinId = params['pin'];
if (pinId) {
const marker = this.markersMap[pinId];
if (marker) {
marker.openPopup();
const latlng = marker.getLatLng();
const zoom = this.map.getZoom();
const offsetLat = 0.05 / Math.pow(2, zoom - 10);
this.map.setView(L.latLng(latlng.lat + offsetLat, latlng.lng), zoom);
}
}
});
}
private initializeMap(): void {
// Initialize the map
this.map = L.map('map', {
maxBounds: L.latLngBounds(L.latLng(-90, -180), L.latLng(90, 180)),
maxBoundsViscosity: 1.0,
minZoom: 2,
maxBounds: L.latLngBounds(
L.latLng(-90, -180), // South-West
L.latLng(90, 180) // North-East
),
maxBoundsViscosity: 1.0, // Prevent dragging the map out of bounds
minZoom: 2, // Prevent zooming out too much
}).setView([46.603354, 1.888334], 6);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '',
}).addTo(this.map);
this.map.attributionControl.setPrefix('');
// Initialiser le groupe de clusters
this.markerClusterGroup = window.L.markerClusterGroup();
this.map.addLayer(this.markerClusterGroup);
// Ajouter l'événement de clic droit sur la carte
this.map.on('contextmenu', (e: L.LeafletMouseEvent) => {
e.originalEvent.preventDefault();
this.showContextMenu(e.latlng);
});
// Fermer le menu contextuel lors d'un clic gauche sur la carte
this.map.on('click', () => {
if (this.contextMenu) {
this.map.closePopup(this.contextMenu);
this.contextMenu = null;
}
});
this.pinsService.getPins().subscribe((pins: Pin[]) => {
this.allPins = pins;
this.extractPersons(pins);
this.renderPins();
this.loadCountriesForFiltrers(pins);
});
}
private showContextMenu(latlng: L.LatLng): void {
// Fermer le menu contextuel existant s'il y en a un
if (this.contextMenu) {
this.map.closePopup(this.contextMenu);
}
// Créer le contenu du menu contextuel
const menuContent = document.createElement('div');
menuContent.className =
'bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden';
const addPinButton = document.createElement('button');
addPinButton.className =
'w-full px-4 py-2.5 text-sm font-medium text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-600 flex items-center gap-2';
// Ajouter l'icône de pin
const pinIcon = document.createElement('span');
pinIcon.innerHTML = `
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 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 1 1 15 0Z" />
</svg>
`;
const buttonText = document.createElement('span');
buttonText.textContent = 'Ajouter un pin ici';
addPinButton.appendChild(pinIcon);
addPinButton.appendChild(buttonText);
addPinButton.onclick = () => {
this.addPinAtLocation(latlng);
if (this.contextMenu) {
this.map.closePopup(this.contextMenu);
this.contextMenu = null;
}
};
menuContent.appendChild(addPinButton);
// Créer et afficher le popup
this.contextMenu = L.popup({
closeButton: false,
className: 'context-menu-popup',
maxWidth: 200,
offset: [0, -10],
})
.setLatLng(latlng)
.setContent(menuContent)
.addTo(this.map);
}
private async addPinAtLocation(latlng: L.LatLng): Promise<void> {
try {
// Obtenir l'adresse à partir des coordonnées
const address = await this.autocompleteService
.getAddressFromCoordinates(latlng.lat, latlng.lng)
.toPromise();
// Ouvrir la modal avec les données pré-remplies
this.modalService.openModal('add-pin-modal', [], {
location: address?.display_name || '',
complete_address: address?.display_name || '',
coordinates: [latlng.lat, latlng.lng],
});
} catch (error) {
console.error("Erreur lors de la récupération de l'adresse:", error);
// En cas d'erreur, ouvrir la modal avec juste les coordonnées
this.modalService.openModal('add-pin-modal', [], {
location: `${latlng.lat}, ${latlng.lng}`,
complete_address: `${latlng.lat}, ${latlng.lng}`,
coordinates: [latlng.lat, latlng.lng],
});
}
}
private loadCountriesForFiltrers(pins: Pin[]): void {
const countrySet = new Set<string>();
const requests = pins.map((pin: Pin) => {
const country = this.extractLastFromDisplayName(pin.complete_address);
if (country) {
this.pinCountries[pin.id] = country;
countrySet.add(country);
}
});
Promise.all(requests).then(() => {
this.availableCountries = Array.from(countrySet).sort();
});
}
private extractLastFromDisplayName(displayName: string): string {
if (!displayName) return '';
const parts = displayName.split(',');
return parts[parts.length - 1].trim();
}
private extractPersons(pins: Pin[]): void {
const personsSet = new Set<string>();
// Pour chaque pin, récupérer ses partages
pins.forEach((pin) => {
if (!pin.is_poi) {
this.pinsService.getPinShares(pin.id).subscribe((response: any) => {
if (response && response.shares) {
response.shares.forEach((share: any) => {
personsSet.add(share.username);
});
this.availablePersons = Array.from(personsSet).sort();
}
});
}
});
}
onCountryChange(country: string) {
this.selectedCountry = country;
this.renderPins();
}
onPersonChange(person: string) {
this.selectedPerson = person;
this.renderPins();
}
private renderPins(): void {
// Remove existing markers
Object.values(this.markersMap).forEach((marker) =>
this.map.removeLayer(marker)
);
this.markerClusterGroup.clearLayers();
this.markersMap = {};
// Define custom icons
const visitedIcon = this.createDivIcon(`
<svg class="w-6 h-6 text-gray-800" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M11.906 1.994a8.002 8.002 0 0 1 8.09 8.421 7.996 7.996 0 0 1-1.297 3.957.996.996 0 0 1-.133.204l-.108.129c-.178.243-.37.477-.573.699l-5.112 6.224a1 1 0 0 1-1.545 0L5.982 15.26l-.002-.002a18.146 18.146 0 0 1-.309-.38l-.133-.163a.999.999 0 0 1-.13-.202 7.995 7.995 0 0 1 6.498-12.518ZM15 9.997a3 3 0 1 1-5.999 0 3 3 0 0 1 5.999 0Z" clip-rule="evenodd"/>
</svg>
`);
const shareIcon = this.createDivIcon(`
<svg class="w-6 h-6 text-pink-600" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M11.906 1.994a8.002 8.002 0 0 1 8.09 8.421 7.996 7.996 0 0 1-1.297 3.957.996.996 0 0 1-.133.204l-.108.129c-.178.243-.37.477-.573.699l-5.112 6.224a1 1 0 0 1-1.545 0L5.982 15.26l-.002-.002a18.146 18.146 0 0 1-.309-.38l-.133-.163a.999.999 0 0 1-.13-.202 7.995 7.995 0 0 1 6.498-12.518ZM15 9.997a3 3 0 1 1-5.999 0 3 3 0 0 1 5.999 0Z" clip-rule="evenodd"/>
const notVisitedIcon = this.createDivIcon(`
<svg class="w-6 h-6 text-gray-800" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="3" d="M11.083 5.104c.35-.8 1.485-.8 1.834 0l1.752 4.022a1 1 0 0 0 .84.597l4.463.342c.9.069 1.255 1.2.556 1.771l-3.33 2.723a1 1 0 0 0-.337 1.016l1.03 4.119c.214.858-.71 1.552-1.474 1.106l-3.913-2.281a1 1 0 0 0-1.008 0L7.583 20.8c-.764.446-1.688-.248-1.474-1.106l1.03-4.119A1 1 0 0 0 6.8 14.56l-3.33-2.723c-.698-.571-.342-1.702.557-1.771l4.462-.342a1 1 0 0 0 .84-.597l1.753-4.022Z"/>
</svg>
`);
const poiPin = this.createDivIcon(`
<svg class="w-6 h-6 text-yellow-500" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 .587l3.668 7.431 8.2 1.191-5.934 5.782 1.401 8.169L12 18.897l-7.335 3.863 1.401-8.169L.132 9.209l8.2-1.191z"
stroke="black"
stroke-width="1"
/>
</svg>
`);
const filteredPins = this.allPins.filter((pin) => {
const pinCountry = this.pinCountries[pin.id];
const matchesCountry =
this.selectedCountry === '__all__'
? true
: pinCountry === this.selectedCountry;
const matchesPerson =
this.selectedPerson === '__all__'
? true
: this.selectedPerson === '__none__'
? !pin.description?.match(/@\w+/)
: pin.description?.includes(`@${this.selectedPerson}`);
return matchesCountry && matchesPerson;
});
filteredPins.forEach((pin) => {
let markerIcon;
if (pin.is_poi) {
markerIcon = poiPin;
} else if (pin.user_id !== this.user_id) {
markerIcon = shareIcon;
} else {
markerIcon = visitedIcon;
}
const marker = L.marker(pin.location as [number, number], {
icon: markerIcon,
});
marker.on('popupclose', () => {
this.router.navigate(['/map']);
});
marker.on('popupopen', () => {
this.router.navigate(['/map'], { queryParams: { pin: pin.id } });
});
const popupDiv = document.createElement('div');
const componentRef =
this.viewContainerRef.createComponent(PinMarkerComponent);
componentRef.instance.pin = pin;
componentRef.instance.marker = marker;
popupDiv.appendChild(componentRef.location.nativeElement);
marker.bindPopup(popupDiv, {
closeButton: false,
minWidth: 300,
maxWidth: 400,
maxHeight: 400,
className: 'custom-popup-fixed-size',
});
this.markersMap[pin.id] = marker;
this.route.queryParams
.subscribe((params) => {
const pinId = params['pin'];
if (pinId) {
const marker = this.markersMap[pinId];
if (marker) {
setTimeout(() => {
marker.openPopup();
}, 1); // 1ms pour que l'événement popupopen se produise correctement
const latlng = marker.getLatLng();
const zoom = this.map.getZoom();
const offsetLat = 0.05 / Math.pow(2, zoom - 10);
this.map.setView(
L.latLng(latlng.lat + offsetLat, latlng.lng),
zoom
);
}
}
})
.unsubscribe();
this.markerClusterGroup.addLayer(marker);
if (
this.selectedCountry === '__all__' &&
this.selectedPerson === '__all__'
) {
this.map.fitBounds(
L.latLngBounds(
filteredPins.map((pin) => pin.location as [number, number])
),
{
padding: [50, 50],
maxZoom: 10,
animate: true,
duration: 1.5,
}
this.getPinsService.getPins().subscribe((monuments: Monument[]) => {
console.log(monuments);
// Add markers
monuments.forEach((monument: Monument) => {
//const icon = monument.visited ? visitedIcon : notVisitedIcon;
const icon = visitedIcon;
const marker = L.marker(monument.location as [number, number], {
icon,
}).addTo(this.map);
// Dynamically create Angular component and attach it to popup
const popupDiv = document.createElement('div');
const componentRef = this.viewContainerRef.createComponent(
MonumentMarkerComponent
);
}
});
this.markerClusterGroup.refreshClusters();
componentRef.instance.monument = monument;
popupDiv.appendChild(componentRef.location.nativeElement);
// Ajuster la vue si un pays est sélectionné
if (this.selectedCountry !== '__all__' && filteredPins.length > 0) {
const bounds = L.latLngBounds(
filteredPins.map((pin) => pin.location as [number, number])
);
this.map.fitBounds(bounds, {
padding: [50, 50], // Ajoute un peu d'espace autour des marqueurs
maxZoom: 10, // Limite le zoom maximum pour garder une vue d'ensemble
animate: true, // Active l'animation
duration: 1.5, // Durée de l'animation en secondes
marker.bindPopup(popupDiv);
});
}
});
}
private createDivIcon(htmlContent: string): L.DivIcon {
@ -401,52 +85,4 @@ export class LeafletMapComponent implements OnInit {
popupAnchor: [0, -24],
});
}
public loadPins(): void {
this.pinsService.getPins().subscribe((pins: Pin[]) => {
// Supprimer du body toutes les divs confirm-modal-* / share-modal-* / edit-pin-popup-*
const modals = document.querySelectorAll(
'div[id^="confirm-modal-"], div[id^="share-modal-"], div[id^="edit-pin-popup-"]'
);
modals.forEach((modal) => {
modal.remove();
});
this.allPins = pins;
this.extractPersons(pins);
this.renderPins(); // Afficher d'abord les pins sans les filtres
this.loadCountriesForFiltrers(pins); // Ensuite, charger les pays en arrière-plan
});
}
async onFilesSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.fileNames = Array.from(input.files).map((f) => f.name);
this.filesSelected.emit(input.files);
this.modalService.openModal('add-pin-modal');
}
}
onDragOver(event: DragEvent) {
event.preventDefault();
}
onDrop(event: DragEvent) {
event.preventDefault();
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
const files = event.dataTransfer.files;
this.filesSelected.emit(files);
this.modalService.openModal('add-pin-modal', Array.from(files));
}
}
toggleFilters(): void {
this.isFiltersVisible = !this.isFiltersVisible;
}
isDesktop(): boolean {
return window.innerWidth >= 768;
}
}

@ -0,0 +1,9 @@
#authentication-modal.show {
opacity: 1;
transition: opacity 0.3s ease-in-out;
}
#authentication-modal.hidden {
opacity: 0;
transition: opacity 0.3s ease-in-out;
}

@ -1,40 +1,35 @@
<!-- Fond assombri -->
<div
class="fixed inset-0 bg-gray-900 bg-opacity-50 w-full h-full transition-opacity duration-300 ease-in-out z-40"
[ngClass]="{
'opacity-0 pointer-events-none': !isLoginModalOpen,
'opacity-100': isLoginModalOpen
}"
(click)="closeLoginModal()"
></div>
<!-- Modal toggle -->
<button
data-modal-target="authentication-modal"
data-modal-toggle="authentication-modal"
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
type="button"
>
Connexion
</button>
<!-- Main modal -->
<div
id="authentication-modal"
tabindex="-1"
aria-hidden="true"
[ngClass]="{
'opacity-0 scale-50 pointer-events-none': !isLoginModalOpen,
'opacity-100 scale-100': isLoginModalOpen
}"
class="fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full h-full transition-transform duration-300 ease-in-out overflow-y-auto"
class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full transition-opacity duration-300 ease-in-out"
>
<div class="relative p-4 w-full max-w-md max-h-full">
<!-- Modal content -->
<div
class="relative bg-white rounded-lg shadow dark:bg-gray-700 transition-transform duration-300 ease-in-out my-8"
>
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<!-- Modal header -->
<div
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600"
>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Se connecter à Memory Map
Sign in to our platform
</h3>
<button
type="button"
(click)="closeLoginModal()"
id="close-login-modal"
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
data-modal-hide="authentication-modal"
>
<svg
class="w-3 h-3"
@ -51,25 +46,25 @@
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
<span class="sr-only">Fermer la fenêtre</span>
<span class="sr-only">Close modal</span>
</button>
</div>
<!-- Modal body -->
<div class="p-4 md:p-5" *ngIf="isLoginModalOpen">
<div class="p-4 md:p-5">
<form [formGroup]="userForm" class="space-y-4">
<div>
<label
for="login"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Identifiant</label
>Your login</label
>
<input
formControlName="login"
type="text"
type="login"
name="login"
id="login"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
placeholder="ex: captain24"
placeholder="user"
required
/>
</div>
@ -77,7 +72,7 @@
<label
for="password"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Mot de passe</label
>Your password</label
>
<input
formControlName="password"
@ -102,13 +97,13 @@
<label
for="remember"
class="ms-2 text-sm font-medium text-gray-900 dark:text-gray-300"
>Se souvenir de moi
</label>
>Remember me</label
>
</div>
<a
href="#"
class="text-sm text-blue-700 hover:underline dark:text-blue-500"
>Mot de passe oublié ?</a
>Lost Password?</a
>
</div>
<div *ngIf="errorMessage" class="text-red-500 text-sm">
@ -118,14 +113,12 @@
(click)="login()"
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
Se connecter
Login to your account
</button>
<div class="text-sm font-medium text-gray-500 dark:text-gray-300">
Vous n'êtes pas encore inscrit ?
<a
(click)="openRegisterModal()"
class="text-blue-700 hover:cursor-pointer hover:underline dark:text-blue-500"
>Créer un compte</a
Not registered?
<a href="#" class="text-blue-700 hover:underline dark:text-blue-500"
>Create account</a
>
</div>
</form>

@ -1,5 +1,5 @@
import { CommonModule, NgIf } from '@angular/common';
import { Component } from '@angular/core';
import { NgIf } from '@angular/common';
import { Component, Renderer2 } from '@angular/core';
import {
FormBuilder,
FormGroup,
@ -8,87 +8,67 @@ import {
Validators,
} from '@angular/forms';
import { Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { User } from '../../model/User';
import { ModalService } from '../../services/modal/modal.service';
import { AuthService } from '../../services/auth/auth.service';
import { LocalStorageService } from '../../services/localstorage.service';
import { LoginService } from '../../services/login.service';
@Component({
selector: 'app-login-page',
imports: [FormsModule, ReactiveFormsModule, NgIf, CommonModule],
imports: [FormsModule, ReactiveFormsModule, NgIf],
templateUrl: './login-page.component.html',
styleUrl: './login-page.component.css',
})
export class LoginPageComponent {
modalId: string = 'login-modal';
userForm: FormGroup;
user: User = { login: '', password: '' };
errorMessage: string = '';
isLoginModalOpen: boolean = false;
private modalSub!: Subscription;
constructor(
private authService: AuthService,
private loginService: LoginService,
private fb: FormBuilder,
private router: Router,
private modalService: ModalService
private localStorageService: LocalStorageService,
private renderer: Renderer2
) {
this.userForm = this.fb.group({
login: [this.user.login, [Validators.required, Validators.minLength(3)]],
password: [
this.user.password,
[Validators.required, Validators.minLength(6)],
[Validators.required, Validators.minLength(3)],
],
});
}
ngOnInit() {
this.modalSub = this.modalService
.getModalState(this.modalId)
.subscribe((open) => {
this.isLoginModalOpen = open;
});
}
ngOnDestroy() {
this.modalSub.unsubscribe();
}
public login() {
if (this.userForm.invalid) {
this.errorMessage =
'Veuillez remplir tous les champs (identifiant de 3 caractères et mot de passe de 6 caractères minimum)';
this.errorMessage = 'Veuillez remplir tous les champs';
return;
}
this.user.login = this.userForm.value.login;
this.user.password = this.userForm.value.password;
this.authService.login(this.user.login, this.user.password).subscribe({
next: () => {
this.closeLoginModal();
this.loginService.login(this.user.login, this.user.password).subscribe({
next: (response) => {
console.log('Connexion OK: ', response);
this.localStorageService.setToken(response.access_token);
this.closeModal();
setTimeout(() => {
this.router.navigate(['/map']);
this.modalService.closeModal(this.modalId);
}, 1);
},
error: (response) => {
console.error('Connexion KO: ', response.error.detail);
console.log('Connexion KO: ', response.error.detail);
this.errorMessage = response.error.detail;
},
});
}
openLoginModal() {
this.modalService.openModal(this.modalId);
}
closeLoginModal() {
this.modalService.closeModal(this.modalId);
}
openRegisterModal() {
this.modalService.closeModal(this.modalId);
this.modalService.openModal('register-modal');
private closeModal() {
const modal = document.getElementById('close-login-modal');
if (modal) {
modal.click();
}
}
}

@ -0,0 +1,81 @@
<div class="text-center">
<strong>{{ monument.title }}</strong>
<div
*ngIf="monument.files.length > 0"
class="relative carousel overflow-hidden"
>
<!-- Carousel wrapper -->
<div
class="relative h-40 mt-2 overflow-hidden rounded-lg flex items-center justify-center"
>
<div
*ngFor="let image of monument.files; let index = index"
[class]="
'absolute inset-0 transition-opacity duration-700 ease-in-out' +
(index === currentIndex ? ' opacity-100' : ' opacity-0')
"
>
<img
[src]="image"
[alt]="monument.title"
class="object-contain max-h-full max-w-full h-full w-auto mx-auto"
/>
</div>
</div>
<!-- Slider controls -->
<div *ngIf="monument.files.length > 1">
<button
type="button"
class="absolute top-0 left-0 z-30 flex items-center justify-center h-full cursor-pointer group focus:outline-none"
(click)="prevSlide()"
>
<span
class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-black/30 dark:bg-black-800/30 group-hover:bg-black/50 dark:group-hover:bg-black-800/60"
>
<svg
class="w-4 h-4 text-white rtl:rotate-180"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 6 10"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 1 1 5l4 4"
/>
</svg>
<span class="sr-only">Previous</span>
</span>
</button>
<button
type="button"
class="absolute top-0 right-0 z-30 flex items-center justify-center h-full cursor-pointer group focus:outline-none"
(click)="nextSlide()"
>
<span
class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-black/30 dark:bg-black-800/30 group-hover:bg-black/50 dark:group-hover:bg-black-800/60"
>
<svg
class="w-4 h-4 text-white rtl:rotate-180"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 6 10"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M1 9l4-4-4-4"
/>
</svg>
<span class="sr-only">Next</span>
</span>
</button>
</div>
</div>
<p [innerHTML]="formattedDescription"></p>
</div>

@ -1,17 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PinMarkerComponent } from './pin-marker.component';
import { MonumentMarkerComponent } from './monument-marker.component';
describe('PinmarkerComponent', () => {
let component: PinMarkerComponent;
let fixture: ComponentFixture<PinMarkerComponent>;
describe('MonumentmarkerComponent', () => {
let component: MonumentMarkerComponent;
let fixture: ComponentFixture<MonumentMarkerComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PinMarkerComponent],
imports: [MonumentMarkerComponent],
}).compileComponents();
fixture = TestBed.createComponent(PinMarkerComponent);
fixture = TestBed.createComponent(MonumentMarkerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

@ -0,0 +1,36 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { Monument } from '../../model/Monument';
@Component({
selector: 'app-monument-marker',
templateUrl: './monument-marker.component.html',
imports: [CommonModule],
})
export class MonumentMarkerComponent {
@Input() monument!: Monument;
currentIndex: number = 0;
get formattedDescription(): string {
return this.formatDescription(this.monument.description);
}
formatDescription(description: string): string {
const regex = /@(\w+(-\w+)*(\.\w+(-\w+)*)*)/g;
return description.replace(
regex,
`<a href="/profile/$1" class="text-blue-500 hover:underline">@$1</a>`
);
}
prevSlide(): void {
this.currentIndex =
(this.currentIndex - 1 + this.monument.files.length) %
this.monument.files.length;
}
nextSlide(): void {
this.currentIndex = (this.currentIndex + 1) % this.monument.files.length;
}
}

@ -1,26 +1,25 @@
<nav class="bg-white border-gray-200 dark:bg-gray-900">
<app-add-pin-popup></app-add-pin-popup>
<div
class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-3"
class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4"
>
<a routerLink="/" class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="./logo.png" class="h-12" alt="Memory Map Logo" />
<a class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="./logo.png" class="h-10" alt="Memory Map Logo" />
<span
class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white hidden lg:inline"
class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white"
>Memory Map</span
>
</a>
<!-- Partie droite du menu -->
<div class="flex lg:order-2 items-center space-x-4">
<!-- Bouton pour afficher la barre de recherche en mobile -->
<div class="flex lg:order-2">
<button
(click)="toggleSearch()"
class="lg:hidden text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5"
type="button"
data-collapse-toggle="navbar-search"
aria-controls="navbar-search"
aria-expanded="false"
class="lg:hidden text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5 me-1"
>
<svg
*ngIf="!isSearchOpen"
class="w-5 h-5"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 20 20"
@ -33,21 +32,6 @@
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"
/>
</svg>
<svg
*ngIf="isSearchOpen"
class="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 17 17"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2 2l13 13M2 15L15 2"
/>
</svg>
<span class="sr-only">Search</span>
</button>
<div class="relative hidden lg:block">
@ -71,40 +55,26 @@
</svg>
<span class="sr-only">Search icon</span>
</div>
<form [formGroup]="searchForm">
<input
type="text"
id="search-navbar"
class="block w-full p-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Rechercher un pin…"
formControlName="searchControl"
(focus)="onFocus()"
(blur)="onBlur()"
/>
</form>
<ul
*ngIf="pinsFiltered.length > 0 && inputFocus"
class="bg-white dark:bg-gray-700 border border-gray-300 mt-2 rounded shadow absolute z-10 max-h-60 overflow-auto w-full"
>
<li
*ngFor="let suggestion of pinsFiltered"
(click)="clickSuggestion(suggestion)"
class="p-2 block text-sm font-medium text-gray-900 dark:text-white hover:bg-gray-500 cursor-pointer"
>
{{ suggestion.title }}
</li>
</ul>
<input
type="text"
id="search-navbar"
class="block w-full p-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Search..."
/>
</div>
<!-- Bouton pour ouvrir/fermer le menu burger en mobile -->
<button
(click)="toggleNavbar()"
class="lg:hidden text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5"
data-collapse-toggle="navbar-search"
type="button"
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg lg:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="navbar-search"
aria-expanded="false"
(click)="isModalOpen = !isModalOpen"
>
<span class="sr-only">Open main menu</span>
<svg
*ngIf="!isNavbarOpen"
*ngIf="!isModalOpen"
class="w-5 h-5"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 17 14"
@ -118,8 +88,9 @@
/>
</svg>
<svg
*ngIf="isNavbarOpen"
*ngIf="isModalOpen"
class="w-5 h-5"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 17 17"
@ -133,129 +104,75 @@
/>
</svg>
</button>
<!-- Bouton de déconnexion (visible uniquement sur desktop) -->
<button
(click)="logout()"
class="hidden lg:block text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-900"
>
Déconnexion
</button>
</div>
<!-- Menu principal (burger en mobile) -->
<div
[ngClass]="{ hidden: !isNavbarOpen, flex: isNavbarOpen }"
class="w-full lg:flex lg:w-auto lg:order-1"
class="items-center justify-between hidden w-full lg:flex lg:w-auto lg:order-1"
id="navbar-search"
>
<div class="w-full flex flex-col lg:flex-row">
<ul
class="w-full flex p-4 flex-col lg:p-0 mt-4 font-medium border border-gray-100 rounded-lg bg-gray-50 lg:space-x-8 lg:flex-row lg:mt-0 lg:border-0 lg:bg-white dark:bg-gray-800 lg:dark:bg-gray-900 dark:border-gray-700"
<div class="relative mt-3 lg:hidden">
<div
class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none"
>
<li id="timeline">
<a
routerLink="/map"
*ngIf="!showTimeline"
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
>Carte</a
>
<a
routerLink="/timeline"
*ngIf="showTimeline"
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
>Timeline</a
>
</li>
<li>
<a
id="quete"
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
>Quêtes</a
>
</li>
<li id="add">
<button
(click)="openPinModal()"
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
type="button"
>
<p>Ajouter un pin</p>
</button>
</li>
<li id="friend">
<app-friend-page></app-friend-page>
</li>
<li>
<a
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
[routerLink]="['/map', { tutorial: true }]"
>Tutorial</a
>
</li>
</ul>
<!-- Bouton de déconnexion (visible uniquement sur mobile) -->
<div class="lg:hidden w-full px-4 p-4">
<button
(click)="logout()"
class="w-full text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-900"
>
Déconnexion
</button>
</div>
</div>
</div>
<!-- Barre de recherche mobile (affichée quand isSearchOpen = true) -->
<div *ngIf="isSearchOpen" class="w-full p-4 lg:hidden">
<div class="lg:block relative w-full">
<div class="relative lg:block">
<div
class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none"
<svg
class="w-4 h-4 text-gray-500 dark:text-gray-400"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 20 20"
>
<svg
class="w-4 h-4 text-gray-500 dark:text-gray-400"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 20 20"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"
/>
</svg>
<span class="sr-only">Search icon</span>
</div>
<form [formGroup]="searchForm">
<input
type="text"
id="search-navbar"
class="block w-full p-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Rechercher un pin…"
formControlName="searchControl"
(focus)="onFocus()"
(blur)="onBlur()"
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"
/>
</form>
<ul
*ngIf="pinsFiltered.length > 0 && inputFocus"
class="bg-white dark:bg-gray-700 border border-gray-300 mt-2 rounded shadow absolute z-10 max-h-60 overflow-auto w-full"
>
<li
*ngFor="let suggestion of pinsFiltered"
(click)="clickSuggestion(suggestion)"
class="p-2 block text-sm font-medium text-gray-900 dark:text-white hover:bg-gray-500 cursor-pointer"
>
{{ suggestion.title }}
</li>
</ul>
</svg>
</div>
<input
type="text"
id="search-navbar"
class="block w-full p-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Search..."
/>
</div>
<ul
class="flex flex-col p-4 lg:p-0 mt-4 font-medium border border-gray-100 rounded-lg bg-gray-50 lg:space-x-8 rtl:space-x-reverse lg:flex-row lg:mt-0 lg:border-0 lg:bg-white dark:bg-gray-800 lg:dark:bg-gray-900 dark:border-gray-700"
>
<li class="flex items-center space-x-2">
<a
href="#"
*ngIf="!isHome"
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
aria-current="page"
>Accueil
</a>
<a
href="/map"
*ngIf="isHome"
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
aria-current="page"
>Carte
</a>
</li>
<li class="flex items-center space-x-2">
<a
href="#"
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
>Quêtes
</a>
</li>
<li class="flex items-center space-x-2">
<app-add-pin-popup></app-add-pin-popup>
</li>
<li class="flex items-center space-x-2">
<a
href="#"
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
>Amis
</a>
</li>
</ul>
</div>
</div>
</nav>

@ -1,171 +1,25 @@
import { CommonModule, NgIf } from '@angular/common';
import { NgIf } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
ReactiveFormsModule,
} from '@angular/forms';
import {
ActivatedRoute,
NavigationEnd,
Router,
RouterLink,
} from '@angular/router';
import {
catchError,
debounceTime,
distinctUntilChanged,
of,
switchMap,
} from 'rxjs';
import { Pin } from '../../model/Pin';
import { AuthService } from '../../services/auth/auth.service';
import { ModalService } from '../../services/modal/modal.service';
import { NavbarService } from '../../services/navbar/navbar.service';
import { PinService } from '../../services/pin/pin.service';
import { NavigationEnd, Router } from '@angular/router';
import { AddPinPopupComponent } from '../add-pin-popup/add-pin-popup.component';
import { FriendPageComponent } from '../friend-page/friend-page.component';
@Component({
selector: 'app-navbar',
imports: [
AddPinPopupComponent,
NgIf,
FriendPageComponent,
CommonModule,
ReactiveFormsModule,
RouterLink,
],
imports: [AddPinPopupComponent, NgIf],
templateUrl: './navbar.component.html',
})
export class NavbarComponent implements OnInit {
showTimeline: boolean = false;
isSearchOpen: boolean = false;
isNavbarOpen: boolean = false;
isHome: boolean = false;
isModalOpen: boolean = false;
pins: Pin[] = [];
pinsFiltered: Pin[] = [];
inputFocus: Boolean = false;
searchForm: FormGroup;
constructor(
private router: Router,
private route: ActivatedRoute,
private pinService: PinService,
private fb: FormBuilder,
private authService: AuthService,
private navbarService: NavbarService,
private modalService: ModalService
) {
this.searchForm = this.fb.group({
searchControl: new FormControl(''),
});
this.navbarService.isSearchOpen$.subscribe((isOpen) => {
this.isSearchOpen = isOpen;
this.isNavbarOpen = false;
});
this.navbarService.isNavbarOpen$.subscribe((isOpen) => {
this.isNavbarOpen = isOpen;
this.isSearchOpen = false;
});
}
toggleSearch(): void {
this.navbarService.toggleSearch();
}
toggleNavbar(): void {
this.navbarService.toggleNavbar();
}
openPinModal() {
this.modalService.openModal('add-pin-modal');
}
constructor(private router: Router) {}
ngOnInit(): void {
this.pins = this.pinService.getPins().subscribe((pins: Pin[]) => {
this.pins = pins;
});
this.showTimeline =
this.router.url !== '/timeline' && this.router.url !== '/';
this.isHome = this.router.url === '/';
this.router.events.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.showTimeline =
event.url !== '/timeline' && this.router.url !== '/';
this.isHome = event.url === '/';
}
});
this.searchForm
.get('searchControl')
?.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((searchTerm) => {
const trimmedQuery = searchTerm?.trim();
if (trimmedQuery && trimmedQuery.length > 1) {
return of(this.filterPins(trimmedQuery));
}
return of([]);
}),
catchError((error) => {
console.error(
'Erreur lors de la récupération des suggestions :',
error
);
return of([]);
})
)
.subscribe((filteredPins) => {
this.pinsFiltered = filteredPins;
});
}
filterPins(searchTerm: string): Pin[] {
const filteredPins: Pin[] = [];
if (this.pins.length === 0) {
this.pins = this.pinService.getPins();
}
this.pins.forEach((pin: Pin) => {
if (
pin.title &&
pin.title.toLowerCase().includes(searchTerm.toLowerCase())
) {
filteredPins.push(pin);
}
});
return filteredPins;
}
clickSuggestion(pin: Pin): void {
this.searchForm.reset();
const queryParams = { pin: pin.id };
this.router.navigate([], {
relativeTo: this.route,
queryParams: queryParams,
queryParamsHandling: 'merge',
});
}
onFocus(): void {
this.inputFocus = true;
}
onBlur(): void {
setTimeout(() => {
this.inputFocus = false;
}, 200);
}
public logout() {
this.authService.logout();
this.router.navigate(['/']);
}
}

@ -1,37 +0,0 @@
.pin-detail {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: calc(100vh - 15rem);
padding: 1rem;
box-sizing: border-box;
}
/* .pin-detail {
height: 100vh;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 0;
padding: 1rem;
box-sizing: border-box;
} */
.card-pin-detail {
background: linear-gradient(145deg, #ffffff, #f9fafb);
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -4px rgba(0, 0, 0, 0.1);
padding: 2.5rem;
max-width: 48rem;
width: 100%;
transition: box-shadow 0.3s ease-in-out;
}
.card-pin-detail:hover {
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}

@ -1,165 +0,0 @@
<!-- Conteneur principal -->
<div class="min-h-screen bg-gray-100 px-4 py-12 overflow-y-auto">
<!-- .pin-detail agit comme conteneur vertical -->
<div class="pin-detail">
<div class="card-pin-detail">
<!-- Bouton retour à gauche -->
<div class="mb-6">
<button
(click)="goBack()"
class="px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700 transition"
>
← Retour à la carte
</button>
</div>
<!-- Titre -->
<h2 class="text-3xl font-semibold mb-4 text-gray-800 text-center">
{{ pin.title }}
</h2>
<!-- Carousel -->
<div
*ngIf="pin.files.length > 0; else noImagesPlaceholder"
class="relative mt-2 mb-4 overflow-hidden rounded-lg flex items-center justify-center"
[ngClass]="{ 'h-32 sm:h-40 md:h-52 lg:h-60': true }"
>
<!-- Images -->
<div
*ngFor="let imageId of pin.files; let index = index"
[class]="
'absolute inset-0 transition-opacity duration-700 ease-in-out' +
(index === currentIndex ? ' opacity-100' : ' opacity-0')
"
>
<div
class="relative w-full h-full overflow-hidden rounded-lg flex items-center justify-center"
>
<img
[src]="imageUrls[index]"
[hidden]="!imagesLoaded"
class="object-contain max-h-full max-w-full h-full w-auto mx-auto"
/>
<div
*ngIf="!imagesLoaded"
class="w-full h-full bg-gray-200 flex items-center justify-center"
>
<span class="text-gray-500">Loading image...</span>
</div>
</div>
</div>
<!-- Contrôles gauche/droite -->
<ng-container *ngIf="pin.files.length > 1">
<!-- Précédent -->
<button
type="button"
class="absolute top-1/2 left-2 z-30 -translate-y-1/2 flex items-center justify-center px-2 cursor-pointer group focus:outline-none"
(click)="prevSlide()"
>
<span
class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-black/30 group-hover:bg-black/50"
>
<svg class="w-4 h-4 text-white" viewBox="0 0 6 10" fill="none">
<path
d="M5 1 1 5l4 4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</button>
<!-- Suivant -->
<button
type="button"
class="absolute top-1/2 right-2 z-30 -translate-y-1/2 flex items-center justify-center px-2 cursor-pointer group focus:outline-none"
(click)="nextSlide()"
>
<span
class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-black/30 group-hover:bg-black/50"
>
<svg class="w-4 h-4 text-white" viewBox="0 0 6 10" fill="none">
<path
d="M1 9l4-4-4-4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</button>
</ng-container>
</div>
<!-- Fallback si pas dimage -->
<ng-template #noImagesPlaceholder>
<div
class="relative mt-2 overflow-hidden rounded-lg flex items-center justify-center bg-gray-200"
[ngClass]="{ 'h-32 sm:h-40 md:h-52 lg:h-60': true }"
>
<span class="text-gray-500">No images available</span>
</div>
</ng-template>
<!-- Adresse -->
<p class="text-sm text-gray-500 mb-2">📍 {{ pin.complete_address }}</p>
<!-- Date -->
<p class="text-sm text-gray-500 mb-6" *ngIf="pin.date">
📅 {{ pin.date | date : "longDate" }}
</p>
<!-- Description -->
<div
#desc
class="text-lg mb-4 text-justify transition-all duration-300"
[ngClass]="{
'max-h-[4.5rem] overflow-hidden whitespace-normal':
!expandedDescription,
'max-h-36 overflow-y-auto whitespace-normal': expandedDescription
}"
style="line-height: 1.5rem; word-break: break-word"
>
{{ pin.description || "Aucune description" }}
</div>
<!-- Voir plus / moins -->
<div *ngIf="showToggleButton" class="text-right mb-6">
<button
(click)="toggleDescription()"
class="text-blue-600 font-semibold hover:underline"
>
{{ expandedDescription ? "Voir moins" : "Voir plus" }}
</button>
</div>
<!-- Section utilisateur / partage -->
<div class="mt-8 border-t border-gray-200 pt-4 text-sm text-gray-600">
<div class="mb-2">
<span class="font-medium text-gray-700">
{{ username === username_session ? "Créé par : " : "Partagé par : " }}
</span>
<span>{{ username }}</span>
</div>
<div *ngIf="sharedUsers.length > 0" class="mt-2">
<span class="font-medium text-gray-700 block mb-1"
>Partagé avec :</span
>
<div class="flex flex-wrap gap-2">
<span
*ngFor="let user of sharedUsers"
class="bg-blue-100 text-blue-800 text-xs font-semibold px-3 py-1 rounded-full shadow-sm"
>
{{ user.username }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>

@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PinDetailComponent } from './pin-detail.component';
describe('PinDetailComponent', () => {
let component: PinDetailComponent;
let fixture: ComponentFixture<PinDetailComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PinDetailComponent]
})
.compileComponents();
fixture = TestBed.createComponent(PinDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -1,163 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
import { Pin } from '../../model/Pin';
import { PinService } from '../../services/pin/pin.service';
import { RouterModule } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { AuthService } from '../../services/auth/auth.service';
import { UserService } from '../../services/user/user.service';
import { ImageService } from '../../services/image/image.service';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { Renderer2 } from '@angular/core';
import { CookiesService } from '../../services/cookies/cookies.service';
@Component({
selector: 'app-pin-detail',
templateUrl: './pin-detail.component.html',
styleUrls: ['./pin-detail.component.css'],
standalone: true,
imports: [CommonModule, RouterModule],
})
export class PinDetailComponent {
pin!: Pin;
username: string = '';
imageUrls: SafeUrl[] = [];
imagesLoaded = false;
sharedUsers: {
user_id: string;
username: string;
can_edit: boolean;
can_delete: boolean;
}[] = [];
expandedDescription = false;
currentIndex: number = 0;
username_session: string = '';
@ViewChild('desc') descriptionDiv!: ElementRef<HTMLDivElement>;
showToggleButton = false;
constructor(
private pinService: PinService,
private route: ActivatedRoute,
private authService: AuthService,
private userService: UserService,
private imageService: ImageService,
private sanitizer: DomSanitizer,
private router: Router,
private renderer: Renderer2,
private cookiesService: CookiesService
) {}
ngOnInit(): void {
// this.renderer.addClass(document.body, 'no-scroll-body');
const pinId = this.route.snapshot.paramMap.get('id');
this.username_session = this.cookiesService.getUsername() || '';
if (pinId) {
this.pinService.getPinById(pinId).subscribe({
next: (data) => {
this.pin = data;
this.loadUsername(data.user_id);
this.loadImages();
},
error: (err) =>
console.error('Erreur lors du chargement du pin :', err),
});
this.pinService.getSharedUsersForPin(pinId).subscribe({
next: (shares) => {
this.sharedUsers = shares.map((share) => ({
user_id: share.user_id,
username: share.username,
can_edit: share.can_edit,
can_delete: share.can_delete,
}));
},
error: (err) => {
console.error('Erreur récupération utilisateurs partagés:', err);
this.sharedUsers = [];
},
});
}
}
loadUsername(userId: string) {
this.userService.getUserById(userId).subscribe({
next: (user) => {
this.username = user.username; // ou user.name selon ton backend
},
error: (err) => {
console.error('Erreur lors de la récupération du pseudo :', err);
this.username = 'Utilisateur inconnu';
},
});
}
loadImages() {
this.pin.files.forEach((imageId) => {
this.imageService.getImage(imageId).subscribe((blob) => {
const objectUrl = URL.createObjectURL(blob);
const safeUrl = this.sanitizer.bypassSecurityTrustUrl(objectUrl);
this.imageUrls.push(safeUrl);
if (this.imageUrls.length === this.pin.files.length) {
this.imagesLoaded = true;
}
});
});
}
loadSharedUsers(pinId: string) {
this.pinService.getSharedUsersForPin(pinId).subscribe({
next: (shares) => {
this.sharedUsers = shares;
},
error: (err) => {
console.error('Erreur récupération utilisateurs partagés:', err);
this.sharedUsers = [];
},
});
}
// toggleDescription(): void {
// this.expandedDescription = !this.expandedDescription;
// }
ngAfterViewInit(): void {
this.checkIfDescriptionIsTruncated();
}
toggleDescription() {
this.expandedDescription = !this.expandedDescription;
if (!this.expandedDescription) {
// re-check quand on replie
setTimeout(() => this.checkIfDescriptionIsTruncated(), 0);
}
}
checkIfDescriptionIsTruncated() {
if (!this.descriptionDiv) return;
const el = this.descriptionDiv.nativeElement;
this.showToggleButton = el.scrollHeight > el.clientHeight;
}
prevSlide(): void {
this.currentIndex =
(this.currentIndex - 1 + this.imageUrls.length) % this.imageUrls.length;
}
nextSlide(): void {
this.currentIndex = (this.currentIndex + 1) % this.imageUrls.length;
}
ngOnDestroy() {
// this.renderer.removeClass(document.body, 'no-scroll-body');
}
goBack() {
this.router.navigate(['/map'], { queryParams: { pin: this.pin.id } });
}
}

@ -1,229 +0,0 @@
<!-- Conteneur cliquable -->
<div class="text-center px-2 sm:px-4"></div>
<!-- Modals -->
<app-confirm-modal
(confirmed)="handleConfirm()"
(cancelled)="handleCancel()"
[pinId]="pin.id"
[pinOpened]="pinOpened"
></app-confirm-modal>
<app-share-modal [pinOpened]="pinOpened" [pinId]="pin.id"></app-share-modal>
<!-- Boutons d'action -->
<div class="flex justify-between items-center flex-wrap mb-2">
<!-- Bouton Voir / Détail à gauche -->
<div>
<button
[routerLink]="['/pin', pin.id]"
(click)="$event.stopPropagation()"
class="p-2 text-gray-800 rounded-full hover:bg-gray-200 focus:outline-none flex items-center shadow-sm transition duration-200"
aria-label="Voir le détail du pin"
>
<svg
class="w-5 h-5 text-gray-800"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-width="2"
d="M16 4h4m0 0v4m0-4-5 5M8 20H4m0 0v-4m0 4 5-5"
/>
</svg>
</button>
</div>
<!-- Autres boutons à droite -->
<div class="flex items-center gap-2">
<app-edit-pin-popup
*ngIf="!this.pin.is_poi"
[pin]="pin"
[pinId]="pin.id"
[pinOpened]="pinOpened"
></app-edit-pin-popup>
<button
*ngIf="!this.pin.is_poi"
class="p-2 text-green-500 rounded-full hover:bg-green-200 focus:outline-none flex items-center shadow-sm transition duration-200"
(click)="sharePin()"
>
<svg
class="w-4 h-4 sm:w-5 sm:h-5 text-gray-800"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-width="2"
d="M7.926 10.898 15 7.727m-7.074 5.39L15 16.29M8 12a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Zm12 5.5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Zm0-11a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Z"
/>
</svg>
</button>
<button
*ngIf="!pin.is_poi"
class="p-2 text-red-500 rounded-full hover:bg-red-100 focus:outline-none flex items-center shadow-sm transition duration-200"
(click)="onDelete()"
>
<svg
class="w-4 h-4 sm:w-5 sm:h-5 text-gray-800"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"
/>
</svg>
</button>
<button
class="p-2 text-gray-500 rounded-full hover:bg-gray-200 focus:outline-none flex items-center shadow-sm transition duration-200"
(click)="onClosePopup()"
>
<svg
class="w-4 h-4 sm:w-5 sm:h-5 text-gray-800"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18 17.94 6M18 18 6.06 6"
/>
</svg>
</button>
</div>
</div>
<!-- Contenu du pin -->
<div class="text-center px-2 sm:px-4">
<strong class="block text-base sm:text-lg">{{ pin.title }}</strong>
<div *ngIf="!this.pin.is_poi">
<ng-container
*ngIf="pin.files.length > 0; else noImagesPlaceholder"
class="relative carousel overflow-hidden"
>
<div
class="relative mt-2 overflow-hidden rounded-lg flex items-center justify-center"
[ngClass]="{
'h-32 sm:h-40 md:h-52 lg:h-60': true
}"
>
<div
*ngFor="let imageId of pin.files; let index = index"
[class]="
'absolute inset-0 transition-opacity duration-700 ease-in-out' +
(index === currentIndex ? ' opacity-100' : ' opacity-0')
"
>
<div
class="relative w-full h-full overflow-hidden rounded-lg flex items-center justify-center"
>
<img
[src]="imageUrls[index]"
[hidden]="!imagesLoaded"
class="object-contain max-h-full max-w-full h-full w-auto mx-auto"
/>
<div
*ngIf="!imagesLoaded"
class="w-full h-full bg-gray-200 flex items-center justify-center"
>
<span class="text-gray-500">Loading image...</span>
</div>
</div>
</div>
</div>
<!-- Slider controls -->
<div *ngIf="pin.files.length > 1">
<button
type="button"
class="absolute top-0 left-0 z-30 flex items-center justify-center h-full px-2 cursor-pointer group focus:outline-none"
(click)="prevSlide()"
>
<span
class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-black/30 group-hover:bg-black/50"
>
<svg
class="w-4 h-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 6 10"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 1 1 5l4 4"
/>
</svg>
</span>
</button>
<button
type="button"
class="absolute top-0 right-0 z-30 flex items-center justify-center h-full px-2 cursor-pointer group focus:outline-none"
(click)="nextSlide()"
>
<span
class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-black/30 group-hover:bg-black/50"
>
<svg
class="w-4 h-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 6 10"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M1 9l4-4-4-4"
/>
</svg>
</span>
</button>
</div>
</ng-container>
<ng-template #noImagesPlaceholder>
<div
class="relative mt-2 overflow-hidden rounded-lg flex items-center justify-center bg-gray-200"
[ngClass]="{
'h-32 sm:h-40 md:h-52 lg:h-60': true
}"
>
<span class="text-gray-500">No images available</span>
</div>
</ng-template>
</div>
<div
class="text-lg mb-4 text-left"
[ngClass]="{
'whitespace-nowrap overflow-hidden truncate': !pin.is_poi,
'text-justify': pin.is_poi
}"
>
{{ pin.description || "Aucune description" }}
</div>
</div>

@ -1,124 +0,0 @@
import { CommonModule, NgIf } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import * as L from 'leaflet';
import { Pin } from '../../model/Pin';
import { ImageService } from '../../services/image/image.service';
import { ModalService } from '../../services/modal/modal.service';
import { PinService } from '../../services/pin/pin.service';
import { ConfirmModalComponent } from '../confirm-modal/confirm-modal.component';
import { EditPinPopupComponent } from '../edit-pin-popup/edit-pin-popup.component';
import { ShareModalComponent } from '../share-modal/share-modal.component';
import { Router } from '@angular/router';
import { RouterModule } from '@angular/router';
@Component({
selector: 'app-pin-marker',
templateUrl: './pin-marker.component.html',
imports: [
CommonModule,
EditPinPopupComponent,
ConfirmModalComponent,
ShareModalComponent,
RouterModule,
NgIf
],
standalone: true,
})
export class PinMarkerComponent {
@Input() pin!: Pin;
@Input() marker!: L.Marker;
currentIndex: number = 0;
imageUrls: SafeUrl[] = [];
imagesLoaded = false;
@Output() pinOpened = new EventEmitter<void>();
constructor(
private pinService: PinService,
private modalService: ModalService,
private imageService: ImageService,
private sanitizer: DomSanitizer,
private router: Router
) {}
ngOnInit() {
// Écouter l'événement d'ouverture du popup
this.marker.on('popupopen', () => {
if (!this.imagesLoaded) {
this.loadImages();
this.pinOpened.emit();
// this.formatDescription(this.pin.description);
}
});
}
loadImages() {
this.pin.files.forEach((imageId) => {
this.imageService.getImage(imageId).subscribe((blob) => {
const objectUrl = URL.createObjectURL(blob);
const safeUrl = this.sanitizer.bypassSecurityTrustUrl(objectUrl);
this.imageUrls.push(safeUrl);
if (this.imageUrls.length === this.pin.files.length) {
this.imagesLoaded = true;
}
});
});
}
sharePin() {
this.modalService.openModal('share-modal-' + this.pin.id);
}
onClosePopup() {
this.marker.closePopup();
}
onDelete() {
this.modalService.openModal('confirm-modal-' + this.pin.id);
}
handleConfirm() {
this.pinService.deletePin(this.pin.id).subscribe(() => {
this.marker.remove();
this.modalService.closeModal('confirm-modal-' + this.pin.id);
});
}
handleCancel() {
this.modalService.closeModal('confirm-modal-' + this.pin.id);
}
// get formattedDescription(): string {
// return this.formatDescription(this.pin.description);
// }
// formatDescription(description: string): string {
// const regex = /@(\w+(-\w+)*(\.\w+(-\w+)*)*)/g;
// return description.replace(
// regex,
// `<a href="/profile/$1" class="text-blue-500 hover:underline">@$1</a>`
// );
// }
prevSlide(): void {
this.currentIndex =
(this.currentIndex - 1 + this.imageUrls.length) % this.imageUrls.length;
}
nextSlide(): void {
this.currentIndex = (this.currentIndex + 1) % this.imageUrls.length;
}
ngOnDestroy() {
// Clean up object URLs to prevent memory leaks
this.imageUrls.forEach((url) => {
URL.revokeObjectURL(url.toString());
});
}
navigateToDetail(): void {
this.router.navigate(['/pin', this.pin.id]);
}
}

@ -1,39 +1,35 @@
<!-- Main modal -->
<div
class="fixed inset-0 z-40 bg-gray-900 bg-opacity-50 w-full h-full transition-opacity duration-300 ease-in-out"
[ngClass]="{
'opacity-0 pointer-events-none': !isRegisterModalOpen,
'opacity-100': isRegisterModalOpen
}"
(click)="closeRegisterModal()"
></div>
<!-- Modal toggle -->
<button
data-modal-target="register-modal"
data-modal-toggle="register-modal"
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
type="button"
>
Inscription
</button>
<!-- Main modal -->
<div
id="register-modal"
tabindex="-1"
aria-hidden="true"
[ngClass]="{
'opacity-0 scale-0 pointer-events-none': !isRegisterModalOpen,
'opacity-100 scale-100': isRegisterModalOpen
}"
class="fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full h-full transition-transform duration-300 ease-in-out overflow-y-auto"
class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full"
>
<div class="relative p-4 w-full max-w-md max-h-full">
<!-- Modal content -->
<div
class="relative bg-white rounded-lg shadow dark:bg-gray-700 transition-transform duration-300 ease-in-out my-8"
>
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<!-- Modal header -->
<div
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600"
>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
S'inscrire à Memory Map
Formulaire d'inscription
</h3>
<button
type="button"
(click)="closeRegisterModal()"
id="close-register-modal"
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
data-modal-hide="register-modal"
>
<svg
class="w-3 h-3"
@ -50,11 +46,11 @@
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
<span class="sr-only">Fermer la fenêtre</span>
<span class="sr-only">Close modal</span>
</button>
</div>
<!-- Modal body -->
<div class="p-4 md:p-5" *ngIf="isRegisterModalOpen">
<div class="p-4 md:p-5">
<form [formGroup]="userForm" class="space-y-4">
<div>
<label
@ -64,7 +60,7 @@
>
<input
formControlName="login"
type="text"
type="login"
name="login"
id="login"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
@ -104,23 +100,12 @@
required
/>
</div>
<div *ngIf="errorMessage" class="text-red-500 text-sm">
{{ errorMessage }}
</div>
<button
(click)="register()"
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
Démarrer l'aventure !
</button>
<div class="text-sm font-medium text-gray-500 dark:text-gray-300">
Déjà un compte ?
<a
(click)="openLoginModal()"
class="text-blue-700 hover:cursor-pointer hover:underline dark:text-blue-500"
>Se connecter</a
>
</div>
</form>
</div>
</div>

@ -1,42 +1,35 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import {
FormBuilder,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { LoginService } from '../../services/login.service';
import { FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { FormBuilder } from '@angular/forms';
import { Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { FormGroup } from '@angular/forms';
import { User } from '../../model/User';
import { AuthService } from '../../services/auth/auth.service';
import { ModalService } from '../../services/modal/modal.service';
import { RegisterService } from '../../services/register.service';
import { LocalStorageService } from '../../services/localstorage.service';
@Component({
selector: 'app-register-page',
imports: [FormsModule, ReactiveFormsModule, CommonModule],
imports: [FormsModule, ReactiveFormsModule],
templateUrl: './register-page.component.html',
styleUrl: './register-page.component.css',
})
export class RegisterPageComponent {
userForm: FormGroup;
user: User = { login: '', password: '' };
errorMessage: string = '';
isRegisterModalOpen: boolean = false;
modalId: string = 'register-modal';
private modalSub!: Subscription;
constructor(
private authService: AuthService,
private registerService: RegisterService,
private fb: FormBuilder,
private modalService: ModalService,
private localStorageService: LocalStorageService,
private router: Router
) {
this.userForm = this.fb.group(
{
login: [
this.user.login,
[Validators.required, Validators.minLength(3)],
[Validators.required, Validators.minLength(6)],
],
password: [
this.user.password,
@ -48,18 +41,6 @@ export class RegisterPageComponent {
);
}
ngOnInit() {
this.modalSub = this.modalService
.getModalState(this.modalId)
.subscribe((open) => {
this.isRegisterModalOpen = open;
});
}
ngOnDestroy() {
this.modalSub.unsubscribe();
}
passwordMatchValidator(formGroup: FormGroup) {
const password = formGroup.get('password')?.value;
const verifyPassword = formGroup.get('verifyPassword')?.value;
@ -69,35 +50,36 @@ export class RegisterPageComponent {
public register() {
if (this.userForm.invalid) {
this.errorMessage =
'Veuillez remplir tous les champs (identifiant de 3 caractères et mot de passe de 6 caractères minimum)';
this.errorMessage = 'Veuillez remplir tous les champs';
return;
}
this.user.login = this.userForm.value.login;
this.user.password = this.userForm.value.password;
this.authService.register(this.user.login, this.user.password).subscribe({
next: () => {
this.closeRegisterModal();
setTimeout(() => {
this.router.navigate(['/map', { tutorial: true }]);
}, 1);
},
error: (response) => {
console.error('Register KO: ', response.error.detail);
this.errorMessage = response.error.detail;
},
});
}
closeRegisterModal() {
this.modalService.closeModal(this.modalId);
this.registerService
.register(this.user.login, this.user.password)
.subscribe({
next: (response) => {
console.log('Connexion OK: ', response);
this.localStorageService.setToken(response.access_token);
this.closeModal();
setTimeout(() => {
this.router.navigate(['/map']);
window.location.reload();
}, 500);
},
error: (response) => {
console.log('Connexion KO: ', response.error.detail);
this.errorMessage = response.error.detail;
},
});
}
openLoginModal() {
this.modalService.closeModal(this.modalId);
this.modalService.openModal('login-modal');
private closeModal() {
const modal = document.getElementById('close-register-modal');
if (modal) {
modal.click();
}
}
}

@ -1,140 +0,0 @@
<div id="share-modal-{{ pinId }}">
<!-- Fond assombri -->
<div
class="fixed inset-0 bg-gray-900 bg-opacity-50 w-full h-full transition-opacity duration-300 ease-in-out z-40"
[ngClass]="{
'opacity-0 pointer-events-none': !isShareModalOpen,
'opacity-100': isShareModalOpen
}"
(click)="closeShareModal()"
id="share-modal-background-{{ pinId }}"
></div>
<!-- Main modal -->
<div
id="share-modal-{{ pinId }}"
tabindex="-1"
aria-hidden="true"
[ngClass]="{
'opacity-0 scale-50 pointer-events-none': !isShareModalOpen,
'opacity-100 scale-100': isShareModalOpen
}"
class="fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full h-full transition-transform duration-300 ease-in-out overflow-y-auto"
>
<div class="relative p-4 w-full max-w-md max-h-full">
<!-- Modal content -->
<div
class="relative bg-white rounded-lg shadow dark:bg-gray-700 transition-transform duration-300 ease-in-out my-8"
>
<!-- Modal header -->
<div
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600"
>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Partager ce souvenir
</h3>
<button
type="button"
(click)="closeShareModal()"
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg
class="w-3 h-3"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
<span class="sr-only">Fermer la modal</span>
</button>
</div>
<!-- Modal body -->
<div class="p-4 md:p-5 space-y-4">
<!-- Options de partage -->
<div class="flex flex-col space-y-4">
<div class="p-4">
<input
type="text"
id="search-friends"
class="w-full p-2 mb-2 border rounded-lg dark:bg-gray-700 dark:text-white"
placeholder="Rechercher un ami..."
[(ngModel)]="searchTerm"
(ngModelChange)="onSearchTermChange($event)"
/>
<div *ngIf="listUser" class="text-gray-500 text-sm">
<div
*ngFor="let user of listUser"
class="friend flex items-center justify-between space-x-3 pt-10"
>
<div class="friend flex items-center space-x-3">
<img
class="w-10 h-10 rounded-full"
src="/avatar.png"
alt="Friend 2"
/>
<span class="text-gray-900 dark:text-white">{{
user.username
}}</span>
</div>
<button
*ngIf="!user.isShared"
(click)="sharePin(user.friend_user_id)"
class="p-2 bg-green-500 text-white rounded-full hover:bg-green-600 transition-colors"
>
<svg
class="w-6 h-6 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-width="2"
d="M7.926 10.898 15 7.727m-7.074 5.39L15 16.29M8 12a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Zm12 5.5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Zm0-11a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Z"
/>
</svg>
</button>
<button
*ngIf="user.isShared"
(click)="unsharePin(user.friend_user_id)"
class="p-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
>
<svg
class="w-6 h-6"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ShareModalComponent } from './share-modal.component';
describe('ShareModalComponent', () => {
let component: ShareModalComponent;
let fixture: ComponentFixture<ShareModalComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ShareModalComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ShareModalComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -1,146 +0,0 @@
import { CommonModule } from '@angular/common';
import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Subject, Subscription } from 'rxjs';
import { FriendsService } from '../../services/friends/friends.service';
import { ModalService } from '../../services/modal/modal.service';
import { PinService } from '../../services/pin/pin.service';
@Component({
selector: 'app-share-modal',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './share-modal.component.html',
})
export class ShareModalComponent implements OnInit, OnDestroy {
modalId: string = 'share-modal';
isShareModalOpen = false;
private modalSub!: Subscription;
isFriendModalOpen: boolean = false;
hasAcceptedFriends: boolean = false;
hasPendingFriends: boolean = false;
searchTerm: string = '';
searchTermChanged = new Subject<string>();
listUser: any[] = [];
listFriend: any[] = [];
pinShares: any[] = [];
@Input() pinId!: string;
@Input() pinOpened!: EventEmitter<void>;
constructor(
private modalService: ModalService,
private friendService: FriendsService,
private pinService: PinService
) {}
ngOnInit() {
this.modalId = 'share-modal-' + this.pinId;
this.modalSub = this.modalService
.getModalState(this.modalId)
.subscribe((open) => {
this.isShareModalOpen = open;
if (open) {
this.getFriend();
}
});
this.pinOpened.subscribe(() => {
this.moveModalToBody();
});
}
ngOnDestroy() {
this.modalSub?.unsubscribe();
}
openShareModal() {
this.modalService.openModal(this.modalId);
}
closeShareModal() {
this.modalService.closeModal(this.modalId);
}
private moveModalToBody(): void {
const modal = document.getElementById(this.modalId);
if (modal && modal.parentElement !== document.body) {
document.body.appendChild(modal);
}
}
onSearchTermChange(value: string): void {
if (!this.listFriend) return;
if (value.trim() === '') {
this.listUser = [...this.listFriend];
} else {
this.listUser = this.listFriend.filter((friend) =>
friend.username?.toLowerCase().includes(value.toLowerCase())
);
}
}
protected getFriend() {
// Récupérer d'abord les partages du pin
this.pinService.getPinShares(this.pinId).subscribe((response: any) => {
this.pinShares = response.shares || [];
// Ensuite récupérer les amis
this.friendService.getFriend().subscribe((friends: any[]) => {
this.listFriend = [];
this.listUser = [];
// Récupérer les détails de chaque ami
friends.forEach((friend) => {
if (friend.status === 'accepted') {
this.friendService
.getFriendById(friend.friend_user_id)
.subscribe((userDetails: any) => {
const friendWithDetails = {
...friend,
username: userDetails.username,
isShared: this.pinShares.some(
(share) => share.user_id === friend.friend_user_id
),
};
this.listFriend.push(friendWithDetails);
this.listUser.push(friendWithDetails);
});
}
});
});
});
}
sharePin(friendId: string) {
if (!this.pinId) {
console.error('No pin ID available');
return;
}
this.pinService.sharePin(this.pinId, friendId).subscribe((data: any) => {
this.closeShareModal();
});
}
unsharePin(friendId: string) {
if (!this.pinId) {
console.error('No pin ID available');
return;
}
this.pinService.deletePinShare(this.pinId, friendId).subscribe(() => {
// Mettre à jour la liste des amis après la suppression
this.getFriend();
});
}
}

@ -1,240 +0,0 @@
<!-- Spinner pendant le chargement -->
<div *ngIf="loading" class="flex justify-center items-center h-64">
<div
class="animate-spin rounded-full h-16 w-16 border-4 border-blue-500 border-t-transparent"
></div>
</div>
<!-- Timeline principale -->
<div
*ngIf="!loading && pins.length > 0"
class="relative mx-auto max-w-7xl py-20 px-6 z-0"
>
<!-- Barre centrale -->
<div
class="absolute left-1/2 transform -translate-x-1/2 h-full bg-blue-500 w-6 rounded-full z-0"
></div>
<!-- Groupement par années -->
<ng-container *ngFor="let year of sortedYears">
<!-- Marqueur d'année -->
<div class="relative mb-24 flex justify-center items-center">
<div
class="absolute left-1/2 transform -translate-x-1/2 h-16 w-8 bg-blue-500 z-0"
></div>
<div
class="bg-blue-600 text-white text-2xl font-bold px-10 py-5 rounded-full shadow-2xl z-10 border-4 border-white"
>
{{ year }}
</div>
</div>
<!-- Pins de l'année -->
<ng-container *ngFor="let pin of groupedPins[year]; let i = index">
<div
class="mb-32 flex flex-col sm:flex-row justify-between items-center w-full relative z-10"
>
<!-- Espace vide -->
<div
class="w-full sm:w-5/12"
[ngClass]="{ 'sm:order-1': i % 2 === 0, 'sm:order-2': i % 2 !== 0 }"
></div>
<!-- Bulle centrale avec la date -->
<div
class="z-20 flex items-center justify-center bg-white border-[6px] border-b-0 sm:border-b-[6px] sm:border-blue-600 border-gray-800 sm:text-blue-700 text-gray-800 font-bold text-base sm:text-lg shadow-2xl sm:rounded-full rounded-t-3xl sm:w-32 sm:h-32 text-center leading-tight px-4 py-2 sm:px-10 sm:py-5 date-bubble"
[ngClass]="{
'sm:order-2': i % 2 === 0,
'sm:order-1': i % 2 !== 0
}"
>
<span>{{ pin.date | date : "d MMMM yyyy" }}</span>
</div>
<!-- Ligne de liaison (desktop uniquement) -->
<div
class="hidden -z-10 sm:block absolute top-1/2 transform -translate-y-1/2 h-2 w-[calc(50%-8rem)] bg-blue-500"
[ngClass]="{
'left-1/2': i % 2 === 0,
'right-1/2': i % 2 !== 0
}"
></div>
<!-- Carte de contenu -->
<div
class="bg-white dark:bg-gray-800 rounded-3xl shadow-2xl px-10 py-8 w-full sm:w-5/12 transition-all duration-300 hover:scale-[1.02] cursor-pointer"
[ngClass]="{
'sm:order-3 sm:text-left text-center': i % 2 === 0,
'sm:order-0 sm:text-right text-left': i % 2 !== 0
}"
(click)="navigateToPinOnMap(pin.id)"
>
<!-- Titre centré -->
<h3
class="text-2xl font-extrabold text-gray-900 dark:text-white mb-4 text-center"
>
{{ pin.title || "Titre inconnu" }}
</h3>
<!-- Description justifiée tronquée -->
<div
class="text-md text-gray-700 dark:text-gray-300 mb-4 text-justify transition-all duration-300"
[ngClass]="{
'line-clamp-5 overflow-hidden':
!expandedDescriptions[pins.indexOf(pin)]
}"
>
{{ pin.description || "Aucune description" }}
</div>
<!-- Bouton "voir plus / moins" -->
<div *ngIf="pin.description.length > 200" class="text-right mb-6">
<button
class="text-blue-600 font-semibold hover:underline"
(click)="toggleDescription(pins.indexOf(pin))"
>
{{
expandedDescriptions[pins.indexOf(pin)]
? "Voir moins"
: "Voir plus"
}}
</button>
</div>
<!-- Carrousel d'images -->
<ng-container *ngIf="imageUrls[pins.indexOf(pin)].length > 0">
<div
class="relative h-64 mt-2 overflow-hidden rounded-lg flex items-center justify-center"
>
<div
*ngFor="
let imageId of imageUrls[pins.indexOf(pin)];
let index = index
"
[class]="
'absolute inset-0 transition-opacity duration-700 ease-in-out' +
(index === carouselIndexes[pins.indexOf(pin)]
? ' opacity-100'
: ' opacity-0')
"
>
<img
[src]="imageId"
class="object-contain max-h-full max-w-full h-full w-auto mx-auto"
alt="image"
/>
</div>
<!-- Slider controls -->
<div *ngIf="imageUrls[pins.indexOf(pin)].length > 1">
<button
type="button"
class="absolute top-0 left-0 z-30 flex items-center justify-center h-full cursor-pointer group focus:outline-none"
(click)="
prevImage(pins.indexOf(pin)); $event.stopPropagation()
"
>
<span
class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-black/30 group-hover:bg-black/50"
>
<svg
class="w-4 h-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 6 10"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 1 1 5l4 4"
/>
</svg>
<span class="sr-only">Précédent</span>
</span>
</button>
<button
type="button"
class="absolute top-0 right-0 z-30 flex items-center justify-center h-full cursor-pointer group focus:outline-none"
(click)="
nextImage(pins.indexOf(pin)); $event.stopPropagation()
"
>
<span
class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-black/30 group-hover:bg-black/50"
>
<svg
class="w-4 h-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 6 10"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M1 9l4-4-4-4"
/>
</svg>
<span class="sr-only">Suivant</span>
</span>
</button>
</div>
</div>
<!-- Indicateur de position -->
<div
*ngIf="imageUrls[pins.indexOf(pin)].length > 1"
class="flex justify-center mt-2 space-x-2"
>
<div
*ngFor="let img of imageUrls[pins.indexOf(pin)]; let j = index"
class="w-3 h-3 rounded-full"
[ngClass]="{
'bg-blue-600': j === carouselIndexes[pins.indexOf(pin)],
'bg-blue-200': j !== carouselIndexes[pins.indexOf(pin)]
}"
></div>
</div>
</ng-container>
<!-- Fallback s'il n'y a pas d'image -->
<ng-container
*ngIf="
!imageUrls[pins.indexOf(pin)] ||
imageUrls[pins.indexOf(pin)].length === 0
"
>
<div class="text-gray-400 italic text-center">Aucune image</div>
</ng-container>
</div>
<!-- Message si vide -->
<div
*ngIf="!loading && pins.length === 0"
class="text-center text-gray-500 py-12 text-xl"
>
Aucun souvenir à afficher pour le moment.
</div>
</div></ng-container
></ng-container
>
</div>
<div
*ngIf="!loading && pins.length === 0"
class="flex flex-col items-center justify-center h-64 space-y-6"
>
<p class="text-xl text-gray-800 text-center">
Commencez à créer votre histoire en ajoutant des souvenirs sur la carte !
</p>
<button
(click)="openPinModal()"
class="px-6 py-3 bg-gray-800 text-white rounded-lg hover:bg-gray-700 transition-colors duration-200 shadow-lg"
>
Ajouter un souvenir
</button>
</div>

@ -1,140 +0,0 @@
import { CommonModule, ViewportScroller } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { NavigationEnd, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { filter } from 'rxjs/operators';
import { Pin } from '../../model/Pin';
import { ImageService } from '../../services/image/image.service';
import { ModalService } from '../../services/modal/modal.service';
import { PinService } from '../../services/pin/pin.service';
@Component({
selector: 'app-timeline',
standalone: true,
imports: [CommonModule],
templateUrl: './timeline.component.html',
})
export class TimelineComponent implements OnInit, OnDestroy {
pins: Pin[] = [];
imageUrls: SafeUrl[][] = [];
loading = true;
groupedPins: { [year: string]: Pin[] } = {};
sortedYears: string[] = [];
carouselIndexes: number[] = [];
expandedDescriptions: { [index: number]: boolean } = {};
private navigationSubscription: Subscription;
constructor(
private pinService: PinService,
private imageService: ImageService,
private sanitizer: DomSanitizer,
private modalService: ModalService,
private router: Router,
private viewportScroller: ViewportScroller
) {
// Écouter les événements de navigation
this.navigationSubscription = this.router.events
.pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => {
// Attendre que le contenu soit chargé
if (!this.loading) {
this.restoreScrollPosition();
}
});
}
ngOnDestroy() {
// Nettoyer la souscription lors de la destruction du composant
if (this.navigationSubscription) {
this.navigationSubscription.unsubscribe();
}
}
private restoreScrollPosition() {
const scrollPosition = sessionStorage.getItem('timelineScrollPosition');
if (scrollPosition) {
window.scrollTo({
top: parseInt(scrollPosition),
behavior: 'smooth',
});
sessionStorage.removeItem('timelineScrollPosition');
}
}
openPinModal() {
this.modalService.openModal('add-pin-modal');
}
navigateToPinOnMap(pinId: string) {
const scrollPosition = window.scrollY;
sessionStorage.setItem('timelineScrollPosition', scrollPosition.toString());
this.router.navigate(['/map'], { queryParams: { pin: pinId } });
}
ngOnInit(): void {
this.pinService.getPins().subscribe((pins: Pin[]) => {
this.pins = pins
.filter((pin) => !!pin.date)
.sort((a, b) => (a.date! > b.date! ? 1 : -1));
this.imageUrls = this.pins.map(() => []); // initialise le tableau d'images
this.pins.forEach((pin, index) => {
if (pin.files && pin.files.length > 0) {
pin.files.forEach((imageId) => {
this.imageService.getImage(imageId).subscribe((blob) => {
const objectUrl = URL.createObjectURL(blob);
const safeUrl = this.sanitizer.bypassSecurityTrustUrl(objectUrl);
this.imageUrls[index].push(safeUrl);
});
});
}
});
this.carouselIndexes = this.pins.map(() => 0);
this.loading = false;
this.groupPinsByYear();
// Attendre que le DOM soit mis à jour avant de restaurer la position
setTimeout(() => {
this.restoreScrollPosition();
}, 100);
});
}
private groupPinsByYear(): void {
this.groupedPins = {};
for (const pin of this.pins) {
const year = new Date(pin.date!).getFullYear().toString();
if (!this.groupedPins[year]) {
this.groupedPins[year] = [];
}
this.groupedPins[year].push(pin);
}
// Trie les pins dans chaque groupe (au cas où)
for (const year in this.groupedPins) {
this.groupedPins[year].sort((a, b) => a.date!.localeCompare(b.date!));
}
// Trie les années dans l'ordre croissant (utilisé dans le template)
this.sortedYears = Object.keys(this.groupedPins).sort((a, b) => +a - +b);
}
nextImage(index: number) {
const images = this.imageUrls[index];
this.carouselIndexes[index] =
(this.carouselIndexes[index] + 1) % images.length;
}
prevImage(index: number) {
const images = this.imageUrls[index];
this.carouselIndexes[index] =
(this.carouselIndexes[index] - 1 + images.length) % images.length;
}
toggleDescription(index: number): void {
this.expandedDescriptions[index] = !this.expandedDescriptions[index];
}
}

@ -0,0 +1,32 @@
import { Monument } from '../model/Monument';
export const monuments: Monument[] = [
{
coords: [48.85837, 2.294481],
name: 'Tour Eiffel',
images: [
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQGGib245tSFiK1Qcx0cB0dZsoVyJElwsY3kA&s',
'https://encrypted-tbn2.gstatic.com/licensed-image?q=tbn:ANd9GcTLB9B0j50rJbcSbdja9_hySHS6_KATbhTK_iCeWeNKtA92hTmTX5nTW3udjjovZrnU1JxqAjMS_VqHnMwHGhTs35-sU-7B29_X_T3uLV8',
],
description: 'Visité en 2020 avec la famille, un moment inoubliable.',
visited: true,
},
{
coords: [43.296482, 5.36978],
name: 'Vieux Port de Marseille',
images: [
'https://upload.wikimedia.org/wikipedia/commons/thumb/7/74/Marseille_Old_Port.jpg/390px-Marseille_Old_Port.jpg',
],
description:
"Découvert lors d'un week-end ensoleillé en 2019 avec @John-Doe.",
visited: true,
},
{
coords: [48.636063, -1.511457],
name: 'Mont Saint-Michel',
images: [],
description: '',
visited: false,
},
// ...
];

@ -1,6 +0,0 @@
export interface AuthResponse {
access_token: string;
token_type: string;
user_id: string;
is_admin: boolean;
}

@ -0,0 +1,6 @@
export interface Monument {
location: number[];
title: string;
files: string[];
description: string;
}

@ -1,11 +0,0 @@
export interface Pin {
id: string;
location: number[];
complete_address: string;
title: string;
files: string[];
description: string;
is_poi: boolean;
user_id: string;
date?: string;
}

@ -1,13 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { ExifService } from './exif.service';
import { AddPinService } from './add-pin.service';
describe('ExifService', () => {
let service: ExifService;
describe('AddPinService', () => {
let service: AddPinService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ExifService);
service = TestBed.inject(AddPinService);
});
it('should be created', () => {

@ -0,0 +1,45 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { switchMap } from 'rxjs';
import { environment } from '../../environments/environment';
import { AutocompleteService } from './auto-complete.service';
@Injectable({
providedIn: 'root',
})
export class AddPinService {
private apiURL = environment.apiURL;
private token = localStorage.getItem('auth_token');
constructor(
private http: HttpClient,
private autoCompleteService: AutocompleteService
) {}
addPin(pin: {
title: string;
description: string;
location: string;
files: any[];
}) {
const url = `${this.apiURL}/pin/add`;
const headers = new HttpHeaders({
'Content-Type': 'application/json',
Authorization: 'Bearer ' + this.token,
});
return this.autoCompleteService.getAdressCoordinates(pin.location).pipe(
switchMap((response: any) => {
const coords: [string, string] = [response[0].lat, response[0].lon];
return this.http.post<any>(
url,
{
title: pin.title,
description: pin.description,
location: coords,
files: pin.files,
},
{ headers }
);
})
);
}
}

@ -1,104 +0,0 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, tap } from 'rxjs';
import { environment } from '../../../environment';
import { AuthResponse } from '../../model/AuthResponse';
import { CookiesService } from '../cookies/cookies.service';
@Injectable({
providedIn: 'root',
})
export class AuthService {
private isAdminSubject = new BehaviorSubject<boolean>(false);
private userIdSubject = new BehaviorSubject<string>('');
isAdmin$ = this.isAdminSubject.asObservable();
username$ = new BehaviorSubject<string>('');
isLoggedIn$ = new BehaviorSubject<boolean>(false);
userId$ = this.userIdSubject.asObservable();
constructor(
private http: HttpClient,
private cookiesService: CookiesService
) {
const token = this.cookiesService.getToken();
if (token) {
this.isAdminSubject.next(this.cookiesService.getIsAdmin() === 'true');
this.username$.next(this.cookiesService.getUsername() || '');
this.userIdSubject.next(this.cookiesService.getUserId() || '');
this.isLoggedIn$.next(true);
}
}
login(username: string, password: string): Observable<AuthResponse> {
const payload = new HttpParams()
.set('username', username)
.set('password', password);
return this.http
.post<AuthResponse>(`${environment.apiURL}/login`, payload)
.pipe(
tap((response) => {
this.cookiesService.setToken(response.access_token);
this.cookiesService.setIsAdmin(response.is_admin);
this.cookiesService.setUsername(username);
this.cookiesService.setUserId(response.user_id);
this.isAdminSubject.next(response.is_admin);
this.username$.next(username);
this.userIdSubject.next(response.user_id);
this.isLoggedIn$.next(true);
})
);
}
logout(): void {
this.cookiesService.removeToken();
this.cookiesService.removeIsAdmin();
this.cookiesService.removeUsername();
this.cookiesService.removeUserId();
this.isAdminSubject.next(false);
this.username$.next('');
this.userIdSubject.next('');
this.isLoggedIn$.next(false);
}
register(username: string, password: string): Observable<AuthResponse> {
return this.http
.post<AuthResponse>(`${environment.apiURL}/register`, {
username,
password,
})
.pipe(
tap((response) => {
this.cookiesService.setToken(response.access_token);
this.cookiesService.setIsAdmin(response.is_admin);
this.cookiesService.setUsername(username);
this.cookiesService.setUserId(response.user_id);
this.isAdminSubject.next(response.is_admin);
this.username$.next(username);
this.userIdSubject.next(response.user_id);
this.isLoggedIn$.next(true);
})
);
}
isAdmin(): boolean {
return this.isAdminSubject.value || false;
}
getAuthHeaders(): HttpHeaders {
const token = this.cookiesService.getToken();
return new HttpHeaders().set('Authorization', `Bearer ${token}`);
}
getUsername(): string {
return this.username$.value || '';
}
getUserId(): string {
return this.userIdSubject.value || '';
}
isLoggedIn(): boolean {
return this.isLoggedIn$.value || false;
}
}

@ -1,13 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { AutocompleteService } from './auto-complete.service';
import { AutoCompleteService } from './auto-complete.service';
describe('AutocompleteService', () => {
let service: AutocompleteService;
describe('AutoCompleteService', () => {
let service: AutoCompleteService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(AutocompleteService);
service = TestBed.inject(AutoCompleteService);
});
it('should be created', () => {

@ -31,16 +31,4 @@ export class AutocompleteService {
},
});
}
getAddressFromCoordinates(lat: number, lon: number): Observable<any> {
return this.http.get(this.apiUrl + '/reverse', {
params: {
lat: lat.toString(),
lon: lon.toString(),
format: 'json',
addressdetails: '1',
},
});
}
}

@ -1,106 +0,0 @@
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { CookieService } from 'ngx-cookie-service';
import { ModalService } from '../modal/modal.service';
@Injectable({
providedIn: 'root',
})
export class CookiesService {
private readonly AUTH_TOKEN_KEY = 'auth_token';
private readonly USERNAME_KEY = 'username';
private readonly USER_ID = 'userId';
private readonly IS_ADMIN_KEY = 'isAdmin';
private readonly COOKIE_OPTIONS = {
path: '/',
domain: window.location.hostname,
secure: true,
sameSite: 'Strict' as const,
};
constructor(
private router: Router,
private modalService: ModalService,
private cookieService: CookieService
) {}
setToken(token: string): void {
this.cookieService.set(this.AUTH_TOKEN_KEY, token, this.COOKIE_OPTIONS);
}
setUsername(username: string): void {
this.cookieService.set(this.USERNAME_KEY, username, this.COOKIE_OPTIONS);
}
setUserId(user_id: string): void {
this.cookieService.set(this.USER_ID, user_id, this.COOKIE_OPTIONS);
}
getUserId(): string | null {
return this.cookieService.get(this.USER_ID) || null;
}
getUsername(): string | null {
return this.cookieService.get(this.USERNAME_KEY) || null;
}
removeUsername(): void {
this.cookieService.delete(
this.USERNAME_KEY,
this.COOKIE_OPTIONS.path,
this.COOKIE_OPTIONS.domain
);
}
getToken(): string | null {
const token = this.cookieService.get(this.AUTH_TOKEN_KEY);
if (token) {
const payload = JSON.parse(atob(token.split('.')[1]));
const expirationDate = new Date(payload.exp * 1000);
if (expirationDate < new Date()) {
this.removeToken();
this.router.navigate(['/']).then(() => {
this.modalService.openModal('login-modal');
});
return null;
}
}
return token || null;
}
removeToken(): void {
this.cookieService.delete(
this.AUTH_TOKEN_KEY,
this.COOKIE_OPTIONS.path,
this.COOKIE_OPTIONS.domain
);
}
removeUserId(): void {
this.cookieService.delete(
this.USER_ID,
this.COOKIE_OPTIONS.path,
this.COOKIE_OPTIONS.domain
);
}
setIsAdmin(isAdmin: boolean): void {
this.cookieService.set(
this.IS_ADMIN_KEY,
isAdmin.toString(),
this.COOKIE_OPTIONS
);
}
getIsAdmin(): string | null {
return this.cookieService.get(this.IS_ADMIN_KEY) || null;
}
removeIsAdmin(): void {
this.cookieService.delete(
this.IS_ADMIN_KEY,
this.COOKIE_OPTIONS.path,
this.COOKIE_OPTIONS.domain
);
}
}

@ -1,34 +0,0 @@
import { Injectable } from '@angular/core';
import * as exifr from 'exifr';
@Injectable({
providedIn: 'root',
})
export class ExifService {
async getOrientation(file: File): Promise<number | undefined> {
try {
return await exifr.orientation(file);
} catch (error) {
return undefined;
}
}
async getLocation(
file: File
): Promise<{ latitude?: number; longitude?: number }> {
try {
return exifr.gps(file);
} catch (error) {
return {};
}
}
async getDateTime(file: File): Promise<string> {
try {
const data = await exifr.parse(file);
return data.DateTimeOriginal.toISOString();
} catch (error) {
return '';
}
}
}

@ -1,60 +0,0 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from '../../../environment';
import { AuthService } from '../auth/auth.service';
@Injectable({
providedIn: 'root',
})
export class FriendsService {
private apiURL = environment.apiURL;
constructor(private http: HttpClient, private authService: AuthService) {}
getFriend() {
const url = `${this.apiURL}/friends`;
const headers = this.authService.getAuthHeaders();
headers.set('Content-Type', 'application/json');
return this.http.get<any[]>(url, { headers });
}
getFriendById(id: string) {
const url = `${this.apiURL}/user/${id}`;
const headers = this.authService.getAuthHeaders();
headers.set('Content-Type', 'application/json');
return this.http.get<any>(url, { headers });
}
addFriend(user_id: string) {
const url = `${this.apiURL}/friend/add`;
const headers = this.authService.getAuthHeaders();
headers.set('Content-Type', 'application/json');
return this.http.post<any>(url, { friend_user_id: user_id }, { headers });
}
acceptFriendById(id: string) {
const url = `${this.apiURL}/friend/${id}/accept`;
const headers = this.authService.getAuthHeaders();
headers.set('Content-Type', 'application/json');
return this.http.patch<any>(url, [], { headers });
}
denyFriendById(id: string) {
const url = `${this.apiURL}/friend/${id}/deny`;
const headers = this.authService.getAuthHeaders();
headers.set('Content-Type', 'application/json');
return this.http.delete<any>(url, { headers });
}
deleteFriend(id: string) {
const url = `${this.apiURL}/friend/${id}/delete`;
const headers = this.authService.getAuthHeaders();
headers.set('Content-Type', 'application/json');
return this.http.delete<any>(url, { headers });
}
}

@ -1,13 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { ImageService } from './image.service';
import { GetPinService } from './get-pin.service';
describe('ImageService', () => {
let service: ImageService;
describe('GetPinService', () => {
let service: GetPinService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ImageService);
service = TestBed.inject(GetPinService);
});
it('should be created', () => {

@ -0,0 +1,21 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root',
})
export class GetPinService {
private apiURL = environment.apiURL;
private token = localStorage.getItem('auth_token');
constructor(private http: HttpClient) {}
getPins(): any {
const url = `${this.apiURL}/pins`;
const headers = new HttpHeaders({
'Content-Type': 'application/json',
Authorization: 'Bearer ' + this.token,
});
return this.http.get<any>(url, { headers });
}
}

@ -1,47 +0,0 @@
import { Injectable } from '@angular/core';
import { environment } from '../../../environment';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
import { AuthService } from '../auth/auth.service';
@Injectable({
providedIn: 'root'
})
export class ImageService {
private apiUrl = environment.apiURL;
constructor(
private http: HttpClient,
private authService: AuthService
) { }
getImage(imageId: string): Observable<Blob> {
const headers = this.authService.getAuthHeaders();
return this.http.get(`${this.apiUrl}/image/${imageId}`, {
headers,
responseType: 'blob'
});
}
postImage(image: File, date: string): Observable<any> {
let url = `${this.apiUrl}/image/pin/null/add`;
const headers = this.authService.getAuthHeaders();
const formData = new FormData();
formData.append('image', image);
if(date !== '') {
url += `?exif_date=${date}`;
}
return this.http.post(url, formData, { headers });
}
getImageMetadata(imageId: string): Observable<any> {
const headers = this.authService.getAuthHeaders();
return this.http.get(`${this.apiUrl}/image/${imageId}/metadata`, { headers });
}
}

@ -1,16 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { IntroService } from './intro.service';
describe('IntroService', () => {
let service: IntroService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(IntroService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

@ -1,138 +0,0 @@
import { Injectable } from '@angular/core';
import introJs from 'intro.js';
import { ModalService } from '../modal/modal.service';
import { NavbarService } from '../navbar/navbar.service';
@Injectable({
providedIn: 'root'
})
export class IntroService {
constructor(
private modalService: ModalService,
private navbarService: NavbarService
) {}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async startIntro() {
await new Promise<void>((resolve) => {
const intro = introJs();
intro.setOptions({
tooltipClass: 'custom-tooltip-with-avatar',
steps: [
{ intro: 'Bienvenue sur MemoryMap ! ' },
{ intro: 'Ensemble nous allons explorer les différentes fonctionnalités disponibles !'},
],
exitOnOverlayClick: false,
disableInteraction: false,
});
intro.oncomplete(() => {
resolve();
});
intro.start();
});
await this.sleep(300);
await new Promise<void>((resolve) => {
const intro = introJs();
intro.setOptions({
tooltipClass: 'custom-tooltip-with-avatar',
steps: [
{ element: '#timeline', intro: 'Ici retrouvez tous vos souvenirs grâce à une frise chronologique de vos voyages!' },
{ element: '#quete', intro: "N'hésitez pas à réaliser les différentes quêtes, pour un petit plaisir personnel, que vous pourrez retrouver ici !" },
],
exitOnOverlayClick: false,
disableInteraction: true,
});
intro.onstart(async () => {
this.navbarService.onpenNavbar();
await this.sleep(100);
});
intro.oncomplete(() => {
resolve();
});
intro.start();
});
await this.sleep(300);
await new Promise<void>((resolve) => {
const intro = introJs();
intro.setOptions({
tooltipClass: 'introjs-tooltip custom-tooltip-with-avatar',
steps: [
{ element: '#add', intro: "Le point important : l'ajout de pin ! <br>Allons voir ensemble comment cela fonctionne." },
{ element: '#add-pin-modal-title', intro: 'Ajoutez le titre de votre pin, le lieu du pin par exemple !' },
{ element: '#add-pin-modal-image', intro: 'Glissez et déposez toutes les images que vous souhaitez ! <br> <br>TIP : La localisation de la première image sera récupérée automatiquement 😎' },
{ element: '#add-pin-modal-localisation', intro: "Si l'adresse n'a pas été récupérée automatiquement, vous pouvez la renseigner manuellement." },
{ element: '#add-pin-modal-description', intro: 'Une petite description de votre voyage pour vous souvenir des points importants !' },
{ element: '#add-pin-modal-date', intro: 'Ajoutez la date de votre voyage, très important pour la frise chronologique !' },
{ element: '#add-pin-modal-validate', intro: "Et voilà vous n'avez plus qu'à valider et ajouter votre pin !" },
],
exitOnOverlayClick: false,
disableInteraction: true
});
intro.onchange(async (element) => {
if (element?.id === 'add-pin-modal-title') {
this.modalService.openModal('add-pin-modal');
await this.sleep(300);
}
});
intro.onexit(() => {
this.modalService.closeModal('add-pin-modal');
resolve();
});
intro.oncomplete(() => {
this.modalService.closeModal('add-pin-modal');
resolve();
});
intro.start();
});
await this.sleep(300);
await new Promise<void>((resolve) => {
const intro = introJs();
intro.setOptions({
tooltipClass: 'custom-tooltip-with-avatar',
steps: [
{ element: '#friend', intro: "Memory Map, c'est aussi du social. Voyons voir comment ajouter un ami !" },
{ element: '#friend-search-bar', intro: "Cherchez votre ami avec son pseudo afin de lui envoyer une demande d'ami !" },
{ element: '#friend-list', intro: "Ici vous retrouverez vos amis, ainsi que vos demandes d'amis, acceptez ou refusez les demandes en attente." }
],
exitOnOverlayClick: false,
disableInteraction: true,
});
intro.onchange(async (element) => {
if (element?.id === 'friend-search-bar') {
this.modalService.openModal('friend-modal');
await this.sleep(300);
}
});
intro.onexit(() => {
this.modalService.closeModal('friend-modal');
resolve();
});
intro.oncomplete(() => {
this.modalService.closeModal('friend-modal');
this.navbarService.closeNavbar();
resolve();
});
intro.start();
});
}
}

@ -0,0 +1,23 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class LocalStorageService {
private readonly AUTH_TOKEN_KEY = 'auth_token';
constructor() { }
setToken(token: string): void {
localStorage.setItem(this.AUTH_TOKEN_KEY, token);
}
getToken(): string | null {
return localStorage.getItem(this.AUTH_TOKEN_KEY);
}
removeToken(): void {
localStorage.removeItem(this.AUTH_TOKEN_KEY);
}
}

@ -1,13 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { AuthService } from './auth.service';
import { LoginService } from './login.service';
describe('AuthService', () => {
let service: AuthService;
describe('LoginService', () => {
let service: LoginService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(AuthService);
service = TestBed.inject(LoginService);
});
it('should be created', () => {

@ -0,0 +1,21 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root',
})
export class LoginService {
private apiUrl = environment.apiURL;
constructor(private http: HttpClient) {}
login(username: string, password: string): Observable<any> {
const payload = new HttpParams()
.set('username', username)
.set('password', password);
return this.http.post(this.apiUrl + '/login', payload);
}
}

@ -1,16 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { MapReloadService } from './map-reload.service';
describe('MapReloadService', () => {
let service: MapReloadService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(MapReloadService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

@ -1,14 +0,0 @@
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class MapReloadService {
private reloadSubject = new Subject<void>();
public reload$ = this.reloadSubject.asObservable();
requestReload(): void {
this.reloadSubject.next();
}
}

@ -1,16 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { ModalService } from './modal.service';
describe('ModalService', () => {
let service: ModalService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ModalService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

@ -1,41 +0,0 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class ModalService {
private modals: Map<string, BehaviorSubject<boolean>> = new Map();
private imageFilesSubject = new BehaviorSubject<File[] | null>(null);
private formDataSubject = new BehaviorSubject<any>(null);
getModalState(id: string): BehaviorSubject<boolean> {
if (!this.modals.has(id)) {
this.modals.set(id, new BehaviorSubject<boolean>(false));
}
return this.modals.get(id)!;
}
openModal(id: string, images?: File[], formData?: any) {
if (images) {
this.imageFilesSubject.next(images);
}
if (formData) {
this.formDataSubject.next(formData);
}
this.getModalState(id).next(true);
}
closeModal(id: string) {
this.getModalState(id).next(false);
this.imageFilesSubject.next(null);
this.formDataSubject.next(null);
}
getImageFiles(): BehaviorSubject<File[] | null> {
return this.imageFilesSubject;
}
getFormData(): BehaviorSubject<any> {
return this.formDataSubject;
}
}

@ -1,29 +0,0 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class NavbarService {
private isSearchOpenSubject = new BehaviorSubject<boolean>(false);
private isNavbarOpenSubject = new BehaviorSubject<boolean>(false);
isSearchOpen$ = this.isSearchOpenSubject.asObservable();
isNavbarOpen$ = this.isNavbarOpenSubject.asObservable();
toggleSearch(): void {
this.isSearchOpenSubject.next(!this.isSearchOpenSubject.value);
}
toggleNavbar(): void {
this.isNavbarOpenSubject.next(!this.isNavbarOpenSubject.value);
}
onpenNavbar(): void {
this.isNavbarOpenSubject.next(true);
}
closeNavbar(): void {
this.isNavbarOpenSubject.next(false);
}
}

@ -1,16 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { PinService } from './pin.service';
describe('PinService', () => {
let service: PinService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(PinService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

@ -1,92 +0,0 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { map } from 'rxjs/operators';
import { environment } from '../../../environment';
import { Pin } from '../../model/Pin';
import { AuthService } from '../auth/auth.service';
@Injectable({
providedIn: 'root',
})
export class PinService {
allPins: Pin[] = [];
filteredPins: Pin[] = [];
private apiURL = environment.apiURL;
constructor(private http: HttpClient, private authService: AuthService) {}
getPinById(id: string) {
const url = `${this.apiURL}/pin/${id}`;
const headers = this.authService.getAuthHeaders();
headers.set('Content-Type', 'application/json');
return this.http.get<Pin>(url, { headers });
}
getPins(): any {
const url = `${this.apiURL}/pins`;
const headers = this.authService.getAuthHeaders();
headers.set('Content-Type', 'application/json');
return this.http.get<any>(url, { headers });
}
addPin(pin: Pin) {
const url = `${this.apiURL}/pin/add`;
const headers = this.authService.getAuthHeaders();
headers.set('Content-Type', 'application/json');
return this.http.post<any>(url, pin, { headers });
}
updatePin(id: string, pin: Pin) {
const url = `${this.apiURL}/pin/${id}`;
const headers = this.authService.getAuthHeaders();
headers.set('Content-Type', 'application/json');
// Obtenir les coordonnées GPS à partir de l'adresse
return this.http.patch<any>(url, pin, { headers });
}
deletePin(id: string) {
const url = `${this.apiURL}/pin/${id}`;
const headers = this.authService.getAuthHeaders();
headers.set('Content-Type', 'application/json');
return this.http.delete<any>(url, { headers });
}
sharePin(pinId: string, friendId: string) {
const url = `${this.apiURL}/pin/${pinId}/share`;
const headers = this.authService.getAuthHeaders();
headers.set('Content-Type', 'application/json');
return this.http.post<any>(url, { friend_id: friendId }, { headers });
}
getPinShares(pinId: string) {
const url = `${this.apiURL}/pin/${pinId}/shares`;
const headers = this.authService.getAuthHeaders();
headers.set('Content-Type', 'application/json');
return this.http.get<any>(url, { headers });
}
getSharedUsersForPin(pinId: string) {
const url = `${this.apiURL}/pin/${pinId}/shares`;
const headers = this.authService.getAuthHeaders();
headers.set('Content-Type', 'application/json');
// On retourne la liste directe des partages (utilisateurs avec qui le pin est partagé)
return this.http.get<{ shares: any[] }>(url, { headers }).pipe(
map((response) => response.shares) // Ne garder que la liste dutilisateurs
);
}
deletePinShare(pinId: string, friendId: string) {
const url = `${this.apiURL}/pin/${pinId}/share/${friendId}`;
const headers = this.authService.getAuthHeaders();
headers.set('Content-Type', 'application/json');
return this.http.delete<any>(url, { headers });
}
}

@ -1,13 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { FriendsService } from './friends.service';
import { RegisterService } from './register.service';
describe('FriendsService', () => {
let service: FriendsService;
describe('RegisterService', () => {
let service: RegisterService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(FriendsService);
service = TestBed.inject(RegisterService);
});
it('should be created', () => {

@ -0,0 +1,17 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root',
})
export class RegisterService {
private apiUrl = environment.apiURL;
constructor(private http: HttpClient) {}
register(username: string, password: string): Observable<any> {
return this.http.post(this.apiUrl + '/register', { username, password });
}
}

@ -1,16 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { UserService } from './user.service';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UserService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save