Compare commits

...

8 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
990f3afa88 Fix lint errors
All checks were successful
CI / Lint (pull_request) Successful in 21s
CI / Test (pull_request) Successful in 34s
2026-03-13 13:51:49 +04:00
18 changed files with 242 additions and 98 deletions

View File

@@ -1,65 +1,10 @@
import Component from '@glimmer/component';
import { htmlSafe } from '@ember/template';
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,
};
import { getIcon } from '../utils/icons';
export default class IconComponent extends Component {
get svg() {
return ICONS[this.args.name];
return getIcon(this.args.name);
}
get size() {

View File

@@ -60,10 +60,13 @@ export default class MapComponent extends Component {
// Create a vector source and layer for bookmarks
this.bookmarkSource = new VectorSource();
const bookmarkStyleFunction = (feature) => {
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 (
originalPlace &&
@@ -71,7 +74,7 @@ export default class MapComponent extends Component {
originalPlace._listIds.length > 0
) {
// Find the first list color
// We need access to storage.lists.
// 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);

View File

@@ -1,5 +1,5 @@
import Component from '@glimmer/component';
import { fn } from '@ember/helper';
import { service } from '@ember/service';
import { on } from '@ember/modifier';
import { htmlSafe } from '@ember/template';
import { humanizeOsmTag } from '../utils/format-text';
@@ -14,9 +14,14 @@ import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class PlaceDetails extends Component {
@service storage;
@tracked isEditing = false;
@tracked showLists = false;
get isSaved() {
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
}
get place() {
return this.args.place || {};
}
@@ -39,7 +44,7 @@ export default class PlaceDetails extends Component {
@action
startEditing() {
if (!this.place.createdAt) return; // Only allow editing saved places
if (!this.isSaved) return; // Only allow editing saved places
this.isEditing = true;
}
@@ -276,29 +281,26 @@ export default class PlaceDetails extends Component {
<div class="save-button-wrapper">
<button
type="button"
class={{if
this.place.createdAt
"btn btn-secondary"
"btn btn-outline"
}}
class={{if this.isSaved "btn btn-secondary" "btn btn-outline"}}
{{on "click" this.toggleLists}}
>
<Icon
@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>
{{#if this.showLists}}
<PlaceListsManager
@place={{this.saveablePlace}}
@onClose={{this.closeLists}}
@isSaved={{this.isSaved}}
/>
{{/if}}
</div>
{{#if this.place.createdAt}}
{{#if this.isSaved}}
<button
type="button"
class="btn btn-outline"

View File

@@ -1,22 +1,30 @@
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 Icon from './icon';
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.place.createdAt;
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;
@@ -26,7 +34,35 @@ export default class PlaceListsManager extends Component {
@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);
@@ -59,10 +95,7 @@ export default class PlaceListsManager extends Component {
}
<template>
<div
class="place-lists-manager"
{{onClickOutside @onClose}}
>
<div class="place-lists-manager" {{onClickOutside @onClose}}>
<div class="list-item master-toggle">
<label>
<input
@@ -70,7 +103,8 @@ export default class PlaceListsManager extends Component {
checked={{this.isSaved}}
{{on "change" this.toggleSaved}}
/>
<span class="list-name">Saved</span>
<span class="list-color"></span>
<span class="list-name">Saved places</span>
</label>
</div>
@@ -86,7 +120,11 @@ export default class PlaceListsManager extends Component {
{{on "change" (fn this.toggleList list)}}
disabled={{unless this.isSaved true}}
/>
<span class="list-color" style="background-color: {{list.color}}"></span>
{{! 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>

View File

@@ -50,6 +50,10 @@ export default class StorageService extends Service {
this.loadLists();
});
this.rs.on('not-connected', () => {
this.loadLists();
});
this.rs.on('disconnected', () => {
this.connected = false;
this.userAddress = null;
@@ -137,6 +141,16 @@ export default class StorageService extends Service {
const lists = await this.places.lists.getAll();
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();
} catch (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);
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
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;
}
isPlaceSaved(id) {
return !!this.findPlaceById(id);
}
async storePlace(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/ */
:root {
--default-list-color: #fc3;
}
html,
body {
height: 100%;
@@ -1021,7 +1025,7 @@ button.create-place {
color: #333;
}
.place-lists-manager input[type="checkbox"] {
.place-lists-manager input[type='checkbox'] {
accent-color: #007bff;
width: 16px;
height: 16px;
@@ -1031,9 +1035,10 @@ button.create-place {
.place-lists-manager .list-color {
width: 12px;
height: 12px;
background-color: var(--default-list-color);
border-radius: 50%;
flex-shrink: 0;
border: 1px solid rgba(0,0,0,0.1);
border: 1px solid rgb(0 0 0 / 10%);
}
.place-lists-manager .divider {

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",
"version": "1.13.3",
"version": "1.14.0",
"private": true,
"description": "Unhosted maps app",
"repository": {
@@ -52,7 +52,7 @@
"@embroider/vite": "^1.5.0",
"@eslint/js": "^9.39.2",
"@glimmer/component": "^2.0.0",
"@remotestorage/module-places": "1.x",
"@remotestorage/module-places": "~1.2.1",
"@rollup/plugin-babel": "^6.1.0",
"@warp-drive/core": "~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
version: 2.0.0
'@remotestorage/module-places':
specifier: 1.x
version: 1.0.0
specifier: ~1.2.1
version: 1.2.1
'@rollup/plugin-babel':
specifier: ^6.1.0
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==}
engines: {node: '>=18.12'}
'@remotestorage/module-places@1.0.0':
resolution: {integrity: sha512-vaqJeTw658gjPyLz70Mq2AbGfDZ66O2mpDFME+gtaGFYl2+UvrvRLCrXWHYuyTE21f3TJdegeXM6C5nZMxLv9A==}
'@remotestorage/module-places@1.2.1':
resolution: {integrity: sha512-hNRuhGoG8RS+cieVvDVzXWBEuNPfyeFirhgNH3z1WoKw9ngHdPY6V0sT0vKbsxB8xaODReZfo2ZKHLTmdFunlw==}
'@rollup/plugin-babel@6.1.0':
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
@@ -7002,7 +7002,7 @@ snapshots:
'@pnpm/error': 1000.0.5
find-up: 5.0.0
'@remotestorage/module-places@1.0.0':
'@remotestorage/module-places@1.2.1':
dependencies:
latlon-geohash: 2.0.0
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-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-gjk9d6Ld.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DAo4Q0R2.css">
<script type="module" crossorigin src="/assets/main-GynTgP18.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BT0n1kYB.css">
</head>
<body>
</body>

View File

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

View File

@@ -41,6 +41,9 @@ module('Acceptance | search', function (hooks) {
findPlaceById() {
return null;
}
isPlaceSaved() {
return false;
}
rs = {
on: () => {},
};
@@ -85,6 +88,9 @@ module('Acceptance | search', function (hooks) {
findPlaceById() {
return null;
}
isPlaceSaved() {
return false;
}
rs = {
on: () => {},
};
@@ -130,6 +136,9 @@ module('Acceptance | search', function (hooks) {
if (id === '999') return this.savedPlaces[0];
return null;
}
isPlaceSaved(id) {
return !!this.findPlaceById(id);
}
rs = {
on: () => {},
};

View File

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