huellas/ts-client/client.ts
Felipe Contreras Salinas bf30b4f491
fix: use correct regex to display instagram urls (#50)
Reviewed-on: #50
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2025-06-14 18:43:57 -04:00

371 lines
11 KiB
TypeScript

import * as L from 'leaflet-contextmenu';
import { Feature, FeatureCollection, Point } from 'geojson';
import * as MessagePack from "@msgpack/msgpack";
interface PlaceModel {
id: number | null;
name: string;
address: string;
open_hours: string;
icon: string;
description: string;
latitude: number;
longitude: number;
url: string | null;
}
async function loadPlaces(): Promise<Array<PlaceModel>> {
let bytes = await fetch('places').then(response => response.body);
return (await MessagePack.decodeAsync(bytes)) as Array<PlaceModel>;
}
function toFeature(place: PlaceModel): Feature {
return {
"type": "Feature",
"properties": {
"name": place.name,
"address": place.address,
"open_hours": place.open_hours,
"icon": place.icon,
"description": place.description,
"url": place.url,
},
"geometry": {
"type": "Point",
"coordinates": [place.longitude, place.latitude]
}
}
}
function toLeafletPlaces(backendPlaces: Array<PlaceModel>): L.GeoJSON {
let result: FeatureCollection = {
type: "FeatureCollection",
features: new Array<Feature>(),
}
for (const place of backendPlaces) {
result.features.push(toFeature(place));
}
return result;
}
let placesLayer: L.GeoJSON;
let places = new Map<string, PlaceModel>();
function toStr(latlng: L.LatLngLiteral) {
return latlng.lng + "|" + latlng.lat;
}
function getPlaceFromForm(): PlaceModel {
const idStr = (document.getElementById("id") as HTMLInputElement).value;
const id = idStr == "" ? null : parseInt(idStr);
const name =
(document.getElementById("name") as HTMLInputElement).value;
const address =
(document.getElementById("address") as HTMLInputElement).value;
const open_hours =
(document.getElementById("open_hours") as HTMLTextAreaElement).value;
const icon =
(document.getElementById("icon") as HTMLSelectElement).value;
const description =
(document.getElementById("description") as HTMLTextAreaElement).value;
const latitude = parseFloat(
(document.getElementById("lat") as HTMLInputElement).value);
const longitude = parseFloat(
(document.getElementById("long") as HTMLSelectElement).value);
const url =
(document.getElementById("url") as HTMLInputElement).value;
return {
id: id,
name: name,
address: address,
open_hours: open_hours,
icon: icon,
description: description,
latitude: latitude,
longitude: longitude,
url: url,
};
}
function clearForm(): void {
/* Get the form elements*/
const idInput = (document.getElementById("id") as HTMLInputElement);
const latInput = (document.getElementById("lat") as HTMLInputElement);
const longInput = (document.getElementById("long") as HTMLInputElement);
const nameInput = (document.getElementById("name") as HTMLInputElement);
const addressInput = (document.getElementById("address") as HTMLInputElement);
const urlInput = (document.getElementById("url") as HTMLInputElement);
const openHoursArea = (document.getElementById("open_hours") as HTMLTextAreaElement);
const descriptionArea = (document.getElementById("description") as HTMLTextAreaElement);
/* Clear them */
idInput.value = "";
latInput.value = "";
longInput.value = "";
nameInput.value = "";
addressInput.value = "";
urlInput.value = "";
openHoursArea.value = "";
descriptionArea.value = "";
/* Clear the callback */
document.getElementById("button").onclick = null;
/* Now you see it, now you don't*/
(document.getElementById("dialog") as HTMLDialogElement).close();
}
async function createPlace(): Promise<void> {
const newPlace = getPlaceFromForm();
await fetch('places', {
method: 'PUT',
headers: {
'Content-Type': 'application/msgpack',
},
body: MessagePack.encode(newPlace),
})
.then((response) => response.body)
.then((bytes) => MessagePack.decodeAsync(bytes))
.then((place: PlaceModel) => {
places.set(
toStr({ lat: place.latitude, lng: place.longitude }),
place
);
placesLayer.addData(toFeature(place));
clearForm();
alert("Lugar añadido exitosamente");
})
.catch((error) => alert(error));
}
async function editPlace(): Promise<void> {
const newPlace = getPlaceFromForm();
await fetch('places', {
method: 'PUT',
headers: {
'Content-Type': 'application/msgpack',
},
body: MessagePack.encode(newPlace),
})
.then((response) => response.body)
.then((bytes) => MessagePack.decodeAsync(bytes))
.then((place: PlaceModel) => {
places.set(
toStr({ lat: place.latitude, lng: place.longitude }),
place
);
clearForm();
alert("Lugar guardado exitosamente. Para ver los cambios, recargue la página.");
})
.catch((error) => alert(error));
}
const enum Operation {
Create = "create",
Edit = "edit",
}
interface MapEvent {
latlng: L.LatLng;
relatedTarget: L.Marker | L.Path | undefined;
}
async function getAddressReverse(lat: number, long: number): Promise<string> {
const nominatimEndpoint = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${long}&format=json`
const address = (await fetch(nominatimEndpoint).then(response => response.json())).address;
return `${address.road} ${address.house_number}, ${address.city}`;
}
function toLink(url: string): string {
let content = url;
const m = url.match(/https:\/\/instagram\.com\/((?:\w|\.)+)\/?/);
if (m) {
content = `@${m[1]}`;
}
return `<a href="${url}" target="_blank">${content}</a>`
}
async function setupMap(): Promise<void> {
/* Create/Edit form */
const dialog = document.getElementById("dialog") as HTMLDialogElement;
const closeButton = document.getElementById("close");
closeButton.onclick = function() {
dialog.close();
}
async function openForm(op: Operation, lat: number, long: number): Promise<void> {
/* Fill the form for us */
const h1 = dialog.getElementsByTagName("h1")[0];
if (op == Operation.Create) {
clearForm()
h1.innerText = "Añadir lugar nuevo";
} else {
h1.innerText = "Editar lugar";
}
const latInput = (document.getElementById("lat") as HTMLInputElement);
const longInput = (document.getElementById("long") as HTMLInputElement);
latInput.value = lat.toString();
longInput.value = long.toString();
const addressInput = (document.getElementById("address") as HTMLInputElement);
if (op == Operation.Edit) {
const place = places.get(toStr({ lat: lat, lng: long }));
console.log(toStr({ lat: lat, lng: long }));
/*Get the form elements*/
const idInput = (document.getElementById("id") as HTMLInputElement);
const nameInput = (document.getElementById("name") as HTMLInputElement);
const urlInput = (document.getElementById("url") as HTMLInputElement);
const openHoursArea = (document.getElementById("open_hours") as HTMLTextAreaElement);
const iconSelect = (document.getElementById("icon") as HTMLSelectElement);
const descriptionArea = (document.getElementById("description") as HTMLTextAreaElement);
/* And set them */
idInput.value = place.id.toString();
nameInput.value = place.name;
addressInput.value = place.address;
urlInput.value = place.url;
openHoursArea.value = place.open_hours;
iconSelect.value = place.icon;
descriptionArea.value = place.description;
} else {
getAddressReverse(lat, long).then(
(address) => {
if (addressInput.value.length == 0) {
addressInput.value = address;
}
}
);
}
/* Plug callbacks */
if (op == Operation.Create) {
document.getElementById("button").onclick = createPlace;
} else {
document.getElementById("button").onclick = editPlace;
}
/* Make it appear */
dialog.showModal();
}
function openCreateForm(e: MapEvent) {
const lat = e.latlng.lat;
const long = e.latlng.lng;
openForm(Operation.Create, lat, long);
}
function openEditForm(e: MapEvent) {
const marker = (e.relatedTarget as L.Marker);
const lat = marker.getLatLng().lat;
const long = marker.getLatLng().lng;
openForm(Operation.Edit, lat, long);
}
/* Get places from backend */
const backendPlaces = await loadPlaces();
const leafletPlaces = toLeafletPlaces(backendPlaces);
for (const place of backendPlaces) {
places.set(
toStr({ lat: place.latitude, lng: place.longitude }),
place
);
}
/* Set up the map*/
const map = new L.map('map', {
contextmenu: true,
contextmenuWidth: 140,
contextmenuItems: [
{
text: 'Añadir lugar',
callback: openCreateForm
}
]
});
/* Create the tile layer with correct attribution*/
const osmUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
const osmAttrib = 'Mapa © <a href="https://openstreetmap.org">OpenStreetMap</a>';
const osm = new L.TileLayer(osmUrl, { minZoom: 4, maxZoom: 20, attribution: osmAttrib });
/* Start the map in Santiago */
map.setView(new L.LatLng(-33.45, -70.666667), 13);
/* Try to get user position, if not, put the map in Santiago again */
map.locate({ setView: true, maxZoom: 16 })
.on('locationerror', function(_event: L.LocationEvent) {
map.setView(new L.LatLng(-33.45, -70.666667), 13);
});
map.addLayer(osm);
function onEachFeature(feature: Feature, layer: L.Layer) {
if (feature.properties) {
let popupStr = `<h3>${feature.properties.name}</h3>`;
popupStr += "<ul>"
if (feature.properties.address)
popupStr += `<li><b>Dirección:</b>${feature.properties.address}</li>`;
if (feature.properties.open_hours)
popupStr += `<li><b>Horario:</b>${feature.properties.open_hours}</li>`;
if (feature.properties.description)
popupStr += `<li>${feature.properties.description}</li>`;
if (feature.properties.url)
popupStr += `<li>${toLink(feature.properties.url)}</li>`;
const lnglat = (feature.geometry as Point).coordinates;
const lng = lnglat[0];
const lat = lnglat[1];
popupStr += `<a href="https://www.google.com/maps/dir//` +
`${lat},${lng}/@${lat},${lng},15z" target="_blank">GMaps</a>`
popupStr += "</ul>";
layer.bindPopup(popupStr);
layer.bindContextMenu({
contextmenu: true,
contextmenuInheritItems: false,
contextmenuItems: [{
text: 'Editar',
callback: openEditForm,
}]
});
}
}
/* Icons */
const icons = new Map<string, L.Icon>();
icons.set('bar', new L.Icon({ iconUrl: 'icons/bar.svg' }));
icons.set('coffee', new L.Icon({ iconUrl: 'icons/coffee.svg' }));
icons.set('cinema', new L.Icon({ iconUrl: 'icons/film.svg' }));
icons.set('dining', new L.Icon({ iconUrl: 'icons/dining.svg' }));
icons.set('food', new L.Icon({ iconUrl: 'icons/food.svg' }));
icons.set('jazz', new L.Icon({ iconUrl: 'icons/saxophone.svg' }));
icons.set('library', new L.Icon({ iconUrl: 'icons/book.svg' }));
icons.set('marker', new L.Icon({ iconUrl: 'icons/marker.svg' }));
icons.set('mask', new L.Icon({ iconUrl: 'icons/mask.svg' }));
icons.set('museum', new L.Icon({ iconUrl: 'icons/museum.svg' }));
icons.set('shop', new L.Icon({ iconUrl: 'icons/store.svg' }));
for (let [_name, icon] of icons) {
icon.options.iconSize = [36, 36];
icon.options.popupAnchor = [0, -18];
}
function pointToLayer(feature: Feature, latlng: L.LatLng) {
let markerIcon = null;
if (feature.properties && feature.properties.icon) {
markerIcon = icons.get(feature.properties.icon);
}
if (markerIcon !== null && markerIcon !== undefined)
return L.marker(latlng, { icon: markerIcon });
else
return L.marker(latlng);
}
placesLayer = L.geoJSON(leafletPlaces, {
onEachFeature: onEachFeature,
pointToLayer: pointToLayer
})
map.addLayer(placesLayer);
}