Compare commits
34 Commits
d827fe263b
...
v1.13.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
ea7cb2f895
|
|||
|
7e94f335ac
|
|||
|
066ddb240d
|
|||
|
df336b87ac
|
|||
|
dbf71e366a
|
|||
|
6a83003acb
|
|||
|
bcc7c2a011
|
|||
|
19f04efecb
|
|||
|
c79bbaa41a
|
|||
|
b07640375a
|
|||
|
ffcb8219b0
|
|||
|
e01cb2ce6f
|
|||
|
808c1ee37b
|
|||
|
34bc15cfa9
|
|||
|
ee5e56910d
|
|||
|
e019fc2d6b
|
|||
|
9e03426b2e
|
|||
|
ecbf77c573
|
|||
|
703a5e8de0
|
|||
|
b3c733769c
|
|||
|
60b2548efd
|
|||
|
2e632658ad
|
|||
|
845be96b71
|
|||
|
9ac4273fae
|
|||
|
3a825c3d6c
|
|||
|
a6ca362876
|
|||
|
95e9c621a5
|
|||
|
e980431c17
|
|||
|
4fdf2e2fb6
|
|||
|
de1b162ee9
|
|||
|
1df77c2045
|
|||
|
eb1445b749
|
|||
|
316a38dbf8
|
|||
|
7bcb572dbf
|
@@ -18,15 +18,15 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Lint
|
- name: Lint
|
||||||
@@ -35,18 +35,16 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: "Test"
|
name: "Test"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: cypress/browsers:node-22.19.0-chrome-139.0.7258.154-1-ff-142.0.1-edge-139.0.3405.125-1
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Install Node
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: pnpm
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
export default {
|
export default {
|
||||||
extends: ['stylelint-config-standard'],
|
extends: ['stylelint-config-standard'],
|
||||||
|
rules: {
|
||||||
|
'no-descending-specificity': null,
|
||||||
|
'property-no-vendor-prefix': null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Project Status: Marco
|
# Project Status: Marco
|
||||||
|
|
||||||
**Last Updated:** Tue Jan 27 2026
|
**Last Updated:** Tue Feb 24 2026
|
||||||
|
|
||||||
## Project Context
|
## Project Context
|
||||||
|
|
||||||
@@ -39,6 +39,9 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
|||||||
- **Smart Zoom:** Implemented `zoomToBbox` to automatically fit complex geometries (ways/relations) within the visible viewport.
|
- **Smart Zoom:** Implemented `zoomToBbox` to automatically fit complex geometries (ways/relations) within the visible viewport.
|
||||||
- **Dynamic Padding:** Calculates padding based on active UI elements (Sidebar on Desktop, Bottom Sheet on Mobile) to ensure the geometry is perfectly centered in the _visible_ map area.
|
- **Dynamic Padding:** Calculates padding based on active UI elements (Sidebar on Desktop, Bottom Sheet on Mobile) to ensure the geometry is perfectly centered in the _visible_ map area.
|
||||||
- **Data Processing:** `OsmService` now calculates bounding boxes for ways and relations by aggregating member node coordinates.
|
- **Data Processing:** `OsmService` now calculates bounding boxes for ways and relations by aggregating member node coordinates.
|
||||||
|
- **Geometry Rendering:**
|
||||||
|
- **Outlines:** Implemented distinct blue outlines for selected OSM `ways` (Polygons) and `relations` (MultiLineStrings/Polygons) to clearly visualize boundaries.
|
||||||
|
- **Data Fetching:** Enhanced routing to fetch full geometry data on-demand if the initial search result (e.g., from Photon) lacks it, ensuring outlines are always available.
|
||||||
|
|
||||||
### 2. RemoteStorage Module (`@remotestorage/module-places`)
|
### 2. RemoteStorage Module (`@remotestorage/module-places`)
|
||||||
|
|
||||||
@@ -78,6 +81,8 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
|||||||
- `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
|
- `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
|
||||||
- **Format Utils:**
|
- **Format Utils:**
|
||||||
- `app/utils/format-text.js` & `humanize-osm-tag` helper: Standardized logic (Title Case, space replacement) for displaying OSM tags like `guest_house` -> "Guest House".
|
- `app/utils/format-text.js` & `humanize-osm-tag` helper: Standardized logic (Title Case, space replacement) for displaying OSM tags like `guest_house` -> "Guest House".
|
||||||
|
- **Tag refinement:** Improved logic for handling generic tags (e.g., `building=yes`). The UI now intelligently displays the key ("Building") instead of the value ("Yes") for better readability.
|
||||||
|
- **Localization:** Added basic `navigator.languages` support to `getLocalizedName` for preferring local names when available.
|
||||||
- **Build & DevOps:**
|
- **Build & DevOps:**
|
||||||
- **Icon Generation:** Added `build:icons` script using `magick` and `rsvg-convert` to automate PNG generation from SVG.
|
- **Icon Generation:** Added `build:icons` script using `magick` and `rsvg-convert` to automate PNG generation from SVG.
|
||||||
- **Dependencies:** Documented system requirements (ImageMagick, librsvg) in `README.md`.
|
- **Dependencies:** Documented system requirements (ImageMagick, librsvg) in `README.md`.
|
||||||
@@ -133,9 +138,10 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
|||||||
|
|
||||||
## Files Currently in Focus
|
## Files Currently in Focus
|
||||||
|
|
||||||
|
- `app/services/osm.js`
|
||||||
- `app/components/map.gjs`
|
- `app/components/map.gjs`
|
||||||
- `app/components/place-edit-form.gjs`
|
- `app/routes/place.js`
|
||||||
- `app/templates/place/new.gjs`
|
- `app/utils/osm.js`
|
||||||
|
|
||||||
## Next Steps & Pending Tasks
|
## Next Steps & Pending Tasks
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
|||||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||||
|
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
|
||||||
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
||||||
import home from 'feather-icons/dist/icons/home.svg?raw';
|
import home from 'feather-icons/dist/icons/home.svg?raw';
|
||||||
|
import instagram from 'feather-icons/dist/icons/instagram.svg?raw';
|
||||||
import logIn from 'feather-icons/dist/icons/log-in.svg?raw';
|
import logIn from 'feather-icons/dist/icons/log-in.svg?raw';
|
||||||
import logOut from 'feather-icons/dist/icons/log-out.svg?raw';
|
import logOut from 'feather-icons/dist/icons/log-out.svg?raw';
|
||||||
|
import mail from 'feather-icons/dist/icons/mail.svg?raw';
|
||||||
import map from 'feather-icons/dist/icons/map.svg?raw';
|
import map from 'feather-icons/dist/icons/map.svg?raw';
|
||||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||||
@@ -23,6 +26,7 @@ import target from 'feather-icons/dist/icons/target.svg?raw';
|
|||||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||||
|
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
'arrow-left': arrowLeft,
|
'arrow-left': arrowLeft,
|
||||||
@@ -30,10 +34,13 @@ const ICONS = {
|
|||||||
bookmark,
|
bookmark,
|
||||||
clock,
|
clock,
|
||||||
edit,
|
edit,
|
||||||
|
facebook,
|
||||||
globe,
|
globe,
|
||||||
home,
|
home,
|
||||||
|
instagram,
|
||||||
'log-in': logIn,
|
'log-in': logIn,
|
||||||
'log-out': logOut,
|
'log-out': logOut,
|
||||||
|
mail,
|
||||||
map,
|
map,
|
||||||
'map-pin': mapPin,
|
'map-pin': mapPin,
|
||||||
menu,
|
menu,
|
||||||
@@ -45,6 +52,7 @@ const ICONS = {
|
|||||||
settings,
|
settings,
|
||||||
target,
|
target,
|
||||||
user,
|
user,
|
||||||
|
wikipedia,
|
||||||
x,
|
x,
|
||||||
zap,
|
zap,
|
||||||
};
|
};
|
||||||
@@ -63,7 +71,9 @@ export default class IconComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get style() {
|
get style() {
|
||||||
return `width:${this.size}px;height:${this.size}px;color:${this.color}`;
|
return htmlSafe(
|
||||||
|
`width:${this.size}px;height:${this.size}px;color:${this.color}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get title() {
|
get title() {
|
||||||
@@ -72,7 +82,11 @@ export default class IconComponent extends Component {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
{{#if this.svg}}
|
{{#if this.svg}}
|
||||||
<span class="icon" style={{this.style}} title={{this.title}}>
|
<span
|
||||||
|
class="icon {{if @filled 'icon-filled'}}"
|
||||||
|
style={{this.style}}
|
||||||
|
title={{this.title}}
|
||||||
|
>
|
||||||
{{htmlSafe this.svg}}
|
{{htmlSafe this.svg}}
|
||||||
</span>
|
</span>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -441,6 +441,7 @@ export default class MapComponent extends Component {
|
|||||||
// Track the selected place from the UI Service (Router -> Map)
|
// Track the selected place from the UI Service (Router -> Map)
|
||||||
updateSelectedPin = modifier(() => {
|
updateSelectedPin = modifier(() => {
|
||||||
const selected = this.mapUi.selectedPlace;
|
const selected = this.mapUi.selectedPlace;
|
||||||
|
const options = this.mapUi.selectionOptions || {};
|
||||||
|
|
||||||
if (!this.selectedPinOverlay || !this.selectedPinElement) return;
|
if (!this.selectedPinOverlay || !this.selectedPinElement) return;
|
||||||
|
|
||||||
@@ -471,7 +472,12 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.bbox) {
|
if (options.preventZoom) {
|
||||||
|
// If we are preventing zoom (e.g. user clicked a bookmark), we still need to center
|
||||||
|
// but without changing the zoom level.
|
||||||
|
// We use animateToSmartCenter without a second argument (zoom=null).
|
||||||
|
this.animateToSmartCenter(coords);
|
||||||
|
} else if (selected.bbox) {
|
||||||
this.zoomToBbox(selected.bbox);
|
this.zoomToBbox(selected.bbox);
|
||||||
} else {
|
} else {
|
||||||
this.handlePinVisibility(coords);
|
this.handlePinVisibility(coords);
|
||||||
@@ -508,33 +514,44 @@ export default class MapComponent extends Component {
|
|||||||
// Top padding: 15% of the VISIBLE height (size[1] * 0.5)
|
// Top padding: 15% of the VISIBLE height (size[1] * 0.5)
|
||||||
const visibleHeight = size[1] * 0.5;
|
const visibleHeight = size[1] * 0.5;
|
||||||
const topPadding = visibleHeight * 0.15;
|
const topPadding = visibleHeight * 0.15;
|
||||||
const bottomPadding = (size[1] * 0.5) + (visibleHeight * 0.15); // Sheet + padding
|
const bottomPadding = size[1] * 0.5 + visibleHeight * 0.15; // Sheet + padding
|
||||||
|
|
||||||
padding[0] = topPadding;
|
padding[0] = topPadding;
|
||||||
padding[2] = bottomPadding;
|
padding[2] = bottomPadding;
|
||||||
}
|
}
|
||||||
// Desktop: Sidebar covers left side (approx 400px)
|
// Desktop: Sidebar covers left side (approx 400px)
|
||||||
else if (this.args.isSidebarOpen) {
|
else if (this.args.isSidebarOpen) {
|
||||||
const sidebarWidth = 400;
|
const sidebarWidth = 400;
|
||||||
const visibleWidth = size[0] - sidebarWidth;
|
const visibleWidth = size[0] - sidebarWidth;
|
||||||
|
|
||||||
// Left padding: Sidebar + 15% of visible width
|
// Left padding: Sidebar + 15% of visible width
|
||||||
padding[3] = sidebarWidth + (visibleWidth * 0.15);
|
padding[3] = sidebarWidth + visibleWidth * 0.15;
|
||||||
// Right padding: 15% of visible width
|
// Right padding: 15% of visible width
|
||||||
padding[1] = visibleWidth * 0.15;
|
padding[1] = visibleWidth * 0.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentZoom = view.getZoom();
|
||||||
|
|
||||||
view.fit(extent, {
|
view.fit(extent, {
|
||||||
padding: padding,
|
padding: padding,
|
||||||
duration: 1000,
|
duration: 1000,
|
||||||
easing: (t) => t * (2 - t),
|
easing: (t) => t * (2 - t),
|
||||||
maxZoom: 19,
|
maxZoom: Math.max(currentZoom, 18),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePinVisibility(coords) {
|
handlePinVisibility(coords) {
|
||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance) return;
|
||||||
|
|
||||||
|
const view = this.mapInstance.getView();
|
||||||
|
const currentZoom = view.getZoom();
|
||||||
|
|
||||||
|
// If too far out (e.g. world view), zoom in to neighborhood level (16)
|
||||||
|
if (currentZoom < 16) {
|
||||||
|
this.animateToSmartCenter(coords, 16);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
|
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
|
||||||
const size = this.mapInstance.getSize();
|
const size = this.mapInstance.getSize();
|
||||||
|
|
||||||
@@ -553,12 +570,17 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
animateToSmartCenter(coords) {
|
animateToSmartCenter(coords, zoom = null) {
|
||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance) return;
|
||||||
|
|
||||||
const size = this.mapInstance.getSize();
|
const size = this.mapInstance.getSize();
|
||||||
const view = this.mapInstance.getView();
|
const view = this.mapInstance.getView();
|
||||||
const resolution = view.getResolution();
|
let resolution = view.getResolution();
|
||||||
|
|
||||||
|
if (zoom !== null) {
|
||||||
|
resolution = view.getResolutionForZoom(zoom);
|
||||||
|
}
|
||||||
|
|
||||||
let targetCenter = coords;
|
let targetCenter = coords;
|
||||||
|
|
||||||
// Check if mobile (width <= 768px matches CSS)
|
// Check if mobile (width <= 768px matches CSS)
|
||||||
@@ -580,11 +602,17 @@ export default class MapComponent extends Component {
|
|||||||
targetCenter = [coords[0], coords[1] - offsetMapUnits];
|
targetCenter = [coords[0], coords[1] - offsetMapUnits];
|
||||||
}
|
}
|
||||||
|
|
||||||
view.animate({
|
const animationOptions = {
|
||||||
center: targetCenter,
|
center: targetCenter,
|
||||||
duration: 1000,
|
duration: 1000,
|
||||||
easing: (t) => t * (2 - t), // Ease-out
|
easing: (t) => t * (2 - t), // Ease-out
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (zoom !== null) {
|
||||||
|
animationOptions.zoom = zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.animate(animationOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
panIfObscured(coords) {
|
panIfObscured(coords) {
|
||||||
@@ -848,6 +876,7 @@ export default class MapComponent extends Component {
|
|||||||
'Clicked bookmark while sidebar open (switching):',
|
'Clicked bookmark while sidebar open (switching):',
|
||||||
clickedBookmark
|
clickedBookmark
|
||||||
);
|
);
|
||||||
|
this.mapUi.preventNextZoom = true;
|
||||||
this.router.transitionTo('place', clickedBookmark);
|
this.router.transitionTo('place', clickedBookmark);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -862,6 +891,7 @@ export default class MapComponent extends Component {
|
|||||||
// Normal behavior (sidebar is closed)
|
// Normal behavior (sidebar is closed)
|
||||||
if (clickedBookmark) {
|
if (clickedBookmark) {
|
||||||
console.debug('Clicked bookmark:', clickedBookmark);
|
console.debug('Clicked bookmark:', clickedBookmark);
|
||||||
|
this.mapUi.preventNextZoom = true;
|
||||||
this.router.transitionTo('place', clickedBookmark);
|
this.router.transitionTo('place', clickedBookmark);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { fn } from '@ember/helper';
|
import { fn } from '@ember/helper';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
|
import { htmlSafe } from '@ember/template';
|
||||||
import { humanizeOsmTag } from '../utils/format-text';
|
import { humanizeOsmTag } from '../utils/format-text';
|
||||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||||
|
import { getSocialInfo } from '../utils/social-links';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import PlaceEditForm from './place-edit-form';
|
import PlaceEditForm from './place-edit-form';
|
||||||
|
|
||||||
@@ -95,21 +97,70 @@ export default class PlaceDetails extends Component {
|
|||||||
return parts.join(', ');
|
return parts.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatMultiLine(val, type) {
|
||||||
|
if (!val) return null;
|
||||||
|
const parts = val
|
||||||
|
.split(';')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
|
||||||
|
if (type === 'phone') {
|
||||||
|
return htmlSafe(
|
||||||
|
parts.map((p) => `<a href="tel:${p}">${p}</a>`).join('<br>')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'email') {
|
||||||
|
return htmlSafe(
|
||||||
|
parts.map((p) => `<a href="mailto:${p}">${p}</a>`).join('<br>')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'url') {
|
||||||
|
return htmlSafe(
|
||||||
|
parts
|
||||||
|
.map(
|
||||||
|
(url) =>
|
||||||
|
`<a href="${url}" target="_blank" rel="noopener noreferrer">${this.getDomain(
|
||||||
|
url
|
||||||
|
)}</a>`
|
||||||
|
)
|
||||||
|
.join('<br>')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlSafe(parts.join('<br>'));
|
||||||
|
}
|
||||||
|
|
||||||
get phone() {
|
get phone() {
|
||||||
return this.tags.phone || this.tags['contact:phone'];
|
const val = this.tags.phone || this.tags['contact:phone'];
|
||||||
|
return this.formatMultiLine(val, 'phone');
|
||||||
|
}
|
||||||
|
|
||||||
|
get email() {
|
||||||
|
const val = this.tags.email || this.tags['contact:email'];
|
||||||
|
return this.formatMultiLine(val, 'email');
|
||||||
}
|
}
|
||||||
|
|
||||||
get website() {
|
get website() {
|
||||||
return this.place.url || this.tags.website || this.tags['contact:website'];
|
const val =
|
||||||
|
this.place.url || this.tags.website || this.tags['contact:website'];
|
||||||
|
return this.formatMultiLine(val, 'url');
|
||||||
}
|
}
|
||||||
|
|
||||||
get websiteDomain() {
|
getDomain(urlStr) {
|
||||||
const url = new URL(this.website);
|
try {
|
||||||
return url.hostname;
|
const url = new URL(urlStr);
|
||||||
|
return url.hostname;
|
||||||
|
} catch {
|
||||||
|
return urlStr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get openingHours() {
|
get openingHours() {
|
||||||
return this.tags.opening_hours;
|
const val = this.tags.opening_hours;
|
||||||
|
return this.formatMultiLine(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
get cuisine() {
|
get cuisine() {
|
||||||
@@ -120,8 +171,21 @@ export default class PlaceDetails extends Component {
|
|||||||
.join(', ');
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get facebook() {
|
||||||
|
return getSocialInfo(this.tags, 'facebook');
|
||||||
|
}
|
||||||
|
|
||||||
|
get instagram() {
|
||||||
|
return getSocialInfo(this.tags, 'instagram');
|
||||||
|
}
|
||||||
|
|
||||||
get wikipedia() {
|
get wikipedia() {
|
||||||
return this.tags.wikipedia;
|
const val = this.tags.wikipedia;
|
||||||
|
if (!val) return null;
|
||||||
|
return val
|
||||||
|
.split(';')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
get geoLink() {
|
get geoLink() {
|
||||||
@@ -215,7 +279,7 @@ export default class PlaceDetails extends Component {
|
|||||||
<div class="meta-info">
|
<div class="meta-info">
|
||||||
|
|
||||||
{{#if this.cuisine}}
|
{{#if this.cuisine}}
|
||||||
<p>
|
<p class="cuisine-info">
|
||||||
<strong>Cuisine:</strong>
|
<strong>Cuisine:</strong>
|
||||||
{{this.cuisine}}
|
{{this.cuisine}}
|
||||||
</p>
|
</p>
|
||||||
@@ -224,36 +288,81 @@ export default class PlaceDetails extends Component {
|
|||||||
{{#if this.openingHours}}
|
{{#if this.openingHours}}
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="clock" @title="Opening hours" />
|
<Icon @name="clock" @title="Opening hours" />
|
||||||
<span>{{this.openingHours}}</span>
|
<span>
|
||||||
|
{{this.openingHours}}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if this.phone}}
|
{{#if this.phone}}
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="phone" @title="Phone" />
|
<Icon @name="phone" @title="Phone" />
|
||||||
<span><a href="tel:{{this.phone}}">{{this.phone}}</a></span>
|
<span>
|
||||||
|
{{this.phone}}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if this.website}}
|
{{#if this.website}}
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="globe" @title="Website" />
|
<Icon @name="globe" @title="Website" />
|
||||||
<span><a
|
<span>
|
||||||
href={{this.website}}
|
{{this.website}}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.email}}
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="mail" @title="Email" />
|
||||||
|
<span>
|
||||||
|
{{this.email}}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.facebook}}
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="facebook" @title="Facebook" />
|
||||||
|
<span>
|
||||||
|
<a
|
||||||
|
href={{this.facebook.url}}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>{{this.websiteDomain}}</a></span>
|
>
|
||||||
|
{{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>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if this.wikipedia}}
|
{{#if this.wikipedia}}
|
||||||
<p>
|
<p class="content-with-icon">
|
||||||
<strong>Wikipedia:</strong>
|
<Icon @name="wikipedia" @title="Wikipedia" @filled={{true}} />
|
||||||
<a
|
<span>
|
||||||
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
|
<a
|
||||||
target="_blank"
|
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
>Article</a>
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Wikipedia
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default class PlaceEditForm extends Component {
|
|||||||
<form class="edit-form" {{on "submit" this.handleSubmit}}>
|
<form class="edit-form" {{on "submit" this.handleSubmit}}>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-title">Title</label>
|
<label for="edit-title">Title</label>
|
||||||
|
{{! template-lint-disable no-autofocus-attribute }}
|
||||||
<input
|
<input
|
||||||
id="edit-title"
|
id="edit-title"
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -145,6 +145,11 @@ export default class PlacesSidebar extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isNearbySearch() {
|
||||||
|
const qp = this.router.currentRoute.queryParams;
|
||||||
|
return !qp.q && qp.lat && qp.lon;
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
@@ -155,7 +160,12 @@ export default class PlacesSidebar extends Component {
|
|||||||
{{on "click" this.clearSelection}}
|
{{on "click" this.clearSelection}}
|
||||||
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
||||||
{{else}}
|
{{else}}
|
||||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" /> Nearby</h2>
|
{{#if this.isNearbySearch}}
|
||||||
|
<h2><Icon @name="target" @size={{20}} @color="#ea4335" />
|
||||||
|
Nearby</h2>
|
||||||
|
{{else}}
|
||||||
|
<h2><Icon @name="search" @size={{20}} @color="#333" /> Results</h2>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
||||||
@name="x"
|
@name="x"
|
||||||
@@ -205,7 +215,11 @@ export default class PlacesSidebar extends Component {
|
|||||||
{{/each}}
|
{{/each}}
|
||||||
</ul>
|
</ul>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="empty-state">No places found nearby.</p>
|
{{#if this.isNearbySearch}}
|
||||||
|
<p class="empty-state">No places found nearby.</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty-state">No results found.</p>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -181,9 +181,11 @@ export default class SearchBoxComponent extends Component {
|
|||||||
<div class="result-info">
|
<div class="result-info">
|
||||||
<span class="result-title">{{result.title}}</span>
|
<span class="result-title">{{result.title}}</span>
|
||||||
{{#if (eq result.source "osm")}}
|
{{#if (eq result.source "osm")}}
|
||||||
<span class="result-desc">{{humanizeOsmTag result.type}}</span>
|
<span class="result-desc">{{humanizeOsmTag
|
||||||
|
result.type
|
||||||
|
}}</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{#if result.description}}
|
{{#if result.description}}
|
||||||
<span class="result-desc">{{result.description}}</span>
|
<span class="result-desc">{{result.description}}</span>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { service } from '@ember/service';
|
|||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import eq from 'ember-truth-helpers/helpers/eq';
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
import not from 'ember-truth-helpers/helpers/not';
|
|
||||||
|
|
||||||
export default class SettingsPane extends Component {
|
export default class SettingsPane extends Component {
|
||||||
@service settings;
|
@service settings;
|
||||||
@@ -22,7 +21,10 @@ export default class SettingsPane extends Component {
|
|||||||
<template>
|
<template>
|
||||||
<div class="sidebar settings-pane">
|
<div class="sidebar settings-pane">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h2>Marco</h2>
|
<h2>
|
||||||
|
<img src="/icons/icon-rounded.svg" alt="" width="32" height="32" />
|
||||||
|
Marco
|
||||||
|
</h2>
|
||||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||||
<Icon @name="x" @size={{20}} @color="#333" />
|
<Icon @name="x" @size={{20}} @color="#333" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
4
app/icons/wikipedia.svg
Normal file
4
app/icons/wikipedia.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="7.15 7.15 113.7 113.7" fill="currentColor">
|
||||||
|
<path d="M 120.85,29.21 C 120.85,29.62 120.72,29.99 120.47,30.33 C 120.21,30.66 119.94,30.83 119.63,30.83 C 117.14,31.07 115.09,31.87 113.51,33.24 C 111.92,34.6 110.29,37.21 108.6,41.05 L 82.8,99.19 C 82.63,99.73 82.16,100 81.38,100 C 80.77,100 80.3,99.73 79.96,99.19 L 65.49,68.93 L 48.85,99.19 C 48.51,99.73 48.04,100 47.43,100 C 46.69,100 46.2,99.73 45.96,99.19 L 20.61,41.05 C 19.03,37.44 17.36,34.92 15.6,33.49 C 13.85,32.06 11.4,31.17 8.27,30.83 C 8,30.83 7.74,30.69 7.51,30.4 C 7.27,30.12 7.15,29.79 7.15,29.42 C 7.15,28.47 7.42,28 7.96,28 C 10.22,28 12.58,28.1 15.05,28.3 C 17.34,28.51 19.5,28.61 21.52,28.61 C 23.58,28.61 26.01,28.51 28.81,28.3 C 31.74,28.1 34.34,28 36.6,28 C 37.14,28 37.41,28.47 37.41,29.42 C 37.41,30.36 37.24,30.83 36.91,30.83 C 34.65,31 32.87,31.58 31.57,32.55 C 30.27,33.53 29.62,34.81 29.62,36.4 C 29.62,37.21 29.89,38.22 30.43,39.43 L 51.38,86.74 L 63.27,64.28 L 52.19,41.05 C 50.2,36.91 48.56,34.23 47.28,33.03 C 46,31.84 44.06,31.1 41.46,30.83 C 41.22,30.83 41,30.69 40.78,30.4 C 40.56,30.12 40.45,29.79 40.45,29.42 C 40.45,28.47 40.68,28 41.16,28 C 43.42,28 45.49,28.1 47.38,28.3 C 49.2,28.51 51.14,28.61 53.2,28.61 C 55.22,28.61 57.36,28.51 59.62,28.3 C 61.95,28.1 64.24,28 66.5,28 C 67.04,28 67.31,28.47 67.31,29.42 C 67.31,30.36 67.15,30.83 66.81,30.83 C 62.29,31.14 60.03,32.42 60.03,34.68 C 60.03,35.69 60.55,37.26 61.6,39.38 L 68.93,54.26 L 76.22,40.65 C 77.23,38.73 77.74,37.11 77.74,35.79 C 77.74,32.69 75.48,31.04 70.96,30.83 C 70.55,30.83 70.35,30.36 70.35,29.42 C 70.35,29.08 70.45,28.76 70.65,28.46 C 70.86,28.15 71.06,28 71.26,28 C 72.88,28 74.87,28.1 77.23,28.3 C 79.49,28.51 81.35,28.61 82.8,28.61 C 83.84,28.61 85.38,28.52 87.4,28.35 C 89.96,28.12 92.11,28 93.83,28 C 94.23,28 94.43,28.4 94.43,29.21 C 94.43,30.29 94.06,30.83 93.32,30.83 C 90.69,31.1 88.57,31.83 86.97,33.01 C 85.37,34.19 83.37,36.87 80.98,41.05 L 71.26,59.02 L 84.42,85.83 L 103.85,40.65 C 104.52,39 104.86,37.48 104.86,36.1 C 104.86,32.79 102.6,31.04 98.08,30.83 C 97.67,30.83 97.47,30.36 97.47,29.42 C 97.47,28.47 97.77,28 98.38,28 C 100.03,28 101.99,28.1 104.25,28.3 C 106.34,28.51 108.1,28.61 109.51,28.61 C 111,28.61 112.72,28.51 114.67,28.3 C 116.7,28.1 118.52,28 120.14,28 C 120.61,28 120.85,28.4 120.85,29.21 z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -72,7 +72,9 @@ export default class PlaceRoute extends Route {
|
|||||||
|
|
||||||
// Notify the Map UI to show the pin
|
// Notify the Map UI to show the pin
|
||||||
if (model) {
|
if (model) {
|
||||||
this.mapUi.selectPlace(model);
|
const options = { preventZoom: this.mapUi.preventNextZoom };
|
||||||
|
this.mapUi.selectPlace(model, options);
|
||||||
|
this.mapUi.preventNextZoom = false;
|
||||||
}
|
}
|
||||||
// Stop the pulse animation if it was running (e.g. redirected from search)
|
// Stop the pulse animation if it was running (e.g. redirected from search)
|
||||||
this.mapUi.stopSearch();
|
this.mapUi.stopSearch();
|
||||||
|
|||||||
@@ -9,18 +9,24 @@ export default class MapUiService extends Service {
|
|||||||
@tracked returnToSearch = false;
|
@tracked returnToSearch = false;
|
||||||
@tracked currentCenter = null;
|
@tracked currentCenter = null;
|
||||||
@tracked searchBoxHasFocus = false;
|
@tracked searchBoxHasFocus = false;
|
||||||
|
@tracked selectionOptions = {};
|
||||||
|
@tracked preventNextZoom = false;
|
||||||
|
|
||||||
selectPlace(place) {
|
selectPlace(place, options = {}) {
|
||||||
this.selectedPlace = place;
|
this.selectedPlace = place;
|
||||||
|
this.selectionOptions = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
this.selectedPlace = null;
|
this.selectedPlace = null;
|
||||||
|
this.selectionOptions = {};
|
||||||
|
this.preventNextZoom = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
startSearch() {
|
startSearch() {
|
||||||
this.isSearching = true;
|
this.isSearching = true;
|
||||||
this.isCreating = false;
|
this.isCreating = false;
|
||||||
|
this.preventNextZoom = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
stopSearch() {
|
stopSearch() {
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export default class OsmService extends Service {
|
|||||||
'building',
|
'building',
|
||||||
'landuse',
|
'landuse',
|
||||||
'public_transport',
|
'public_transport',
|
||||||
'highway',
|
|
||||||
'aeroway',
|
'aeroway',
|
||||||
];
|
];
|
||||||
const typeKeysQuery = [`~"^(${typeKeys.join('|')})$"~".*"`];
|
const typeKeysQuery = [`~"^(${typeKeys.join('|')})$"~".*"`];
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default class SettingsService extends Service {
|
|||||||
overpassApis = [
|
overpassApis = [
|
||||||
{
|
{
|
||||||
name: 'overpass-api.de (DE)',
|
name: 'overpass-api.de (DE)',
|
||||||
url: 'https://overpass-api.de/api/interpreter'
|
url: 'https://overpass-api.de/api/interpreter',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'private.coffee (AT)',
|
name: 'private.coffee (AT)',
|
||||||
@@ -32,7 +32,15 @@ export default class SettingsService extends Service {
|
|||||||
loadSettings() {
|
loadSettings() {
|
||||||
const savedApi = localStorage.getItem('marco:overpass-api');
|
const savedApi = localStorage.getItem('marco:overpass-api');
|
||||||
if (savedApi) {
|
if (savedApi) {
|
||||||
this.overpassApi = savedApi;
|
// Check if saved API is still in the allowed list
|
||||||
|
const isValid = this.overpassApis.some((api) => api.url === savedApi);
|
||||||
|
if (isValid) {
|
||||||
|
this.overpassApi = savedApi;
|
||||||
|
} else {
|
||||||
|
// If not valid, revert to default
|
||||||
|
this.overpassApi = 'https://overpass-api.de/api/interpreter';
|
||||||
|
localStorage.setItem('marco:overpass-api', this.overpassApi);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedKinetic = localStorage.getItem('marco:map-kinetic');
|
const savedKinetic = localStorage.getItem('marco:map-kinetic');
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ body {
|
|||||||
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
|
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden; /* Ensure flex children are contained */
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-pane.sidebar {
|
.settings-pane.sidebar {
|
||||||
@@ -239,7 +240,11 @@ body {
|
|||||||
.sidebar-content {
|
.sidebar-content {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1; /* Take up remaining vertical space */
|
-webkit-overflow-scrolling: touch;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
touch-action: pan-y;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-form {
|
.edit-form {
|
||||||
@@ -345,7 +350,6 @@ body {
|
|||||||
.meta-info a {
|
.meta-info a {
|
||||||
color: #007bff;
|
color: #007bff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding-bottom: 4rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-info a:hover {
|
.meta-info a:hover {
|
||||||
@@ -375,10 +379,7 @@ body {
|
|||||||
.places-list {
|
.places-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: -1rem -1rem 0 -1rem;
|
margin: -1rem -1rem 0;
|
||||||
}
|
|
||||||
|
|
||||||
.places-list li {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.place-item {
|
.place-item {
|
||||||
@@ -431,6 +432,10 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.place-details {
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.place-details h3 {
|
.place-details h3 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -512,6 +517,7 @@ body {
|
|||||||
border: 2px solid rgb(255 204 51 / 80%); /* Gold/Yellow to match markers */
|
border: 2px solid rgb(255 204 51 / 80%); /* Gold/Yellow to match markers */
|
||||||
background: rgb(255 204 51 / 20%);
|
background: rgb(255 204 51 / 20%);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
/* Use translate3d for GPU acceleration on iOS */
|
/* Use translate3d for GPU acceleration on iOS */
|
||||||
transform: translate3d(-50%, -50%, 0);
|
transform: translate3d(-50%, -50%, 0);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -549,6 +555,7 @@ body {
|
|||||||
.ol-control.ol-attribution {
|
.ol-control.ol-attribution {
|
||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-control.ol-attribution {
|
.ol-touch .ol-control.ol-attribution {
|
||||||
bottom: 0.5rem;
|
bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -556,6 +563,7 @@ body {
|
|||||||
.ol-control.ol-zoom {
|
.ol-control.ol-zoom {
|
||||||
bottom: 3rem;
|
bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-control.ol-zoom {
|
.ol-touch .ol-control.ol-zoom {
|
||||||
bottom: 3.5rem;
|
bottom: 3.5rem;
|
||||||
}
|
}
|
||||||
@@ -563,6 +571,7 @@ body {
|
|||||||
.ol-control.ol-locate {
|
.ol-control.ol-locate {
|
||||||
bottom: 6.5rem;
|
bottom: 6.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-control.ol-locate {
|
.ol-touch .ol-control.ol-locate {
|
||||||
bottom: 8.5rem;
|
bottom: 8.5rem;
|
||||||
}
|
}
|
||||||
@@ -570,6 +579,7 @@ body {
|
|||||||
.ol-control.ol-rotate {
|
.ol-control.ol-rotate {
|
||||||
bottom: 9rem;
|
bottom: 9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-control.ol-rotate {
|
.ol-touch .ol-control.ol-rotate {
|
||||||
bottom: 11.5rem;
|
bottom: 11.5rem;
|
||||||
}
|
}
|
||||||
@@ -610,13 +620,22 @@ span.icon {
|
|||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-filled svg {
|
||||||
|
stroke: none;
|
||||||
|
fill: currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
.content-with-icon {
|
.content-with-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-with-icon .icon {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Selected Pin Animation */
|
/* Selected Pin Animation */
|
||||||
.selected-pin-container {
|
.selected-pin-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -687,6 +706,7 @@ span.icon {
|
|||||||
/* Map Crosshair for "Create Place" mode */
|
/* Map Crosshair for "Create Place" mode */
|
||||||
.map-crosshair {
|
.map-crosshair {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
/* Default Center */
|
/* Default Center */
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -707,8 +727,11 @@ span.icon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar is open (Desktop: Left 300px) */
|
/* Sidebar is open (Desktop: Left 300px) */
|
||||||
|
|
||||||
/* We want to center in the remaining space (width - 300px) */
|
/* We want to center in the remaining space (width - 300px) */
|
||||||
|
|
||||||
/* Center X = 300 + (width - 300) / 2 = 300 + width/2 - 150 = width/2 + 150 */
|
/* Center X = 300 + (width - 300) / 2 = 300 + width/2 - 150 = width/2 + 150 */
|
||||||
|
|
||||||
/* So shift left by 150px from center */
|
/* So shift left by 150px from center */
|
||||||
.map-container.sidebar-open .map-crosshair {
|
.map-container.sidebar-open .map-crosshair {
|
||||||
left: calc(50% + 150px);
|
left: calc(50% + 150px);
|
||||||
@@ -716,6 +739,7 @@ span.icon {
|
|||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
|
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
|
||||||
|
|
||||||
/* Center Y = (height/2) / 2 = height/4 = 25% */
|
/* Center Y = (height/2) / 2 = height/4 = 25% */
|
||||||
.map-container.sidebar-open .map-crosshair {
|
.map-container.sidebar-open .map-crosshair {
|
||||||
left: 50%; /* Reset desktop shift */
|
left: 50%; /* Reset desktop shift */
|
||||||
@@ -753,7 +777,6 @@ button.create-place {
|
|||||||
|
|
||||||
.sidebar-content {
|
.sidebar-content {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain; /* Prevent scroll chaining */
|
|
||||||
|
|
||||||
/* Ensure content doesn't get hidden behind bottom safe areas on mobile */
|
/* Ensure content doesn't get hidden behind bottom safe areas on mobile */
|
||||||
padding-bottom: env(safe-area-inset-bottom, 20px);
|
padding-bottom: env(safe-area-inset-bottom, 20px);
|
||||||
@@ -769,19 +792,25 @@ button.create-place {
|
|||||||
z-index: 3002; /* Higher than menu button to be safe */
|
z-index: 3002; /* Higher than menu button to be safe */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.search-box {
|
||||||
|
max-width: calc(100vw - 65px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.search-form {
|
.search-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 24px; /* Pill shape */
|
border-radius: 24px; /* Pill shape */
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 5px rgb(0 0 0 / 15%);
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
height: 48px; /* Slightly taller for touch targets */
|
height: 48px; /* Slightly taller for touch targets */
|
||||||
transition: box-shadow 0.2s;
|
transition: box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-form:focus-within {
|
.search-form:focus-within {
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Integrated Menu Button */
|
/* Integrated Menu Button */
|
||||||
@@ -799,7 +828,7 @@ button.create-place {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-btn-integrated:hover {
|
.menu-btn-integrated:hover {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgb(0 0 0 / 5%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fallback Search Icon (Left) */
|
/* Fallback Search Icon (Left) */
|
||||||
@@ -823,6 +852,7 @@ button.create-place {
|
|||||||
outline: none;
|
outline: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
|
|
||||||
/* Remove native search cancel button in WebKit */
|
/* Remove native search cancel button in WebKit */
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
@@ -844,25 +874,11 @@ button.create-place {
|
|||||||
color: #5f6368;
|
color: #5f6368;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
border-left: 1px solid #ddd; /* Separator like Google Maps */
|
|
||||||
padding-left: 12px;
|
|
||||||
border-radius: 0; /* Reset for separator look */
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-submit-btn:hover {
|
|
||||||
/* No background on hover if we use separator style, or maybe just change icon color */
|
|
||||||
color: #1a73e8; /* Blue on hover */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* If we want the separator style, we need to adjust border-radius carefully or use a pseudo element */
|
|
||||||
/* Let's stick to a simple button for now, maybe without the separator if it looks cleaner */
|
|
||||||
.search-submit-btn {
|
|
||||||
border-left: none; /* Remove separator for cleaner look */
|
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
border-radius: 50%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-submit-btn:hover {
|
.search-submit-btn:hover {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgb(0 0 0 / 5%);
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,7 +896,7 @@ button.create-place {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-clear-btn:hover {
|
.search-clear-btn:hover {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgb(0 0 0 / 5%);
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -893,7 +909,7 @@ button.create-place {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -33,27 +33,38 @@ export function getLocalizedName(tags, defaultName = 'Untitled Place') {
|
|||||||
return defaultName;
|
return defaultName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLACE_TYPE_KEYS = [
|
||||||
|
'amenity',
|
||||||
|
'shop',
|
||||||
|
'tourism',
|
||||||
|
'historic',
|
||||||
|
'leisure',
|
||||||
|
'office',
|
||||||
|
'craft',
|
||||||
|
'building',
|
||||||
|
'landuse',
|
||||||
|
'public_transport',
|
||||||
|
'highway',
|
||||||
|
'aeroway',
|
||||||
|
'waterway',
|
||||||
|
'natural',
|
||||||
|
'place',
|
||||||
|
'border_type',
|
||||||
|
'admin_title',
|
||||||
|
];
|
||||||
|
|
||||||
export function getPlaceType(tags) {
|
export function getPlaceType(tags) {
|
||||||
if (!tags) return null;
|
if (!tags) return null;
|
||||||
|
|
||||||
const rawType =
|
for (const key of PLACE_TYPE_KEYS) {
|
||||||
tags.amenity ||
|
const value = tags[key];
|
||||||
tags.shop ||
|
if (value) {
|
||||||
tags.tourism ||
|
if (value === 'yes') {
|
||||||
tags.historic ||
|
return humanizeOsmTag(key);
|
||||||
tags.leisure ||
|
}
|
||||||
tags.office ||
|
return humanizeOsmTag(value);
|
||||||
tags.craft ||
|
}
|
||||||
tags.building ||
|
}
|
||||||
tags.landuse ||
|
|
||||||
tags.place ||
|
|
||||||
tags.natural ||
|
|
||||||
tags.public_transport ||
|
|
||||||
tags.highway ||
|
|
||||||
tags.aeroway ||
|
|
||||||
tags.waterway ||
|
|
||||||
tags.border_type ||
|
|
||||||
tags.admin_title;
|
|
||||||
|
|
||||||
return humanizeOsmTag(rawType);
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Marco</title>
|
<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">
|
<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 -->
|
<!-- App identity -->
|
||||||
<meta name="application-name" content="Marco">
|
<meta name="application-name" content="Marco">
|
||||||
<meta name="apple-mobile-web-app-title" content="Marco">
|
<meta name="apple-mobile-web-app-title" content="Marco">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.11.4",
|
"version": "1.13.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -25,9 +25,10 @@
|
|||||||
"format": "prettier . --cache --write",
|
"format": "prettier . --cache --write",
|
||||||
"lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto",
|
"lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto",
|
||||||
"lint:css": "stylelint \"**/*.css\"",
|
"lint:css": "stylelint \"**/*.css\"",
|
||||||
"lint:css:fix": "concurrently \"pnpm:lint:css -- --fix\"",
|
"lint:css:fix": "stylelint \"**/*.css\" --fix",
|
||||||
"lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\" --prefixColors auto && pnpm format",
|
"lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\" --prefixColors auto && pnpm format",
|
||||||
"lint:format": "prettier . --cache --check",
|
"lint:format": "prettier . --cache --check",
|
||||||
|
"lint:format:fix": "prettier . --cache --write",
|
||||||
"lint:hbs": "ember-template-lint .",
|
"lint:hbs": "ember-template-lint .",
|
||||||
"lint:hbs:fix": "ember-template-lint . --fix",
|
"lint:hbs:fix": "ember-template-lint . --fix",
|
||||||
"lint:js": "eslint . --cache",
|
"lint:js": "eslint . --cache",
|
||||||
|
|||||||
1
release/assets/main-DAo4Q0R2.css
Normal file
1
release/assets/main-DAo4Q0R2.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
release/assets/main-gjk9d6Ld.js
Normal file
2
release/assets/main-gjk9d6Ld.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
File diff suppressed because one or more lines are too long
@@ -3,9 +3,22 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Marco</title>
|
<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">
|
<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 -->
|
<!-- App identity -->
|
||||||
<meta name="application-name" content="Marco">
|
<meta name="application-name" content="Marco">
|
||||||
<meta name="apple-mobile-web-app-title" content="Marco">
|
<meta name="apple-mobile-web-app-title" content="Marco">
|
||||||
@@ -26,8 +39,8 @@
|
|||||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-ji2SNMnp.js"></script>
|
<script type="module" crossorigin src="/assets/main-gjk9d6Ld.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-G8wPYi_P.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-DAo4Q0R2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { visit, currentURL, click, settled } from '@ember/test-helpers';
|
import { visit, currentURL, click } from '@ember/test-helpers';
|
||||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||||
import Service from '@ember/service';
|
import Service from '@ember/service';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ module('Acceptance | search', function (hooks) {
|
|||||||
await visit('/search?q=Berlin');
|
await visit('/search?q=Berlin');
|
||||||
|
|
||||||
assert.strictEqual(currentURL(), '/search?q=Berlin');
|
assert.strictEqual(currentURL(), '/search?q=Berlin');
|
||||||
|
assert.dom('.sidebar-header h2').includesText('Results');
|
||||||
assert.dom('.places-list li').exists({ count: 2 });
|
assert.dom('.places-list li').exists({ count: 2 });
|
||||||
assert.dom('.places-list li:first-child .place-name').hasText('Berlin');
|
assert.dom('.places-list li:first-child .place-name').hasText('Berlin');
|
||||||
});
|
});
|
||||||
@@ -99,6 +100,7 @@ module('Acceptance | search', function (hooks) {
|
|||||||
await visit('/search?lat=52.52&lon=13.405');
|
await visit('/search?lat=52.52&lon=13.405');
|
||||||
|
|
||||||
assert.strictEqual(currentURL(), '/search?lat=52.52&lon=13.405');
|
assert.strictEqual(currentURL(), '/search?lat=52.52&lon=13.405');
|
||||||
|
assert.dom('.sidebar-header h2').includesText('Nearby');
|
||||||
assert.dom('.places-list li').exists({ count: 1 });
|
assert.dom('.places-list li').exists({ count: 1 });
|
||||||
assert.dom('.places-list li .place-name').hasText('Nearby Cafe');
|
assert.dom('.places-list li .place-name').hasText('Nearby Cafe');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ module('Integration | Component | search-box', function (hooks) {
|
|||||||
this.owner.register('service:router', MockRouterService);
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
this.noop = () => {};
|
this.noop = () => {};
|
||||||
await render(<template><SearchBox @onToggleMenu={{this.noop}} /></template>);
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
assert.dom('.search-input').exists();
|
assert.dom('.search-input').exists();
|
||||||
assert.dom('.search-results-popover').doesNotExist();
|
assert.dom('.search-results-popover').doesNotExist();
|
||||||
@@ -86,7 +88,9 @@ module('Integration | Component | search-box', function (hooks) {
|
|||||||
this.owner.register('service:router', MockRouterService);
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
this.noop = () => {};
|
this.noop = () => {};
|
||||||
await render(<template><SearchBox @onToggleMenu={{this.noop}} /></template>);
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
await fillIn('.search-input', 'berlin');
|
await fillIn('.search-input', 'berlin');
|
||||||
await click('.search-input'); // Focus
|
await click('.search-input'); // Focus
|
||||||
@@ -118,7 +122,9 @@ module('Integration | Component | search-box', function (hooks) {
|
|||||||
this.owner.register('service:photon', MockPhotonService);
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
this.noop = () => {};
|
this.noop = () => {};
|
||||||
await render(<template><SearchBox @onToggleMenu={{this.noop}} /></template>);
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
await fillIn('.search-input', 'cafe');
|
await fillIn('.search-input', 'cafe');
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ module('Unit | Route | place', function (hooks) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MapUiStub extends Service {
|
class MapUiStub extends Service {
|
||||||
selectPlace(place) {
|
selectPlace() {
|
||||||
selectPlaceCalled = true;
|
selectPlaceCalled = true;
|
||||||
}
|
}
|
||||||
stopSearch() {}
|
stopSearch() {}
|
||||||
|
|||||||
104
tests/unit/utils/osm-test.js
Normal file
104
tests/unit/utils/osm-test.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupTest } from 'marco/tests/helpers';
|
||||||
|
import { getLocalizedName, getPlaceType } from 'marco/utils/osm';
|
||||||
|
|
||||||
|
module('Unit | Utility | osm', function (hooks) {
|
||||||
|
setupTest(hooks);
|
||||||
|
|
||||||
|
test('getLocalizedName returns default name if tags are missing', function (assert) {
|
||||||
|
const result = getLocalizedName(null);
|
||||||
|
assert.strictEqual(result, 'Untitled Place');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLocalizedName returns name tag', function (assert) {
|
||||||
|
const tags = { name: 'Foo' };
|
||||||
|
const result = getLocalizedName(tags);
|
||||||
|
assert.strictEqual(result, 'Foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLocalizedName falls back to name:en if name is missing', function (assert) {
|
||||||
|
const tags = { 'name:en': 'English Name' };
|
||||||
|
const result = getLocalizedName(tags);
|
||||||
|
assert.strictEqual(result, 'English Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLocalizedName returns local name (name tag) if no preferred language match found', function (assert) {
|
||||||
|
// Assuming the test environment doesn't have 'fr' as a preferred language
|
||||||
|
const tags = { name: 'Local Name', 'name:fr': 'French Name' };
|
||||||
|
// Temporarily mock navigator to ensure no match
|
||||||
|
const originalLanguages = navigator.languages;
|
||||||
|
const originalLanguage = navigator.language;
|
||||||
|
Object.defineProperty(navigator, 'languages', {
|
||||||
|
value: ['es'],
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(navigator, 'language', {
|
||||||
|
value: 'es',
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = getLocalizedName(tags);
|
||||||
|
assert.strictEqual(result, 'Local Name');
|
||||||
|
} finally {
|
||||||
|
// Restore
|
||||||
|
Object.defineProperty(navigator, 'languages', {
|
||||||
|
value: originalLanguages,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(navigator, 'language', {
|
||||||
|
value: originalLanguage,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLocalizedName matches user preferred language', function (assert) {
|
||||||
|
const tags = {
|
||||||
|
name: 'Standard Name',
|
||||||
|
'name:de': 'Deutscher Name',
|
||||||
|
'name:fr': 'Nom Français',
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalLanguages = navigator.languages;
|
||||||
|
Object.defineProperty(navigator, 'languages', {
|
||||||
|
value: ['de', 'en'],
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = getLocalizedName(tags);
|
||||||
|
assert.strictEqual(result, 'Deutscher Name');
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(navigator, 'languages', {
|
||||||
|
value: originalLanguages,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPlaceType returns value for normal tags', function (assert) {
|
||||||
|
const tags = { amenity: 'restaurant' };
|
||||||
|
const result = getPlaceType(tags);
|
||||||
|
assert.strictEqual(result, 'Restaurant');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPlaceType returns key name if value is "yes"', function (assert) {
|
||||||
|
const tags = { building: 'yes' };
|
||||||
|
const result = getPlaceType(tags);
|
||||||
|
assert.strictEqual(result, 'Building');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPlaceType prioritizes order (amenity > shop > building)', function (assert) {
|
||||||
|
// If something is both a shop and a building, it should be a shop
|
||||||
|
const tags = { building: 'yes', shop: 'supermarket' };
|
||||||
|
const result = getPlaceType(tags);
|
||||||
|
assert.strictEqual(result, 'Supermarket');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPlaceType returns null if no known type found', function (assert) {
|
||||||
|
const tags = { foo: 'bar' };
|
||||||
|
const result = getPlaceType(tags);
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,9 +3,9 @@ import { extensions, ember } from '@embroider/vite';
|
|||||||
import { babel } from '@rollup/plugin-babel';
|
import { babel } from '@rollup/plugin-babel';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
// server: {
|
server: {
|
||||||
// host: '0.0.0.0'
|
host: '0.0.0.0'
|
||||||
// },
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
ember(),
|
ember(),
|
||||||
// extra plugins here
|
// extra plugins here
|
||||||
|
|||||||
Reference in New Issue
Block a user