wip: club add map

This commit is contained in:
Thibaut Valentin 2024-07-13 18:32:01 +02:00
parent 6c4b01590d
commit 1b74c0a3bd
6 changed files with 267 additions and 180 deletions

View File

@ -0,0 +1,18 @@
package fr.titionfire;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/api")
public class BlackPage {
@GET
@Produces(MediaType.TEXT_PLAIN)
public Response get() {
return Response.noContent().build();
}
}

View File

@ -16,6 +16,7 @@
"axios": "^1.6.5",
"browser-image-compression": "^2.0.2",
"leaflet": "^1.9.4",
"proj4": "^2.11.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
@ -3184,6 +3185,11 @@
"yallist": "^3.0.2"
}
},
"node_modules/mgrs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz",
"integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA=="
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@ -3503,6 +3509,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/proj4": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/proj4/-/proj4-2.11.0.tgz",
"integrity": "sha512-SasuTkAx8HnWQHfIyhkdUNJorSJqINHAN3EyMWYiQRVorftz9DHz650YraFgczwgtHOxqnfuDxSNv3C8MUnHeg==",
"dependencies": {
"mgrs": "1.0.0",
"wkt-parser": "^1.3.3"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -4423,6 +4438,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wkt-parser": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.3.3.tgz",
"integrity": "sha512-ZnV3yH8/k58ZPACOXeiHaMuXIiaTk1t0hSUVisbO0t4RjA5wPpUytcxeyiN2h+LZRrmuHIh/1UlrR9e7DHDvTw=="
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View File

@ -18,6 +18,7 @@
"axios": "^1.6.5",
"browser-image-compression": "^2.0.2",
"leaflet": "^1.9.4",
"proj4": "^2.11.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",

View File

@ -10,37 +10,15 @@ import {CheckField, CountryList, TextField} from "../../../components/MemberCust
import {MapContainer, Marker, Popup, TileLayer, useMap} from 'react-leaflet'
import {ListEditorTest} from "../../../components/ListEditor.jsx";
import {useEffect, useReducer, useState} from "react";
import {useEffect, useReducer, useRef, useState} from "react";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faPen, faTrashCan} from "@fortawesome/free-solid-svg-icons";
import proj4 from "proj4";
import {SimpleReducer} from "../../../utils/SimpleReducer.jsx";
import {LocationEditor} from "./LocationEditor.jsx";
const vite_url = import.meta.env.VITE_URL;
function SimpleReducer(datas, action) {
switch (action.type) {
case 'ADD':
return [
...datas,
action.payload
]
case 'REMOVE':
return datas.filter(data => data.id !== action.payload)
case 'UPDATE_OR_ADD':
const index = datas.findIndex(data => data.id === action.payload.id)
if (index === -1) {
return [
...datas,
action.payload
]
} else {
datas[index] = action.payload
return [...datas]
}
default:
throw new Error()
}
}
export function ClubPage() {
const {id} = useParams()
const navigate = useNavigate();
@ -69,12 +47,12 @@ export function ClubPage() {
{data
? <div>
<div className="row">
<div className="col-lg-8">
<div className="col-lg-9">
<LoadingProvider>
<InformationForm data={data}/>
</LoadingProvider>
</div>
<div className="col-lg-4">
<div className="col-lg-3">
<LoadingProvider><AffiliationCard clubData={data}/></LoadingProvider>
<div className="col" style={{textAlign: 'right', marginTop: '1em'}}>
<button className="btn btn-danger btn-sm" data-bs-toggle="modal"
@ -124,95 +102,11 @@ function InformationForm({data}) {
<TextField name="contact_intern" text="Contact" value={"contact_intern"}/>
<CheckField name="international" text="Club international" value={data.international}/>
<MainMap/>
</div>
</div>;
}
export function LocationEditor({data}) {
const [modal, setModal] = useState({id: -1})
const [state, dispatch] = useReducer(SimpleReducer, [])
useEffect(() => {
JSON.parse(data.training_location).forEach((d, index) => {
dispatch({type: 'UPDATE_OR_ADD', payload: {id: index, data: d}})
})
}, [data.training_location]);
const sendAffiliation = (e) => {
dispatch({type: 'UPDATE_OR_ADD', payload: e})
}
return <>
<ul className="list-group">
{state.map((d, index) => {
return <div key={index} className={"list-group-item d-flex justify-content-between align-items-start"}>
<div className="me-auto">{d.data.text}</div>
<button className="badge btn btn-primary rounded-pill" data-bs-toggle="modal"
data-bs-target="#EditModal" onClick={_ => setModal(d)}>
<FontAwesomeIcon icon={faPen}/></button>
<button className="badge btn btn-danger rounded-pill"
onClick={() => dispatch({type: 'REMOVE', payload: d.id})}>
<FontAwesomeIcon icon={faTrashCan}/></button>
</div>
})}
</ul>
<div className="modal fade" id="EditModal" tabIndex="-1" aria-labelledby="EditModalLabel"
aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h1 className="modal-title fs-5" id="EditModalLabel">Edition de l'adresse</h1>
<button type="button" className="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div className="modal-body">
<div className="input-group mb-3 justify-content-md-center">
<form onSubmit={e => sendAffiliation(e, dispatch)}>
<input name="id" value={modal.id} readOnly hidden/>
</form>
<Autoc/>
</div>
</div>
</div>
</div>
</div>
</>
}
function Autoc() {
const [location, setLocation] = useState("9 rue Gracchus")
const {
data,
error,
refresh
} = useFetch(`https://api-adresse.data.gouv.fr/search/?q=${encodeURI(location)}&type=housenumber&autocomplete=1`)
useEffect(() => {
refresh(`https://api-adresse.data.gouv.fr/search/?q=${encodeURI(location)}&type=housenumber&autocomplete=1`)
}, [location]);
return <>
<div className="form-group">
<label htmlFor="input-datalist">Timezone</label>
<input role="combobox" aria-autocomplete="list" aria-expanded="false" autoComplete="off"
placeholder="Chercher une adresse..." aria-label="Recherche"
className="form-control" list="addr" value={location}
onChange={e => setLocation(e.target.value)}/>
<datalist id="addr">
{data && data.features.map((d, index) => {
return <option key={index}>{d.properties.label}</option>
})}
</datalist>
</div>
</>
}
export function ContactEditor({
data
}) {
export function ContactEditor({data}) {
const [state, dispatch] = useReducer(SimpleReducer, [])
useEffect(() => {
@ -221,73 +115,41 @@ export function ContactEditor({
}
}, [data.contact]);
return <>
<ul className="list-group">
{state.map((d, index) => {
if (d.data === undefined)
return;
return <div className="row">
<div className="input-group mb-3">
<span className="input-group-text">Contact</span>
<ul className="list-group form-control">
{state.map((d, index) => {
if (d.data === undefined)
return;
return <div key={index} className={"list-group-item d-flex justify-content-between align-items-start"}>
<div className="input-group">
<select className="form-select" aria-label="type" defaultValue={d.id}
onChange={(e) => {
dispatch({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: undefined}})
dispatch({type: 'UPDATE_OR_ADD', payload: {id: e.target.value, data: d.data}})
}}>
{Object.keys(data.contactMap).map((key, _) => {
let b = false;
for (let s of state) {
if (s.id === key && s.data !== undefined) b = true;
}
return (<option key={key} value={key} disabled={b}>{data.contactMap[key]}</option>)
})}
</select>
<input type="text" className="form-control" defaultValue={d.data} required
onChange={(e) => {
dispatch({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: e.target.value}})
}}/>
<button className="btn btn-danger" type="button"
onClick={() => dispatch({type: 'REMOVE', payload: d.id})}><FontAwesomeIcon
icon={faTrashCan}/>
</button>
return <div key={index} className={"list-group-item d-flex justify-content-between align-items-start"}>
<div className="input-group">
<select className="form-select" aria-label="type" defaultValue={d.id}
onChange={(e) => {
dispatch({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: undefined}})
dispatch({type: 'UPDATE_OR_ADD', payload: {id: e.target.value, data: d.data}})
}}>
{Object.keys(data.contactMap).map((key, _) => {
let b = false;
for (let s of state) {
if (s.id === key && s.data !== undefined) b = true;
}
return (<option key={key} value={key} disabled={b}>{data.contactMap[key]}</option>)
})}
</select>
<input type="text" className="form-control" defaultValue={d.data} required
onChange={(e) => {
dispatch({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: e.target.value}})
}}/>
<button className="btn btn-danger" type="button"
onClick={() => dispatch({type: 'REMOVE', payload: d.id})}><FontAwesomeIcon
icon={faTrashCan}/>
</button>
</div>
</div>
</div>
})}
</ul>
</>
}
// https://annuaire-entreprises.data.gouv.fr/entreprise/la-mesnie-des-chevaliers-de-st-georges-et-de-st-michel-500213731
const position = [51.505, -0.09]
function MainMap() {
function handleReturnCurrentPosition() {
console.log("I have clicked return button!!");
//const newCurrentPositionId = uuidv4();
//setReturnCurrentPosition(newCurrentPositionId);
//console.log(newCurrentPositionId);
}
return (
<>
<MapContainer center={position} zoom={13} scrollWheelZoom={false} style={{height: "30em", width: "50em"}}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={position}>
<Popup>
A pretty CSS3 popup. <br/> Easily customizable.
</Popup>
</Marker>
</MapContainer>
<button className="btn btn-primary" onClick={handleReturnCurrentPosition}>Return current position</button>
<SearchBarMap/>
</>
)
}
function SearchBarMap() {
return <>
</>
})}
</ul>
</div>
</div>
}

View File

@ -0,0 +1,162 @@
import {useEffect, useReducer, useRef, useState} from "react";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faPen, faTrashCan} from "@fortawesome/free-solid-svg-icons";
import proj4 from "proj4";
import {useFetch} from "../../../hooks/useFetch.js";
import {MapContainer, Marker, TileLayer} from "react-leaflet";
import {SimpleReducer} from "../../../utils/SimpleReducer.jsx";
export function LocationEditor({data}) {
const [modal, setModal] = useState({id: -1})
const [state, dispatch] = useReducer(SimpleReducer, [])
useEffect(() => {
if (data.training_location === null)
return
JSON.parse(data.training_location).forEach((d, index) => {
dispatch({type: 'UPDATE_OR_ADD', payload: {id: index, data: d}})
})
}, [data.training_location]);
const sendAffiliation = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
dispatch({
type: 'UPDATE_OR_ADD', payload: {
id: Number(formData.get('id')),
data: {
text: formData.get('loc_text'),
lat: Number(formData.get('loc_lat')),
lng: Number(formData.get('loc_lng'))
}
}
})
}
return <div className="row">
<div className="input-group mb-3">
<span className="input-group-text">Lieux d'entrainement</span>
<ul className="list-group form-control">
{state.map((d, index) => {
return <div key={index} className={"list-group-item d-flex justify-content-between align-items-start"}>
<div className="me-auto">{d.data.text}</div>
<button className="badge btn btn-primary rounded-pill" data-bs-toggle="modal"
data-bs-target="#EditModal" onClick={_ => setModal(d)}>
<FontAwesomeIcon icon={faPen}/></button>
<button className="badge btn btn-danger rounded-pill"
onClick={() => dispatch({type: 'REMOVE', payload: d.id})}>
<FontAwesomeIcon icon={faTrashCan}/></button>
</div>
})}
</ul>
<div className="modal fade" id="EditModal" tabIndex="-1" aria-labelledby="EditModalLabel"
aria-hidden="true">
<div className="modal-dialog">
<form onSubmit={e => sendAffiliation(e)}>
<div className="modal-content">
<div className="modal-header">
<h1 className="modal-title fs-5" id="EditModalLabel">Edition de l'adresse</h1>
<button type="button" className="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div className="modal-body">
<LocationEditorModalBody modal={modal}/>
</div>
<div className="modal-footer">
<button type="submit" className="btn btn-primary" data-bs-dismiss="modal">Enregistrer</button>
<button type="reset" className="btn btn-secondary" data-bs-dismiss="modal">Annuler</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
}
proj4.defs("EPSG:9794", "+proj=lcc +lat_1=44 +lat_2=49 +lat_0=46.5 +lon_0=3 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs");
function convertLambert93ToLatLng(x, y) {
const lambertPoint = proj4.toPoint([x, y]);
const wgs84Point = proj4("EPSG:9794", "EPSG:4326", lambertPoint);
return {lat: wgs84Point.y, lng: wgs84Point.x};
}
function LocationEditorModalBody({modal}) {
const [location, setLocation] = useState("")
const [locationObj, setLocationObj] = useState({text: "", lng: undefined, lat: undefined})
const [mapPosition, setMapPosition] = useState([46.652195, 2.430226])
const {data, error, refresh} = useFetch(``)
const map = useRef(null)
useEffect(() => {
if (modal.data !== undefined) {
setLocation(modal.data.text)
}
}, [modal])
useEffect(() => {
if (location.length < 3)
return
const delayDebounceFn = setTimeout(() => {
refresh(`https://api-adresse.data.gouv.fr/search/?q=${encodeURI(location)}&type=housenumber&autocomplete=1`)
}, 500)
return () => clearTimeout(delayDebounceFn)
}, [location]);
useEffect(() => {
if (data?.features?.length === 1) {
const {lat, lng} = convertLambert93ToLatLng(data.features[0].properties.x, data.features[0].properties.y)
setLocationObj({text: data.features[0].properties.label, lng: lng, lat: lat})
} else {
setLocationObj({text: "", lng: undefined, lat: undefined})
}
}, [data]);
useEffect(() => {
if (locationObj.lat !== undefined) {
setMapPosition([locationObj.lat, locationObj.lng])
map.current?.setView([locationObj.lat, locationObj.lng], 19)
} else {
map.current?.setView([46.652195, 2.430226], 5)
}
const delayDebounceFn = setTimeout(() => {
map.current.invalidateSize(false);
}, 300)
return () => clearTimeout(delayDebounceFn)
}, [locationObj, modal])
return <>
<input name="id" value={modal.id} readOnly hidden/>
<input name="loc_text" value={locationObj.text} readOnly hidden/>
<input name="loc_lat" value={locationObj.lat ? locationObj.lat : -142} readOnly hidden/>
<input name="loc_lng" value={locationObj.lng ? locationObj.lng : -142} readOnly hidden/>
<div className="row">
<div className="input-group mb-3">
<label className="input-group-text">Adresse</label>
<input className="form-control" aria-autocomplete="list" aria-expanded="true" autoComplete="true"
placeholder="Chercher une adresse..." aria-label="Recherche" list="addr" value={location}
onChange={e => setLocation(e.target.value)}/>
<datalist id="addr">
{data?.features && data.features.map((d, index) => {
return <option key={index}>{d.properties.label}</option>
})}
</datalist>
</div>
</div>
<div className="row">
<MapContainer ref={map} center={mapPosition} zoom={13} scrollWheelZoom={true} style={{height: "30em"}}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{locationObj.lat !== undefined && <Marker position={mapPosition}/>}
</MapContainer>
</div>
</>
}

View File

@ -0,0 +1,24 @@
export function SimpleReducer(datas, action) {
switch (action.type) {
case 'ADD':
return [
...datas,
action.payload
]
case 'REMOVE':
return datas.filter(data => data.id !== action.payload)
case 'UPDATE_OR_ADD':
const index = datas.findIndex(data => data.id === action.payload.id)
if (index === -1) {
return [
...datas,
action.payload
]
} else {
datas[index] = action.payload
return [...datas]
}
default:
throw new Error()
}
}