Add & display loves on map

This commit is contained in:
2024-10-01 19:33:44 +02:00
parent f2d1eb4289
commit bf42f5c49b
11 changed files with 765 additions and 74 deletions
+313
View File
@@ -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,
}
);
}
};
+75
View File
@@ -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<HTMLFormElement>) => {
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 };
};
-43
View File
@@ -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,
};
};
+119
View File
@@ -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<any>({
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 [];
};
+99 -6
View File
@@ -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 <p className='appMessage'>Chargement...</p>;
@@ -35,10 +62,13 @@ const Home = () => {
}
return (
<div className='page'>
<div className='mapContainer'>
<DeckGL
initialViewState={initialState}
layers={[]}
ref={deckRef}
initialViewState={viewState}
onViewStateChange={(e) => setViewState(e.viewState)}
layers={layers}
controller
useDevicePixels={false}
getCursor={({ isDragging }) =>
@@ -52,15 +82,78 @@ const Home = () => {
height: '100%',
}}
mapboxAccessToken={process.env.MAPBOX_TOKEN}
mapStyle={'mapbox://styles/mapbox/standard'}
mapStyle={
'mapbox://styles/unepicier/cm1p8yuvq00qx01r24vg4f3ej'
}
preserveDrawingBuffer
></Map>
</DeckGL>
{isAddLoveOpened && (
<Image
src={pin}
alt='pin'
width={30}
className='pinImage'
/>
)}
</div>
<button className='dropLove'>
{isAddLoveOpened && (
<div className='addLoveContainer'>
<p className='addLoveHeader'>Ajouter un love ici</p>
<form
className='addLoveForm'
onSubmit={addPoint}
>
<Input
type='text'
name='location'
placeholder="C'était où ?"
autoComplete='off'
required
/>
<Input
type='text'
name='comment'
placeholder='Un commentaire'
autoComplete='off'
/>
<div className='buttonsContainer'>
<button
type='button'
className='button'
onClick={toggleLoveMenu}
disabled={isLoading}
>
<CloseIcon />
Annuler
</button>
<button
type='submit'
className='button'
disabled={isLoading}
>
{isLoading ? <Loader /> : <AddIcon />}
Ajouter
</button>
</div>
</form>
</div>
)}
{!isAddLoveOpened && (
<button
type='button'
className='dropLove'
onClick={toggleLoveMenu}
>
<HeartIcon />
Placer un love
</button>
)}
</div>
);
};
+61 -1
View File
@@ -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;
}
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import { SVGProps } from 'react';
const AddIcon = (props: SVGProps<SVGSVGElement>) => {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
{...props}
>
<path
fill='none'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='32'
d='M256 112v288M400 256H112'
/>
</svg>
);
};
export default AddIcon;
+1 -1
View File
@@ -10,7 +10,7 @@ const Loader = (props?: SVGProps<SVGSVGElement>) => {
>
<g
fill='none'
fill-rule='evenodd'
fillRule='evenodd'
>
<g
transform='translate(1 1)'
+37
View File
@@ -0,0 +1,37 @@
const PinIcon = (props) => {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
version='1.1'
x='0px'
y='0px'
viewBox='0 0 100 100'
{...props}
>
<g>
<path d='M82.1,34.9C80.9,19.2,68.1,6.5,52.3,5.4C33.5,4.1,17.8,19,17.8,37.6c0,6.7,2,12.9,5.5,18c4.2,6.2,16.2,23.7,22.6,33.1 c2,2.9,6.3,2.9,8.2,0c6.4-9.3,18.4-26.9,22.6-33.1C80.7,49.8,82.7,42.6,82.1,34.9z M50,56c-10.2,0-18.5-8.3-18.5-18.5 S39.8,19,50,19c10.2,0,18.5,8.3,18.5,18.5S60.2,56,50,56z' />
</g>
</svg>
);
};
export default PinIcon;
export const StringifiedPinIcon = () => {
return `
<svg
xmlns='http://www.w3.org/2000/svg'
version='1.1'
width='100'
height='100'
viewBox='0 0 100 100'
fill='#d94253'
>
<g>
<path
d='M82.1,34.9C80.9,19.2,68.1,6.5,52.3,5.4C33.5,4.1,17.8,19,17.8,37.6c0,6.7,2,12.9,5.5,18c4.2,6.2,16.2,23.7,22.6,33.1 c2,2.9,6.3,2.9,8.2,0c6.4-9.3,18.4-26.9,22.6-33.1C80.7,49.8,82.7,42.6,82.1,34.9z M50,56c-10.2,0-18.5-8.3-18.5-18.5 S39.8,19,50,19c10.2,0,18.5,8.3,18.5,18.5S60.2,56,50,56z'
/>
</g>
</svg>
`;
};
+15 -1
View File
@@ -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",
+2 -1
View File
@@ -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",