Compare commits

..
8 Commits
Author SHA1 Message Date
raucao 9200e6f5c8 1.34.5
CI / Lint (push) Successful in 1m26s
CI / Test (push) Successful in 1m48s
2026-09-25 16:45:46 +02:00
raucao bf09101a24 Move operator info to the bottom of its section
CI / Lint (push) Successful in 1m6s
CI / Test (push) Successful in 1m26s
2026-09-25 16:22:54 +02:00
raucao df720d1b21 Merge pull request 'Save/restore scroll position for bookmark lists' (#112) from bugfix/saved-places_scroll into master
CI / Lint (push) Successful in 1m9s
CI / Test (push) Successful in 1m29s
Reviewed-on: #112
2026-09-25 14:02:30 +00:00
raucao e74c01b1d1 Save/restore scroll position for bookmark lists
CI / Lint (pull_request) Successful in 1m6s
CI / Test (pull_request) Successful in 1m25s
Release Drafter / Update release notes draft (pull_request) Successful in 6s
Keep items loaded, same as for Activity and My Contributions lists
2026-09-25 15:55:47 +02:00
raucao 0f6272006c 1.34.4
CI / Lint (push) Successful in 1m8s
CI / Test (push) Successful in 1m27s
2026-09-24 19:08:53 +02:00
raucao 85de25032e Merge pull request 'Add operator info to place details' (#111) from feature/operator into master
CI / Lint (push) Successful in 1m3s
CI / Test (push) Successful in 1m22s
Reviewed-on: #111
2026-09-24 17:07:24 +00:00
raucao b9103b28a9 Add operator info to place details
CI / Lint (pull_request) Successful in 1m5s
CI / Test (pull_request) Successful in 1m23s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-09-24 19:02:27 +02:00
raucao b8a9c71657 Update README
CI / Lint (push) Successful in 1m3s
CI / Test (push) Successful in 1m21s
2026-09-21 12:09:38 +02:00
13 changed files with 332 additions and 9 deletions
+6 -2
View File
@@ -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
+103
View File
@@ -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">
+16
View File
@@ -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
}
});
+10 -1
View File
@@ -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
+1 -1
View File
@@ -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}}
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+81
View File
@@ -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');
});
});