From bf42f5c49bcbaa221f79972c67bf819e67137fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Un=20=C3=89picier?= Date: Tue, 1 Oct 2024 19:33:30 +0200 Subject: [PATCH] :sparkles: Add & display loves on map --- app/api/point/route.ts | 313 ++++++++++++++++++++++++++++++++++++++++ app/hooks/useActions.ts | 75 ++++++++++ app/hooks/useData.js | 43 ------ app/hooks/useData.ts | 119 +++++++++++++++ app/page.tsx | 147 +++++++++++++++---- app/styles.scss | 62 +++++++- assets/AddIcon.tsx | 22 +++ assets/Loader.tsx | 2 +- assets/PinIcon.tsx | 37 +++++ package-lock.json | 16 +- package.json | 3 +- 11 files changed, 765 insertions(+), 74 deletions(-) create mode 100644 app/api/point/route.ts create mode 100644 app/hooks/useActions.ts delete mode 100644 app/hooks/useData.js create mode 100644 app/hooks/useData.ts create mode 100644 assets/AddIcon.tsx create mode 100644 assets/PinIcon.tsx diff --git a/app/api/point/route.ts b/app/api/point/route.ts new file mode 100644 index 0000000..c550d5d --- /dev/null +++ b/app/api/point/route.ts @@ -0,0 +1,313 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDBConnexion } from '../utils'; +import { v4 } from 'uuid'; + +export const GET = async (req: NextRequest) => { + const userID = req.headers.get('Authorization'); + + if (!userID) { + return NextResponse.json( + { + error: true, + message: 'Unauthorized request', + }, + { + status: 403, + } + ); + } + + try { + const pool = getDBConnexion(); + + const { rowCount } = await pool.query( + `SELECT COUNT(*) FROM "user" WHERE "id"=$1 LIMIT 1;`, + [userID] + ); + if (rowCount != 1) { + return NextResponse.json( + { + error: true, + message: 'Unauthorized request', + }, + { + status: 403, + } + ); + } + + const { rows } = await pool.query( + `SELECT * FROM "point" WHERE "userid"=$1;`, + [userID] + ); + + await pool.end(); + + return NextResponse.json(rows); + } catch (error) { + console.error(error); + return NextResponse.json( + { + error: true, + message: 'Internal error', + }, + { + status: 500, + } + ); + } +}; + +export const POST = async (req: NextRequest) => { + const userID = req.headers.get('Authorization'); + + if (!userID) { + return NextResponse.json( + { + error: true, + message: 'Unauthorized request', + }, + { + status: 403, + } + ); + } + + try { + const pool = getDBConnexion(); + + const { rowCount } = await pool.query( + `SELECT COUNT(*) FROM "user" WHERE "id"=$1 LIMIT 1;`, + [userID] + ); + if (rowCount != 1) { + return NextResponse.json( + { + error: true, + message: 'Unauthorized request', + }, + { + status: 403, + } + ); + } + + const { geopoint, location, comment } = await req.json(); + + if (!geopoint || !location) { + return NextResponse.json( + { + error: true, + message: + 'Missing geopoint and/or location field(s) in request body', + }, + { + status: 400, + } + ); + } + + const pointId = v4(); + + await pool.query( + 'INSERT INTO "point" ("id", "userid", "geopoint", "location", "comment") VALUES ($1, $2, $3, $4, $5);', + [pointId, userID, geopoint, location, comment] + ); + + await pool.end(); + + return NextResponse.json({ + message: 'Successfully added point', + }); + } catch (error) { + console.error(error); + return NextResponse.json( + { + error: true, + message: 'Internal error', + }, + { + status: 500, + } + ); + } +}; + +export const PUT = async (req: NextRequest) => { + const userID = req.headers.get('Authorization'); + + if (!userID) { + return NextResponse.json( + { + error: true, + message: 'Unauthorized request', + }, + { + status: 403, + } + ); + } + + try { + const pool = getDBConnexion(); + + const { rowCount } = await pool.query( + `SELECT COUNT(*) FROM "user" WHERE "id"=$1 LIMIT 1;`, + [userID] + ); + if (rowCount != 1) { + return NextResponse.json( + { + error: true, + message: 'Unauthorized request', + }, + { + status: 403, + } + ); + } + + const { pointId, geopoint, location, comment } = await req.json(); + + if (!pointId || !geopoint || !location) { + return NextResponse.json( + { + error: true, + message: + 'Missing pointId and/or geopoint and/or location field(s) in request body', + }, + { + status: 400, + } + ); + } + + const { rowCount: pointsCount } = await pool.query( + `SELECT COUNT(*) FROM "point" WHERE "id"=$1 AND "userid"=$2 LIMIT 1;`, + [pointId, userID] + ); + + if (pointsCount != 1) { + return NextResponse.json( + { + error: true, + message: 'Point not found', + }, + { + status: 404, + } + ); + } + + await pool.query( + 'UPDATE "point" SET "geopoint"=$1, "location"=$2, "comment"=$3 WHERE "id"=$4 AND "userid"=$5;', + [geopoint, location, comment, pointId, userID] + ); + + await pool.end(); + + return NextResponse.json({ + message: 'Successfully updated point', + }); + } catch (error) { + console.error(error); + return NextResponse.json( + { + error: true, + message: 'Internal error', + }, + { + status: 500, + } + ); + } +}; + +export const DELETE = async (req: NextRequest) => { + const userID = req.headers.get('Authorization'); + + if (!userID) { + return NextResponse.json( + { + error: true, + message: 'Unauthorized request', + }, + { + status: 403, + } + ); + } + + try { + const pool = getDBConnexion(); + + const { rowCount } = await pool.query( + `SELECT COUNT(*) FROM "user" WHERE "id"=$1 LIMIT 1;`, + [userID] + ); + if (rowCount != 1) { + return NextResponse.json( + { + error: true, + message: 'Unauthorized request', + }, + { + status: 403, + } + ); + } + + const { pointId } = await req.json(); + + if (!pointId) { + return NextResponse.json( + { + error: true, + message: 'Missing pointId field in request body', + }, + { + status: 400, + } + ); + } + + const { rowCount: pointsCount } = await pool.query( + `SELECT COUNT(*) FROM "point" WHERE "id"=$1 AND "userid"=$2 LIMIT 1;`, + [pointId, userID] + ); + + if (pointsCount != 1) { + return NextResponse.json( + { + error: true, + message: 'Point not found', + }, + { + status: 404, + } + ); + } + + await pool.query('DELETE FROM "point" WHERE "id"=$1 AND "userid"=$2;', [ + pointId, + userID, + ]); + + await pool.end(); + + return NextResponse.json({ + message: 'Successfully delete point', + }); + } catch (error) { + console.error(error); + return NextResponse.json( + { + error: true, + message: 'Internal error', + }, + { + status: 500, + } + ); + } +}; diff --git a/app/hooks/useActions.ts b/app/hooks/useActions.ts new file mode 100644 index 0000000..9f6c094 --- /dev/null +++ b/app/hooks/useActions.ts @@ -0,0 +1,75 @@ +import { FormEvent } from 'react'; + +export const useActions = ({ + viewState, + setIsAddLoveOpened, + setIsLoading, + setAddLoveMessage, +}) => { + const toggleLoveMenu = () => { + setIsAddLoveOpened((prev) => !prev); + }; + + const addPoint = async (ev: FormEvent) => { + ev.preventDefault(); + + if (!ev.currentTarget.reportValidity()) { + return; + } + + setIsLoading(true); + + const formData = new FormData(ev.currentTarget); + const location = formData.get('location'); + const comment = formData.get('comment'); + + const body: { [key: string]: any } = { + location: location.toString(), + }; + + if (comment && comment.toString().length > 0) { + body.comment = comment.toString(); + } + + const token = localStorage.getItem('token'); + + const { latitude, longitude } = viewState; + + const point = { + type: 'Point', + coordinates: [longitude, latitude], + }; + + body.geopoint = point; + + try { + const request = await fetch('/api/point', { + method: 'POST', + headers: { + Authorization: token, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (request.status == 403) { + setIsLoading(false); + setAddLoveMessage('Reconnectez-vous pour réessayer'); + return; + } + if (request.status == 400) { + setIsLoading(false); + setAddLoveMessage('Erreur interne. Veuillez réessayer'); + return; + } + + setIsLoading(false); + } catch (error) { + console.error(error); + setAddLoveMessage('Erreur interne. Veuillez réessayer'); + setIsLoading(false); + } + }; + + return { toggleLoveMenu, addPoint }; +}; diff --git a/app/hooks/useData.js b/app/hooks/useData.js deleted file mode 100644 index a853dd1..0000000 --- a/app/hooks/useData.js +++ /dev/null @@ -1,43 +0,0 @@ -import { useEffect, useState } from 'react'; - -export const useData = () => { - const [canUseGeolocation, setCanUseGeolocation] = useState(false); - const [geoError, setGeoError] = useState(null); - - const [initialState, setInitialState] = useState({ - longitude: 2.209666999999996, - latitude: 46.232192999999995, - zoom: 5, - }); - - useEffect(() => { - if ('geolocation' in navigator == false) { - setCanUseGeolocation(false); - return; - } - - setCanUseGeolocation(true); - - navigator.geolocation.getCurrentPosition( - position => { - setInitialState(prev => ({ - ...prev, - latitude: position.coords.latitude, - longitude: position.coords.longitude, - })); - }, - error => { - if (error.PERMISSION_DENIED) { - setGeoError('permission_denied'); - console.warn(error.message); - } - } - ); - }, []); - - return { - initialState, - geoError, - canUseGeolocation, - }; -}; diff --git a/app/hooks/useData.ts b/app/hooks/useData.ts new file mode 100644 index 0000000..52ed6a6 --- /dev/null +++ b/app/hooks/useData.ts @@ -0,0 +1,119 @@ +import { IconLayer } from 'deck.gl'; +import { useEffect, useRef, useState } from 'react'; +import { StringifiedPinIcon } from '@/assets/PinIcon'; + +export const useData = () => { + const deckRef = useRef(null); + + const [canUseGeolocation, setCanUseGeolocation] = useState(false); + const [geoError, setGeoError] = useState(null); + + const [layers, setLayers] = useState([]); + + const [isAddLoveOpened, setIsAddLoveOpened] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [addLoveMessage, setAddLoveMessage] = useState(null); + + const [viewState, setViewState] = useState({ + longitude: 2.209666999999996, + latitude: 46.232192999999995, + zoom: 5, + }); + + useEffect(() => { + if ('geolocation' in navigator == false) { + setCanUseGeolocation(false); + return; + } + + setCanUseGeolocation(true); + + navigator.geolocation.getCurrentPosition( + (position) => { + setViewState((prev) => ({ + ...prev, + latitude: position.coords.latitude, + longitude: position.coords.longitude, + })); + }, + (error) => { + if (error.PERMISSION_DENIED) { + setGeoError('permission_denied'); + console.warn(error.message); + } + } + ); + + (async () => { + const dataset = await getDataset(); + + const datasetLayer = new IconLayer({ + id: 'lovePoints', + data: dataset, + iconAtlas: `data:image/svg+xml;charset=utf-8,${encodeURIComponent( + StringifiedPinIcon() + )}`, + iconMapping: { + marker: { + x: 0, + y: 0, + width: 100, + height: 100, + anchorY: 100, + }, + }, + getIcon: (d) => 'marker', + getSize: (d) => 35, + getPosition: (d) => d.geometry.coordinates, + billboard: true, + pickable: true, + }); + + setLayers([datasetLayer]); + })(); + }, []); + + return { + deckRef, + viewState, + setViewState, + geoError, + layers, + canUseGeolocation, + isAddLoveOpened, + setIsAddLoveOpened, + isLoading, + setIsLoading, + addLoveMessage, + setAddLoveMessage, + }; +}; + +const getDataset = async () => { + const token = localStorage.getItem('token'); + const request = await fetch('/api/point', { + method: 'GET', + headers: { + Authorization: token, + }, + }); + + const response = await request.json(); + + if (response) { + const formattedData = response.map((point) => { + const properties = { ...point }; + delete properties.geopoint; + + return { + type: 'Feature', + geometry: point.geopoint, + properties: properties, + }; + }); + + return formattedData; + } + + return []; +}; diff --git a/app/page.tsx b/app/page.tsx index deae3a3..3b676a8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,25 +1,52 @@ 'use client'; // --------------------------------------------------------------------------------------------------------------------- -// Imports +//! Imports // --------------------------------------------------------------------------------------------------------------------- // --------------------------------------------------- Components ------------------------------------------------------ import { DeckGL } from 'deck.gl'; import { Map } from 'react-map-gl'; +import Image from 'next/image'; // --------------------------------------------------------------------------------------------------------------------- // -------------------------------------------------- Hooks & Utils ---------------------------------------------------- import { useData } from './hooks/useData'; +import { useActions } from './hooks/useActions'; // --------------------------------------------------------------------------------------------------------------------- // ------------------------------------------------- Assets & Styles --------------------------------------------------- import HeartIcon from '@/assets/HeartIcon'; +import Input from '@/components/Input/Input'; +import CloseIcon from '@/assets/CloseIcon'; +import AddIcon from '@/assets/AddIcon'; +import pin from '@/assets/pin.png'; import 'mapbox-gl/dist/mapbox-gl.css'; import './styles.scss'; +import Loader from '@/assets/Loader'; // --------------------------------------------------------------------------------------------------------------------- const Home = () => { - const { canUseGeolocation, geoError, initialState } = useData(); + const { + deckRef, + viewState, + setViewState, + geoError, + canUseGeolocation, + layers, + isAddLoveOpened, + setIsAddLoveOpened, + isLoading, + setIsLoading, + addLoveMessage, + setAddLoveMessage, + } = useData(); + + const { toggleLoveMenu, addPoint } = useActions({ + viewState, + setIsAddLoveOpened, + setIsLoading, + setAddLoveMessage, + }); if (!canUseGeolocation && !geoError) { return

Chargement...

; @@ -35,32 +62,98 @@ const Home = () => { } return ( -
- - isDragging ? 'grabbing' : 'grab' - } - > - - +
+
+ setViewState(e.viewState)} + layers={layers} + controller + useDevicePixels={false} + getCursor={({ isDragging }) => + isDragging ? 'grabbing' : 'grab' + } + > + + + {isAddLoveOpened && ( + pin + )} +
- + {isAddLoveOpened && ( +
+

Ajouter un love ici

+ +
+ + + +
+ + + +
+
+
+ )} + + {!isAddLoveOpened && ( + + )}
); }; diff --git a/app/styles.scss b/app/styles.scss index 632f250..8b86ee9 100644 --- a/app/styles.scss +++ b/app/styles.scss @@ -1,6 +1,20 @@ -.mapContainer { +.page { position: relative; flex-grow: 1; + display: flex; + flex-direction: column; + + & > .mapContainer { + flex-grow: 1; + position: relative; + + & > .pinImage { + z-index: 1; + position: absolute; + top: 50%; + left: 50%; + } + } & > .dropLove { z-index: 1; @@ -18,6 +32,52 @@ background-color: #d94253; color: #ffffff; box-shadow: 0 2px 6px rgba(255, 255, 255, 0.6); + cursor: pointer; + } + + & > .addLoveContainer { + display: flex; + flex-direction: column; + gap: 10px; + padding-bottom: 40px; + + & > .addLoveHeader { + padding: 10px; + background-color: #d94253; + font-size: 1.1rem; + text-align: center; + color: #ffffff; + } + + & > .addLoveForm { + display: flex; + flex-direction: column; + gap: 10px; + padding: 5px 10px; + + & > .buttonsContainer { + display: grid; + grid-template-columns: repeat(2, 1fr); + align-items: center; + gap: 20px; + margin-top: 10px; + + & > .button { + display: grid; + grid-template-columns: 20px max-content; + justify-content: center; + align-items: center; + gap: 5px; + padding: 5px 15px; + border: none; + border-radius: 5px; + background-color: #d94253; + font-size: 1rem; + color: #ffffff; + cursor: pointer; + } + } + } } } diff --git a/assets/AddIcon.tsx b/assets/AddIcon.tsx new file mode 100644 index 0000000..f29d15b --- /dev/null +++ b/assets/AddIcon.tsx @@ -0,0 +1,22 @@ +import { SVGProps } from 'react'; + +const AddIcon = (props: SVGProps) => { + return ( + + + + ); +}; + +export default AddIcon; diff --git a/assets/Loader.tsx b/assets/Loader.tsx index 75b1e62..194c355 100644 --- a/assets/Loader.tsx +++ b/assets/Loader.tsx @@ -10,7 +10,7 @@ const Loader = (props?: SVGProps) => { > { + return ( + + + + + + ); +}; + +export default PinIcon; + +export const StringifiedPinIcon = () => { + return ` + + + + + + `; +}; diff --git a/package-lock.json b/package-lock.json index 2f177b7..97a39bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "react": "^18", "react-dom": "^18", "react-map-gl": "^7.1.7", - "react-slot-counter": "^3.0.1" + "react-slot-counter": "^3.0.1", + "uuid": "^10.0.0" }, "devDependencies": { "@types/bcrypt": "^5.0.2", @@ -8430,6 +8431,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vt-pbf": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", diff --git a/package.json b/package.json index 90006a8..a842825 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "react": "^18", "react-dom": "^18", "react-map-gl": "^7.1.7", - "react-slot-counter": "^3.0.1" + "react-slot-counter": "^3.0.1", + "uuid": "^10.0.0" }, "devDependencies": { "@types/bcrypt": "^5.0.2",