Compare commits

..

7 Commits

Author SHA1 Message Date
3b71531de2 1.14.0
All checks were successful
CI / Lint (push) Successful in 22s
CI / Test (push) Successful in 37s
2026-03-14 13:06:07 +04:00
6ef7549ea9 Merge pull request 'Add places to default lists' (#27) from feature/1-lists into master
All checks were successful
CI / Lint (push) Successful in 25s
CI / Test (push) Successful in 38s
Reviewed-on: #27
2026-03-14 09:04:21 +00:00
9097c63a55 Upgrade places module to latest release
All checks were successful
CI / Lint (pull_request) Successful in 25s
CI / Test (pull_request) Successful in 42s
... with lists support
2026-03-14 12:36:49 +04:00
ec0d5a30f9 Extract icon imports to separate util
All checks were successful
CI / Lint (pull_request) Successful in 28s
CI / Test (pull_request) Successful in 43s
So icons can be used from anywhere, e.g. map component JS
2026-03-14 12:28:17 +04:00
f1779131e8 Also load/init lists in anonymous mode
Some checks failed
CI / Lint (pull_request) Failing after 22s
CI / Test (pull_request) Successful in 34s
2026-03-13 17:04:29 +04:00
37cf47b3dd Properly handle place removals
Some checks failed
CI / Lint (pull_request) Failing after 23s
CI / Test (pull_request) Successful in 36s
* Transition to OSM route or index instead of staying on ghost route/ID
  (closes sidebar if it was a custom place)
* Ensure save button and lists are in the correct state
2026-03-13 15:33:29 +04:00
ff68b5addc Move default yellow to var, add in list UI 2026-03-13 14:56:12 +04:00
18 changed files with 227 additions and 87 deletions

View File

@@ -1,65 +1,10 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { htmlSafe } from '@ember/template'; import { htmlSafe } from '@ember/template';
import { getIcon } from '../utils/icons';
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import activity from 'feather-icons/dist/icons/activity.svg?raw';
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
import clock from 'feather-icons/dist/icons/clock.svg?raw';
import edit from 'feather-icons/dist/icons/edit.svg?raw';
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
import globe from 'feather-icons/dist/icons/globe.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 logOut from 'feather-icons/dist/icons/log-out.svg?raw';
import mail from 'feather-icons/dist/icons/mail.svg?raw';
import map from 'feather-icons/dist/icons/map.svg?raw';
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
import menu from 'feather-icons/dist/icons/menu.svg?raw';
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
import phone from 'feather-icons/dist/icons/phone.svg?raw';
import plus from 'feather-icons/dist/icons/plus.svg?raw';
import server from 'feather-icons/dist/icons/server.svg?raw';
import search from 'feather-icons/dist/icons/search.svg?raw';
import settings from 'feather-icons/dist/icons/settings.svg?raw';
import target from 'feather-icons/dist/icons/target.svg?raw';
import user from 'feather-icons/dist/icons/user.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw';
import zap from 'feather-icons/dist/icons/zap.svg?raw';
import wikipedia from '../icons/wikipedia.svg?raw';
const ICONS = {
'arrow-left': arrowLeft,
activity,
bookmark,
clock,
edit,
facebook,
globe,
home,
instagram,
'log-in': logIn,
'log-out': logOut,
mail,
map,
'map-pin': mapPin,
menu,
navigation,
phone,
plus,
server,
search,
settings,
target,
user,
wikipedia,
x,
zap,
};
export default class IconComponent extends Component { export default class IconComponent extends Component {
get svg() { get svg() {
return ICONS[this.args.name]; return getIcon(this.args.name);
} }
get size() { get size() {

View File

@@ -63,7 +63,10 @@ export default class MapComponent extends Component {
const bookmarkStyleFunction = (feature) => { const bookmarkStyleFunction = (feature) => {
const originalPlace = feature.get('originalPlace'); const originalPlace = feature.get('originalPlace');
let color = '#ffcc33'; // Default Yellow let color =
getComputedStyle(document.documentElement)
.getPropertyValue('--default-list-color')
.trim() || '#000000'; // Fallback to black if variable is missing to make error obvious
if ( if (
originalPlace && originalPlace &&

View File

@@ -1,4 +1,5 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
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';
@@ -13,9 +14,14 @@ 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; @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 || {};
} }
@@ -38,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;
} }
@@ -275,29 +281,26 @@ export default class PlaceDetails extends Component {
<div class="save-button-wrapper"> <div class="save-button-wrapper">
<button <button
type="button" type="button"
class={{if class={{if this.isSaved "btn btn-secondary" "btn btn-outline"}}
this.place.createdAt
"btn btn-secondary"
"btn btn-outline"
}}
{{on "click" this.toggleLists}} {{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.showLists}} {{#if this.showLists}}
<PlaceListsManager <PlaceListsManager
@place={{this.saveablePlace}} @place={{this.saveablePlace}}
@onClose={{this.closeLists}} @onClose={{this.closeLists}}
@isSaved={{this.isSaved}}
/> />
{{/if}} {{/if}}
</div> </div>
{{#if this.place.createdAt}} {{#if this.isSaved}}
<button <button
type="button" type="button"
class="btn btn-outline" class="btn btn-outline"

View File

@@ -1,6 +1,7 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
import { fn } from '@ember/helper'; import { fn } from '@ember/helper';
import { htmlSafe } from '@ember/template'; import { htmlSafe } from '@ember/template';
@@ -8,12 +9,15 @@ import onClickOutside from '../modifiers/on-click-outside';
export default class PlaceListsManager extends Component { export default class PlaceListsManager extends Component {
@service storage; @service storage;
@service router;
@tracked _forceClear = false;
get isSaved() { get isSaved() {
return !!this.args.place.createdAt; return this.args.isSaved;
} }
get placeListIds() { get placeListIds() {
if (this._forceClear) return [];
return this.args.place._listIds || []; return this.args.place._listIds || [];
} }
@@ -30,7 +34,35 @@ export default class PlaceListsManager extends Component {
@action @action
async toggleSaved() { async toggleSaved() {
if (this.isSaved) { if (this.isSaved) {
const { osmId, osmType } = this.args.place;
await this.storage.removePlace(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(); if (this.args.onClose) this.args.onClose();
} else { } else {
await this.storage.storePlace(this.args.place); await this.storage.storePlace(this.args.place);
@@ -71,7 +103,8 @@ export default class PlaceListsManager extends Component {
checked={{this.isSaved}} checked={{this.isSaved}}
{{on "change" this.toggleSaved}} {{on "change" this.toggleSaved}}
/> />
<span class="list-name">Saved</span> <span class="list-color"></span>
<span class="list-name">Saved places</span>
</label> </label>
</div> </div>

View File

@@ -50,6 +50,10 @@ export default class StorageService extends Service {
this.loadLists(); this.loadLists();
}); });
this.rs.on('not-connected', () => {
this.loadLists();
});
this.rs.on('disconnected', () => { this.rs.on('disconnected', () => {
this.connected = false; this.connected = false;
this.userAddress = null; this.userAddress = null;
@@ -137,6 +141,16 @@ export default class StorageService extends Service {
const lists = await this.places.lists.getAll(); const lists = await this.places.lists.getAll();
this.lists = lists || []; this.lists = lists || [];
// Decorate with hardcoded icons for default lists (in-memory only)
this.lists.forEach((list) => {
if (list.id === 'to-go') {
list.icon = 'bookmark';
} else if (list.id === 'to-do') {
list.icon = 'check-square';
}
});
this.refreshPlaceListAssociations(); this.refreshPlaceListAssociations();
} catch (e) { } catch (e) {
console.error('Failed to load lists:', e); console.error('Failed to load lists:', e);
@@ -282,11 +296,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);

View File

@@ -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: #fc3;
}
html, html,
body { body {
height: 100%; height: 100%;
@@ -1031,6 +1035,7 @@ button.create-place {
.place-lists-manager .list-color { .place-lists-manager .list-color {
width: 12px; width: 12px;
height: 12px; height: 12px;
background-color: var(--default-list-color);
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
border: 1px solid rgb(0 0 0 / 10%); border: 1px solid rgb(0 0 0 / 10%);

61
app/utils/icons.js Normal file
View File

@@ -0,0 +1,61 @@
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import activity from 'feather-icons/dist/icons/activity.svg?raw';
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
import clock from 'feather-icons/dist/icons/clock.svg?raw';
import edit from 'feather-icons/dist/icons/edit.svg?raw';
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
import globe from 'feather-icons/dist/icons/globe.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 logOut from 'feather-icons/dist/icons/log-out.svg?raw';
import mail from 'feather-icons/dist/icons/mail.svg?raw';
import map from 'feather-icons/dist/icons/map.svg?raw';
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
import menu from 'feather-icons/dist/icons/menu.svg?raw';
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
import phone from 'feather-icons/dist/icons/phone.svg?raw';
import plus from 'feather-icons/dist/icons/plus.svg?raw';
import server from 'feather-icons/dist/icons/server.svg?raw';
import search from 'feather-icons/dist/icons/search.svg?raw';
import settings from 'feather-icons/dist/icons/settings.svg?raw';
import target from 'feather-icons/dist/icons/target.svg?raw';
import user from 'feather-icons/dist/icons/user.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw';
import zap from 'feather-icons/dist/icons/zap.svg?raw';
import wikipedia from '../icons/wikipedia.svg?raw';
const ICONS = {
'arrow-left': arrowLeft,
activity,
bookmark,
'check-square': checkSquare,
clock,
edit,
facebook,
globe,
home,
instagram,
'log-in': logIn,
'log-out': logOut,
mail,
map,
'map-pin': mapPin,
menu,
navigation,
phone,
plus,
server,
search,
settings,
target,
user,
wikipedia,
x,
zap,
};
export function getIcon(name) {
return ICONS[name];
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "marco", "name": "marco",
"version": "1.13.3", "version": "1.14.0",
"private": true, "private": true,
"description": "Unhosted maps app", "description": "Unhosted maps app",
"repository": { "repository": {
@@ -52,7 +52,7 @@
"@embroider/vite": "^1.5.0", "@embroider/vite": "^1.5.0",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@glimmer/component": "^2.0.0", "@glimmer/component": "^2.0.0",
"@remotestorage/module-places": "1.x", "@remotestorage/module-places": "~1.2.1",
"@rollup/plugin-babel": "^6.1.0", "@rollup/plugin-babel": "^6.1.0",
"@warp-drive/core": "~5.8.0", "@warp-drive/core": "~5.8.0",
"@warp-drive/ember": "~5.8.0", "@warp-drive/ember": "~5.8.0",

10
pnpm-lock.yaml generated
View File

@@ -55,8 +55,8 @@ importers:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
'@remotestorage/module-places': '@remotestorage/module-places':
specifier: 1.x specifier: ~1.2.1
version: 1.0.0 version: 1.2.1
'@rollup/plugin-babel': '@rollup/plugin-babel':
specifier: ^6.1.0 specifier: ^6.1.0
version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1) version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1)
@@ -1380,8 +1380,8 @@ packages:
resolution: {integrity: sha512-4rdu8GPY9TeQwsYp5D2My74dC3dSVS3tghAvisG80ybK4lqa0gvlrglaSTBxogJbxqHRw/NjI/liEtb3+SD+Bw==} resolution: {integrity: sha512-4rdu8GPY9TeQwsYp5D2My74dC3dSVS3tghAvisG80ybK4lqa0gvlrglaSTBxogJbxqHRw/NjI/liEtb3+SD+Bw==}
engines: {node: '>=18.12'} engines: {node: '>=18.12'}
'@remotestorage/module-places@1.0.0': '@remotestorage/module-places@1.2.1':
resolution: {integrity: sha512-vaqJeTw658gjPyLz70Mq2AbGfDZ66O2mpDFME+gtaGFYl2+UvrvRLCrXWHYuyTE21f3TJdegeXM6C5nZMxLv9A==} resolution: {integrity: sha512-hNRuhGoG8RS+cieVvDVzXWBEuNPfyeFirhgNH3z1WoKw9ngHdPY6V0sT0vKbsxB8xaODReZfo2ZKHLTmdFunlw==}
'@rollup/plugin-babel@6.1.0': '@rollup/plugin-babel@6.1.0':
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==} resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
@@ -7002,7 +7002,7 @@ snapshots:
'@pnpm/error': 1000.0.5 '@pnpm/error': 1000.0.5
find-up: 5.0.0 find-up: 5.0.0
'@remotestorage/module-places@1.0.0': '@remotestorage/module-places@1.2.1':
dependencies: dependencies:
latlon-geohash: 2.0.0 latlon-geohash: 2.0.0
ulid: 3.0.2 ulid: 3.0.2

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -39,8 +39,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6"> <meta name="msapplication-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-gjk9d6Ld.js"></script> <script type="module" crossorigin src="/assets/main-GynTgP18.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DAo4Q0R2.css"> <link rel="stylesheet" crossorigin href="/assets/main-BT0n1kYB.css">
</head> </head>
<body> <body>
</body> </body>

View File

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

View File

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

View File

@@ -9,10 +9,18 @@ module('Integration | Component | place-details', function (hooks) {
class StorageService extends Service { class StorageService extends Service {
lists = [ lists = [
{ id: 'to-go', title: 'Want to go', color: '#ff00ff' }, { id: 'to-go', title: 'Want to go', color: '#2e9e4f' },
{ id: 'to-do', title: 'To do', color: '#008000' }, { id: 'to-do', title: 'To do', color: '#2a7fff' },
]; ];
isPlaceSaved() {
return false;
}
findPlaceById() {
return null;
}
async storePlace(place) { async storePlace(place) {
return { ...place, id: '123', createdAt: new Date().toISOString() }; return { ...place, id: '123', createdAt: new Date().toISOString() };
} }
@@ -28,6 +36,12 @@ module('Integration | Component | place-details', function (hooks) {
hooks.beforeEach(function () { hooks.beforeEach(function () {
this.owner.register('service:storage', StorageService); 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) {
@@ -95,6 +109,12 @@ module('Integration | Component | place-details', function (hooks) {
storedPlace = place; storedPlace = place;
return { ...place, id: 'new-id', createdAt: new Date().toISOString() }; return { ...place, id: 'new-id', createdAt: new Date().toISOString() };
} }
isPlaceSaved() {
return false;
}
findPlaceById() {
return null;
}
} }
this.owner.register('service:storage', MockStorage); this.owner.register('service:storage', MockStorage);
@@ -126,12 +146,15 @@ module('Integration | Component | place-details', function (hooks) {
}); });
test('it handles removing a saved place via master toggle', async function (assert) { test('it handles removing a saved place via master toggle', async function (assert) {
let removedPlace = null; let removedPlaceId = null;
class MockStorage extends Service { class MockStorage extends Service {
lists = []; lists = [];
async removePlace(place) { async removePlace(place) {
removedPlace = place; removedPlaceId = place.id;
}
isPlaceSaved() {
return true;
} }
} }
this.owner.register('service:storage', MockStorage); this.owner.register('service:storage', MockStorage);
@@ -160,7 +183,9 @@ module('Integration | Component | place-details', function (hooks) {
// Click it to remove // Click it to remove
await click(masterToggle); await click(masterToggle);
assert.strictEqual(removedPlace.id, 'saved-id', 'removePlace was called'); 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) { test('it adds place to a list', async function (assert) {
@@ -175,6 +200,9 @@ module('Integration | Component | place-details', function (hooks) {
listId = id; listId = id;
shouldAdd = add; shouldAdd = add;
} }
isPlaceSaved() {
return true;
}
} }
this.owner.register('service:storage', MockStorage); this.owner.register('service:storage', MockStorage);
@@ -202,4 +230,29 @@ module('Integration | Component | place-details', function (hooks) {
assert.strictEqual(placeArg.id, 'p1'); assert.strictEqual(placeArg.id, 'p1');
assert.true(shouldAdd); 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');
});
}); });