Files
marco/app/components/place-details.gjs
T
raucao 77db625b11
CI / Lint (pull_request) Successful in 1m3s
CI / Test (pull_request) Successful in 1m14s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
Fix WhatsApp links/numbers
Our links would work after opening WhatsApp, but not render e.g. known
business names associated with the number on the intermediate page that
you open the app or web app from
2026-08-19 12:39:50 -06:00

654 lines
16 KiB
Plaintext

import Component from '@glimmer/component';
import { service } from '@ember/service';
import { on } from '@ember/modifier';
import { htmlSafe } from '@ember/template';
import { humanizeOsmTag } from '../utils/format-text';
import { getLocalizedName, getPlaceType } from '../utils/osm';
import { mapToStorageSchema } from '../utils/place-mapping';
import { getSocialInfo } from '../utils/social-links';
import { parsePlacePhotos } from '../utils/nostr';
import Icon from '../components/icon';
import PlaceEditForm from './place-edit-form';
import PlaceListsManager from './place-lists-manager';
import PlacePhotoUpload from './place-photo-upload';
import NostrConnect from './nostr-connect';
import Modal from './modal';
import PhotoCarousel from './photo-carousel';
import PhotoGallery from './photo-gallery';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class PlaceDetails extends Component {
@service storage;
@service nostrAuth;
@service nostrData;
@service mapUi;
@tracked isEditing = false;
@tracked showLists = false;
@tracked isPhotoUploadActive = false;
@tracked isConnectingNostr = false;
@tracked isGalleryOpen = false;
@tracked selectedGalleryPhoto = null;
@tracked isPhotoUploadModalOpen = false;
@tracked isNostrConnectModalOpen = false;
@tracked newlyUploadedPhotoId = null;
@action
handleUploadStateChange(isActive) {
this.isPhotoUploadActive = isActive;
}
@action
openPhotoUploadModal(e) {
if (e) {
e.preventDefault();
}
if (!this.nostrAuth.isConnected) {
this.isNostrConnectModalOpen = true;
} else {
this.isPhotoUploadModalOpen = true;
}
}
@action
closePhotoUploadModal(eventId) {
if (this.isPhotoUploadActive) return;
this.isPhotoUploadModalOpen = false;
if (typeof eventId === 'string') {
this.newlyUploadedPhotoId = eventId;
// Allow DOM to update first, then scroll to the top to show the new photo in the carousel
setTimeout(() => {
const sidebar = document.querySelector('.sidebar-content');
if (sidebar) {
sidebar.scrollTop = 0;
}
}, 50);
}
}
@action
closeNostrConnectModal() {
this.isNostrConnectModalOpen = false;
}
@action
onNostrConnected() {
this.isNostrConnectModalOpen = false;
this.isPhotoUploadModalOpen = true;
}
get isSaved() {
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
}
get place() {
return this.args.place || {};
}
get saveablePlace() {
if (this.place.createdAt) {
return this.place;
}
return mapToStorageSchema(this.place);
}
get tags() {
return this.place.osmTags || {};
}
get name() {
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
}
get photos() {
const rawPhotos = this.nostrData.placePhotos;
const parsedPhotos = parsePlacePhotos(rawPhotos);
return parsedPhotos.map((photo) => ({
...photo,
style: htmlSafe(`--slide-ratio: ${photo.aspectRatio};`),
}));
}
@action
startEditing() {
if (!this.isSaved) return; // Only allow editing saved places
this.isEditing = true;
}
@action
cancelEditing() {
this.isEditing = false;
}
@action
toggleLists(event) {
// Prevent this click from propagating to the document listener
// which handles the "click outside" logic.
if (event) {
event.stopPropagation();
}
this.showLists = !this.showLists;
}
@action
closeLists() {
this.showLists = false;
}
@action
async saveChanges(changes) {
if (this.args.onSave) {
await this.args.onSave({
...this.place,
...changes,
});
}
this.isEditing = false;
}
get type() {
return getPlaceType(this.tags);
}
get address() {
const t = this.tags;
const parts = [];
// Helper to get value from multiple keys
const get = (...keys) => {
for (const k of keys) {
if (t[k]) return t[k];
}
return null;
};
// Street + Number
let street = get('addr:street', 'street');
const number = get('addr:housenumber', 'housenumber');
if (street) {
if (number) {
street = `${street} ${number}`;
}
parts.push(street);
}
// Postcode + City
let city = get('addr:city', 'city');
const postcode = get('addr:postcode', 'postcode');
if (city) {
if (postcode) {
city = `${postcode} ${city}`;
}
parts.push(city);
}
// State + Country (if not already covered)
const state = get('addr:state', 'state');
const country = get('addr:country', 'country');
if (state && state !== city) parts.push(state);
if (country) parts.push(country);
if (parts.length === 0) return null;
return parts.join(', ');
}
formatMultiLine(val, type) {
if (!val) return null;
const parts = [
...new Set(
val
.split(';')
.map((s) => s.trim())
.filter(Boolean)
),
];
if (parts.length === 0) return null;
if (type === 'phone') {
return htmlSafe(
parts
.map((p) => {
const safeTel = p.replace(/[\s-]+/g, '');
return `<a href="tel:${safeTel}">${p}</a>`;
})
.join('<br>')
);
}
if (type === 'email') {
return htmlSafe(
parts.map((p) => `<a href="mailto:${p}">${p}</a>`).join('<br>')
);
}
if (type === 'whatsapp') {
return htmlSafe(
parts
.map((p) => {
const safeTel = p.replace(/[\s()+.-]/g, '');
return `<a href="https://wa.me/${safeTel}" target="_blank" rel="noopener noreferrer">${p}</a>`;
})
.join('<br>')
);
}
if (type === 'url') {
return htmlSafe(
parts
.map(
(url) =>
`<a href="${url}" target="_blank" rel="noopener noreferrer">${this.getDomain(
url
)}</a>`
)
.join('<br>')
);
}
return htmlSafe(parts.join('<br>'));
}
get phone() {
const rawValues = [
this.tags.phone,
this.tags['contact:phone'],
this.tags.mobile,
this.tags['contact:mobile'],
].filter(Boolean);
if (rawValues.length === 0) return null;
return this.formatMultiLine(rawValues.join(';'), 'phone');
}
get whatsapp() {
const rawValues = [
this.tags.whatsapp,
this.tags['contact:whatsapp'],
].filter(Boolean);
if (rawValues.length === 0) return null;
return this.formatMultiLine(rawValues.join(';'), 'whatsapp');
}
get email() {
const val = this.tags.email || this.tags['contact:email'];
return this.formatMultiLine(val, 'email');
}
get website() {
const val =
this.place.url || this.tags.website || this.tags['contact:website'];
return this.formatMultiLine(val, 'url');
}
getDomain(urlStr) {
try {
const url = new URL(urlStr);
return url.hostname;
} catch {
return urlStr;
}
}
get openingHours() {
const val = this.tags.opening_hours;
return this.formatMultiLine(val);
}
get cuisine() {
if (!this.tags.cuisine) return null;
return this.tags.cuisine
.split(';')
.map((c) => humanizeOsmTag(c))
.join(', ');
}
get facebook() {
return getSocialInfo(this.tags, 'facebook');
}
get instagram() {
return getSocialInfo(this.tags, 'instagram');
}
get wikipedia() {
const val = this.tags.wikipedia;
if (!val) return null;
return val
.split(';')
.map((s) => s.trim())
.filter(Boolean)[0];
}
get geoLink() {
const lat = this.place.lat;
const lon = this.place.lon;
if (!lat || !lon) return '#';
const label = encodeURIComponent(this.name);
return `geo:${lat},${lon}?q=${lat},${lon}(${label})`;
}
get visibleGeoLink() {
const lat = this.place.lat;
const lon = this.place.lon;
if (!lat || !lon) return '';
return `${Number(lat).toFixed(6)}, ${Number(lon).toFixed(6)}`;
}
get osmUrl() {
const id = this.place.osmId;
if (id) {
const type = this.place.osmType || 'node';
return `https://www.openstreetmap.org/${type}/${id}`;
}
const lat = this.place.lat;
const lon = this.place.lon;
if (!lat || !lon) return null;
const viewLat = this.mapUi.currentCenter?.lat ?? lat;
const viewLon = this.mapUi.currentCenter?.lon ?? lon;
const zoom = this.mapUi.currentZoom ?? 17;
const roundedZoom = Math.round(zoom);
return `https://www.openstreetmap.org/search?lat=${lat}&lon=${lon}&zoom=${roundedZoom}#map=${roundedZoom}/${Number(viewLat).toFixed(5)}/${Number(viewLon).toFixed(5)}`;
}
get gmapsUrl() {
const id = this.place.gmapsId || this.place.osmId;
if (!id) return null;
return `https://www.google.com/maps/search/?api=1&query=${this.name}&query=${this.place.lat},${this.place.lon}`;
}
get showDescription() {
// If it's a Photon result, the description IS the address.
// Since we are showing the address in the meta section (bottom),
// we should hide the description to avoid duplication.
if (this.place.source === 'photon') return false;
// Otherwise (e.g. saved place with custom description), show it.
return !!this.place.description;
}
@action
openGallery(photo) {
this.selectedGalleryPhoto = photo;
this.isGalleryOpen = true;
}
@action
closeGallery() {
this.isGalleryOpen = false;
this.selectedGalleryPhoto = null;
}
<template>
<div class="place-details">
{{#if this.isEditing}}
<PlaceEditForm
@place={{this.place}}
@onSave={{this.saveChanges}}
@onCancel={{this.cancelEditing}}
/>
{{else}}
<PhotoCarousel
@variant="inline"
@photos={{this.photos}}
@name={{this.name}}
@resetKey={{this.place.osmId}}
@scrollToEventId={{this.newlyUploadedPhotoId}}
@onPhotoClick={{this.openGallery}}
/>
<h3>{{this.name}}</h3>
<p class="place-type">
{{this.type}}
</p>
{{#if this.showDescription}}
<p class="place-description">
{{this.place.description}}
</p>
{{/if}}
{{/if}}
<div class="actions">
<div class="save-button-wrapper">
<button
type="button"
class={{if this.isSaved "btn btn-secondary" "btn btn-outline"}}
{{on "click" this.toggleLists}}
>
<Icon
@name="bookmark"
@color={{if this.isSaved "currentColor" "var(--link-color)"}}
/>
{{if this.isSaved "Saved" "Save"}}
</button>
{{#if this.showLists}}
<PlaceListsManager
@place={{this.saveablePlace}}
@onClose={{this.closeLists}}
@isSaved={{this.isSaved}}
/>
{{/if}}
</div>
{{#if this.isSaved}}
<button
type="button"
class="btn btn-outline"
title="Edit"
disabled={{this.isEditing}}
{{on "click" this.startEditing}}
>
<Icon @name="edit" @color="var(--link-color)" />
Edit
</button>
{{/if}}
</div>
<div class="meta-info">
{{#if this.cuisine}}
<p class="content-with-icon">
<Icon @name="fork-and-knife" @title="Cuisine" />
<span>
{{this.cuisine}}
</span>
</p>
{{/if}}
{{#if this.openingHours}}
<p class="content-with-icon">
<Icon @name="clock" @title="Opening hours" />
<span>
{{this.openingHours}}
</span>
</p>
{{/if}}
{{#if this.phone}}
<p class="content-with-icon">
<Icon @name="phone" @title="Phone" />
<span>
{{this.phone}}
</span>
</p>
{{/if}}
{{#if this.whatsapp}}
<p class="content-with-icon">
<Icon @name="whatsapp" @title="WhatsApp" />
<span>
{{this.whatsapp}}
</span>
</p>
{{/if}}
{{#if this.website}}
<p class="content-with-icon">
<Icon @name="globe" @title="Website" />
<span>
{{this.website}}
</span>
</p>
{{/if}}
{{#if this.email}}
<p class="content-with-icon">
<Icon @name="mail" @title="Email" />
<span>
{{this.email}}
</span>
</p>
{{/if}}
{{#if this.facebook}}
<p class="content-with-icon">
<Icon @name="facebook" @title="Facebook" />
<span>
<a
href={{this.facebook.url}}
target="_blank"
rel="noopener noreferrer"
>
{{this.facebook.username}}
</a>
</span>
</p>
{{/if}}
{{#if this.instagram}}
<p class="content-with-icon">
<Icon @name="instagram" @title="Instagram" />
<span>
<a
href={{this.instagram.url}}
target="_blank"
rel="noopener noreferrer"
>
{{this.instagram.username}}
</a>
</span>
</p>
{{/if}}
{{#if this.wikipedia}}
<p class="content-with-icon">
<Icon @name="wikipedia" @title="Wikipedia" />
<span>
<a
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
target="_blank"
rel="noopener noreferrer"
>
Wikipedia
</a>
</span>
</p>
{{/if}}
</div>
<div class="meta-info">
{{#if this.address}}
<p class="content-with-icon">
<Icon @name="home" @title="Address" />
<span>{{this.address}}</span>
</p>
{{/if}}
<p class="content-with-icon">
<Icon @name="map-pin" @title="Geo link" />
<span>
<a href={{this.geoLink}} target="_blank" rel="noopener noreferrer">
{{this.visibleGeoLink}}
</a>
</span>
</p>
{{#if this.osmUrl}}
<p class="content-with-icon">
<Icon @name="map" />
<span>
<a href={{this.osmUrl}} target="_blank" rel="noopener noreferrer">
OpenStreetMap
</a>
</span>
</p>
{{/if}}
{{#if this.gmapsUrl}}
<p class="content-with-icon">
<Icon @name="map" />
<span>
<a
href={{this.gmapsUrl}}
target="_blank"
rel="noopener noreferrer"
>
Google Maps
</a>
</span>
</p>
{{/if}}
</div>
{{#if this.place.osmId}}
<div class="meta-info">
<p class="content-with-icon">
<Icon @name="feather-camera" />
<span>
<button
type="button"
class="btn-link"
{{on "click" this.openPhotoUploadModal}}
>
Add a photo
</button>
</span>
</p>
</div>
{{/if}}
</div>
{{#if this.isPhotoUploadModalOpen}}
<Modal
@onClose={{this.closePhotoUploadModal}}
@disableClose={{this.isPhotoUploadActive}}
>
<PlacePhotoUpload
@place={{this.saveablePlace}}
@onClose={{this.closePhotoUploadModal}}
@onUploadStateChange={{this.handleUploadStateChange}}
/>
</Modal>
{{/if}}
{{#if this.isNostrConnectModalOpen}}
<Modal @onClose={{this.closeNostrConnectModal}}>
<NostrConnect @onConnect={{this.onNostrConnected}} />
</Modal>
{{/if}}
{{#if this.isGalleryOpen}}
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedGalleryPhoto}}
@placeName={{this.name}}
@onClose={{this.closeGallery}}
/>
{{/if}}
</template>
}