Compare commits

..

174 Commits

Author SHA1 Message Date
Alix JEUDI--LEMOINE bb0c0eb046 Removed query parameter subscription on renderPins and added a check to open the popup of a selected marker (fix popup opens if pin in cluster).
continuous-integration/drone/push Build is passing Details
5 hours ago
Alix JEUDI--LEMOINE 796efb2119 🔧 Updated production command in package.json to disable hot reload and sleep mode.
continuous-integration/drone/push Build is passing Details
15 hours ago
Alix JEUDI--LEMOINE e05a7593d5 🔧 Increased budget size limits in angular.json, edited the index path in ngsw-config.json, and added a new Vite configuration file.
15 hours ago
Alix JEUDI--LEMOINE b28033ae33 🔧 Simplified HTTP calls to services by removing redundant authentication headers.
continuous-integration/drone/push Build is passing Details
15 hours ago
Alix JEUDI--LEMOINE d7283bcf20 Added an HTTP interceptor to handle tokens and updated cookie handling to include the refresh token. Modified the HTTP configuration to use the interceptor.
15 hours ago
Alix JEUDI--LEMOINE 93088776ce Added redirection from home to the map page if the user is logged + removed the routerLink to home in the Navbar component.
continuous-integration/drone/push Build is passing Details
17 hours ago
Alix JEUDI--LEMOINE 930add4452 🔄 Delete AuthService from the PushService (loop injection). Using CookiesService for token management and add unsubscribe method.
continuous-integration/drone/push Build is passing Details
18 hours ago
Alix JEUDI--LEMOINE 33cb033320 Push service integration into AuthService to handle login/register and logout.
18 hours ago
Alix JEUDI--LEMOINE ce2615ca35 🔒Improved token management: delete ENTIRE local user data when the token expires.
18 hours ago
Alix JEUDI--LEMOINE 15e2ebd5f8 🗑️ Removed notification subscription buttons from the navigation bar.
18 hours ago
Alix JEUDI--LEMOINE 1b0ef7a351 🧹 Removed useless import in app component
18 hours ago
Alix JEUDI--LEMOINE 546b1b19f6 🚧 Test add icon-128x128.png asset for push notifications.
continuous-integration/drone/push Build is passing Details
1 day ago
Alix JEUDI--LEMOINE cf4aff473c 🔒 Update COOKIE_OPTIONS to handle secure cookies based on environment (localhost vs production).
1 day ago
Alix JEUDI--LEMOINE 53ce0dc21f Merge branch 'master' of https://codefirst.iut.uca.fr/git/SAE3A_MemoryMap/front
continuous-integration/drone/push Build is passing Details
2 days ago
Alix JEUDI--LEMOINE eb526be367 Added a button to subscribe to notifications in the navbar and integrated Push service to manage notifications.
2 days ago
Alix JEUDI--LEMOINE 8e0b1123fc Added Push service for managing notifications.
2 days ago
Alexis Feron 95e53c02d8 🐛 Fix add modal name on navbar
continuous-integration/drone/push Build is passing Details
3 days ago
Mathis FRAMIT c3471c48fb 💄 detail pin page
continuous-integration/drone/push Build is passing Details
3 days ago
Alexis Feron 210359e500 🎨 Change the color of shared pins
continuous-integration/drone/push Build is passing Details
6 days ago
Alexis Feron 3c6bf958c1 🐛 Fix add & register modal openning
continuous-integration/drone/push Build is passing Details
6 days ago
Maxence JOUANNET a4f9ae6e0d fix poi on map
continuous-integration/drone/push Build is passing Details
6 days ago
Alexis Feron 92d573819e Add deletion to share
continuous-integration/drone/push Build is passing Details
6 days ago
Maxence JOUANNET 38ed20ea83 change interface pin for poi
continuous-integration/drone/push Build is passing Details
1 week ago
Alix JEUDI--LEMOINE e87cee5ab3 Enhanced map view adjustment for all selections.
continuous-integration/drone/push Build is passing Details
1 week ago
Mathis FRAMIT 4e01b39da7 update pin and user services
continuous-integration/drone/push Build is passing Details
1 week ago
Mathis FRAMIT 4f5f78b56d 💄 pin marker design changes
1 week ago
Mathis FRAMIT 30534df9ae 💥 pin detail page
1 week ago
Maxence JOUANNET b57475551c add pin for quete page
continuous-integration/drone/push Build is passing Details
1 week ago
Alix JEUDI--LEMOINE 3041a4dec5 🔍 Updated placeholder text in navbar search input for consistency and improved user experience.
continuous-integration/drone/push Build is passing Details
1 week ago
Alix JEUDI--LEMOINE 4d76c71cba Added functionality to adjust the view based on selected country.
continuous-integration/drone/push Build is passing Details
1 week ago
Alix JEUDI--LEMOINE fcc9c59ca9 Added leaflet.markercluster for improved map marker management and updated styles in angular.json. Updated package.json and package-lock.json to include new dependencies.
continuous-integration/drone/push Build is passing Details
1 week ago
Alix JEUDI--LEMOINE f95f5cd56e 🐛 Enhanced error handling and improved asynchronous processing in EditPinPopup component. Updated EXIF data retrieval and file name extraction logic.
continuous-integration/drone/push Build is passing Details
1 week ago
Alix JEUDI--LEMOINE d28868896f 🚑 CI/CD down (containers deleted by admin)
continuous-integration/drone/push Build is passing Details
1 week ago
Alexis Feron d2c5a76993 🚸 Improved timeline
continuous-integration/drone/push Build encountered an error Details
2 weeks ago
Alix JEUDI--LEMOINE 424c3567eb Refactor the submitForm methods in AddPinPopup and EditPinPopup to handle EXIF ​​dates and make the functions asynchronous.
continuous-integration/drone/push Build is passing Details
2 weeks ago
Alix JEUDI--LEMOINE 46d830b2f7 ️ Modified the postImage method in the Image service to include the EXIF ​​date in the URL if available.
2 weeks ago
Alix JEUDI--LEMOINE 261c7033a1 Updated the EXIF ​​service to simplify EXIF ​​data handling.
2 weeks ago
Alix JEUDI--LEMOINE 9515db02bf 🔄 Replaced the LocalStorage service with the Cookies service in the AuthGuard, LoginPage, and AuthService components. Updated tests and imports accordingly.
2 weeks ago
Alix JEUDI--LEMOINE 53cb0d72d2 Added ngx-cookie-service for cookie management and replaced the old local storage service. Updated imports and dependencies in the relevant files.
2 weeks ago
Alexis Feron 5a000cc03c 💄 Timeline ui fix
continuous-integration/drone/push Build is passing Details
2 weeks ago
Alexis Feron 7d510e86b1 🐛 Fix timeline
continuous-integration/drone/push Build is passing Details
2 weeks ago
Mathis FRAMIT a8576a2501 Merge branch 'master' of https://codefirst.iut.uca.fr/git/SAE3A_MemoryMap/front
continuous-integration/drone/push Build is passing Details
2 weeks ago
Mathis FRAMIT a28e9b11ab fix typo : "rechercher un pin"
2 weeks ago
Maxence JOUANNET f808aa5f93 fix intro
continuous-integration/drone/push Build is passing Details
2 weeks ago
Alix JEUDI--LEMOINE 9f3507ed49 🔧 Update service worker configuration to change index file path from '/index.html' to '/'
continuous-integration/drone/push Build is passing Details
2 weeks ago
Alix JEUDI--LEMOINE 6b56bdc42b 🎨 Mise à jour des styles de la popup personnalisée et ajustement des dimensions maximales dans le composant LeafletMap.
continuous-integration/drone/push Build is passing Details
2 weeks ago
Alix JEUDI--LEMOINE e64bae7b5f 🎨 Adjusted the adventurer position and descriptions in the intro service.
continuous-integration/drone/push Build is passing Details
2 weeks ago
Alix JEUDI--LEMOINE 0807a61fd5 ♻️ Fixed spelling mistakes and improved descriptions in the intro service.
continuous-integration/drone/push Build is passing Details
2 weeks ago
Maxence JOUANNET ca353e8cb9 fix
continuous-integration/drone/push Build is failing Details
2 weeks ago
Maxence JOUANNET ddcfc06241 Merge branch 'master' into tutorial
continuous-integration/drone/push Build is passing Details
2 weeks ago
Maxence JOUANNET 453f6d05b6 end of intro
continuous-integration/drone/push Build is passing Details
2 weeks ago
Alexis Feron 3aa1ba2eeb 🚸 Improve filters on mobile
continuous-integration/drone/push Build is passing Details
2 weeks ago
Maxence JOUANNET f92f412fcd intro for phone
continuous-integration/drone/push Build is passing Details
2 weeks ago
Maxence JOUANNET aeb2d58684 texte intro
continuous-integration/drone/push Build is passing Details
2 weeks ago
Mathis FRAMIT 3706192f72 💄 UI pin marker
continuous-integration/drone/push Build is passing Details
2 weeks ago
Alix JEUDI--LEMOINE ed86df940b Integrate NavbarService for improved navbar and search state management in NavbarComponent
continuous-integration/drone/push Build is passing Details
2 weeks ago
Maxence JOUANNET c0bf95f377 merge master
continuous-integration/drone/push Build is passing Details
2 weeks ago
Alix JEUDI--LEMOINE 8c43bc57e2 Add tutorial flag to map component and update navigation from register page
continuous-integration/drone/push Build is passing Details
2 weeks ago
Maxence JOUANNET d50f5d959d adding css style theme for intro
continuous-integration/drone/push Build is passing Details
2 weeks ago
Alexis Feron 3b44c026a3 💄 Align the zoom button on the leaflet map
continuous-integration/drone/push Build is passing Details
2 weeks ago
Alexis Feron ca7c0007fe 🚸 Improved share modal & updated user filter
continuous-integration/drone/push Build is passing Details
2 weeks ago
Maxence JOUANNET ec59956625 avancement tuto
continuous-integration/drone/push Build is passing Details
2 weeks ago
Mathis FRAMIT 4f983285f0 💄 new sliders controls in timeline page
continuous-integration/drone/push Build is passing Details
2 weeks ago
Mathis FRAMIT f84b3be085 button "see more" for long description in the popup pin
continuous-integration/drone/push Build is passing Details
2 weeks ago
Mathis FRAMIT 2e570f0951 💄 fix images in timeline
2 weeks ago
Alix JEUDI--LEMOINE 4279711d5c 🔒 Refactor authentication handling by replacing LocalStorage/Login/Register services with AuthService across components and services
continuous-integration/drone/push Build is passing Details
2 weeks ago
Alix JEUDI--LEMOINE 1109094599 🎨 Enhance zoom controls styling in the map component
continuous-integration/drone/push Build is passing Details
2 weeks ago
Alexis Feron d9c6da550a 📱 Responsive timeline
continuous-integration/drone/push Build is passing Details
2 weeks ago
Alix JEUDI--LEMOINE e18b20faf2 🔧 Update getFileNames method to handle string file inputs correctly (fix error in console when editing pin)
continuous-integration/drone/push Build is passing Details
2 weeks ago
Alix JEUDI--LEMOINE 15f0a697d8 Clean up modals on pin load by removing elements from the DOM when reloading pins
continuous-integration/drone/push Build is passing Details
2 weeks ago
Mathis FRAMIT 98134b8955 💄 fix elements on timeline
continuous-integration/drone/push Build is passing Details
2 weeks ago
Mathis FRAMIT 7189e80d56 🎨 timeline page
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE 4287038e2c 🚑 Fail in the api URL
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE 9d54907449 🔥 Remove unuseful css file in admin footer component
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE a7046f8231 🦺 Validate fields Titre/Localisation/Description on edit modal
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE c8404b4e4e ️ Use unique name for every modal + new event from parent to children to load content + load images when pin opened instead of when page loads
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE 530322ae2d ️ Unique name for confirm delete modal + new event from parent to children instead of NavigationEnd + all component in one div (avoid moving two divs in the body at the same time)
3 weeks ago
Alix JEUDI--LEMOINE 6b43cf7c25 ️ Check if modal is open for drag&drop events
3 weeks ago
Alix JEUDI--LEMOINE f3ea54f8af ️ Unique name for edit modal + fixed bug empty content after closing modal one time + new event from parent to children instead of NavigationEnd + all component in one div (avoid moving two divs in the body at the same time) + check if modal is open for drag&drop events
3 weeks ago
Alix JEUDI--LEMOINE 8c9b32a9ad 🐛 Changed openModal call to fit with the last edit
3 weeks ago
Alix JEUDI--LEMOINE db5c7f6514 ️ Unique name for modal share + new event from parent to children instead of NavigationEnd + all component in one div (avoid moving two divs in the body at the same time)
3 weeks ago
Alix JEUDI--LEMOINE 2f11aae5a2 ️ Improved modal service
3 weeks ago
Alix JEUDI--LEMOINE 438e733b9a 💄 Changed padding of popup content (leaflet)
3 weeks ago
Alix JEUDI--LEMOINE a7f6727400 🎨 Removed environments replace in angular.json
3 weeks ago
Alix JEUDI--LEMOINE 36af71b45c 🗑️ Remove unuseful import
3 weeks ago
Alix JEUDI--LEMOINE e315f6bb9a 🔥 Remove dev env
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE 6f2d0de9c2 🔐 Added admin footer to redirect to administration panel
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE 7fedd381c8 Merge branch 'master' of https://codefirst.iut.uca.fr/git/SAE3A_MemoryMap/front
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE 973c89f8b7 🥅 Error display in drag&drop component
3 weeks ago
Alix JEUDI--LEMOINE 98c6559e58 Add get image metadata in image service
3 weeks ago
Alix JEUDI--LEMOINE d7d87732cb Display already uploaded images name in edit pin popup + error handling
3 weeks ago
Alix JEUDI--LEMOINE c5888c9d8e Added error handling when uploading images and keeping existing files when adding another one
3 weeks ago
Alexis Feron f90a08dec4 🍱 Add a local avatar image asset
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alexis Feron 11310baf99 🐛 Fix the share modal window position and removal of some css
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE 28d2f95206 🦺 Add right click context menu + validators to pin add modal
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE 8857b2bdf4 🎨 Updated icon bg colors and adjusted view metadata to disable zooming on mobile.
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE ffbcfe73f5 💄 Change Z-index of filter menu to not override modals
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE bacf42b7df 💄 Move logout at bottom of burger menu on mobile UI
3 weeks ago
Alix JEUDI--LEMOINE efc3b424df ♻️ Remove unused imports
3 weeks ago
Alix JEUDI--LEMOINE 915f665210 🐛 Reset add pin form after modal close
3 weeks ago
Alix JEUDI--LEMOINE 4a5d46ed72 🐛 Add missing bracket
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE dc0d9b0f14 Merge branch 'nominatim_fix'
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE 04a1d01904 Now complete address is sent to API to avoid multiple nominatim requests
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alexis Feron cb5ff1406f Add pin sharing
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alexis Feron 26cf6d2c91 🐛 Fix edit modal and drag-drop files
continuous-integration/drone/push Build is passing Details
3 weeks ago
Maxence JOUANNET cb62ad1605 tutorial
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE e030dadca4 🚑 Fix budget
continuous-integration/drone/push Build is passing Details
3 weeks ago
Maxence JOUANNET 2eafef1371 starting intro
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE bcffa683db 🎨 Fix logo size in logged-out navbar
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE 8571b7a958 👷 Add production command to package.json & run in Dockerfile
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE c95681a553 👷 Frontend CI/CD deployement
continuous-integration/drone/push Build is passing Details
3 weeks ago
Alix JEUDI--LEMOINE 3e8d447106 Add home routerLink on the navbar icon + fix timeline btn
3 weeks ago
Alix JEUDI--LEMOINE 260891e8e8 🐛 Fix "Commencer l'aventure" button on home page
3 weeks ago
Alix JEUDI--LEMOINE 0d5651a44d 💄 New logo/favicon (remove transp. borders)
3 weeks ago
Alexis Feron a83133a6f0 Add date to pin and timeline page
3 weeks ago
Alexis Feron d2e6f8d567 🐛 Minor bug fixes
3 weeks ago
Mathis FRAMIT 72e1e9fff1 Drag and drop of images on the leaflet map + Creation of pins with images
4 weeks ago
Alexis Feron 44d8f017ad 📱 Add PWA
1 month ago
Alix JEUDI--LEMOINE 3b4203a8ad Images in pin modals !!!!
1 month ago
Alix JEUDI--LEMOINE b9088e8d3d Create image service to pull images from API
1 month ago
Alexis Feron ab8d48234c 🗑️ Cleanup
1 month ago
Alexis FERON 685fbf1103 Merge pull request 'friend' (#26) from friend into master
1 month ago
Maxence JOUANNET 37a978c640 Merge branch 'master' into friend
1 month ago
Maxence JOUANNET 8d08868560 end friend component
1 month ago
Alexis Feron 69b8adec7e ️ Improve pins perf and modal background
1 month ago
Maxence JOUANNET 70914dddba friend component
1 month ago
Alexis Feron 25fd24ffdf 🚸 Improving the UX of the pin CRUD
1 month ago
Alexis Feron 6934dddda4 🚸 Improved login and registration modal
1 month ago
Alexis Feron f3c229628c 🐛 Add an id to modal service
1 month ago
Alexis Feron 7c0e5029f6 🐛 Fix modal opening on mobile
1 month ago
Alexis Feron 2ad09ed578 🚸 Improve style and UX
1 month ago
Alexis Feron 3146b28f3e Add pin filters
2 months ago
Alexis Feron 58d7526834 Pin removal added
2 months ago
Alexis Feron 144af2e06f Pin update added
2 months ago
Alexis Feron a5f4ab4e99 🐛 Fix style imports
2 months ago
Alexis Feron 275e0cfc34 🎨 Service structure changes
2 months ago
Alexis Feron ec73968949 🩹 Fix auth guard actions order
2 months ago
Alexis Feron bfb3197fcb 🚸 Improve token expiration ux
2 months ago
Alexis Feron 152d25a1e9 Add auth guard
2 months ago
Alexis Feron b7b5574dbc 🐛 Fix zoom on marker pop-up open
2 months ago
Alix JEUDI--LEMOINE ffb4fe781e Fix list friend real time display
4 months ago
Alexis Feron e95582ce7b 💄 Fix friend modal text color
4 months ago
Alexis Feron 93f4c6f34a 🐛 Fix navbar router
4 months ago
Maxence JOUANNET b1af0390bb merge friend branch to master
4 months ago
Alexis Feron 153b70867e 🐛 Navbar correction
4 months ago
Alexis Feron 7a93cb30a0 Merge branch 'master' into navbar
4 months ago
Mathis FRAMIT f01fb82c3c Merge branch 'master' into search-pin
4 months ago
Alix JEUDI--LEMOINE f543310c4f Add autocomplete address to pin add form !!!
4 months ago
Alix JEUDI--LEMOINE c703ce699e Add reverse coordinateurs to address in auto complete service
4 months ago
Alix JEUDI--LEMOINE 1c5de5099c Add EXIF service
4 months ago
Alix JEUDI--LEMOINE 527f33ca7c Added exifr to try extraction of picture's data
4 months ago
Mathis FRAMIT ce6a91b160 Merge branch 'master' into search-pin
4 months ago
Maxence JOUANNET 6206b5d964 add accept/deny and delete friend
4 months ago
Mathis FRAMIT b349bba725 Recherche de pin par titre + Click sur la suggestion : Modification de l'url + Recentrage de la map
4 months ago
Mathis FRAMIT 5d92f57481 Monument => Pin
4 months ago
Alexis Feron 0f9d09495f 🐛 Fix pin service
4 months ago
Alix JEUDI--LEMOINE 67070bee19 💄 Added animation + fixed login bug
4 months ago
Maxence JOUANNET d9d8c46aba starting add or deny friend
4 months ago
Alix JEUDI--LEMOINE fc23aca65e 🐛 Fix login/register autofill of password managers when modal hidden
4 months ago
Maxence JOUANNET fe3b3180fb api connected to friend page for display
5 months ago
Alexis Feron 1149c37b1a 🚸 Improve navbar and home-navbar
5 months ago
Maxence JOUANNET c4a16feed2 add tick to accept or deny friends + starting get to api
5 months ago
Maxence JOUANNET 79d5fd5aef Merge branch 'master' into friend
5 months ago
Alexis Feron 5da162fcb7 🔀 Merge add and get pin services
5 months ago
Alexis Feron 61a5acd811 🐛 Fix username validators
5 months ago
Maxence JOUANNET 6f49f42f0d add friend service + modify navbar to open friend popup
5 months ago
Alexis Feron b44ec6572e 🔒️ Fix add pin assign by user
5 months ago
Alix JEUDI--LEMOINE 8618b50e67 💄 Modal animations (also fixes lot of UI bugs w/ open/close & auth)
5 months ago
Maxence JOUANNET 61611ae374 start friend pop up
5 months ago
Alexis FERON ad0cdc1d81 Merge pull request 'bouton déconnection' (#22) from logout into master
5 months ago
Mathis FRAMIT cbc36757dc bouton déconnection
5 months ago
Alix JEUDI--LEMOINE c1615a0b82 ️ Changed href to routerLink on map/home link
5 months ago
Alix JEUDI--LEMOINE ae7d5e0f36 🎨 Change display of navbars
5 months ago
Mathis FRAMIT e1868ac695 Merge pull request 'show-pins' (#21) from show-pins into master
5 months ago

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

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

@ -0,0 +1,17 @@
# 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" ]

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

75
package-lock.json generated

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 239 KiB

@ -0,0 +1,67 @@
{
"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,4 +1,6 @@
<app-navbar *ngIf="isAuth"></app-navbar>
<app-home-navbar *ngIf="!isAuth"></app-home-navbar>
<app-navbar *ngIf="authService.isLoggedIn()"></app-navbar>
<app-home-navbar *ngIf="!authService.isLoggedIn()"></app-home-navbar>
<router-outlet />
<app-admin-footer *ngIf="authService.isAdmin()"></app-admin-footer>

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

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

@ -1,13 +1,29 @@
import { provideHttpClient } from '@angular/common/http';
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {
ApplicationConfig,
inject,
isDevMode,
LOCALE_ID,
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';
import { AuthInterceptor } from './auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(),
provideHttpClient(
withInterceptors([AuthInterceptor])
),
{ provide: LOCALE_ID, useValue: 'fr-FR' },
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000',
}),
CookieService
],
};

@ -1,12 +1,15 @@
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 },
{ path: 'sign', component: LoginPageComponent },
{ path: 'map', component: LeafletMapComponent, canActivate: [AuthGuard] },
{ path: 'timeline', component: TimelineComponent, canActivate: [AuthGuard] },
{ path: 'pin/:id', component: PinDetailComponent, canActivate: [AuthGuard] },
{ path: '**', component: NotFoundComponent },
];

@ -0,0 +1,49 @@
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();
});
});

@ -0,0 +1,26 @@
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;
}
}
}

@ -0,0 +1,48 @@
import {
HttpInterceptorFn,
HttpRequest,
HttpHandlerFn,
HttpEvent,
HttpErrorResponse,
} from '@angular/common/http';
import { Observable, from, throwError } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { inject } from '@angular/core';
import { CookiesService } from './services/cookies/cookies.service';
import { ModalService } from './services/modal/modal.service';
import { Router } from '@angular/router';
export const AuthInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
// Exclure l'endpoint de refresh token pour éviter la récursion infinie
if (req.url.includes('/refresh-token')) {
return next(req);
}
const cookiesService = inject(CookiesService);
const router = inject(Router);
const modalService = inject(ModalService);
return from(cookiesService.getValidToken()).pipe(
switchMap((token) => {
const authReq = token
? req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
})
: req;
return next(authReq);
}),
catchError((err: HttpErrorResponse) => {
if (err.status === 401) {
cookiesService.clearSession();
router.navigate(['/']).then(() => {
modalService.openModal('login-modal');
});
}
return throwError(() => err);
})
);
};

@ -1,23 +1,29 @@
<!-- Modal toggle -->
<button
data-modal-target="authentication-modal"
data-modal-toggle="authentication-modal"
class="block py-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
type="button"
>
<p *ngIf="!isHomePage">Ajouter un pin</p>
</button>
<!-- 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
id="authentication-modal"
id="pin-modal"
tabindex="-1"
aria-hidden="true"
class="hidden overflow-auto absolute top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full"
[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">
<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"
@ -27,9 +33,8 @@
</h3>
<button
type="button"
id="close-modal"
(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"
data-modal-hide="authentication-modal"
>
<svg
class="w-3 h-3"
@ -49,27 +54,37 @@
<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>
<div id="add-pin-modal-title" class="mb-4">
<label
for="title"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Titre</label
>
<input
type="title"
name="title"
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
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Titre"
/>
<div
*ngIf="form.get('title')?.invalid && form.get('title')?.touched"
class="mt-1 text-sm text-red-600 dark:text-red-500"
>
<span *ngIf="form.get('title')?.errors?.['required']"
>Le titre est requis</span
>
<span *ngIf="form.get('title')?.errors?.['minlength']"
>Le titre doit contenir au moins 3 caractères</span
>
</div>
</div>
<div>
<div id="add-pin-modal-localisation">
<label
for="localisation"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
@ -79,11 +94,21 @@
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"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Localisation"
(focus)="onFocus()"
(blur)="onBlur()"
/>
<div
*ngIf="
form.get('location')?.invalid && form.get('location')?.touched
"
class="mt-1 text-sm text-red-600 dark:text-red-500"
>
<span *ngIf="form.get('location')?.errors?.['required']"
>La localisation est requise</span
>
</div>
<ul
*ngIf="suggestions.length > 0 && inputFocused"
class="bg-white dark:bg-gray-700 border border-gray-300 mt-2 rounded shadow absolute z-10 mr-5 max-h-60 overflow-auto"
@ -98,12 +123,22 @@
</ul>
</div>
<div>
<div id="add-pin-modal-image">
<label
for="files"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Images</label
>
<app-drag-drop
*ngIf="isPinModalOpen"
[initialFiles]="getFileNames()"
(filesSelected)="onFilesReceived($event)"
(fileRemoved)="removeFile($event)"
[errorMessage]="uploadError"
></app-drag-drop>
</div>
<div>
<div id="add-pin-modal-description" class="mb-4">
<label
for="description"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
@ -113,30 +148,77 @@
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..."
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Description"
></textarea>
</div>
<div class="flex justify-between">
<button
type="reset"
data-modal-hide="authentication-modal"
class="w-full text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
<div
*ngIf="
form.get('description')?.invalid &&
form.get('description')?.touched
"
class="mt-1 text-sm text-red-600 dark:text-red-500"
>
Annuler
</button>
<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 class="flex justify-between">
<button
type="submit"
(click)="submitForm()"
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
<!-- <div *ngIf="files.length > 0">
<div *ngFor="let file of files">
<img
[src]="getImagePreview(file)"
alt="Image preview"
width="100"
/>
</div>
</div> -->
<!-- <div *ngIf="files.length > 0">
<div *ngFor="let file of files">
<img
[src]="getImagePreview(file)"
alt="Image preview"
width="100"
/>
</div>
</div> -->
<div id="add-pin-modal-date">
<label
for="date"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Date (optionnel)</label
>
Valider
</button>
<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
id="add-pin-modal-validate"
type="submit"
(click)="submitForm()"
class="w-1/2 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
Valider
</button>
</div>
</div>
</div>
</div>

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

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

@ -0,0 +1,9 @@
<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>

@ -0,0 +1,9 @@
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 { }

@ -0,0 +1,71 @@
<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>

@ -0,0 +1,23 @@
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();
});
});

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

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

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

@ -0,0 +1,231 @@
<!-- 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,17 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MonumentMarkerComponent } from './monument-marker.component';
import { EditPinPopupComponent } from './edit-pin-popup.component';
describe('MonumentmarkerComponent', () => {
let component: MonumentMarkerComponent;
let fixture: ComponentFixture<MonumentMarkerComponent>;
describe('EditPinPopupComponent', () => {
let component: EditPinPopupComponent;
let fixture: ComponentFixture<EditPinPopupComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MonumentMarkerComponent],
imports: [EditPinPopupComponent],
}).compileComponents();
fixture = TestBed.createComponent(MonumentMarkerComponent);
fixture = TestBed.createComponent(EditPinPopupComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

@ -0,0 +1,300 @@
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] : '');
}
}

@ -0,0 +1,256 @@
<!-- 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>

@ -0,0 +1,23 @@
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();
});
});

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

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

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

@ -1,11 +1,30 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ModalService } from '../../services/modal/modal.service';
import { Router } from '@angular/router';
import { AuthService } from '../../services/auth/auth.service';
@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
) {
if(this.authService.isLoggedIn()) {
this.router.navigate(['/map']);
}
}
openLogin() {
if (!this.authService.isLoggedIn()) {
this.loginModalService.openModal('login-modal');
} else {
this.router.navigate(['/map']);
}
}
}

@ -1,3 +1,95 @@
<div class="map-container h-[calc(100vh_-_72px)]">
<div id="map" class="h-full w-full z-0"></div>
<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>

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

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

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

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

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

@ -1,25 +1,172 @@
import { NgIf } from '@angular/common';
import { CommonModule, NgIf } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
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 { AddPinPopupComponent } from '../add-pin-popup/add-pin-popup.component';
import { FriendPageComponent } from '../friend-page/friend-page.component';
import { PushService } from '../../services/push/push.service';
@Component({
selector: 'app-navbar',
imports: [AddPinPopupComponent, NgIf],
imports: [
AddPinPopupComponent,
NgIf,
FriendPageComponent,
CommonModule,
ReactiveFormsModule,
RouterLink,
],
templateUrl: './navbar.component.html',
})
export class NavbarComponent implements OnInit {
isHome: boolean = false;
isModalOpen: boolean = false;
showTimeline: boolean = false;
isSearchOpen: boolean = false;
isNavbarOpen: boolean = false;
constructor(private router: Router) {}
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,
private pushService: PushService
) {
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');
}
ngOnInit(): void {
this.isHome = this.router.url === '/';
this.pins = this.pinService.getPins().subscribe((pins: Pin[]) => {
this.pins = pins;
});
this.showTimeline =
this.router.url !== '/timeline' && this.router.url !== '/';
this.router.events.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.isHome = event.url === '/';
this.showTimeline =
event.url !== '/timeline' && this.router.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();
}
}

@ -0,0 +1,37 @@
.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);
}

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

@ -0,0 +1,23 @@
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();
});
});

@ -0,0 +1,163 @@
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 } });
}
}

@ -0,0 +1,229 @@
<!-- 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>

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

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

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

@ -0,0 +1,140 @@
<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>

@ -0,0 +1,23 @@
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();
});
});

@ -0,0 +1,146 @@
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();
});
}
}

@ -0,0 +1,240 @@
<!-- 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>

@ -0,0 +1,140 @@
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];
}
}

@ -1,32 +0,0 @@
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,
},
// ...
];

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

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

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

@ -0,0 +1,107 @@
import { HttpClient, 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';
import { PushService } from '../push/push.service';
import { Router } from '@angular/router';
@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,
private pushService: PushService,
private router: Router
) {
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.setRefreshToken(response.refresh_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);
this.pushService.enableNotifications();
})
);
}
logout(): void {
this.pushService.unsubscribe().finally(() => {
this.isAdminSubject.next(false);
this.username$.next('');
this.userIdSubject.next('');
this.isLoggedIn$.next(false);
this.cookiesService.clearSession();
this.router.navigate(['/']);
});
}
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.setRefreshToken(response.refresh_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);
this.pushService.enableNotifications();
})
);
}
isAdmin(): boolean {
return this.isAdminSubject.value || false;
}
getUsername(): string {
return this.username$.value || '';
}
getUserId(): string {
return this.userIdSubject.value || '';
}
isLoggedIn(): boolean {
return this.isLoggedIn$.value || false;
}
}

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

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

@ -0,0 +1,176 @@
import { Injectable } from '@angular/core';
import { CookieService } from 'ngx-cookie-service';
import { tap, firstValueFrom } from 'rxjs';
import { AuthResponse } from '../../model/AuthResponse';
import { environment } from '../../../environment';
import { HttpClient } from '@angular/common/http';
@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 REFRESH_TOKEN_KEY = 'refresh_token';
private get COOKIE_OPTIONS() {
const isLocalhost = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname.includes('localhost');
return {
path: '/',
domain: window.location.hostname,
secure: !isLocalhost,
sameSite: 'Strict' as const,
};
}
constructor(
private cookieService: CookieService,
private http: HttpClient
) {}
setToken(token: string): void {
this.cookieService.set(this.AUTH_TOKEN_KEY, token, this.COOKIE_OPTIONS);
}
setRefreshToken(refresh_token: string): void {
this.cookieService.set(this.REFRESH_TOKEN_KEY, refresh_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 {
return this.cookieService.get(this.AUTH_TOKEN_KEY);
}
clearSession() {
this.removeToken();
this.removeRefreshToken();
this.removeIsAdmin();
this.removeUsername();
this.removeUserId();
}
getRefreshToken(): string | null {
return this.cookieService.get(this.REFRESH_TOKEN_KEY) || null;
}
async getValidToken(): Promise<string | null> {
const token = this.cookieService.get(this.AUTH_TOKEN_KEY);
if (!token) return null;
const payload = JSON.parse(atob(token.split('.')[1]));
const expirationDate = new Date(payload.exp * 1000);
console.log('Token expiration date:', expirationDate);
console.log('Current date:', new Date());
if (expirationDate < new Date()) {
console.log('Token expired, trying to refresh');
try {
const refreshedResponse = await this.tryRefreshToken();
return refreshedResponse ? this.cookieService.get(this.AUTH_TOKEN_KEY) : null;
} catch (e) {
console.error('Erreur lors du refresh token', e);
return null;
}
}
return token;
}
async tryRefreshToken(): Promise<boolean> {
const refreshToken = this.getRefreshToken();
if (!refreshToken) return false;
try {
const response = await firstValueFrom(
this.http.post<AuthResponse>(`${environment.apiURL}/refresh-token`, {
refresh_token: refreshToken
}).pipe(
tap((response) => {
this.setToken(response.access_token);
this.setRefreshToken(response.refresh_token);
this.setIsAdmin(response.is_admin);
this.setUserId(response.user_id);
})
)
);
return !!response;
} catch (error) {
console.error('Erreur lors du refresh token', error);
return false;
}
}
removeToken(): void {
this.cookieService.delete(
this.AUTH_TOKEN_KEY,
this.COOKIE_OPTIONS.path,
this.COOKIE_OPTIONS.domain
);
}
removeRefreshToken(): void {
this.cookieService.delete(
this.REFRESH_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,13 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { GetPinService } from './get-pin.service';
import { ExifService } from './exif.service';
describe('GetPinService', () => {
let service: GetPinService;
describe('ExifService', () => {
let service: ExifService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(GetPinService);
service = TestBed.inject(ExifService);
});
it('should be created', () => {

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

@ -0,0 +1,36 @@
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() {
return this.http.get<any[]>(`${this.apiURL}/friends`);
}
getFriendById(id: string) {
return this.http.get<any>(`${this.apiURL}/user/${id}`);
}
addFriend(user_id: string) {
return this.http.post<any>(`${this.apiURL}/friend/add`, { friend_user_id: user_id });
}
acceptFriendById(id: string) {
return this.http.patch<any>(`${this.apiURL}/friend/${id}/accept`, []);
}
denyFriendById(id: string) {
return this.http.delete<any>(`${this.apiURL}/friend/${id}/deny`);
}
deleteFriend(id: string) {
return this.http.delete<any>(`${this.apiURL}/friend/${id}/delete`);
}
}

@ -1,21 +0,0 @@
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,13 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { AddPinService } from './add-pin.service';
import { ImageService } from './image.service';
describe('AddPinService', () => {
let service: AddPinService;
describe('ImageService', () => {
let service: ImageService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(AddPinService);
service = TestBed.inject(ImageService);
});
it('should be created', () => {

@ -0,0 +1,38 @@
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> {
return this.http.get(`${this.apiUrl}/image/${imageId}`, { responseType: 'blob' });
}
postImage(image: File, date: string): Observable<any> {
let url = `${this.apiUrl}/image/pin/null/add`;
const formData = new FormData();
formData.append('image', image);
if(date !== '') {
url += `?exif_date=${date}`;
}
return this.http.post(url, formData);
}
getImageMetadata(imageId: string): Observable<any> {
return this.http.get(`${this.apiUrl}/image/${imageId}/metadata`);
}
}

@ -0,0 +1,16 @@
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();
});
});

@ -0,0 +1,138 @@
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();
});
}
}

@ -1,23 +0,0 @@
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,21 +0,0 @@
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);
}
}

@ -0,0 +1,16 @@
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();
});
});

@ -0,0 +1,14 @@
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();
}
}

@ -0,0 +1,16 @@
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();
});
});

@ -0,0 +1,41 @@
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;
}
}

@ -0,0 +1,29 @@
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);
}
}

@ -0,0 +1,16 @@
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();
});
});

@ -0,0 +1,57 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { map } from 'rxjs/operators';
import { environment } from '../../../environment';
import { Pin } from '../../model/Pin';
import { AuthService } from '../auth/auth.service';
@Injectable({
providedIn: 'root',
})
export class PinService {
allPins: Pin[] = [];
filteredPins: Pin[] = [];
private apiURL = environment.apiURL;
constructor(private http: HttpClient, private authService: AuthService) {}
getPinById(id: string) {
return this.http.get<Pin>(`${this.apiURL}/pin/${id}`);
}
getPins(): any {
return this.http.get<any>(`${this.apiURL}/pins`);
}
addPin(pin: Pin) {
return this.http.post<any>(`${this.apiURL}/pin/add`, pin);
}
updatePin(id: string, pin: Pin) {
// Obtenir les coordonnées GPS à partir de l'adresse
return this.http.patch<any>(`${this.apiURL}/pin/${id}`, pin);
}
deletePin(id: string) {
return this.http.delete<any>(`${this.apiURL}/pin/${id}`);
}
sharePin(pinId: string, friendId: string) {
return this.http.post<any>(`${this.apiURL}/pin/${pinId}/share`, { friend_id: friendId });
}
getPinShares(pinId: string) {
return this.http.get<any>(`${this.apiURL}/pin/${pinId}/shares`);
}
getSharedUsersForPin(pinId: string) {
return this.http.get<{ shares: any[] }>(`${this.apiURL}/pin/${pinId}/shares`).pipe(
map((response) => response.shares)
);
}
deletePinShare(pinId: string, friendId: string) {
return this.http.delete<any>(`${this.apiURL}/pin/${pinId}/share/${friendId}`);
}
}

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

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

Loading…
Cancel
Save