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,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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
Before Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 89 KiB |
Before Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 45 KiB |
Before Width: | Height: | Size: 154 KiB |
Before Width: | Height: | Size: 257 KiB |
Before Width: | Height: | Size: 7.0 KiB |
Before Width: | Height: | Size: 12 KiB |
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,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,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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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,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>
|
||||
|
@ -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;
|
||||
}
|
@ -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,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,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,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', () => {
|
@ -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,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();
|
||||
});
|
||||
});
|