Compare commits
45 Commits
v1.12.0
...
feature/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
6da004e199
|
|||
|
8877a9e1c8
|
|||
|
03d6a86569
|
|||
|
5baebbb846
|
|||
|
dca2991754
|
|||
|
aee7f9d181
|
|||
|
56a077cceb
|
|||
|
7e5a034cac
|
|||
|
5892bd0cda
|
|||
|
baaf027900
|
|||
|
beb3d12169
|
|||
|
a5aa396411
|
|||
|
21cb8e6cc2
|
|||
|
12ec7fcbbf
|
|||
|
78d7aeba2c
|
|||
|
3b71531de2
|
|||
|
6ef7549ea9
|
|||
|
9097c63a55
|
|||
|
ec0d5a30f9
|
|||
|
f1779131e8
|
|||
|
37cf47b3dd
|
|||
|
ff68b5addc
|
|||
|
990f3afa88
|
|||
|
b2220b8310
|
|||
|
a8613ab81a
|
|||
|
bcb9b20e85
|
|||
|
466b1d5383
|
|||
|
ea7cb2f895
|
|||
|
7e94f335ac
|
|||
|
066ddb240d
|
|||
|
df336b87ac
|
|||
|
dbf71e366a
|
|||
|
6a83003acb
|
|||
|
bcc7c2a011
|
|||
|
19f04efecb
|
|||
|
c79bbaa41a
|
|||
|
b07640375a
|
|||
|
ffcb8219b0
|
|||
|
e01cb2ce6f
|
|||
|
808c1ee37b
|
|||
|
34bc15cfa9
|
|||
|
ee5e56910d
|
|||
|
e019fc2d6b
|
|||
|
9e03426b2e
|
|||
|
ecbf77c573
|
14
.gitea/release-drafter.yml
Normal file
14
.gitea/release-drafter.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
name-template: 'v$RESOLVED_VERSION'
|
||||
tag-template: 'v$RESOLVED_VERSION'
|
||||
version-resolver:
|
||||
major:
|
||||
labels:
|
||||
- 'release/major'
|
||||
minor:
|
||||
labels:
|
||||
- 'release/minor'
|
||||
- 'feature'
|
||||
patch:
|
||||
labels:
|
||||
- 'release/patch'
|
||||
default: patch
|
||||
13
.gitea/workflows/release_drafter.yml
Normal file
13
.gitea/workflows/release_drafter.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
name: Release Drafter
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
jobs:
|
||||
release_drafter_job:
|
||||
name: Update release notes draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Release Drafter
|
||||
uses: https://github.com/raucao/gitea-release-drafter@dev
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
81
app/components/app-menu/about.gjs
Normal file
81
app/components/app-menu/about.gjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from '#components/icon';
|
||||
|
||||
<template>
|
||||
{{! template-lint-disable no-nested-interactive }}
|
||||
<div class="sidebar-header">
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2>About</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<section class="settings-section">
|
||||
<p>
|
||||
<strong>Marco</strong>
|
||||
(as in
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Marco Polo</a>) is an unhosted maps application that respects your
|
||||
privacy and choices.
|
||||
</p>
|
||||
<p>
|
||||
Connect your own
|
||||
<a
|
||||
href="https://remotestorage.io/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>remote storage</a>
|
||||
to sync place bookmarks across apps and devices.
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
<Icon @name="gift" @size={{20}} />
|
||||
<span>Open Source</span>
|
||||
</summary>
|
||||
<div class="details-content">
|
||||
<ul class="link-list">
|
||||
<li>
|
||||
<a
|
||||
href="https://gitea.kosmos.org/raucao/marco"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Source Code
|
||||
</a>
|
||||
(<a
|
||||
href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>AGPL</a>)
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://openstreetmap.org/copyright"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Map Data © OpenStreetMap
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>
|
||||
<Icon @name="heart" @size={{20}} @color="#e5533d" />
|
||||
<span>Contribute</span>
|
||||
</summary>
|
||||
<div class="details-content">
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
36
app/components/app-menu/home.gjs
Normal file
36
app/components/app-menu/home.gjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
import Icon from '#components/icon';
|
||||
import iconRounded from '../../icons/icon-rounded.svg?raw';
|
||||
|
||||
<template>
|
||||
<div class="sidebar-header">
|
||||
<h2>
|
||||
<span class="app-logo-icon">
|
||||
{{htmlSafe iconRounded}}
|
||||
</span>
|
||||
Marco
|
||||
</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<ul class="app-menu">
|
||||
<li>
|
||||
<button type="button" {{on "click" (fn @onNavigate "settings")}}>
|
||||
<Icon @name="settings" @size={{20}} />
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" {{on "click" (fn @onNavigate "about")}}>
|
||||
<Icon @name="info" @size={{20}} />
|
||||
<span>About</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
38
app/components/app-menu/index.gjs
Normal file
38
app/components/app-menu/index.gjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { fn } from '@ember/helper';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
|
||||
import AppMenuHome from './home';
|
||||
import AppMenuSettings from './settings';
|
||||
import AppMenuAbout from './about';
|
||||
|
||||
export default class AppMenu extends Component {
|
||||
@tracked currentView = 'menu'; // 'menu', 'settings', 'about'
|
||||
|
||||
@action
|
||||
setView(view) {
|
||||
this.currentView = view;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar settings-pane">
|
||||
{{#if (eq this.currentView "menu")}}
|
||||
<AppMenuHome @onNavigate={{this.setView}} @onClose={{@onClose}} />
|
||||
|
||||
{{else if (eq this.currentView "settings")}}
|
||||
<AppMenuSettings
|
||||
@onBack={{fn this.setView "menu"}}
|
||||
@onClose={{@onClose}}
|
||||
/>
|
||||
|
||||
{{else if (eq this.currentView "about")}}
|
||||
<AppMenuAbout
|
||||
@onBack={{fn this.setView "menu"}}
|
||||
@onClose={{@onClose}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
100
app/components/app-menu/settings.gjs
Normal file
100
app/components/app-menu/settings.gjs
Normal file
@@ -0,0 +1,100 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import Icon from '#components/icon';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
|
||||
export default class AppMenuSettings extends Component {
|
||||
@service settings;
|
||||
|
||||
@action
|
||||
updateApi(event) {
|
||||
this.settings.updateOverpassApi(event.target.value);
|
||||
}
|
||||
|
||||
@action
|
||||
toggleKinetic(event) {
|
||||
this.settings.updateMapKinetic(event.target.value === 'true');
|
||||
}
|
||||
|
||||
@action
|
||||
updatePhotonApi(event) {
|
||||
this.settings.updatePhotonApi(event.target.value);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar-header">
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2>Settings</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<section class="settings-section">
|
||||
<div class="form-group">
|
||||
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
||||
<select
|
||||
id="map-kinetic"
|
||||
class="form-control"
|
||||
{{on "change" this.toggleKinetic}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if this.settings.mapKinetic "selected"}}
|
||||
>
|
||||
On
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless this.settings.mapKinetic "selected"}}
|
||||
>
|
||||
Off
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overpass-api">Overpass API Provider</label>
|
||||
<select
|
||||
id="overpass-api"
|
||||
class="form-control"
|
||||
{{on "change" this.updateApi}}
|
||||
>
|
||||
{{#each this.settings.overpassApis as |api|}}
|
||||
<option
|
||||
value={{api.url}}
|
||||
selected={{if
|
||||
(eq api.url this.settings.overpassApi)
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
{{api.name}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="photon-api">Photon API Provider</label>
|
||||
<select
|
||||
id="photon-api"
|
||||
class="form-control"
|
||||
{{on "change" this.updatePhotonApi}}
|
||||
>
|
||||
{{#each this.settings.photonApis as |api|}}
|
||||
<option
|
||||
value={{api.url}}
|
||||
selected={{if (eq api.url this.settings.photonApi) "selected"}}
|
||||
>
|
||||
{{api.name}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -1,59 +1,10 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
|
||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
||||
import home from 'feather-icons/dist/icons/home.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 map from 'feather-icons/dist/icons/map.svg?raw';
|
||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||
import search from 'feather-icons/dist/icons/search.svg?raw';
|
||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||
|
||||
const ICONS = {
|
||||
'arrow-left': arrowLeft,
|
||||
activity,
|
||||
bookmark,
|
||||
clock,
|
||||
edit,
|
||||
globe,
|
||||
home,
|
||||
'log-in': logIn,
|
||||
'log-out': logOut,
|
||||
map,
|
||||
'map-pin': mapPin,
|
||||
menu,
|
||||
navigation,
|
||||
phone,
|
||||
plus,
|
||||
server,
|
||||
search,
|
||||
settings,
|
||||
target,
|
||||
user,
|
||||
wikipedia,
|
||||
x,
|
||||
zap,
|
||||
};
|
||||
import { getIcon } from '../utils/icons';
|
||||
|
||||
export default class IconComponent extends Component {
|
||||
get svg() {
|
||||
return ICONS[this.args.name];
|
||||
return getIcon(this.args.name);
|
||||
}
|
||||
|
||||
get size() {
|
||||
|
||||
@@ -60,9 +60,30 @@ export default class MapComponent extends Component {
|
||||
|
||||
// Create a vector source and layer for bookmarks
|
||||
this.bookmarkSource = new VectorSource();
|
||||
const bookmarkLayer = new VectorLayer({
|
||||
source: this.bookmarkSource,
|
||||
style: [
|
||||
|
||||
const bookmarkStyleFunction = (feature) => {
|
||||
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({
|
||||
image: new Circle({
|
||||
radius: 10,
|
||||
@@ -73,14 +94,19 @@ export default class MapComponent extends Component {
|
||||
new Style({
|
||||
image: new Circle({
|
||||
radius: 9,
|
||||
fill: new Fill({ color: '#ffcc33' }), // Gold/Yellow
|
||||
fill: new Fill({ color: color }),
|
||||
stroke: new Stroke({
|
||||
color: '#fff',
|
||||
width: 2,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
];
|
||||
};
|
||||
|
||||
const bookmarkLayer = new VectorLayer({
|
||||
source: this.bookmarkSource,
|
||||
style: bookmarkStyleFunction,
|
||||
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)
|
||||
updateSelectedPin = modifier(() => {
|
||||
const selected = this.mapUi.selectedPlace;
|
||||
const options = this.mapUi.selectionOptions || {};
|
||||
|
||||
if (!this.selectedPinOverlay || !this.selectedPinElement) return;
|
||||
|
||||
@@ -471,7 +498,11 @@ export default class MapComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.bbox) {
|
||||
if (options.preventZoom) {
|
||||
// If we are preventing zoom (e.g. user clicked a bookmark), we rely on visibility check.
|
||||
// This avoids unnecessary panning if the place is already visible.
|
||||
this.handlePinVisibility(coords, { maintainZoom: true });
|
||||
} else if (selected.bbox) {
|
||||
this.zoomToBbox(selected.bbox);
|
||||
} else {
|
||||
this.handlePinVisibility(coords);
|
||||
@@ -515,7 +546,10 @@ export default class MapComponent extends Component {
|
||||
}
|
||||
// Desktop: Sidebar covers left side (approx 400px)
|
||||
else if (this.args.isSidebarOpen) {
|
||||
const sidebarWidth = 400;
|
||||
const sidebarWidthVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--sidebar-width')
|
||||
.trim();
|
||||
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
|
||||
const visibleWidth = size[0] - sidebarWidth;
|
||||
|
||||
// Left padding: Sidebar + 15% of visible width
|
||||
@@ -524,17 +558,29 @@ export default class MapComponent extends Component {
|
||||
padding[1] = visibleWidth * 0.15;
|
||||
}
|
||||
|
||||
const currentZoom = view.getZoom();
|
||||
|
||||
view.fit(extent, {
|
||||
padding: padding,
|
||||
duration: 1000,
|
||||
easing: (t) => t * (2 - t),
|
||||
maxZoom: 19,
|
||||
maxZoom: Math.max(currentZoom, 18),
|
||||
});
|
||||
}
|
||||
|
||||
handlePinVisibility(coords) {
|
||||
handlePinVisibility(coords, options = {}) {
|
||||
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)
|
||||
// UNLESS we want to maintain the current zoom
|
||||
if (!options.maintainZoom && currentZoom < 16) {
|
||||
this.animateToSmartCenter(coords, 16);
|
||||
return;
|
||||
}
|
||||
|
||||
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
|
||||
const size = this.mapInstance.getSize();
|
||||
|
||||
@@ -547,18 +593,27 @@ export default class MapComponent extends Component {
|
||||
pixel[1] > size[1];
|
||||
|
||||
if (isOffScreen) {
|
||||
this.animateToSmartCenter(coords);
|
||||
// If off-screen, center it smartly (considering sidebar/bottom sheet)
|
||||
// Pass maintainZoom to prevent zoom reset if desired
|
||||
const zoom = options.maintainZoom ? null : 16;
|
||||
this.animateToSmartCenter(coords, zoom);
|
||||
} else {
|
||||
// If on-screen, only pan if obscured by UI
|
||||
this.panIfObscured(coords);
|
||||
}
|
||||
}
|
||||
|
||||
animateToSmartCenter(coords) {
|
||||
animateToSmartCenter(coords, zoom = null) {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
const size = this.mapInstance.getSize();
|
||||
const view = this.mapInstance.getView();
|
||||
const resolution = view.getResolution();
|
||||
let resolution = view.getResolution();
|
||||
|
||||
if (zoom !== null) {
|
||||
resolution = view.getResolutionForZoom(zoom);
|
||||
}
|
||||
|
||||
let targetCenter = coords;
|
||||
|
||||
// Check if mobile (width <= 768px matches CSS)
|
||||
@@ -579,45 +634,113 @@ export default class MapComponent extends Component {
|
||||
// To move the camera South (Lower Y), we subtract.
|
||||
targetCenter = [coords[0], coords[1] - offsetMapUnits];
|
||||
}
|
||||
// Desktop: Check if sidebar is open
|
||||
else if (this.args.isSidebarOpen) {
|
||||
const sidebarWidthVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--sidebar-width')
|
||||
.trim();
|
||||
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
|
||||
|
||||
view.animate({
|
||||
// We want the pin to be in the center of the remaining space.
|
||||
// Remaining space starts at x = sidebarWidth.
|
||||
// Center of remaining space = sidebarWidth + (totalWidth - sidebarWidth) / 2
|
||||
// = sidebarWidth/2 + totalWidth/2
|
||||
// Map Center is totalWidth/2
|
||||
// Offset = sidebarWidth/2 (to the right)
|
||||
|
||||
const offsetPixels = sidebarWidth / 2;
|
||||
const offsetMapUnits = offsetPixels * resolution;
|
||||
|
||||
// We want pin at center + offset.
|
||||
// So map center must be pin - offset.
|
||||
// X increases to the right.
|
||||
targetCenter = [coords[0] - offsetMapUnits, coords[1]];
|
||||
}
|
||||
|
||||
const animationOptions = {
|
||||
center: targetCenter,
|
||||
duration: 1000,
|
||||
easing: (t) => t * (2 - t), // Ease-out
|
||||
});
|
||||
};
|
||||
|
||||
if (zoom !== null) {
|
||||
animationOptions.zoom = zoom;
|
||||
}
|
||||
|
||||
view.animate(animationOptions);
|
||||
}
|
||||
|
||||
panIfObscured(coords) {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
const size = this.mapInstance.getSize();
|
||||
// Check if mobile (width <= 768px matches CSS)
|
||||
if (size[0] > 768) return;
|
||||
|
||||
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
|
||||
if (!pixel) return;
|
||||
|
||||
const height = size[1];
|
||||
|
||||
// Sidebar covers the bottom 50%
|
||||
const splitPoint = height / 2;
|
||||
|
||||
// If the pin is in the bottom half (y > splitPoint), it is obscured
|
||||
if (pixel[1] > splitPoint) {
|
||||
// Target position: Center of top half = height * 0.25
|
||||
const targetY = height * 0.25;
|
||||
const deltaY = pixel[1] - targetY;
|
||||
|
||||
const view = this.mapInstance.getView();
|
||||
const center = view.getCenter();
|
||||
const resolution = view.getResolution();
|
||||
|
||||
// Move the map center SOUTH (decrease Y) to move the pin UP (decrease pixel Y)
|
||||
const deltaMapUnits = deltaY * resolution;
|
||||
const newCenter = [center[0], center[1] - deltaMapUnits];
|
||||
// Default targets (current position)
|
||||
let targetPixelX = pixel[0];
|
||||
let targetPixelY = pixel[1];
|
||||
let needsPan = false;
|
||||
|
||||
// 1. Mobile Bottom Sheet Logic (Screen <= 768px)
|
||||
if (size[0] <= 768) {
|
||||
const height = size[1];
|
||||
const splitPoint = height / 2;
|
||||
|
||||
// If in bottom half
|
||||
if (pixel[1] > splitPoint) {
|
||||
targetPixelY = height * 0.25; // Target: Center of top half
|
||||
needsPan = true;
|
||||
}
|
||||
}
|
||||
// 2. Desktop Sidebar Logic (Screen > 768px + Sidebar Open)
|
||||
else if (this.args.isSidebarOpen) {
|
||||
const sidebarWidthVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--sidebar-width')
|
||||
.trim();
|
||||
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
|
||||
|
||||
// If under sidebar
|
||||
if (pixel[0] < sidebarWidth) {
|
||||
const visibleWidth = size[0] - sidebarWidth;
|
||||
targetPixelX = sidebarWidth + visibleWidth / 2; // Target: Center of visible area
|
||||
needsPan = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Header Logic (Any screen size)
|
||||
// Check if the (potentially new) target Y is under the header
|
||||
const headerHeight = 60;
|
||||
const minTopDistance = headerHeight + 20; // 80px
|
||||
|
||||
if (targetPixelY < minTopDistance) {
|
||||
targetPixelY = minTopDistance + 30; // Move it to ~110px, clear of header
|
||||
needsPan = true;
|
||||
}
|
||||
|
||||
if (needsPan) {
|
||||
const deltaPixelX = pixel[0] - targetPixelX;
|
||||
const deltaPixelY = pixel[1] - targetPixelY;
|
||||
|
||||
// X: Camera moves same direction as we want the world to move? No.
|
||||
// If we want pin to move RIGHT (pixel increases), Camera must move LEFT (X decreases).
|
||||
// deltaPixelX = current - target. If current < target (want move right), delta is negative.
|
||||
// center + negative = decrease. Correct.
|
||||
const newCenterX = center[0] + deltaPixelX * resolution;
|
||||
|
||||
// Y: Camera moves opposite direction to world relative to pixel coords.
|
||||
// Pixel Y increases DOWN. Map Y increases UP.
|
||||
// If we want pin to move DOWN (pixel increases), Camera must move UP (Y increases).
|
||||
// deltaPixelY = current - target. If current < target (want move down), delta is negative.
|
||||
// center - negative = increase. Correct.
|
||||
const newCenterY = center[1] - deltaPixelY * resolution;
|
||||
|
||||
view.animate({
|
||||
center: newCenter,
|
||||
center: [newCenterX, newCenterY],
|
||||
duration: 500,
|
||||
easing: (t) => t * (2 - t), // Ease-out
|
||||
});
|
||||
@@ -848,6 +971,7 @@ export default class MapComponent extends Component {
|
||||
'Clicked bookmark while sidebar open (switching):',
|
||||
clickedBookmark
|
||||
);
|
||||
this.mapUi.preventNextZoom = true;
|
||||
this.router.transitionTo('place', clickedBookmark);
|
||||
return;
|
||||
}
|
||||
@@ -862,6 +986,7 @@ export default class MapComponent extends Component {
|
||||
// Normal behavior (sidebar is closed)
|
||||
if (clickedBookmark) {
|
||||
console.debug('Clicked bookmark:', clickedBookmark);
|
||||
this.mapUi.preventNextZoom = true;
|
||||
this.router.transitionTo('place', clickedBookmark);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { fn } from '@ember/helper';
|
||||
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 Icon from '../components/icon';
|
||||
import PlaceEditForm from './place-edit-form';
|
||||
import PlaceListsManager from './place-lists-manager';
|
||||
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class PlaceDetails extends Component {
|
||||
@service storage;
|
||||
@tracked isEditing = false;
|
||||
@tracked showLists = false;
|
||||
|
||||
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 || {};
|
||||
}
|
||||
@@ -27,7 +44,7 @@ export default class PlaceDetails extends Component {
|
||||
|
||||
@action
|
||||
startEditing() {
|
||||
if (!this.place.createdAt) return; // Only allow editing saved places
|
||||
if (!this.isSaved) return; // Only allow editing saved places
|
||||
this.isEditing = true;
|
||||
}
|
||||
|
||||
@@ -36,6 +53,21 @@ export default class PlaceDetails extends Component {
|
||||
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) {
|
||||
@@ -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') {
|
||||
return htmlSafe(
|
||||
parts
|
||||
@@ -131,6 +169,11 @@ export default class PlaceDetails extends Component {
|
||||
return this.formatMultiLine(val, 'phone');
|
||||
}
|
||||
|
||||
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'];
|
||||
@@ -159,6 +202,14 @@ export default class PlaceDetails extends Component {
|
||||
.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;
|
||||
@@ -227,23 +278,29 @@ export default class PlaceDetails extends Component {
|
||||
{{/if}}
|
||||
|
||||
<div class="actions">
|
||||
<div class="save-button-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class={{if
|
||||
this.place.createdAt
|
||||
"btn btn-secondary"
|
||||
"btn btn-outline"
|
||||
}}
|
||||
{{on "click" (fn @onToggleSave this.place)}}
|
||||
class={{if this.isSaved "btn btn-secondary" "btn btn-outline"}}
|
||||
{{on "click" this.toggleLists}}
|
||||
>
|
||||
<Icon
|
||||
@name="bookmark"
|
||||
@color={{if this.place.createdAt "currentColor" "#007bff"}}
|
||||
@color={{if this.isSaved "currentColor" "#007bff"}}
|
||||
/>
|
||||
{{if this.place.createdAt "Saved" "Save"}}
|
||||
{{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
|
||||
type="button"
|
||||
class="btn btn-outline"
|
||||
@@ -292,6 +349,45 @@ export default class PlaceDetails extends Component {
|
||||
</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" @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,7 +51,7 @@ export default class PlacesSidebar extends Component {
|
||||
if (!place) return;
|
||||
|
||||
if (place.createdAt) {
|
||||
if (confirm(`Delete "${place.title}"?`)) {
|
||||
// Direct delete without confirmation
|
||||
try {
|
||||
await this.storage.removePlace(place);
|
||||
console.debug('Place deleted:', place.title);
|
||||
@@ -85,7 +85,6 @@ export default class PlacesSidebar extends Component {
|
||||
console.error('Failed to delete:', e);
|
||||
alert('Failed to delete: ' + e.message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// It's a fresh POI -> Save it
|
||||
const placeData = {
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import Icon from '#components/icon';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
|
||||
export default class SettingsPane extends Component {
|
||||
@service settings;
|
||||
|
||||
@action
|
||||
updateApi(event) {
|
||||
this.settings.updateOverpassApi(event.target.value);
|
||||
}
|
||||
|
||||
@action
|
||||
toggleKinetic(event) {
|
||||
this.settings.updateMapKinetic(event.target.value === 'true');
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar settings-pane">
|
||||
<div class="sidebar-header">
|
||||
<h2>
|
||||
<img src="/icons/icon-rounded.svg" alt="" width="32" height="32" />
|
||||
Marco
|
||||
</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<section class="settings-section">
|
||||
<h3>Settings</h3>
|
||||
<div class="form-group">
|
||||
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
||||
<select
|
||||
id="map-kinetic"
|
||||
class="form-control"
|
||||
{{on "change" this.toggleKinetic}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if this.settings.mapKinetic "selected"}}
|
||||
>
|
||||
On
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless this.settings.mapKinetic "selected"}}
|
||||
>
|
||||
Off
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overpass-api">Overpass API Provider</label>
|
||||
<select
|
||||
id="overpass-api"
|
||||
class="form-control"
|
||||
{{on "change" this.updateApi}}
|
||||
>
|
||||
{{#each this.settings.overpassApis as |api|}}
|
||||
<option
|
||||
value={{api.url}}
|
||||
selected={{if
|
||||
(eq api.url this.settings.overpassApi)
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
{{api.name}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-section">
|
||||
<h3>About</h3>
|
||||
<p>
|
||||
<strong>Marco</strong>
|
||||
(as in
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Marco Polo</a>) is an unhosted maps application that respects your
|
||||
privacy and choices.
|
||||
</p>
|
||||
<p>
|
||||
Connect your own
|
||||
<a
|
||||
href="https://remotestorage.io/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>remote storage</a>
|
||||
to sync place bookmarks across apps and devices.
|
||||
</p>
|
||||
<ul class="link-list">
|
||||
<li>
|
||||
<a
|
||||
href="https://gitea.kosmos.org/raucao/marco"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Source Code
|
||||
</a>
|
||||
(<a
|
||||
href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>AGPL</a>)
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://openstreetmap.org/copyright"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Map Data © OpenStreetMap
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
45
app/icons/icon-rounded.svg
Normal file
45
app/icons/icon-rounded.svg
Normal file
@@ -0,0 +1,45 @@
|
||||
<svg
|
||||
width="1024"
|
||||
height="1024"
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<!-- Background -->
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="1024"
|
||||
height="1024"
|
||||
rx="220"
|
||||
fill="#F6E9A6"
|
||||
/>
|
||||
|
||||
<!-- Subtle map grid (kept well outside safe zone) -->
|
||||
<g stroke="#E6D88A" stroke-width="10" opacity="0.6">
|
||||
<line x1="256" y1="0" x2="256" y2="1024" />
|
||||
<line x1="512" y1="0" x2="512" y2="1024" />
|
||||
<line x1="768" y1="0" x2="768" y2="1024" />
|
||||
|
||||
<line x1="0" y1="256" x2="1024" y2="256" />
|
||||
<line x1="0" y1="512" x2="1024" y2="512" />
|
||||
<line x1="0" y1="768" x2="1024" y2="768" />
|
||||
</g>
|
||||
|
||||
<!-- Location pin (exact app shape, larger, centered, safe-zone compliant) -->
|
||||
<!-- Safe zone target: ~680px diameter -->
|
||||
<g
|
||||
transform="
|
||||
translate(512 512)
|
||||
scale(22)
|
||||
translate(-12 -12)
|
||||
"
|
||||
fill="#ea4335"
|
||||
stroke="#b31412"
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" fill="#b31412" stroke="none" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
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
|
||||
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)
|
||||
this.mapUi.stopSearch();
|
||||
|
||||
@@ -9,18 +9,24 @@ export default class MapUiService extends Service {
|
||||
@tracked returnToSearch = false;
|
||||
@tracked currentCenter = null;
|
||||
@tracked searchBoxHasFocus = false;
|
||||
@tracked selectionOptions = {};
|
||||
@tracked preventNextZoom = false;
|
||||
|
||||
selectPlace(place) {
|
||||
selectPlace(place, options = {}) {
|
||||
this.selectedPlace = place;
|
||||
this.selectionOptions = options;
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.selectedPlace = null;
|
||||
this.selectionOptions = {};
|
||||
this.preventNextZoom = false;
|
||||
}
|
||||
|
||||
startSearch() {
|
||||
this.isSearching = true;
|
||||
this.isCreating = false;
|
||||
this.preventNextZoom = false;
|
||||
}
|
||||
|
||||
stopSearch() {
|
||||
|
||||
@@ -35,7 +35,6 @@ export default class OsmService extends Service {
|
||||
'building',
|
||||
'landuse',
|
||||
'public_transport',
|
||||
'highway',
|
||||
'aeroway',
|
||||
];
|
||||
const typeKeysQuery = [`~"^(${typeKeys.join('|')})$"~".*"`];
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import Service from '@ember/service';
|
||||
import Service, { service } from '@ember/service';
|
||||
import { getPlaceType } from '../utils/osm';
|
||||
import { humanizeOsmTag } from '../utils/format-text';
|
||||
|
||||
export default class PhotonService extends Service {
|
||||
baseUrl = 'https://photon.komoot.io/api/';
|
||||
@service settings;
|
||||
|
||||
get baseUrl() {
|
||||
return this.settings.photonApi;
|
||||
}
|
||||
|
||||
async search(query, lat, lon, limit = 10) {
|
||||
if (!query || query.length < 2) return [];
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tracked } from '@glimmer/tracking';
|
||||
export default class SettingsService extends Service {
|
||||
@tracked overpassApi = 'https://overpass-api.de/api/interpreter';
|
||||
@tracked mapKinetic = true;
|
||||
@tracked photonApi = 'https://photon.komoot.io/api/';
|
||||
|
||||
overpassApis = [
|
||||
{
|
||||
@@ -24,6 +25,13 @@ export default class SettingsService extends Service {
|
||||
// },
|
||||
];
|
||||
|
||||
photonApis = [
|
||||
{
|
||||
name: 'photon.komoot.io',
|
||||
url: 'https://photon.komoot.io/api/',
|
||||
},
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.loadSettings();
|
||||
@@ -59,4 +67,8 @@ export default class SettingsService extends Service {
|
||||
this.mapKinetic = enabled;
|
||||
localStorage.setItem('marco:map-kinetic', String(enabled));
|
||||
}
|
||||
|
||||
updatePhotonApi(url) {
|
||||
this.photonApi = url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export default class StorageService extends Service {
|
||||
@tracked savedPlaces = [];
|
||||
@tracked loadedPrefixes = [];
|
||||
@tracked currentBbox = null;
|
||||
@tracked lists = [];
|
||||
@tracked version = 0; // Shared version tracker for bookmarks
|
||||
@tracked initialSyncDone = false;
|
||||
@tracked connected = false;
|
||||
@@ -46,6 +47,11 @@ export default class StorageService extends Service {
|
||||
this.rs.on('connected', () => {
|
||||
this.connected = true;
|
||||
this.userAddress = this.rs.remote.userAddress;
|
||||
this.loadLists();
|
||||
});
|
||||
|
||||
this.rs.on('not-connected', () => {
|
||||
this.loadLists();
|
||||
});
|
||||
|
||||
this.rs.on('disconnected', () => {
|
||||
@@ -54,6 +60,7 @@ export default class StorageService extends Service {
|
||||
this.placesInView = [];
|
||||
this.savedPlaces = [];
|
||||
this.loadedPrefixes = [];
|
||||
this.lists = [];
|
||||
this.initialSyncDone = false;
|
||||
});
|
||||
|
||||
@@ -61,13 +68,18 @@ export default class StorageService extends Service {
|
||||
// console.debug('[rs] sync done:', result);
|
||||
if (!this.initialSyncDone) {
|
||||
this.initialSyncDone = true;
|
||||
this.loadLists();
|
||||
}
|
||||
});
|
||||
|
||||
this.rs.scope('/places/').on('change', (event) => {
|
||||
// console.debug(event);
|
||||
if (event.relativePath.startsWith('_lists/')) {
|
||||
this.loadLists();
|
||||
} else {
|
||||
this.handlePlaceChange(event);
|
||||
debounceTask(this, 'reloadCurrentView', 200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,6 +132,98 @@ export default class StorageService extends Service {
|
||||
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 || [];
|
||||
|
||||
// Decorate with hardcoded icons for default lists (in-memory only)
|
||||
this.lists.forEach((list) => {
|
||||
if (list.id === 'to-go') {
|
||||
list.icon = 'bookmark';
|
||||
} else if (list.id === 'to-do') {
|
||||
list.icon = 'check-square';
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
// 1. Calculate required prefixes
|
||||
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
||||
@@ -173,6 +277,8 @@ export default class StorageService extends Service {
|
||||
// Full reload
|
||||
this.placesInView = places;
|
||||
}
|
||||
// Refresh list associations
|
||||
this.refreshPlaceListAssociations();
|
||||
} else {
|
||||
if (!prefixes) this.placesInView = [];
|
||||
}
|
||||
@@ -190,11 +296,22 @@ export default class StorageService extends Service {
|
||||
let place = this.savedPlaces.find((p) => p.id && String(p.id) === strId);
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
isPlaceSaved(id) {
|
||||
return !!this.findPlaceById(id);
|
||||
}
|
||||
|
||||
async storePlace(placeData) {
|
||||
const savedPlace = await this.places.store(placeData);
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */
|
||||
|
||||
:root {
|
||||
--default-list-color: #fc3;
|
||||
--hover-bg: #f8f9fa;
|
||||
--sidebar-width: 360px;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
@@ -197,12 +203,13 @@ body {
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 300px;
|
||||
width: var(--sidebar-width);
|
||||
background: white;
|
||||
z-index: 3100; /* Higher than Header (3000) */
|
||||
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden; /* Ensure flex children are contained */
|
||||
}
|
||||
|
||||
.settings-pane.sidebar {
|
||||
@@ -239,13 +246,126 @@ body {
|
||||
.sidebar-content {
|
||||
padding: 1rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.app-menu {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 -1rem;
|
||||
}
|
||||
|
||||
.app-menu button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
padding-left: 1.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.app-menu button:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.app-menu .icon {
|
||||
color: #666;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.sidebar-content details {
|
||||
margin: 0 -1rem; /* Top margin, negative side margins to span full width */
|
||||
}
|
||||
|
||||
.sidebar-content details summary {
|
||||
list-style: none; /* Hide default triangle */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
padding-left: 1.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-content details summary::-webkit-details-marker {
|
||||
display: none; /* Hide default triangle in WebKit */
|
||||
}
|
||||
|
||||
.sidebar-content details summary:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.sidebar-content details summary .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.sidebar-content details summary::after {
|
||||
content: '';
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'/%3E%3C/svg%3E");
|
||||
background-size: 20px 20px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-content details[open] summary::after {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sidebar-content details .details-content {
|
||||
padding: 1rem 1.4rem;
|
||||
animation: details-slide-down 0.2s ease-out;
|
||||
}
|
||||
|
||||
.sidebar-content details .link-list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes details-slide-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-content details .link-list li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-content details .link-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
margin: -1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: #f8f9fa;
|
||||
background: var(--hover-bg);
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
@@ -267,8 +387,10 @@ body {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
font-size: 0.95rem;
|
||||
box-sizing: border-box; /* Ensure padding doesn't overflow width */
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
@@ -277,6 +399,17 @@ body {
|
||||
box-shadow: 0 0 0 2px rgb(0 123 255 / 10%);
|
||||
}
|
||||
|
||||
select.form-control {
|
||||
appearance: none;
|
||||
background-color: #fff;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
background-size: 16px 16px;
|
||||
padding-right: 2.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -285,15 +418,7 @@ body {
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
margin: 0 0 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.settings-section .form-group {
|
||||
@@ -391,7 +516,7 @@ body {
|
||||
}
|
||||
|
||||
.place-item:hover {
|
||||
background: #eee;
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.place-name {
|
||||
@@ -427,6 +552,10 @@ body {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.place-details {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.place-details h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-top: 0;
|
||||
@@ -593,6 +722,17 @@ body {
|
||||
|
||||
/* Icons */
|
||||
|
||||
.app-logo-icon {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.app-logo-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
span.icon {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -717,15 +857,14 @@ span.icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Sidebar is open (Desktop: Left 300px) */
|
||||
/* Sidebar is open (Desktop: Left var(--sidebar-width)) */
|
||||
|
||||
/* We want to center in the remaining space (width - 300px) */
|
||||
/* We want to center in the remaining space (width - var(--sidebar-width)) */
|
||||
|
||||
/* Center X = 300 + (width - 300) / 2 = 300 + width/2 - 150 = width/2 + 150 */
|
||||
/* Center X = var(--sidebar-width) + (width - var(--sidebar-width)) / 2 = var(--sidebar-width)/2 + 50% */
|
||||
|
||||
/* So shift left by 150px from center */
|
||||
.map-container.sidebar-open .map-crosshair {
|
||||
left: calc(50% + 150px);
|
||||
left: calc(50% + var(--sidebar-width) / 2);
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
@@ -768,7 +907,6 @@ button.create-place {
|
||||
|
||||
.sidebar-content {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain; /* Prevent scroll chaining */
|
||||
|
||||
/* Ensure content doesn't get hidden behind bottom safe areas on mobile */
|
||||
padding-bottom: env(safe-area-inset-bottom, 20px);
|
||||
@@ -934,7 +1072,7 @@ button.create-place {
|
||||
|
||||
.search-result-item:hover,
|
||||
.search-result-item:focus {
|
||||
background: #f5f5f5;
|
||||
background: var(--hover-bg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -973,3 +1111,64 @@ button.create-place {
|
||||
text-overflow: ellipsis;
|
||||
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: var(--hover-bg);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Component from '@glimmer/component';
|
||||
import { pageTitle } from 'ember-page-title';
|
||||
import Map from '#components/map';
|
||||
import AppHeader from '#components/app-header';
|
||||
import SettingsPane from '#components/settings-pane';
|
||||
import AppMenu from '#components/app-menu/index';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
@@ -14,7 +14,7 @@ export default class ApplicationComponent extends Component {
|
||||
@service mapUi;
|
||||
@service router;
|
||||
|
||||
@tracked isSettingsOpen = false;
|
||||
@tracked isAppMenuOpen = false;
|
||||
|
||||
get isSidebarOpen() {
|
||||
// We consider the sidebar "open" if we are in search or place routes.
|
||||
@@ -34,19 +34,19 @@ export default class ApplicationComponent extends Component {
|
||||
}
|
||||
|
||||
@action
|
||||
toggleSettings() {
|
||||
this.isSettingsOpen = !this.isSettingsOpen;
|
||||
toggleAppMenu() {
|
||||
this.isAppMenuOpen = !this.isAppMenuOpen;
|
||||
}
|
||||
|
||||
@action
|
||||
closeSettings() {
|
||||
this.isSettingsOpen = false;
|
||||
closeAppMenu() {
|
||||
this.isAppMenuOpen = false;
|
||||
}
|
||||
|
||||
@action
|
||||
handleOutsideClick() {
|
||||
if (this.isSettingsOpen) {
|
||||
this.closeSettings();
|
||||
if (this.isAppMenuOpen) {
|
||||
this.closeAppMenu();
|
||||
} else if (this.router.currentRouteName === 'search') {
|
||||
this.router.transitionTo('index');
|
||||
} else if (this.router.currentRouteName === 'place') {
|
||||
@@ -65,7 +65,7 @@ export default class ApplicationComponent extends Component {
|
||||
<template>
|
||||
{{pageTitle "Marco"}}
|
||||
|
||||
<AppHeader @onToggleMenu={{this.toggleSettings}} />
|
||||
<AppHeader @onToggleMenu={{this.toggleAppMenu}} />
|
||||
|
||||
<div
|
||||
id="rs-widget-container"
|
||||
@@ -81,12 +81,12 @@ export default class ApplicationComponent extends Component {
|
||||
{{/if}}
|
||||
|
||||
<Map
|
||||
@isSidebarOpen={{or this.isSidebarOpen this.isSettingsOpen}}
|
||||
@isSidebarOpen={{or this.isSidebarOpen this.isAppMenuOpen}}
|
||||
@onOutsideClick={{this.handleOutsideClick}}
|
||||
/>
|
||||
|
||||
{{#if this.isSettingsOpen}}
|
||||
<SettingsPane @onClose={{this.closeSettings}} />
|
||||
{{#if this.isAppMenuOpen}}
|
||||
<AppMenu @onClose={{this.closeAppMenu}} />
|
||||
{{/if}}
|
||||
|
||||
{{outlet}}
|
||||
|
||||
67
app/utils/icons.js
Normal file
67
app/utils/icons.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
|
||||
import gift from 'feather-icons/dist/icons/gift.svg?raw';
|
||||
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
||||
import heart from 'feather-icons/dist/icons/heart.svg?raw';
|
||||
import home from 'feather-icons/dist/icons/home.svg?raw';
|
||||
import info from 'feather-icons/dist/icons/info.svg?raw';
|
||||
import instagram from 'feather-icons/dist/icons/instagram.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 mail from 'feather-icons/dist/icons/mail.svg?raw';
|
||||
import map from 'feather-icons/dist/icons/map.svg?raw';
|
||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||
import search from 'feather-icons/dist/icons/search.svg?raw';
|
||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||
|
||||
const ICONS = {
|
||||
'arrow-left': arrowLeft,
|
||||
activity,
|
||||
bookmark,
|
||||
'check-square': checkSquare,
|
||||
clock,
|
||||
edit,
|
||||
facebook,
|
||||
gift,
|
||||
globe,
|
||||
heart,
|
||||
home,
|
||||
info,
|
||||
instagram,
|
||||
'log-in': logIn,
|
||||
'log-out': logOut,
|
||||
mail,
|
||||
map,
|
||||
'map-pin': mapPin,
|
||||
menu,
|
||||
navigation,
|
||||
phone,
|
||||
plus,
|
||||
server,
|
||||
search,
|
||||
settings,
|
||||
target,
|
||||
user,
|
||||
wikipedia,
|
||||
x,
|
||||
zap,
|
||||
};
|
||||
|
||||
export function getIcon(name) {
|
||||
return ICONS[name];
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
15
index.html
15
index.html
@@ -3,9 +3,22 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Marco</title>
|
||||
<meta name="description" content="">
|
||||
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Marco">
|
||||
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://marco.kosmos.org">
|
||||
<meta property="og:image" content="https://marco.kosmos.org/icons/icon-512.png">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Marco">
|
||||
<meta name="twitter:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta name="twitter:image" content="https://marco.kosmos.org/icons/icon-512.png">
|
||||
|
||||
<!-- App identity -->
|
||||
<meta name="application-name" content="Marco">
|
||||
<meta name="apple-mobile-web-app-title" content="Marco">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.12.0",
|
||||
"version": "1.14.0",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build --outDir release/",
|
||||
"build:icons": "for size in 32 48 144 180 192 512; do if [ \"$size\" -le 64 ]; then magick public/icons/icon.svg -define svg:remove-groups=map-grid -resize ${size}x${size} public/icons/icon-${size}.png; else rsvg-convert -w $size -h $size public/icons/icon.svg -o public/icons/icon-${size}.png; fi; done && rsvg-convert -w 512 -h 512 public/icons/icon.svg -o public/icons/icon-maskable.png",
|
||||
"build:icons": "cp public/icons/icon-rounded.svg app/icons/icon-rounded.svg && for size in 32 48 144 180 192 512; do if [ \"$size\" -le 64 ]; then magick public/icons/icon.svg -define svg:remove-groups=map-grid -resize ${size}x${size} public/icons/icon-${size}.png; else rsvg-convert -w $size -h $size public/icons/icon.svg -o public/icons/icon-${size}.png; fi; done && rsvg-convert -w 512 -h 512 public/icons/icon.svg -o public/icons/icon-maskable.png",
|
||||
"format": "prettier . --cache --write",
|
||||
"lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto",
|
||||
"lint:css": "stylelint \"**/*.css\"",
|
||||
@@ -52,7 +52,7 @@
|
||||
"@embroider/vite": "^1.5.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@glimmer/component": "^2.0.0",
|
||||
"@remotestorage/module-places": "1.x",
|
||||
"@remotestorage/module-places": "~1.2.1",
|
||||
"@rollup/plugin-babel": "^6.1.0",
|
||||
"@warp-drive/core": "~5.8.0",
|
||||
"@warp-drive/ember": "~5.8.0",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -55,8 +55,8 @@ importers:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@remotestorage/module-places':
|
||||
specifier: 1.x
|
||||
version: 1.0.0
|
||||
specifier: ~1.2.1
|
||||
version: 1.2.1
|
||||
'@rollup/plugin-babel':
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1)
|
||||
@@ -1380,8 +1380,8 @@ packages:
|
||||
resolution: {integrity: sha512-4rdu8GPY9TeQwsYp5D2My74dC3dSVS3tghAvisG80ybK4lqa0gvlrglaSTBxogJbxqHRw/NjI/liEtb3+SD+Bw==}
|
||||
engines: {node: '>=18.12'}
|
||||
|
||||
'@remotestorage/module-places@1.0.0':
|
||||
resolution: {integrity: sha512-vaqJeTw658gjPyLz70Mq2AbGfDZ66O2mpDFME+gtaGFYl2+UvrvRLCrXWHYuyTE21f3TJdegeXM6C5nZMxLv9A==}
|
||||
'@remotestorage/module-places@1.2.1':
|
||||
resolution: {integrity: sha512-hNRuhGoG8RS+cieVvDVzXWBEuNPfyeFirhgNH3z1WoKw9ngHdPY6V0sT0vKbsxB8xaODReZfo2ZKHLTmdFunlw==}
|
||||
|
||||
'@rollup/plugin-babel@6.1.0':
|
||||
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
|
||||
@@ -7002,7 +7002,7 @@ snapshots:
|
||||
'@pnpm/error': 1000.0.5
|
||||
find-up: 5.0.0
|
||||
|
||||
'@remotestorage/module-places@1.0.0':
|
||||
'@remotestorage/module-places@1.2.1':
|
||||
dependencies:
|
||||
latlon-geohash: 2.0.0
|
||||
ulid: 3.0.2
|
||||
|
||||
1
release/assets/main-BT0n1kYB.css
Normal file
1
release/assets/main-BT0n1kYB.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
2
release/assets/main-GynTgP18.js
Normal file
2
release/assets/main-GynTgP18.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,9 +3,22 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Marco</title>
|
||||
<meta name="description" content="">
|
||||
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Marco">
|
||||
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://marco.kosmos.org">
|
||||
<meta property="og:image" content="https://marco.kosmos.org/icons/icon-512.png">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Marco">
|
||||
<meta name="twitter:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta name="twitter:image" content="https://marco.kosmos.org/icons/icon-512.png">
|
||||
|
||||
<!-- App identity -->
|
||||
<meta name="application-name" content="Marco">
|
||||
<meta name="apple-mobile-web-app-title" content="Marco">
|
||||
@@ -26,8 +39,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-C68xq8aX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DoLYcE7E.css">
|
||||
<script type="module" crossorigin src="/assets/main-GynTgP18.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BT0n1kYB.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
@@ -42,6 +42,9 @@ class MockStorageService extends Service {
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
isPlaceSaved() {
|
||||
return false;
|
||||
}
|
||||
loadPlacesInBounds() {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ module('Acceptance | search', function (hooks) {
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
isPlaceSaved() {
|
||||
return false;
|
||||
}
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
@@ -85,6 +88,9 @@ module('Acceptance | search', function (hooks) {
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
isPlaceSaved() {
|
||||
return false;
|
||||
}
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
@@ -130,6 +136,9 @@ module('Acceptance | search', function (hooks) {
|
||||
if (id === '999') return this.savedPlaces[0];
|
||||
return null;
|
||||
}
|
||||
isPlaceSaved(id) {
|
||||
return !!this.findPlaceById(id);
|
||||
}
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,49 @@
|
||||
import { module, test } from 'qunit';
|
||||
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';
|
||||
|
||||
module('Integration | Component | place-details', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
class StorageService extends Service {
|
||||
lists = [
|
||||
{ id: 'to-go', title: 'Want to go', color: '#2e9e4f' },
|
||||
{ id: 'to-do', title: 'To do', color: '#2a7fff' },
|
||||
];
|
||||
|
||||
isPlaceSaved() {
|
||||
return false;
|
||||
}
|
||||
|
||||
findPlaceById() {
|
||||
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) {
|
||||
const 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('.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({
|
||||
// server: {
|
||||
// host: '0.0.0.0'
|
||||
// host: '0.0.0.0',
|
||||
// },
|
||||
plugins: [
|
||||
ember(),
|
||||
|
||||
Reference in New Issue
Block a user