Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9200e6f5c8
|
||
|
|
bf09101a24
|
||
|
|
df720d1b21
|
||
|
|
e74c01b1d1
|
||
|
|
0f6272006c
|
||
|
|
85de25032e
|
||
|
|
b9103b28a9
|
||
|
|
b8a9c71657
|
@@ -8,8 +8,11 @@
|
||||
<br>
|
||||
|
||||
Marco (as in [Marco Polo][1]) is an unhosted maps application that respects
|
||||
your privacy and choices. It allows users to connect their own remote storage
|
||||
to sync place bookmarks across apps and devices.
|
||||
your privacy and choices.
|
||||
|
||||
It allows users to connect their own remote storage to sync place bookmarks
|
||||
across apps and devices. It also supports the decentralized sharing of place
|
||||
photos (and soon reviews) via [Nostr][2].
|
||||
|
||||
This README outlines the details of collaborating on this Ember application, or
|
||||
just building and deploying it for yourself.
|
||||
@@ -79,3 +82,4 @@ To run the script, you need `imagemagick` and `librsvg` installed:
|
||||
- [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/)
|
||||
|
||||
[1]: https://en.wikipedia.org/wiki/Marco_Polo
|
||||
[2]: https://nostr.org
|
||||
|
||||
@@ -316,6 +316,70 @@ export default class PlaceDetails extends Component {
|
||||
return this.formatMultiLine(val);
|
||||
}
|
||||
|
||||
get operatorNames() {
|
||||
const raw = this.tags.operator;
|
||||
if (!raw) return null;
|
||||
const values = raw
|
||||
.split(';')
|
||||
.map((value) => value.trim())
|
||||
.filter(
|
||||
(value) =>
|
||||
value && value.toLowerCase() !== 'yes' && value.toLowerCase() !== 'no'
|
||||
);
|
||||
if (values.length === 0) return null;
|
||||
return this.formatMultiLine(values.join(';'));
|
||||
}
|
||||
|
||||
get operatorWebsite() {
|
||||
const val = this.tags['operator:website'];
|
||||
return val ? val.trim() : null;
|
||||
}
|
||||
|
||||
get operatorWebsiteLabel() {
|
||||
return this.operatorWebsite ? this.getDomain(this.operatorWebsite) : null;
|
||||
}
|
||||
|
||||
get operatorWikidata() {
|
||||
const val = this.tags['operator:wikidata'];
|
||||
if (!val) return null;
|
||||
return (
|
||||
val
|
||||
.split(';')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)[0] || null
|
||||
);
|
||||
}
|
||||
|
||||
get operatorWikipedia() {
|
||||
const val = this.tags['operator:wikipedia'];
|
||||
if (!val) return null;
|
||||
return (
|
||||
val
|
||||
.split(';')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)[0] || null
|
||||
);
|
||||
}
|
||||
|
||||
get operatorWikipediaUrl() {
|
||||
const val = this.operatorWikipedia;
|
||||
if (!val) return null;
|
||||
const match = val.match(/^([a-z-]+):(.+)$/i);
|
||||
if (match) {
|
||||
return `https://${match[1]}.wikipedia.org/wiki/${match[2]}`;
|
||||
}
|
||||
return `https://wikipedia.org/wiki/${val}`;
|
||||
}
|
||||
|
||||
get showOperator() {
|
||||
return !!(
|
||||
this.operatorNames ||
|
||||
this.operatorWebsite ||
|
||||
this.operatorWikidata ||
|
||||
this.operatorWikipedia
|
||||
);
|
||||
}
|
||||
|
||||
get cuisine() {
|
||||
if (!this.tags.cuisine) return null;
|
||||
return this.tags.cuisine
|
||||
@@ -571,6 +635,45 @@ export default class PlaceDetails extends Component {
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.showOperator}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="briefcase" @title="Operator" />
|
||||
<span>
|
||||
{{this.operatorNames}}
|
||||
{{#if this.operatorWebsite}}
|
||||
<br />
|
||||
<a
|
||||
href={{this.operatorWebsite}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{this.operatorWebsiteLabel}}
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if this.operatorWikidata}}
|
||||
<br />
|
||||
<a
|
||||
href="https://www.wikidata.org/wiki/{{this.operatorWikidata}}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Wikidata
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if this.operatorWikipedia}}
|
||||
<br />
|
||||
<a
|
||||
href={{this.operatorWikipediaUrl}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Wikipedia
|
||||
</a>
|
||||
{{/if}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
<div class="meta-info">
|
||||
|
||||
|
||||
@@ -23,18 +23,34 @@ export default class ListsListController extends Controller {
|
||||
|
||||
@tracked model;
|
||||
@tracked loadedPlaces = [];
|
||||
@tracked loadedListId = null;
|
||||
|
||||
get listId() {
|
||||
return this.model?.list_id;
|
||||
}
|
||||
|
||||
get isLoading() {
|
||||
// Only show the spinner when there is nothing to display yet. On re-entry
|
||||
// the previously loaded places (plus live storage state) render instantly,
|
||||
// so we avoid a loading flash and preserve restored scroll position.
|
||||
return this.loadPlacesTask.isRunning && this.places.length === 0;
|
||||
}
|
||||
|
||||
loadPlacesTask = task({ restartable: true }, async (listId) => {
|
||||
// Already loaded this list: keep the existing places so returning from
|
||||
// place details is instant (mirrors the activity service's load guard).
|
||||
if (this.loadedListId === listId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadedListId = listId;
|
||||
this.loadedPlaces = []; // Clear previous elements immediately to show fresh loader
|
||||
try {
|
||||
this.loadedPlaces = await this.storage.getPlacesInList(listId);
|
||||
} catch (e) {
|
||||
console.error('Failed to load places in list', listId, e);
|
||||
this.loadedPlaces = [];
|
||||
this.loadedListId = null; // Allow a retry on next entry
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fn } from '@ember/helper';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from '#components/icon';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
import restoreScroll from '../../modifiers/restore-scroll';
|
||||
|
||||
export default class ListsIndexTemplate extends Component {
|
||||
@service storage;
|
||||
@@ -26,8 +27,16 @@ export default class ListsIndexTemplate extends Component {
|
||||
return htmlSafe(`background-color: ${finalColor}`);
|
||||
}
|
||||
|
||||
get scrollTop() {
|
||||
return this.mapUi.getScrollPosition('lists-index');
|
||||
}
|
||||
|
||||
@action
|
||||
selectList(listId) {
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
if (sidebarContent) {
|
||||
this.mapUi.saveScrollPosition('lists-index', sidebarContent.scrollTop);
|
||||
}
|
||||
this.router.transitionTo('lists.list', listId);
|
||||
}
|
||||
|
||||
@@ -59,7 +68,7 @@ export default class ListsIndexTemplate extends Component {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-content" {{restoreScroll this.scrollTop}}>
|
||||
<ul class="places-list">
|
||||
<li>
|
||||
<button
|
||||
|
||||
@@ -7,7 +7,7 @@ import PlacesSidebar from '#components/places-sidebar';
|
||||
@title={{@controller.listTitle}}
|
||||
@color={{@controller.listColor}}
|
||||
@scrollTop={{@controller.scrollTop}}
|
||||
@isLoading={{@controller.loadPlacesTask.isRunning}}
|
||||
@isLoading={{@controller.isLoading}}
|
||||
@onSelect={{@controller.selectPlace}}
|
||||
@onClose={{@controller.close}}
|
||||
@onBack={{@controller.backToLists}}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||
import briefcase from 'feather-icons/dist/icons/briefcase.svg?raw';
|
||||
import featherCamera from 'feather-icons/dist/icons/camera.svg?raw';
|
||||
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
||||
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
|
||||
@@ -182,6 +183,7 @@ const ICONS = {
|
||||
'boxing-glove-up': boxingGloveUp,
|
||||
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
|
||||
bridge,
|
||||
briefcase,
|
||||
bus,
|
||||
camera,
|
||||
'feather-camera': featherCamera,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.34.3",
|
||||
"version": "1.34.5",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
|
||||
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
+1
-1
@@ -42,7 +42,7 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-CZdZiL1p.js"></script>
|
||||
<script type="module" crossorigin src="/assets/main-BlRXtNAu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Bv3zmRKA.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -271,4 +271,85 @@ module('Acceptance | collections navigation', function (hooks) {
|
||||
'Saved Places are ordered by createdAt in descending order'
|
||||
);
|
||||
});
|
||||
|
||||
test('returning from place details preserves scroll position without a loading flash', async function (assert) {
|
||||
const places = Array.from({ length: 40 }, (_, i) => ({
|
||||
id: `place-${i}`,
|
||||
title: `Place ${i}`,
|
||||
geohash: 'u33dc0',
|
||||
lat: 48.1,
|
||||
lon: 11.5,
|
||||
createdAt: new Date(2023, 0, 1, 12, 0, i).toISOString(),
|
||||
osmTags: { name: `Place ${i}` },
|
||||
}));
|
||||
|
||||
class ManyPlacesStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = places;
|
||||
lists = [
|
||||
{
|
||||
id: 'to-go',
|
||||
title: 'Want to go',
|
||||
color: '#2e9e4f',
|
||||
placeRefs: places.map((p) => ({ id: p.id, geohash: p.geohash })),
|
||||
},
|
||||
];
|
||||
|
||||
findPlaceById(id) {
|
||||
return this.savedPlaces.find((p) => p.id === id) || null;
|
||||
}
|
||||
|
||||
isPlaceSaved() {
|
||||
return true;
|
||||
}
|
||||
|
||||
loadPlacesInBounds() {
|
||||
return [];
|
||||
}
|
||||
|
||||
getPlacesInList(listId) {
|
||||
if (listId === 'to-go') {
|
||||
return Promise.resolve(this.savedPlaces);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
this.owner.unregister('service:storage');
|
||||
this.owner.register('service:storage', ManyPlacesStorageService);
|
||||
|
||||
await visit('/lists/to-go');
|
||||
await waitFor('.places-list');
|
||||
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
sidebarContent.scrollTop = 300;
|
||||
assert.strictEqual(sidebarContent.scrollTop, 300, 'List is scrolled');
|
||||
|
||||
// Open place details from an item that is currently visible so the browser
|
||||
// does not scroll it back into view (which would reset our saved position),
|
||||
// then return via the back button.
|
||||
const visibleItem = document.querySelectorAll('.place-item')[5];
|
||||
await click(visibleItem);
|
||||
await waitFor('.sidebar-content');
|
||||
await click('.back-btn');
|
||||
await waitFor('.places-list');
|
||||
|
||||
// Allow the restore-scroll requestAnimationFrame to run
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
|
||||
const restored = document.querySelector('.sidebar-content');
|
||||
assert.strictEqual(
|
||||
restored.scrollTop,
|
||||
300,
|
||||
'Scroll position is restored on return'
|
||||
);
|
||||
assert
|
||||
.dom('.sidebar-loading')
|
||||
.doesNotExist('No loading spinner flashes when returning');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,4 +457,112 @@ module('Integration | Component | place-details', function (hooks) {
|
||||
|
||||
assert.dom('.place-payment-methods').doesNotExist();
|
||||
});
|
||||
|
||||
test('it renders the operator name with a briefcase icon', async function (assert) {
|
||||
const place = {
|
||||
title: 'Operated Cafe',
|
||||
osmTags: {
|
||||
operator: 'Le Méridien',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const operatorBlock = this.element.querySelector(
|
||||
'.meta-info .content-with-icon span.icon[title="Operator"]'
|
||||
);
|
||||
assert.ok(operatorBlock, 'Operator block is rendered');
|
||||
|
||||
const row = operatorBlock.closest('.content-with-icon');
|
||||
assert.dom(row).includesText('Le Méridien');
|
||||
});
|
||||
|
||||
test('it splits semicolon-separated operators onto separate lines', async function (assert) {
|
||||
const place = {
|
||||
title: 'Multi Operator Stop',
|
||||
osmTags: {
|
||||
operator: 'De Lijn;TEC',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const row = this.element
|
||||
.querySelector('span.icon[title="Operator"]')
|
||||
.closest('.content-with-icon');
|
||||
|
||||
assert.dom(row).includesText('De Lijn');
|
||||
assert.dom(row).includesText('TEC');
|
||||
assert.true(
|
||||
row.querySelector('span:not(.icon)').innerHTML.includes('<br>'),
|
||||
'Operators are separated by a line break'
|
||||
);
|
||||
});
|
||||
|
||||
test('it suppresses invalid operator values of yes and no', async function (assert) {
|
||||
const place = {
|
||||
title: 'Invalid Operator',
|
||||
osmTags: {
|
||||
operator: 'yes',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
assert
|
||||
.dom('span.icon[title="Operator"]')
|
||||
.doesNotExist('Operator block is hidden for operator=yes');
|
||||
});
|
||||
|
||||
test('it renders operator wikidata and website links', async function (assert) {
|
||||
const place = {
|
||||
title: 'Linked Operator',
|
||||
osmTags: {
|
||||
operator: 'Flixbus',
|
||||
'operator:wikidata': 'Q15712278',
|
||||
'operator:website': 'https://www.flixbus.com/',
|
||||
'operator:wikipedia': 'en:FlixBus',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const row = this.element
|
||||
.querySelector('span.icon[title="Operator"]')
|
||||
.closest('.content-with-icon');
|
||||
|
||||
const wikidataLink = row.querySelector(
|
||||
'a[href="https://www.wikidata.org/wiki/Q15712278"]'
|
||||
);
|
||||
assert.ok(wikidataLink, 'Wikidata link is rendered');
|
||||
assert.dom(wikidataLink).hasText('Wikidata');
|
||||
|
||||
const websiteLink = row.querySelector('a[href="https://www.flixbus.com/"]');
|
||||
assert.ok(websiteLink, 'Operator website link is rendered');
|
||||
assert.dom(websiteLink).hasText('www.flixbus.com');
|
||||
|
||||
const wikipediaLink = row.querySelector(
|
||||
'a[href="https://en.wikipedia.org/wiki/FlixBus"]'
|
||||
);
|
||||
assert.ok(wikipediaLink, 'Operator Wikipedia link is rendered');
|
||||
assert.dom(wikipediaLink).hasText('Wikipedia');
|
||||
});
|
||||
|
||||
test('it renders the operator block when only operator subkeys are present', async function (assert) {
|
||||
const place = {
|
||||
title: 'Subkey Only',
|
||||
osmTags: {
|
||||
'operator:wikidata': 'Q42',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
assert
|
||||
.dom('span.icon[title="Operator"]')
|
||||
.exists('Operator block is rendered from subkeys alone');
|
||||
assert
|
||||
.dom('a[href="https://www.wikidata.org/wiki/Q42"]')
|
||||
.exists('Wikidata link is rendered');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user