Compare commits
22 Commits
v1.12.2
...
feature/1-
| Author | SHA1 | Date | |
|---|---|---|---|
|
f1779131e8
|
|||
|
37cf47b3dd
|
|||
|
ff68b5addc
|
|||
|
990f3afa88
|
|||
|
b2220b8310
|
|||
|
a8613ab81a
|
|||
|
bcb9b20e85
|
|||
|
466b1d5383
|
|||
|
ea7cb2f895
|
|||
|
7e94f335ac
|
|||
|
066ddb240d
|
|||
|
df336b87ac
|
|||
|
dbf71e366a
|
|||
|
6a83003acb
|
|||
|
bcc7c2a011
|
|||
|
19f04efecb
|
|||
|
c79bbaa41a
|
|||
|
b07640375a
|
|||
|
ffcb8219b0
|
|||
|
e01cb2ce6f
|
|||
|
808c1ee37b
|
|||
|
34bc15cfa9
|
@@ -6,10 +6,13 @@ import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
|||||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||||
|
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
|
||||||
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
||||||
import home from 'feather-icons/dist/icons/home.svg?raw';
|
import home from 'feather-icons/dist/icons/home.svg?raw';
|
||||||
|
import instagram from 'feather-icons/dist/icons/instagram.svg?raw';
|
||||||
import logIn from 'feather-icons/dist/icons/log-in.svg?raw';
|
import logIn from 'feather-icons/dist/icons/log-in.svg?raw';
|
||||||
import logOut from 'feather-icons/dist/icons/log-out.svg?raw';
|
import logOut from 'feather-icons/dist/icons/log-out.svg?raw';
|
||||||
|
import mail from 'feather-icons/dist/icons/mail.svg?raw';
|
||||||
import map from 'feather-icons/dist/icons/map.svg?raw';
|
import map from 'feather-icons/dist/icons/map.svg?raw';
|
||||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||||
@@ -31,10 +34,13 @@ const ICONS = {
|
|||||||
bookmark,
|
bookmark,
|
||||||
clock,
|
clock,
|
||||||
edit,
|
edit,
|
||||||
|
facebook,
|
||||||
globe,
|
globe,
|
||||||
home,
|
home,
|
||||||
|
instagram,
|
||||||
'log-in': logIn,
|
'log-in': logIn,
|
||||||
'log-out': logOut,
|
'log-out': logOut,
|
||||||
|
mail,
|
||||||
map,
|
map,
|
||||||
'map-pin': mapPin,
|
'map-pin': mapPin,
|
||||||
menu,
|
menu,
|
||||||
|
|||||||
@@ -60,9 +60,30 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
// Create a vector source and layer for bookmarks
|
// Create a vector source and layer for bookmarks
|
||||||
this.bookmarkSource = new VectorSource();
|
this.bookmarkSource = new VectorSource();
|
||||||
const bookmarkLayer = new VectorLayer({
|
|
||||||
source: this.bookmarkSource,
|
const bookmarkStyleFunction = (feature) => {
|
||||||
style: [
|
const originalPlace = feature.get('originalPlace');
|
||||||
|
let color =
|
||||||
|
getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--default-list-color')
|
||||||
|
.trim() || '#000000'; // Fallback to black if variable is missing to make error obvious
|
||||||
|
|
||||||
|
if (
|
||||||
|
originalPlace &&
|
||||||
|
originalPlace._listIds &&
|
||||||
|
originalPlace._listIds.length > 0
|
||||||
|
) {
|
||||||
|
// Find the first list color
|
||||||
|
// We need access to storage.lists.
|
||||||
|
// Since this is inside setupMap, 'this' refers to the component instance.
|
||||||
|
const firstListId = originalPlace._listIds[0];
|
||||||
|
const list = this.storage.lists.find((l) => l.id === firstListId);
|
||||||
|
if (list && list.color) {
|
||||||
|
color = list.color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
new Style({
|
new Style({
|
||||||
image: new Circle({
|
image: new Circle({
|
||||||
radius: 10,
|
radius: 10,
|
||||||
@@ -73,14 +94,19 @@ export default class MapComponent extends Component {
|
|||||||
new Style({
|
new Style({
|
||||||
image: new Circle({
|
image: new Circle({
|
||||||
radius: 9,
|
radius: 9,
|
||||||
fill: new Fill({ color: '#ffcc33' }), // Gold/Yellow
|
fill: new Fill({ color: color }),
|
||||||
stroke: new Stroke({
|
stroke: new Stroke({
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
width: 2,
|
width: 2,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookmarkLayer = new VectorLayer({
|
||||||
|
source: this.bookmarkSource,
|
||||||
|
style: bookmarkStyleFunction,
|
||||||
zIndex: 10, // Ensure it sits above the map tiles
|
zIndex: 10, // Ensure it sits above the map tiles
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -441,6 +467,7 @@ export default class MapComponent extends Component {
|
|||||||
// Track the selected place from the UI Service (Router -> Map)
|
// Track the selected place from the UI Service (Router -> Map)
|
||||||
updateSelectedPin = modifier(() => {
|
updateSelectedPin = modifier(() => {
|
||||||
const selected = this.mapUi.selectedPlace;
|
const selected = this.mapUi.selectedPlace;
|
||||||
|
const options = this.mapUi.selectionOptions || {};
|
||||||
|
|
||||||
if (!this.selectedPinOverlay || !this.selectedPinElement) return;
|
if (!this.selectedPinOverlay || !this.selectedPinElement) return;
|
||||||
|
|
||||||
@@ -471,7 +498,12 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.bbox) {
|
if (options.preventZoom) {
|
||||||
|
// If we are preventing zoom (e.g. user clicked a bookmark), we still need to center
|
||||||
|
// but without changing the zoom level.
|
||||||
|
// We use animateToSmartCenter without a second argument (zoom=null).
|
||||||
|
this.animateToSmartCenter(coords);
|
||||||
|
} else if (selected.bbox) {
|
||||||
this.zoomToBbox(selected.bbox);
|
this.zoomToBbox(selected.bbox);
|
||||||
} else {
|
} else {
|
||||||
this.handlePinVisibility(coords);
|
this.handlePinVisibility(coords);
|
||||||
@@ -524,17 +556,28 @@ export default class MapComponent extends Component {
|
|||||||
padding[1] = visibleWidth * 0.15;
|
padding[1] = visibleWidth * 0.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentZoom = view.getZoom();
|
||||||
|
|
||||||
view.fit(extent, {
|
view.fit(extent, {
|
||||||
padding: padding,
|
padding: padding,
|
||||||
duration: 1000,
|
duration: 1000,
|
||||||
easing: (t) => t * (2 - t),
|
easing: (t) => t * (2 - t),
|
||||||
maxZoom: 19,
|
maxZoom: Math.max(currentZoom, 18),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePinVisibility(coords) {
|
handlePinVisibility(coords) {
|
||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance) return;
|
||||||
|
|
||||||
|
const view = this.mapInstance.getView();
|
||||||
|
const currentZoom = view.getZoom();
|
||||||
|
|
||||||
|
// If too far out (e.g. world view), zoom in to neighborhood level (16)
|
||||||
|
if (currentZoom < 16) {
|
||||||
|
this.animateToSmartCenter(coords, 16);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
|
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
|
||||||
const size = this.mapInstance.getSize();
|
const size = this.mapInstance.getSize();
|
||||||
|
|
||||||
@@ -553,12 +596,17 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
animateToSmartCenter(coords) {
|
animateToSmartCenter(coords, zoom = null) {
|
||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance) return;
|
||||||
|
|
||||||
const size = this.mapInstance.getSize();
|
const size = this.mapInstance.getSize();
|
||||||
const view = this.mapInstance.getView();
|
const view = this.mapInstance.getView();
|
||||||
const resolution = view.getResolution();
|
let resolution = view.getResolution();
|
||||||
|
|
||||||
|
if (zoom !== null) {
|
||||||
|
resolution = view.getResolutionForZoom(zoom);
|
||||||
|
}
|
||||||
|
|
||||||
let targetCenter = coords;
|
let targetCenter = coords;
|
||||||
|
|
||||||
// Check if mobile (width <= 768px matches CSS)
|
// Check if mobile (width <= 768px matches CSS)
|
||||||
@@ -580,11 +628,17 @@ export default class MapComponent extends Component {
|
|||||||
targetCenter = [coords[0], coords[1] - offsetMapUnits];
|
targetCenter = [coords[0], coords[1] - offsetMapUnits];
|
||||||
}
|
}
|
||||||
|
|
||||||
view.animate({
|
const animationOptions = {
|
||||||
center: targetCenter,
|
center: targetCenter,
|
||||||
duration: 1000,
|
duration: 1000,
|
||||||
easing: (t) => t * (2 - t), // Ease-out
|
easing: (t) => t * (2 - t), // Ease-out
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (zoom !== null) {
|
||||||
|
animationOptions.zoom = zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.animate(animationOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
panIfObscured(coords) {
|
panIfObscured(coords) {
|
||||||
@@ -848,6 +902,7 @@ export default class MapComponent extends Component {
|
|||||||
'Clicked bookmark while sidebar open (switching):',
|
'Clicked bookmark while sidebar open (switching):',
|
||||||
clickedBookmark
|
clickedBookmark
|
||||||
);
|
);
|
||||||
|
this.mapUi.preventNextZoom = true;
|
||||||
this.router.transitionTo('place', clickedBookmark);
|
this.router.transitionTo('place', clickedBookmark);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -862,6 +917,7 @@ export default class MapComponent extends Component {
|
|||||||
// Normal behavior (sidebar is closed)
|
// Normal behavior (sidebar is closed)
|
||||||
if (clickedBookmark) {
|
if (clickedBookmark) {
|
||||||
console.debug('Clicked bookmark:', clickedBookmark);
|
console.debug('Clicked bookmark:', clickedBookmark);
|
||||||
|
this.mapUi.preventNextZoom = true;
|
||||||
this.router.transitionTo('place', clickedBookmark);
|
this.router.transitionTo('place', clickedBookmark);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,39 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { fn } from '@ember/helper';
|
import { service } from '@ember/service';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import { htmlSafe } from '@ember/template';
|
import { htmlSafe } from '@ember/template';
|
||||||
import { humanizeOsmTag } from '../utils/format-text';
|
import { humanizeOsmTag } from '../utils/format-text';
|
||||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||||
|
import { mapToStorageSchema } from '../utils/place-mapping';
|
||||||
|
import { getSocialInfo } from '../utils/social-links';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import PlaceEditForm from './place-edit-form';
|
import PlaceEditForm from './place-edit-form';
|
||||||
|
import PlaceListsManager from './place-lists-manager';
|
||||||
|
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
|
|
||||||
export default class PlaceDetails extends Component {
|
export default class PlaceDetails extends Component {
|
||||||
|
@service storage;
|
||||||
@tracked isEditing = false;
|
@tracked isEditing = false;
|
||||||
|
@tracked showLists = false;
|
||||||
|
|
||||||
|
get isSaved() {
|
||||||
|
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
|
||||||
|
}
|
||||||
|
|
||||||
get place() {
|
get place() {
|
||||||
return this.args.place || {};
|
return this.args.place || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get saveablePlace() {
|
||||||
|
if (this.place.createdAt) {
|
||||||
|
return this.place;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapToStorageSchema(this.place);
|
||||||
|
}
|
||||||
|
|
||||||
get tags() {
|
get tags() {
|
||||||
return this.place.osmTags || {};
|
return this.place.osmTags || {};
|
||||||
}
|
}
|
||||||
@@ -27,7 +44,7 @@ export default class PlaceDetails extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
startEditing() {
|
startEditing() {
|
||||||
if (!this.place.createdAt) return; // Only allow editing saved places
|
if (!this.isSaved) return; // Only allow editing saved places
|
||||||
this.isEditing = true;
|
this.isEditing = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +53,21 @@ export default class PlaceDetails extends Component {
|
|||||||
this.isEditing = false;
|
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
|
@action
|
||||||
async saveChanges(changes) {
|
async saveChanges(changes) {
|
||||||
if (this.args.onSave) {
|
if (this.args.onSave) {
|
||||||
@@ -110,6 +142,12 @@ export default class PlaceDetails extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'email') {
|
||||||
|
return htmlSafe(
|
||||||
|
parts.map((p) => `<a href="mailto:${p}">${p}</a>`).join('<br>')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'url') {
|
if (type === 'url') {
|
||||||
return htmlSafe(
|
return htmlSafe(
|
||||||
parts
|
parts
|
||||||
@@ -131,6 +169,11 @@ export default class PlaceDetails extends Component {
|
|||||||
return this.formatMultiLine(val, 'phone');
|
return this.formatMultiLine(val, 'phone');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get email() {
|
||||||
|
const val = this.tags.email || this.tags['contact:email'];
|
||||||
|
return this.formatMultiLine(val, 'email');
|
||||||
|
}
|
||||||
|
|
||||||
get website() {
|
get website() {
|
||||||
const val =
|
const val =
|
||||||
this.place.url || this.tags.website || this.tags['contact:website'];
|
this.place.url || this.tags.website || this.tags['contact:website'];
|
||||||
@@ -159,6 +202,14 @@ export default class PlaceDetails extends Component {
|
|||||||
.join(', ');
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get facebook() {
|
||||||
|
return getSocialInfo(this.tags, 'facebook');
|
||||||
|
}
|
||||||
|
|
||||||
|
get instagram() {
|
||||||
|
return getSocialInfo(this.tags, 'instagram');
|
||||||
|
}
|
||||||
|
|
||||||
get wikipedia() {
|
get wikipedia() {
|
||||||
const val = this.tags.wikipedia;
|
const val = this.tags.wikipedia;
|
||||||
if (!val) return null;
|
if (!val) return null;
|
||||||
@@ -227,23 +278,33 @@ export default class PlaceDetails extends Component {
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button
|
<div class="save-button-wrapper">
|
||||||
type="button"
|
<button
|
||||||
class={{if
|
type="button"
|
||||||
this.place.createdAt
|
class={{if
|
||||||
"btn btn-secondary"
|
this.isSaved
|
||||||
"btn btn-outline"
|
"btn btn-secondary"
|
||||||
}}
|
"btn btn-outline"
|
||||||
{{on "click" (fn @onToggleSave this.place)}}
|
}}
|
||||||
>
|
{{on "click" this.toggleLists}}
|
||||||
<Icon
|
>
|
||||||
@name="bookmark"
|
<Icon
|
||||||
@color={{if this.place.createdAt "currentColor" "#007bff"}}
|
@name="bookmark"
|
||||||
/>
|
@color={{if this.isSaved "currentColor" "#007bff"}}
|
||||||
{{if this.place.createdAt "Saved" "Save"}}
|
/>
|
||||||
</button>
|
{{if this.isSaved "Saved" "Save"}}
|
||||||
|
</button>
|
||||||
|
|
||||||
{{#if this.place.createdAt}}
|
{{#if this.showLists}}
|
||||||
|
<PlaceListsManager
|
||||||
|
@place={{this.saveablePlace}}
|
||||||
|
@onClose={{this.closeLists}}
|
||||||
|
@isSaved={{this.isSaved}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if this.isSaved}}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline"
|
class="btn btn-outline"
|
||||||
@@ -292,6 +353,45 @@ export default class PlaceDetails extends Component {
|
|||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/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}}
|
{{#if this.wikipedia}}
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="wikipedia" @title="Wikipedia" @filled={{true}} />
|
<Icon @name="wikipedia" @title="Wikipedia" @filled={{true}} />
|
||||||
|
|||||||
135
app/components/place-lists-manager.gjs
Normal file
135
app/components/place-lists-manager.gjs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import { htmlSafe } from '@ember/template';
|
||||||
|
import onClickOutside from '../modifiers/on-click-outside';
|
||||||
|
|
||||||
|
export default class PlaceListsManager extends Component {
|
||||||
|
@service storage;
|
||||||
|
@service router;
|
||||||
|
@tracked _forceClear = false;
|
||||||
|
|
||||||
|
get isSaved() {
|
||||||
|
return this.args.isSaved;
|
||||||
|
}
|
||||||
|
|
||||||
|
get placeListIds() {
|
||||||
|
if (this._forceClear) return [];
|
||||||
|
return this.args.place._listIds || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
styleFor(color) {
|
||||||
|
return htmlSafe(`background-color: ${color}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
isInList(list) {
|
||||||
|
if (!this.placeListIds) return false;
|
||||||
|
return this.placeListIds.includes(list.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async toggleSaved() {
|
||||||
|
if (this.isSaved) {
|
||||||
|
const { osmId, osmType } = this.args.place;
|
||||||
|
|
||||||
|
await this.storage.removePlace(this.args.place);
|
||||||
|
|
||||||
|
// Clean up the local object reference immediately to prevent UI flicker
|
||||||
|
// or stale state if the transition is delayed/cancelled.
|
||||||
|
if (this.args.place) {
|
||||||
|
this.args.place.id = null;
|
||||||
|
this.args.place.createdAt = null;
|
||||||
|
this.args.place._listIds = [];
|
||||||
|
this._forceClear = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition immediately to the canonical state
|
||||||
|
if (osmId && osmType) {
|
||||||
|
// Create a transient copy that looks like a fresh OSM result
|
||||||
|
const rawPlace = { ...this.args.place };
|
||||||
|
delete rawPlace.id;
|
||||||
|
delete rawPlace.createdAt;
|
||||||
|
delete rawPlace._listIds;
|
||||||
|
|
||||||
|
// Transition to the place route using the raw object
|
||||||
|
// This updates the URL to 'osm:...' and renders immediately
|
||||||
|
this.router.transitionTo('place', rawPlace);
|
||||||
|
} else {
|
||||||
|
// Custom place deleted -> go home
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.args.onClose) this.args.onClose();
|
||||||
|
} else {
|
||||||
|
await this.storage.storePlace(this.args.place);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async toggleList(list) {
|
||||||
|
const isMember = this.placeListIds.includes(list.id);
|
||||||
|
const shouldAdd = !isMember;
|
||||||
|
|
||||||
|
if (shouldAdd && !this.isSaved) {
|
||||||
|
// Auto-save if adding to list
|
||||||
|
await this.storage.storePlace(this.args.place);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Toggle membership
|
||||||
|
// We must pass the SAVED place (with ID) to the toggle function
|
||||||
|
// If we just saved it above, the args.place might still be the old object reference unless storage updates it in-place?
|
||||||
|
// StorageService.storePlace returns the new object.
|
||||||
|
// But togglePlaceList handles saving internally if ID is missing.
|
||||||
|
|
||||||
|
// Let's rely on storage.togglePlaceList to handle the "save if needed" part.
|
||||||
|
await this.storage.togglePlaceList(this.args.place, list.id, shouldAdd);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Failed to update list: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="place-lists-manager" {{onClickOutside @onClose}}>
|
||||||
|
<div class="list-item master-toggle">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={{this.isSaved}}
|
||||||
|
{{on "change" this.toggleSaved}}
|
||||||
|
/>
|
||||||
|
<span class="list-color"></span>
|
||||||
|
<span class="list-name">Saved places</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="lists-container">
|
||||||
|
{{#each this.storage.lists as |list|}}
|
||||||
|
<div class="list-item">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={{this.isInList list}}
|
||||||
|
{{on "change" (fn this.toggleList list)}}
|
||||||
|
disabled={{unless this.isSaved true}}
|
||||||
|
/>
|
||||||
|
{{! template-lint-disable no-inline-styles }}
|
||||||
|
<span
|
||||||
|
class="list-color"
|
||||||
|
style={{this.styleFor list.color}}
|
||||||
|
></span>
|
||||||
|
<span class="list-name">{{list.title}}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -51,40 +51,39 @@ export default class PlacesSidebar extends Component {
|
|||||||
if (!place) return;
|
if (!place) return;
|
||||||
|
|
||||||
if (place.createdAt) {
|
if (place.createdAt) {
|
||||||
if (confirm(`Delete "${place.title}"?`)) {
|
// Direct delete without confirmation
|
||||||
try {
|
try {
|
||||||
await this.storage.removePlace(place);
|
await this.storage.removePlace(place);
|
||||||
console.debug('Place deleted:', place.title);
|
console.debug('Place deleted:', place.title);
|
||||||
|
|
||||||
// Notify parent to refresh map bookmarks
|
// Notify parent to refresh map bookmarks
|
||||||
if (this.args.onBookmarkChange) {
|
if (this.args.onBookmarkChange) {
|
||||||
this.args.onBookmarkChange();
|
this.args.onBookmarkChange();
|
||||||
}
|
|
||||||
|
|
||||||
if (this.args.onUpdate) {
|
|
||||||
// Reconstruct the "original" place without ID/Geohash/CreatedAt
|
|
||||||
const freshPlace = {
|
|
||||||
...place,
|
|
||||||
id: undefined,
|
|
||||||
geohash: undefined,
|
|
||||||
createdAt: undefined,
|
|
||||||
};
|
|
||||||
this.args.onUpdate(freshPlace);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also fire onSelect if it exists (for list view)
|
|
||||||
if (this.args.onSelect) {
|
|
||||||
this.args.onSelect(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close sidebar after delete
|
|
||||||
if (this.args.onClose) {
|
|
||||||
this.args.onClose();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to delete:', e);
|
|
||||||
alert('Failed to delete: ' + e.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.args.onUpdate) {
|
||||||
|
// Reconstruct the "original" place without ID/Geohash/CreatedAt
|
||||||
|
const freshPlace = {
|
||||||
|
...place,
|
||||||
|
id: undefined,
|
||||||
|
geohash: undefined,
|
||||||
|
createdAt: undefined,
|
||||||
|
};
|
||||||
|
this.args.onUpdate(freshPlace);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also fire onSelect if it exists (for list view)
|
||||||
|
if (this.args.onSelect) {
|
||||||
|
this.args.onSelect(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close sidebar after delete
|
||||||
|
if (this.args.onClose) {
|
||||||
|
this.args.onClose();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete:', e);
|
||||||
|
alert('Failed to delete: ' + e.message);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// It's a fresh POI -> Save it
|
// It's a fresh POI -> Save it
|
||||||
|
|||||||
21
app/modifiers/on-click-outside.js
Normal file
21
app/modifiers/on-click-outside.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { modifier } from 'ember-modifier';
|
||||||
|
|
||||||
|
export default modifier((element, [callback]) => {
|
||||||
|
const handler = (event) => {
|
||||||
|
// Check if the click target is contained within the element
|
||||||
|
if (element && !element.contains(event.target)) {
|
||||||
|
callback(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delay attaching the listener to avoid catching the opening click
|
||||||
|
// (using a microtask or setTimeout 0)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
document.addEventListener('click', handler);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener('click', handler);
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -72,7 +72,9 @@ export default class PlaceRoute extends Route {
|
|||||||
|
|
||||||
// Notify the Map UI to show the pin
|
// Notify the Map UI to show the pin
|
||||||
if (model) {
|
if (model) {
|
||||||
this.mapUi.selectPlace(model);
|
const options = { preventZoom: this.mapUi.preventNextZoom };
|
||||||
|
this.mapUi.selectPlace(model, options);
|
||||||
|
this.mapUi.preventNextZoom = false;
|
||||||
}
|
}
|
||||||
// Stop the pulse animation if it was running (e.g. redirected from search)
|
// Stop the pulse animation if it was running (e.g. redirected from search)
|
||||||
this.mapUi.stopSearch();
|
this.mapUi.stopSearch();
|
||||||
|
|||||||
@@ -9,18 +9,24 @@ export default class MapUiService extends Service {
|
|||||||
@tracked returnToSearch = false;
|
@tracked returnToSearch = false;
|
||||||
@tracked currentCenter = null;
|
@tracked currentCenter = null;
|
||||||
@tracked searchBoxHasFocus = false;
|
@tracked searchBoxHasFocus = false;
|
||||||
|
@tracked selectionOptions = {};
|
||||||
|
@tracked preventNextZoom = false;
|
||||||
|
|
||||||
selectPlace(place) {
|
selectPlace(place, options = {}) {
|
||||||
this.selectedPlace = place;
|
this.selectedPlace = place;
|
||||||
|
this.selectionOptions = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
this.selectedPlace = null;
|
this.selectedPlace = null;
|
||||||
|
this.selectionOptions = {};
|
||||||
|
this.preventNextZoom = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
startSearch() {
|
startSearch() {
|
||||||
this.isSearching = true;
|
this.isSearching = true;
|
||||||
this.isCreating = false;
|
this.isCreating = false;
|
||||||
|
this.preventNextZoom = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
stopSearch() {
|
stopSearch() {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default class StorageService extends Service {
|
|||||||
@tracked savedPlaces = [];
|
@tracked savedPlaces = [];
|
||||||
@tracked loadedPrefixes = [];
|
@tracked loadedPrefixes = [];
|
||||||
@tracked currentBbox = null;
|
@tracked currentBbox = null;
|
||||||
|
@tracked lists = [];
|
||||||
@tracked version = 0; // Shared version tracker for bookmarks
|
@tracked version = 0; // Shared version tracker for bookmarks
|
||||||
@tracked initialSyncDone = false;
|
@tracked initialSyncDone = false;
|
||||||
@tracked connected = false;
|
@tracked connected = false;
|
||||||
@@ -46,6 +47,11 @@ export default class StorageService extends Service {
|
|||||||
this.rs.on('connected', () => {
|
this.rs.on('connected', () => {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.userAddress = this.rs.remote.userAddress;
|
this.userAddress = this.rs.remote.userAddress;
|
||||||
|
this.loadLists();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rs.on('not-connected', () => {
|
||||||
|
this.loadLists();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rs.on('disconnected', () => {
|
this.rs.on('disconnected', () => {
|
||||||
@@ -54,6 +60,7 @@ export default class StorageService extends Service {
|
|||||||
this.placesInView = [];
|
this.placesInView = [];
|
||||||
this.savedPlaces = [];
|
this.savedPlaces = [];
|
||||||
this.loadedPrefixes = [];
|
this.loadedPrefixes = [];
|
||||||
|
this.lists = [];
|
||||||
this.initialSyncDone = false;
|
this.initialSyncDone = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,13 +68,18 @@ export default class StorageService extends Service {
|
|||||||
// console.debug('[rs] sync done:', result);
|
// console.debug('[rs] sync done:', result);
|
||||||
if (!this.initialSyncDone) {
|
if (!this.initialSyncDone) {
|
||||||
this.initialSyncDone = true;
|
this.initialSyncDone = true;
|
||||||
|
this.loadLists();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rs.scope('/places/').on('change', (event) => {
|
this.rs.scope('/places/').on('change', (event) => {
|
||||||
// console.debug(event);
|
// console.debug(event);
|
||||||
this.handlePlaceChange(event);
|
if (event.relativePath.startsWith('_lists/')) {
|
||||||
debounceTask(this, 'reloadCurrentView', 200);
|
this.loadLists();
|
||||||
|
} else {
|
||||||
|
this.handlePlaceChange(event);
|
||||||
|
debounceTask(this, 'reloadCurrentView', 200);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +132,88 @@ export default class StorageService extends Service {
|
|||||||
this.loadAllPlaces(required);
|
this.loadAllPlaces(required);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadLists() {
|
||||||
|
try {
|
||||||
|
if (!this.places.lists) return; // Wait for module init
|
||||||
|
|
||||||
|
// Ensure defaults exist first
|
||||||
|
await this.places.lists.initDefaults();
|
||||||
|
|
||||||
|
const lists = await this.places.lists.getAll();
|
||||||
|
this.lists = lists || [];
|
||||||
|
this.refreshPlaceListAssociations();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load lists:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPlaceListAssociations() {
|
||||||
|
// 1. Build an index of PlaceID -> ListID[]
|
||||||
|
const placeToListMap = new Map();
|
||||||
|
|
||||||
|
this.lists.forEach((list) => {
|
||||||
|
if (list.placeRefs && Array.isArray(list.placeRefs)) {
|
||||||
|
list.placeRefs.forEach((ref) => {
|
||||||
|
if (!ref.id) return;
|
||||||
|
if (!placeToListMap.has(ref.id)) {
|
||||||
|
placeToListMap.set(ref.id, []);
|
||||||
|
}
|
||||||
|
placeToListMap.get(ref.id).push(list.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Helper to attach lists to a place object
|
||||||
|
const attachLists = (place) => {
|
||||||
|
const listIds = placeToListMap.get(place.id) || [];
|
||||||
|
// Assign directly to object property (non-tracked mutation is fine as we trigger updates below)
|
||||||
|
place._listIds = listIds;
|
||||||
|
return place;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Update savedPlaces
|
||||||
|
this.savedPlaces = this.savedPlaces.map((p) => attachLists({ ...p }));
|
||||||
|
|
||||||
|
// 4. Update placesInView
|
||||||
|
this.placesInView = this.placesInView.map((p) => attachLists({ ...p }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async togglePlaceList(place, listId, shouldBeInList) {
|
||||||
|
if (!place) return;
|
||||||
|
|
||||||
|
// Ensure place is saved first if it's new
|
||||||
|
let savedPlace = place;
|
||||||
|
if (!place.id || !place.geohash) {
|
||||||
|
if (shouldBeInList) {
|
||||||
|
// If adding to a list, we must save the place first
|
||||||
|
savedPlace = await this.storePlace(place);
|
||||||
|
} else {
|
||||||
|
return; // Can't remove an unsaved place from a list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (shouldBeInList) {
|
||||||
|
await this.places.lists.addPlace(
|
||||||
|
listId,
|
||||||
|
savedPlace.id,
|
||||||
|
savedPlace.geohash
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.places.lists.removePlace(listId, savedPlace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload lists to reflect changes
|
||||||
|
await this.loadLists();
|
||||||
|
|
||||||
|
// Return the updated place
|
||||||
|
return this.findPlaceById(savedPlace.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to toggle place in list:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadPlacesInBounds(bbox) {
|
async loadPlacesInBounds(bbox) {
|
||||||
// 1. Calculate required prefixes
|
// 1. Calculate required prefixes
|
||||||
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
||||||
@@ -173,6 +267,8 @@ export default class StorageService extends Service {
|
|||||||
// Full reload
|
// Full reload
|
||||||
this.placesInView = places;
|
this.placesInView = places;
|
||||||
}
|
}
|
||||||
|
// Refresh list associations
|
||||||
|
this.refreshPlaceListAssociations();
|
||||||
} else {
|
} else {
|
||||||
if (!prefixes) this.placesInView = [];
|
if (!prefixes) this.placesInView = [];
|
||||||
}
|
}
|
||||||
@@ -190,11 +286,22 @@ export default class StorageService extends Service {
|
|||||||
let place = this.savedPlaces.find((p) => p.id && String(p.id) === strId);
|
let place = this.savedPlaces.find((p) => p.id && String(p.id) === strId);
|
||||||
if (place) return place;
|
if (place) return place;
|
||||||
|
|
||||||
|
// Check placesInView as fallback
|
||||||
|
place = this.placesInView.find((p) => p.id && String(p.id) === strId);
|
||||||
|
if (place) return place;
|
||||||
|
|
||||||
// Then search by OSM ID
|
// Then search by OSM ID
|
||||||
place = this.savedPlaces.find((p) => p.osmId && String(p.osmId) === strId);
|
place = this.savedPlaces.find((p) => p.osmId && String(p.osmId) === strId);
|
||||||
|
if (place) return place;
|
||||||
|
|
||||||
|
place = this.placesInView.find((p) => p.osmId && String(p.osmId) === strId);
|
||||||
return place;
|
return place;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isPlaceSaved(id) {
|
||||||
|
return !!this.findPlaceById(id);
|
||||||
|
}
|
||||||
|
|
||||||
async storePlace(placeData) {
|
async storePlace(placeData) {
|
||||||
const savedPlace = await this.places.store(placeData);
|
const savedPlace = await this.places.store(placeData);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */
|
/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--default-list-color: #ffcc33;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -203,6 +207,7 @@ body {
|
|||||||
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
|
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden; /* Ensure flex children are contained */
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-pane.sidebar {
|
.settings-pane.sidebar {
|
||||||
@@ -239,7 +244,11 @@ body {
|
|||||||
.sidebar-content {
|
.sidebar-content {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1; /* Take up remaining vertical space */
|
-webkit-overflow-scrolling: touch;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
touch-action: pan-y;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-form {
|
.edit-form {
|
||||||
@@ -427,6 +436,10 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.place-details {
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.place-details h3 {
|
.place-details h3 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -768,7 +781,6 @@ button.create-place {
|
|||||||
|
|
||||||
.sidebar-content {
|
.sidebar-content {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain; /* Prevent scroll chaining */
|
|
||||||
|
|
||||||
/* Ensure content doesn't get hidden behind bottom safe areas on mobile */
|
/* Ensure content doesn't get hidden behind bottom safe areas on mobile */
|
||||||
padding-bottom: env(safe-area-inset-bottom, 20px);
|
padding-bottom: env(safe-area-inset-bottom, 20px);
|
||||||
@@ -973,3 +985,64 @@ button.create-place {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Place Lists Manager */
|
||||||
|
.save-button-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||||
|
width: 220px;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager .list-item {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager .list-item:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager input[type='checkbox'] {
|
||||||
|
accent-color: #007bff;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager .list-color {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-color: var(--default-list-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid rgb(0 0 0 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager .divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #eee;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|||||||
15
app/utils/place-mapping.js
Normal file
15
app/utils/place-mapping.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { getLocalizedName } from './osm';
|
||||||
|
|
||||||
|
export function mapToStorageSchema(place) {
|
||||||
|
return {
|
||||||
|
title: place.title || getLocalizedName(place.osmTags, 'Untitled Place'),
|
||||||
|
lat: place.lat,
|
||||||
|
lon: place.lon,
|
||||||
|
tags: [],
|
||||||
|
url: place.osmTags?.website,
|
||||||
|
osmId: String(place.osmId || place.id),
|
||||||
|
osmType: place.osmType,
|
||||||
|
osmTags: place.osmTags || {},
|
||||||
|
description: place.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
52
app/utils/social-links.js
Normal file
52
app/utils/social-links.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Helper to get value from multiple keys
|
||||||
|
const get = (tags, ...keys) => {
|
||||||
|
for (const k of keys) {
|
||||||
|
if (tags[k]) return tags[k];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSocialInfo(tags, platform) {
|
||||||
|
if (!tags) return null;
|
||||||
|
|
||||||
|
const key = platform;
|
||||||
|
const domain = `${platform}.com`;
|
||||||
|
const val = get(tags, `contact:${key}`, key);
|
||||||
|
|
||||||
|
if (!val) return null;
|
||||||
|
|
||||||
|
// Check if it's a full URL
|
||||||
|
if (val.startsWith('http')) {
|
||||||
|
try {
|
||||||
|
const url = new URL(val);
|
||||||
|
|
||||||
|
// Handle Facebook profile.php?id=...
|
||||||
|
if (
|
||||||
|
platform === 'facebook' &&
|
||||||
|
url.pathname === '/profile.php' &&
|
||||||
|
url.searchParams.has('id')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
url: val,
|
||||||
|
username: url.searchParams.get('id'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up pathname to get username
|
||||||
|
let username = url.pathname.replace(/^\/|\/$/g, '');
|
||||||
|
return {
|
||||||
|
url: val,
|
||||||
|
username: username || val, // Fallback to full URL if path is empty
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { url: val, username: val };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assume it's a username
|
||||||
|
const username = val.replace(/^@/, ''); // Remove leading @
|
||||||
|
return {
|
||||||
|
url: `https://${domain}/${username}`,
|
||||||
|
username: username,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.12.2",
|
"version": "1.13.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
1
release/assets/main-DAo4Q0R2.css
Normal file
1
release/assets/main-DAo4Q0R2.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,8 +39,8 @@
|
|||||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-BpHxSZoe.js"></script>
|
<script type="module" crossorigin src="/assets/main-gjk9d6Ld.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-DoLYcE7E.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-DAo4Q0R2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ class MockStorageService extends Service {
|
|||||||
findPlaceById() {
|
findPlaceById() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
loadPlacesInBounds() {
|
loadPlacesInBounds() {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ module('Acceptance | search', function (hooks) {
|
|||||||
findPlaceById() {
|
findPlaceById() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
rs = {
|
rs = {
|
||||||
on: () => {},
|
on: () => {},
|
||||||
};
|
};
|
||||||
@@ -85,6 +88,9 @@ module('Acceptance | search', function (hooks) {
|
|||||||
findPlaceById() {
|
findPlaceById() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
rs = {
|
rs = {
|
||||||
on: () => {},
|
on: () => {},
|
||||||
};
|
};
|
||||||
@@ -130,6 +136,9 @@ module('Acceptance | search', function (hooks) {
|
|||||||
if (id === '999') return this.savedPlaces[0];
|
if (id === '999') return this.savedPlaces[0];
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
isPlaceSaved(id) {
|
||||||
|
return !!this.findPlaceById(id);
|
||||||
|
}
|
||||||
rs = {
|
rs = {
|
||||||
on: () => {},
|
on: () => {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,49 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
import { render } from '@ember/test-helpers';
|
import { render, click } from '@ember/test-helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
import PlaceDetails from 'marco/components/place-details';
|
import PlaceDetails from 'marco/components/place-details';
|
||||||
|
|
||||||
module('Integration | Component | place-details', function (hooks) {
|
module('Integration | Component | place-details', function (hooks) {
|
||||||
setupRenderingTest(hooks);
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
class StorageService extends Service {
|
||||||
|
lists = [
|
||||||
|
{ id: 'to-go', title: 'Want to go', color: '#ff00ff' },
|
||||||
|
{ id: 'to-do', title: 'To do', color: '#008000' },
|
||||||
|
];
|
||||||
|
|
||||||
|
isPlaceSaved(id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
findPlaceById(id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async storePlace(place) {
|
||||||
|
return { ...place, id: '123', createdAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePlace() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async togglePlaceList() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register('service:storage', StorageService);
|
||||||
|
|
||||||
|
// Mock Router for all tests
|
||||||
|
class MockRouter extends Service {
|
||||||
|
transitionTo() {}
|
||||||
|
}
|
||||||
|
this.owner.register('service:router', MockRouter);
|
||||||
|
});
|
||||||
|
|
||||||
test('it formats coordinates correctly', async function (assert) {
|
test('it formats coordinates correctly', async function (assert) {
|
||||||
const place = {
|
const place = {
|
||||||
title: 'Test Place',
|
title: 'Test Place',
|
||||||
@@ -34,4 +72,187 @@ module('Integration | Component | place-details', function (hooks) {
|
|||||||
assert.dom('.place-details h3').hasText('Place without Coords');
|
assert.dom('.place-details h3').hasText('Place without Coords');
|
||||||
assert.dom('.meta-info a[href*="geo:"]').doesNotExist();
|
assert.dom('.meta-info a[href*="geo:"]').doesNotExist();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it reveals the list manager when save is clicked', async function (assert) {
|
||||||
|
const place = {
|
||||||
|
title: 'Cool Cafe',
|
||||||
|
lat: 10,
|
||||||
|
lon: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
// Manager is initially hidden
|
||||||
|
assert.dom('.place-lists-manager').doesNotExist();
|
||||||
|
|
||||||
|
// Find the Save button
|
||||||
|
// It's the first button in .actions
|
||||||
|
const saveBtn = this.element.querySelector('.actions button');
|
||||||
|
await click(saveBtn);
|
||||||
|
|
||||||
|
// Manager should be visible now
|
||||||
|
assert.dom('.place-lists-manager').exists();
|
||||||
|
|
||||||
|
// Check for default lists from mock service
|
||||||
|
assert.dom('.place-lists-manager').includesText('Want to go');
|
||||||
|
assert.dom('.place-lists-manager').includesText('To do');
|
||||||
|
assert.dom('.place-lists-manager').includesText('Saved');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles saving a new place via master toggle', async function (assert) {
|
||||||
|
let storedPlace = null;
|
||||||
|
|
||||||
|
// Override mock service specifically for this test to spy on storePlace
|
||||||
|
class MockStorage extends Service {
|
||||||
|
lists = [];
|
||||||
|
async storePlace(place) {
|
||||||
|
storedPlace = place;
|
||||||
|
return { ...place, id: 'new-id', createdAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
findPlaceById() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorage);
|
||||||
|
|
||||||
|
const place = {
|
||||||
|
title: 'New Spot',
|
||||||
|
lat: 20,
|
||||||
|
lon: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
// Open manager
|
||||||
|
await click('.actions button');
|
||||||
|
|
||||||
|
// Find master "Saved" toggle
|
||||||
|
const masterToggle = this.element.querySelector(
|
||||||
|
'.place-lists-manager .master-toggle input'
|
||||||
|
);
|
||||||
|
|
||||||
|
// It should be unchecked initially for a new place
|
||||||
|
assert.dom(masterToggle).isNotChecked();
|
||||||
|
|
||||||
|
// Click it to save
|
||||||
|
await click(masterToggle);
|
||||||
|
|
||||||
|
// Verify storePlace was called
|
||||||
|
assert.ok(storedPlace, 'storePlace was called');
|
||||||
|
assert.strictEqual(storedPlace.title, 'New Spot');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles removing a saved place via master toggle', async function (assert) {
|
||||||
|
let removedPlaceId = null;
|
||||||
|
|
||||||
|
class MockStorage extends Service {
|
||||||
|
lists = [];
|
||||||
|
async removePlace(place) {
|
||||||
|
removedPlaceId = place.id;
|
||||||
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorage);
|
||||||
|
|
||||||
|
const place = {
|
||||||
|
id: 'saved-id',
|
||||||
|
title: 'Saved Spot',
|
||||||
|
lat: 30,
|
||||||
|
lon: 30,
|
||||||
|
createdAt: '2023-01-01', // Marks it as saved
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
// Open manager
|
||||||
|
await click('.actions button');
|
||||||
|
|
||||||
|
// Find master "Saved" toggle
|
||||||
|
const masterToggle = this.element.querySelector(
|
||||||
|
'.place-lists-manager .master-toggle input'
|
||||||
|
);
|
||||||
|
|
||||||
|
// It should be checked initially for a saved place
|
||||||
|
assert.dom(masterToggle).isChecked();
|
||||||
|
|
||||||
|
// Click it to remove
|
||||||
|
await click(masterToggle);
|
||||||
|
|
||||||
|
assert.strictEqual(removedPlaceId, 'saved-id', 'removePlace was called');
|
||||||
|
|
||||||
|
assert.deepEqual(place._listIds, [], '_listIds was cleared on the object');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it adds place to a list', async function (assert) {
|
||||||
|
let listId = null;
|
||||||
|
let placeArg = null;
|
||||||
|
let shouldAdd = null;
|
||||||
|
|
||||||
|
class MockStorage extends Service {
|
||||||
|
lists = [{ id: 'favs', title: 'Favorites', color: 'red' }];
|
||||||
|
async togglePlaceList(place, id, add) {
|
||||||
|
placeArg = place;
|
||||||
|
listId = id;
|
||||||
|
shouldAdd = add;
|
||||||
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorage);
|
||||||
|
|
||||||
|
// Provide a place that is already saved
|
||||||
|
const place = {
|
||||||
|
id: 'p1',
|
||||||
|
title: 'My Spot',
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
_listIds: [], // Not in any list yet
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
// Open manager
|
||||||
|
await click('.actions button');
|
||||||
|
|
||||||
|
// Find the checkbox for "Favorites"
|
||||||
|
const checkbox = this.element.querySelectorAll(
|
||||||
|
'.place-lists-manager input[type="checkbox"]'
|
||||||
|
)[1]; // Index 1 because 0 is master toggle
|
||||||
|
|
||||||
|
await click(checkbox);
|
||||||
|
|
||||||
|
assert.strictEqual(listId, 'favs');
|
||||||
|
assert.strictEqual(placeArg.id, 'p1');
|
||||||
|
assert.true(shouldAdd);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it respects storage service state over stale place object', async function (assert) {
|
||||||
|
class MockStorage extends Service {
|
||||||
|
lists = [];
|
||||||
|
isPlaceSaved() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
findPlaceById() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorage);
|
||||||
|
|
||||||
|
const place = {
|
||||||
|
id: 'stale-id',
|
||||||
|
title: 'Stale Place',
|
||||||
|
createdAt: '2023-01-01', // Looks saved
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
// Button should say "Save", not "Saved" because isPlaceSaved returns false
|
||||||
|
assert.dom('.actions button').hasText('Save');
|
||||||
|
assert.dom('.actions button').doesNotHaveClass('btn-secondary');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
58
tests/unit/utils/place-mapping-test.js
Normal file
58
tests/unit/utils/place-mapping-test.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { mapToStorageSchema } from 'marco/utils/place-mapping';
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
|
||||||
|
module('Unit | Utility | place-mapping', function () {
|
||||||
|
test('it maps a raw place object to the storage schema', function (assert) {
|
||||||
|
const rawPlace = {
|
||||||
|
osmId: 12345,
|
||||||
|
osmType: 'node',
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
osmTags: {
|
||||||
|
name: 'Test Place',
|
||||||
|
website: 'https://example.com',
|
||||||
|
},
|
||||||
|
description: 'A test description',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mapToStorageSchema(rawPlace);
|
||||||
|
|
||||||
|
assert.strictEqual(result.title, 'Test Place');
|
||||||
|
assert.strictEqual(result.lat, 52.52);
|
||||||
|
assert.strictEqual(result.lon, 13.405);
|
||||||
|
assert.strictEqual(result.osmId, '12345');
|
||||||
|
assert.strictEqual(result.osmType, 'node');
|
||||||
|
assert.strictEqual(result.url, 'https://example.com');
|
||||||
|
assert.strictEqual(result.description, 'A test description');
|
||||||
|
assert.deepEqual(result.osmTags, rawPlace.osmTags);
|
||||||
|
assert.deepEqual(result.tags, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it prioritizes place.title over osmTags.name', function (assert) {
|
||||||
|
const rawPlace = {
|
||||||
|
osmId: 123,
|
||||||
|
lat: 0,
|
||||||
|
lon: 0,
|
||||||
|
title: 'Custom Title',
|
||||||
|
osmTags: {
|
||||||
|
name: 'OSM Name',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mapToStorageSchema(rawPlace);
|
||||||
|
assert.strictEqual(result.title, 'Custom Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles fallback title correctly when no name is present', function (assert) {
|
||||||
|
const rawPlace = {
|
||||||
|
id: 987,
|
||||||
|
lat: 10,
|
||||||
|
lon: 20,
|
||||||
|
osmTags: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mapToStorageSchema(rawPlace);
|
||||||
|
assert.strictEqual(result.title, 'Untitled Place');
|
||||||
|
assert.strictEqual(result.osmId, '987');
|
||||||
|
});
|
||||||
|
});
|
||||||
66
tests/unit/utils/social-links-test.js
Normal file
66
tests/unit/utils/social-links-test.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { getSocialInfo } from 'marco/utils/social-links';
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
|
||||||
|
module('Unit | Utility | social-links', function () {
|
||||||
|
test('it returns null if tags are missing', function (assert) {
|
||||||
|
let result = getSocialInfo({}, 'facebook');
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns null if specific platform tags are missing', function (assert) {
|
||||||
|
let result = getSocialInfo({ twitter: 'foo' }, 'facebook');
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles simple usernames', function (assert) {
|
||||||
|
let result = getSocialInfo({ facebook: 'foo' }, 'facebook');
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
url: 'https://facebook.com/foo',
|
||||||
|
username: 'foo',
|
||||||
|
});
|
||||||
|
|
||||||
|
result = getSocialInfo({ 'contact:instagram': '@bar' }, 'instagram');
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
url: 'https://instagram.com/bar',
|
||||||
|
username: 'bar',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles full URLs', function (assert) {
|
||||||
|
let result = getSocialInfo(
|
||||||
|
{ facebook: 'https://www.facebook.com/foo' },
|
||||||
|
'facebook'
|
||||||
|
);
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
url: 'https://www.facebook.com/foo',
|
||||||
|
username: 'foo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles Facebook profile.php URLs', function (assert) {
|
||||||
|
let result = getSocialInfo(
|
||||||
|
{ facebook: 'https://www.facebook.com/profile.php?id=12345' },
|
||||||
|
'facebook'
|
||||||
|
);
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
url: 'https://www.facebook.com/profile.php?id=12345',
|
||||||
|
username: '12345',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it falls back gracefully for malformed URLs', function (assert) {
|
||||||
|
let result = getSocialInfo({ facebook: 'http://' }, 'facebook');
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
url: 'http://',
|
||||||
|
username: 'http://',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it prioritizes contact:tag over tag', function (assert) {
|
||||||
|
let result = getSocialInfo(
|
||||||
|
{ 'contact:facebook': 'priority', facebook: 'fallback' },
|
||||||
|
'facebook'
|
||||||
|
);
|
||||||
|
assert.strictEqual(result.username, 'priority');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { babel } from '@rollup/plugin-babel';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
// server: {
|
// server: {
|
||||||
// host: '0.0.0.0'
|
// host: '0.0.0.0',
|
||||||
// },
|
// },
|
||||||
plugins: [
|
plugins: [
|
||||||
ember(),
|
ember(),
|
||||||
|
|||||||
Reference in New Issue
Block a user