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