Compare commits

..

43 Commits

Author SHA1 Message Date
6da004e199 Merge pull request 'Increase sidebar width, improve auto-panning' (#30) from ui/autopan into feature/app_menu
Reviewed-on: #30
2026-03-17 09:20:46 +00:00
8877a9e1c8 Remove unnecessary whitespace
All checks were successful
CI / Lint (pull_request) Successful in 49s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Successful in 18s
2026-03-17 13:18:17 +04:00
03d6a86569 Increase sidebar width, improve auto-panning
Some checks failed
CI / Lint (pull_request) Failing after 54s
CI / Test (pull_request) Has been cancelled
* Increase sidebar with on desktop to fit more content
* Move sidebar width to CSS variable
* Auto-pan when a selected place would be obstructed by the desktop
  sidebar
* Consider the header obscuring selected places, too
* Only autopan when actually necessary
2026-03-17 13:14:21 +04:00
5baebbb846 Add details elements/sections to App Menu sidebar
Starting with some About details
2026-03-16 18:23:46 +04:00
dca2991754 Style dropdown menu form controls 2026-03-16 17:07:38 +04:00
aee7f9d181 Move Photon API URL to Settings 2026-03-16 17:07:24 +04:00
56a077cceb Change release drafter token config
All checks were successful
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 57s
Another day, another try
2026-03-16 16:24:40 +04:00
7e5a034cac Merge pull request 'Prevent app icon loading when opening the app menu' (#29) from dev/app-icon into master
All checks were successful
CI / Lint (push) Successful in 47s
CI / Test (push) Successful in 56s
Reviewed-on: #29
2026-03-16 12:06:49 +00:00
5892bd0cda Prevent app icon loading when opening the app menu
Some checks failed
CI / Lint (pull_request) Successful in 50s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Has been cancelled
Compile into app code by importing the icon in JS.
2026-03-16 16:02:57 +04:00
baaf027900 Fix missing token
All checks were successful
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
Hopefully
2026-03-14 16:57:52 +04:00
beb3d12169 Merge pull request 'Refactor app menu sidebar' (#28) from feature/app_menu into master
All checks were successful
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
Reviewed-on: #28
2026-03-14 12:40:53 +00:00
a5aa396411 Merge branch 'master' into feature/app_menu
Some checks failed
CI / Test (pull_request) Successful in 58s
Release Drafter / Update release notes draft (pull_request) Failing after 17s
CI / Lint (pull_request) Successful in 48s
2026-03-14 16:31:42 +04:00
21cb8e6cc2 Add release drafter workflow
All checks were successful
CI / Lint (push) Successful in 54s
CI / Test (push) Successful in 1m8s
2026-03-14 16:31:22 +04:00
12ec7fcbbf Add release drafter config
Some checks failed
CI / Lint (push) Successful in 52s
CI / Test (push) Has been cancelled
2026-03-14 16:30:23 +04:00
78d7aeba2c Refactor app menu sidebar
* Rename to "app menu" across code
* Move content to dedicated sections/components, introduce app menu links
* Use CSS variable for hover background color across the app
2026-03-14 16:24:36 +04:00
3b71531de2 1.14.0
All checks were successful
CI / Lint (push) Successful in 22s
CI / Test (push) Successful in 37s
2026-03-14 13:06:07 +04:00
6ef7549ea9 Merge pull request 'Add places to default lists' (#27) from feature/1-lists into master
All checks were successful
CI / Lint (push) Successful in 25s
CI / Test (push) Successful in 38s
Reviewed-on: #27
2026-03-14 09:04:21 +00:00
9097c63a55 Upgrade places module to latest release
All checks were successful
CI / Lint (pull_request) Successful in 25s
CI / Test (pull_request) Successful in 42s
... with lists support
2026-03-14 12:36:49 +04:00
ec0d5a30f9 Extract icon imports to separate util
All checks were successful
CI / Lint (pull_request) Successful in 28s
CI / Test (pull_request) Successful in 43s
So icons can be used from anywhere, e.g. map component JS
2026-03-14 12:28:17 +04:00
f1779131e8 Also load/init lists in anonymous mode
Some checks failed
CI / Lint (pull_request) Failing after 22s
CI / Test (pull_request) Successful in 34s
2026-03-13 17:04:29 +04:00
37cf47b3dd Properly handle place removals
Some checks failed
CI / Lint (pull_request) Failing after 23s
CI / Test (pull_request) Successful in 36s
* Transition to OSM route or index instead of staying on ghost route/ID
  (closes sidebar if it was a custom place)
* Ensure save button and lists are in the correct state
2026-03-13 15:33:29 +04:00
ff68b5addc Move default yellow to var, add in list UI 2026-03-13 14:56:12 +04:00
990f3afa88 Fix lint errors
All checks were successful
CI / Lint (pull_request) Successful in 21s
CI / Test (pull_request) Successful in 34s
2026-03-13 13:51:49 +04:00
b2220b8310 Close list dropdown when clicking outside of it
Some checks failed
CI / Lint (pull_request) Failing after 25s
CI / Test (pull_request) Successful in 34s
2026-03-13 13:40:28 +04:00
a8613ab81a Remove confirmation dialog when deleting place bookmarks 2026-03-13 13:27:01 +04:00
bcb9b20e85 WIP Add places to lists 2026-03-13 12:22:51 +04:00
466b1d5383 Comment dev config for remote access
All checks were successful
CI / Lint (push) Successful in 20s
CI / Test (push) Successful in 34s
2026-03-11 18:26:45 +04:00
ea7cb2f895 1.13.3
Some checks failed
CI / Lint (push) Failing after 18s
CI / Test (push) Successful in 29s
2026-03-11 18:19:15 +04:00
7e94f335ac Prevent zooming when selecting saved places 2026-03-11 18:16:24 +04:00
066ddb240d 1.13.2
Some checks failed
CI / Lint (push) Failing after 23s
CI / Test (push) Successful in 34s
2026-03-11 17:53:06 +04:00
df336b87ac Smart auto zoom for search/select 2026-03-11 17:51:26 +04:00
dbf71e366a Further improve scrolling 2026-03-11 17:19:48 +04:00
6a83003acb 1.13.1 2026-03-11 16:30:33 +04:00
bcc7c2a011 Improve bottom card scrolling on Android 2026-03-11 16:29:31 +04:00
19f04efecb 1.13.0 2026-03-11 16:16:57 +04:00
c79bbaa41a Merge pull request 'Add email, FB, Instagram to place details' (#26) from feature/social_links into master
All checks were successful
CI / Lint (push) Successful in 21s
CI / Test (push) Successful in 34s
Reviewed-on: #26
2026-03-11 12:11:13 +00:00
b07640375a Add some white space to place details bottom
All checks were successful
CI / Lint (pull_request) Successful in 23s
CI / Test (pull_request) Successful in 39s
2026-03-11 16:07:37 +04:00
ffcb8219b0 Add email links 2026-03-11 15:22:34 +04:00
e01cb2ce6f Add Facebook and Instagram links 2026-03-11 15:02:47 +04:00
808c1ee37b 1.12.3
All checks were successful
CI / Lint (push) Successful in 21s
CI / Test (push) Successful in 35s
2026-02-24 22:28:56 +04:00
34bc15cfa9 Only zoom out, not in, when fitting ways/relations 2026-02-24 22:27:52 +04:00
ee5e56910d 1.12.2
All checks were successful
CI / Lint (push) Successful in 21s
CI / Test (push) Successful in 37s
2026-02-24 21:51:21 +04:00
e019fc2d6b Don't include roads and similar in nearby search
Meant to include bus stops, but have to be more specific when re-adding
2026-02-24 21:49:59 +04:00
39 changed files with 1673 additions and 317 deletions

View 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

View 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 }}

View 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>

View 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>

View 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>
}

View 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>
}

View File

@@ -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() {

View File

@@ -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];
const view = this.mapInstance.getView();
const center = view.getCenter();
const resolution = view.getResolution();
// Sidebar covers the bottom 50%
const splitPoint = height / 2;
// Default targets (current position)
let targetPixelX = pixel[0];
let targetPixelY = pixel[1];
let needsPan = false;
// 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;
// 1. Mobile Bottom Sheet Logic (Screen <= 768px)
if (size[0] <= 768) {
const height = size[1];
const splitPoint = height / 2;
const view = this.mapInstance.getView();
const center = view.getCenter();
const resolution = view.getResolution();
// 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;
// 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];
// 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;
}

View File

@@ -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">
<button
type="button"
class={{if
this.place.createdAt
"btn btn-secondary"
"btn btn-outline"
}}
{{on "click" (fn @onToggleSave this.place)}}
>
<Icon
@name="bookmark"
@color={{if this.place.createdAt "currentColor" "#007bff"}}
/>
{{if this.place.createdAt "Saved" "Save"}}
</button>
<div class="save-button-wrapper">
<button
type="button"
class={{if this.isSaved "btn btn-secondary" "btn btn-outline"}}
{{on "click" this.toggleLists}}
>
<Icon
@name="bookmark"
@color={{if this.isSaved "currentColor" "#007bff"}}
/>
{{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}} />

View 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>
}

View File

@@ -51,40 +51,39 @@ export default class PlacesSidebar extends Component {
if (!place) return;
if (place.createdAt) {
if (confirm(`Delete "${place.title}"?`)) {
try {
await this.storage.removePlace(place);
console.debug('Place deleted:', place.title);
// Direct delete without confirmation
try {
await this.storage.removePlace(place);
console.debug('Place deleted:', place.title);
// Notify parent to refresh map bookmarks
if (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);
// Notify parent to refresh map bookmarks
if (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);
}
} else {
// It's a fresh POI -> Save it

View File

@@ -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>
}

View 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

View 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);
};
});

View File

@@ -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();

View File

@@ -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() {

View File

@@ -35,7 +35,6 @@ export default class OsmService extends Service {
'building',
'landuse',
'public_transport',
'highway',
'aeroway',
];
const typeKeysQuery = [`~"^(${typeKeys.join('|')})$"~".*"`];

View File

@@ -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 [];

View File

@@ -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;
}
}

View File

@@ -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);
this.handlePlaceChange(event);
debounceTask(this, 'reloadCurrentView', 200);
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);

View File

@@ -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;
}

View File

@@ -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
View 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];
}

View 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
View 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,
};
}

View File

@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.12.1",
"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
View File

@@ -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

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

File diff suppressed because one or more lines are too long

View File

@@ -39,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>

View File

@@ -42,6 +42,9 @@ class MockStorageService extends Service {
findPlaceById() {
return null;
}
isPlaceSaved() {
return false;
}
loadPlacesInBounds() {
return [];
}

View File

@@ -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: () => {},
};

View File

@@ -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');
});
});

View 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');
});
});

View 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');
});
});

View File

@@ -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(),