21 Commits

Author SHA1 Message Date
25d45a62c3 1.6.1 2026-01-24 18:00:47 +07:00
76dd8cdf24 Comment for app settings 2026-01-24 18:00:23 +07:00
269a6c9eef 1.6.0 2026-01-24 17:55:00 +07:00
1a2aae631d Fix JS linting errors 2026-01-24 17:54:34 +07:00
94b7959fd8 Fix CSS linting, organize properly 2026-01-24 17:47:37 +07:00
9082fb9762 Fix template linting errors 2026-01-24 16:42:53 +07:00
90730a935d Update status doc 2026-01-24 16:33:07 +07:00
0f44f42c23 Add settings/about pane 2026-01-24 16:18:39 +07:00
0d5a0325f4 Allow editing of bookmarks/places 2026-01-24 16:15:48 +07:00
e8f7e74e40 WIP Add settings/about pane 2026-01-24 14:33:00 +07:00
f60dacac80 1.5.0 2026-01-24 14:16:40 +07:00
9a02363515 Move all map controls to bottom right corner 2026-01-24 14:16:08 +07:00
f28be0c994 Add user/accounts menu, RS connect 2026-01-24 13:51:29 +07:00
721fe5f01d Fix linting/formatting 2026-01-24 12:52:19 +07:00
518685b7dc Improve secondary btn style 2026-01-24 11:17:07 +07:00
262e5b61a8 1.4.3 2026-01-23 16:52:21 +07:00
f87d8bdda9 Improve save button styles 2026-01-23 16:51:53 +07:00
f17f8ca17b Use feather icons in sidebar header 2026-01-23 15:49:59 +07:00
026d1c4712 1.4.2 2026-01-23 12:59:37 +07:00
6bd55843bb Switch to bkero's API (for now) 2026-01-23 12:59:11 +07:00
33a6469a19 Various layout and style improvements for place details 2026-01-23 12:41:27 +07:00
24 changed files with 978 additions and 176 deletions

View File

@@ -1,3 +1,6 @@
export default {
extends: 'recommended',
rules: {
'link-rel-noopener': 'off',
},
};

View File

@@ -1,6 +1,6 @@
# Project Status: Marco
**Last Updated:** Wed Jan 21 2026
**Last Updated:** Sat Jan 24 2026
## Project Context
@@ -57,6 +57,9 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- `place-details.gjs`: Dedicated component for displaying rich place information.
- **Features:** Icons (via `feather-icons`), Address, Phone, Website, Opening Hours, Cuisine, Wikipedia.
- **Layout:** Polished UI with distinct sections for Actions and Meta info.
- `app-header.gjs`: Transparent header with "Menu" button (Settings) and User Avatar (Login).
- `settings-pane.gjs`: Sidebar component for app info ("About" section) and settings.
- **Mobile:** Renders as a 2/3 height bottom sheet on mobile.
- **Geo Utils:**
- `app/utils/geo.js`: Haversine distance calculations.
- `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
@@ -78,21 +81,20 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
4. Sidebar displays details via `<PlaceDetails>` component (Bottom sheet on mobile).
5. User clicks "Save Bookmark" -> Stores JSON in RemoteStorage.
6. RemoteStorage change event -> Debounced reload updates the map reactive-ly.
7. **Editing:** User can edit the Title and Description of saved bookmarks via an "Edit" button in the details view.
## Files Currently in Focus
- `app/styles/app.css`: Responsive sidebar styles and mobile optimizations.
- `app/components/map.gjs`: Map rendering, interaction, and mobile auto-panning.
- `app/templates/application.gjs`: Root template handling place selection logic.
- `app/services/storage.js`: Data sync logic.
- `app/components/place-details.gjs`: Place display and editing logic.
- `app/services/storage.js`: Data sync and update logic.
## Next Steps & Pending Tasks
1. **App Header:** Implement a transparent header bar with the App Logo (left) and Login/User Info (right).
2. **Edit Bookmarks:** Allow users to edit the title and description of saved places.
3. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, and address unused variables/runloop usage.
4. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
5. **Testing:** Add automated tests for the geohash coverage and retry logic.
1. **Collections/Lists:** Implement ability to organize bookmarks into lists/collections.
2. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, and address unused variables/runloop usage.
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
4. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
## Technical Constraints

View File

@@ -0,0 +1,64 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import Icon from '#components/icon';
import UserMenu from '#components/user-menu';
export default class AppHeaderComponent extends Component {
@service storage;
@tracked isUserMenuOpen = false;
@action
toggleUserMenu() {
this.isUserMenuOpen = !this.isUserMenuOpen;
}
@action
closeUserMenu() {
this.isUserMenuOpen = false;
}
<template>
<header class="app-header">
<div class="header-left">
<button
class="icon-btn"
type="button"
aria-label="Menu"
{{on "click" @onToggleMenu}}
>
<Icon @name="menu" @size={{24}} @color="#333" />
</button>
</div>
<div class="header-right">
<div class="user-menu-container">
<button
class="user-btn"
type="button"
aria-label="User Menu"
{{on "click" this.toggleUserMenu}}
>
<div class="user-avatar-placeholder">
<Icon @name="user" @size={{20}} @color="white" />
</div>
</button>
{{#if this.isUserMenuOpen}}
<UserMenu
@storage={{this.storage}}
@onClose={{this.closeUserMenu}}
/>
<div
class="menu-backdrop"
{{on "click" this.closeUserMenu}}
role="button"
></div>
{{/if}}
</div>
</div>
</header>
</template>
}

View File

@@ -1,26 +1,46 @@
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 globe from 'feather-icons/dist/icons/globe.svg?raw';
import home from 'feather-icons/dist/icons/home.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 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 user from 'feather-icons/dist/icons/user.svg?raw';
import server from 'feather-icons/dist/icons/server.svg?raw';
import settings from 'feather-icons/dist/icons/settings.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';
const ICONS = {
'arrow-left': arrowLeft,
activity,
bookmark,
clock,
edit,
globe,
home,
'log-in': logIn,
'log-out': logOut,
map,
mapPin,
'map-pin': mapPin,
menu,
navigation,
phone,
server,
settings,
user,
settings
x,
zap,
};
export default class IconComponent extends Component {

View File

@@ -16,7 +16,6 @@ import Geolocation from 'ol/Geolocation.js';
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
import { apply } from 'ol-mapbox-style';
import { getDistance } from '../utils/geo';
import Icon from '../components/icon';
export default class MapComponent extends Component {
@service osm;
@@ -61,7 +60,6 @@ export default class MapComponent extends Component {
zIndex: 10, // Ensure it sits above the map tiles
});
// Default view settings
let center = [99.05738, 7.55087];
let zoom = 13.0;
@@ -95,7 +93,11 @@ export default class MapComponent extends Component {
target: element,
layers: [openfreemap, bookmarkLayer],
view: view,
controls: defaultControls({ zoom: false, rotate: true, attribution: true }),
controls: defaultControls({
zoom: false,
rotate: true,
attribution: true,
}),
});
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
@@ -210,7 +212,9 @@ export default class MapComponent extends Component {
geolocation.un('change:position', zoomToLocation);
locateListenerKey = null;
}
} catch (e) { /* ignore */ }
} catch {
/* ignore */
}
// Hide pulse
if (this.locationOverlayElement) {
@@ -261,7 +265,8 @@ export default class MapComponent extends Component {
else if (accuracy) {
const viewportWidthMeters = 6.325 * accuracy;
const minDimensionPixels = Math.min(size[0], size[1]);
const requiredResolutionMeters = viewportWidthMeters / minDimensionPixels;
const requiredResolutionMeters =
viewportWidthMeters / minDimensionPixels;
const metersPerMapUnit = getPointResolution(
view.getProjection(),
1,
@@ -443,23 +448,23 @@ export default class MapComponent extends Component {
// If the pin is in the bottom half (y > splitPoint), it is obscured
if (pixel[1] > splitPoint) {
// Target position: Center of top half = height * 0.25
const targetY = height * 0.25;
const deltaY = pixel[1] - targetY;
// Target position: Center of top half = height * 0.25
const targetY = height * 0.25;
const deltaY = pixel[1] - targetY;
const view = this.mapInstance.getView();
const center = view.getCenter();
const resolution = view.getResolution();
const view = this.mapInstance.getView();
const center = view.getCenter();
const resolution = view.getResolution();
// Move the map center SOUTH (decrease Y) to move the pin UP (decrease pixel Y)
const deltaMapUnits = deltaY * resolution;
const newCenter = [center[0], center[1] - deltaMapUnits];
// Move the map center SOUTH (decrease Y) to move the pin UP (decrease pixel Y)
const deltaMapUnits = deltaY * resolution;
const newCenter = [center[0], center[1] - deltaMapUnits];
view.animate({
center: newCenter,
duration: 500,
easing: (t) => t * (2 - t) // Ease-out
});
view.animate({
center: newCenter,
duration: 500,
easing: (t) => t * (2 - t), // Ease-out
});
}
}
@@ -525,7 +530,7 @@ export default class MapComponent extends Component {
const viewState = {
center: currentCenter,
zoom: currentZoom
zoom: currentZoom,
};
localStorage.setItem('marco:map-view', JSON.stringify(viewState));
@@ -545,7 +550,9 @@ export default class MapComponent extends Component {
if (features && features.length > 0) {
console.debug(`Found ${features.length} features in map layer:`);
for (const f of features) { console.debug(f) }
for (const f of features) {
console.debug(f);
}
const bookmarkFeature = features.find((f) => f.get('isBookmark'));
if (bookmarkFeature) {
clickedBookmark = bookmarkFeature.get('originalPlace');
@@ -686,7 +693,6 @@ export default class MapComponent extends Component {
{{this.setupMap}}
{{this.updateBookmarks}}
{{this.updateSelectedPin}}
style="position: absolute; inset: 0;"
></div>
</template>
}

View File

@@ -4,7 +4,19 @@ import { on } from '@ember/modifier';
import capitalize from '../helpers/capitalize';
import Icon from '../components/icon';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class PlaceDetails extends Component {
@tracked isEditing = false;
@tracked editTitle = '';
@tracked editDescription = '';
constructor() {
super(...arguments);
this.resetEditFields();
}
get place() {
return this.args.place || {};
}
@@ -22,6 +34,47 @@ export default class PlaceDetails extends Component {
);
}
@action
resetEditFields() {
this.editTitle = this.name;
this.editDescription = this.place.description || '';
}
@action
startEditing() {
if (!this.place.createdAt) return; // Only allow editing saved places
this.resetEditFields();
this.isEditing = true;
}
@action
cancelEditing() {
this.isEditing = false;
}
@action
async saveChanges(event) {
event.preventDefault();
if (this.args.onSave) {
await this.args.onSave({
...this.place,
title: this.editTitle,
description: this.editDescription,
});
}
this.isEditing = false;
}
@action
updateTitle(e) {
this.editTitle = e.target.value;
}
@action
updateDescription(e) {
this.editDescription = e.target.value;
}
get type() {
return (
this.tags.amenity ||
@@ -67,6 +120,11 @@ export default class PlaceDetails extends Component {
return this.place.url || this.tags.website || this.tags['contact:website'];
}
get websiteDomain() {
const url = new URL(this.website);
return url.hostname;
}
get openingHours() {
return this.tags.opening_hours;
}
@@ -74,10 +132,10 @@ export default class PlaceDetails extends Component {
get cuisine() {
if (!this.tags.cuisine) return null;
return this.tags.cuisine
.split(';')
.map(c => capitalize.compute([c]))
.map(c => c.replace('_', ' '))
.join(', ');
.split(';')
.map((c) => capitalize.compute([c]))
.map((c) => c.replace('_', ' '))
.join(', ');
}
get wikipedia() {
@@ -106,29 +164,83 @@ export default class PlaceDetails extends Component {
return `https://www.openstreetmap.org/${type}/${id}`;
}
get gmapsUrl() {
return `https://www.google.com/maps/search/?api=1&query=${this.name}&query=${this.place.lat},${this.place.lon}`;
}
<template>
<div class="place-details">
<h3>{{this.name}}</h3>
<p class="place-type">
{{this.type}}
</p>
{{#if this.place.description}}
<p class="place-description">
{{this.place.description}}
{{#if this.isEditing}}
<form class="edit-form" {{on "submit" this.saveChanges}}>
<div class="form-group">
<label for="edit-title">Title</label>
<input
id="edit-title"
type="text"
value={{this.editTitle}}
{{on "input" this.updateTitle}}
class="form-control"
/>
</div>
<div class="form-group">
<label for="edit-desc">Description</label>
<textarea
id="edit-desc"
value={{this.editDescription}}
{{on "input" this.updateDescription}}
class="form-control"
rows="3"
></textarea>
</div>
<div class="edit-actions">
<button type="submit" class="btn btn-blue btn-sm">Save</button>
<button type="button" class="btn btn-outline btn-sm" {{on "click" this.cancelEditing}}>Cancel</button>
</div>
</form>
{{else}}
<h3>{{this.name}}</h3>
<p class="place-type">
{{this.type}}
</p>
{{#if this.place.description}}
<p class="place-description">
{{this.place.description}}
</p>
{{/if}}
{{/if}}
<div class="actions">
<button
type="button"
class={{if this.place.createdAt "btn-secondary" "btn-primary"}}
class={{if
this.place.createdAt
"btn btn-secondary"
"btn btn-outline"
}}
{{on "click" (fn @onToggleSave this.place)}}
>
{{if this.place.createdAt "Saved ✓" "Save"}}
<Icon
@name="bookmark"
@color={{if this.place.createdAt "currentColor" "#007bff"}}
/>
{{if this.place.createdAt "Saved" "Save"}}
</button>
{{#if this.place.createdAt}}
<button
type="button"
class="btn btn-outline"
title="Edit"
{{on "click" this.startEditing}}
>
<Icon @name="edit" @color="#007bff" />
Edit
</button>
{{/if}}
</div>
<div class="meta-info">
{{#if this.cuisine}}
<p>
<strong>Cuisine:</strong>
@@ -153,18 +265,27 @@ export default class PlaceDetails extends Component {
{{#if this.website}}
<p class="content-with-icon">
<Icon @name="globe" @title="Website" />
<span><a href={{this.website}} target="_blank" rel="noopener noreferrer">Website</a></span>
<span><a
href={{this.website}}
target="_blank"
rel="noopener noreferrer"
>{{this.websiteDomain}}</a></span>
</p>
{{/if}}
{{#if this.wikipedia}}
<p>
<strong>Wikipedia:</strong>
<a href="https://wikipedia.org/wiki/{{this.wikipedia}}" target="_blank" rel="noopener noreferrer">Article</a>
<a
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
target="_blank"
rel="noopener noreferrer"
>Article</a>
</p>
{{/if}}
<hr class="meta-divider">
</div>
<div class="meta-info">
{{#if this.address}}
<p class="content-with-icon">
@@ -174,7 +295,7 @@ export default class PlaceDetails extends Component {
{{/if}}
<p class="content-with-icon">
<Icon @name="mapPin" @title="Geo link" />
<Icon @name="map-pin" @title="Geo link" />
<span>
<a href={{this.geoLink}} target="_blank" rel="noopener noreferrer">
{{this.visibleGeoLink}}
@@ -192,6 +313,16 @@ export default class PlaceDetails extends Component {
</span>
</p>
{{/if}}
<p class="content-with-icon">
<Icon @name="map" @title="OSM ID" />
<span>
<a href={{this.gmapsUrl}} target="_blank" rel="noopener noreferrer">
Google Maps
</a>
</span>
</p>
</div>
</div>
</template>

View File

@@ -5,6 +5,7 @@ import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import or from 'ember-truth-helpers/helpers/or';
import PlaceDetails from './place-details';
import Icon from './icon';
export default class PlacesSidebar extends Component {
@service storage;
@@ -49,23 +50,23 @@ export default class PlacesSidebar extends Component {
// Update selection to the new saved place object
// This updates the local UI state immediately without a route refresh
if (this.args.onUpdate) {
// When deleting, we revert to a "fresh" object or just close.
// Since we close the sidebar below, we might not strictly need to update local state,
// but it's good practice.
// Reconstruct the "original" place without ID/Geohash/CreatedAt
const freshPlace = {
...place,
id: undefined,
geohash: undefined,
createdAt: undefined
};
this.args.onUpdate(freshPlace);
// When deleting, we revert to a "fresh" object or just close.
// Since we close the sidebar below, we might not strictly need to update local state,
// but it's good practice.
// Reconstruct the "original" place without ID/Geohash/CreatedAt
const freshPlace = {
...place,
id: undefined,
geohash: undefined,
createdAt: undefined,
};
this.args.onUpdate(freshPlace);
}
// Also fire onSelect if it exists (for list view)
// Also fire onSelect if it exists (for list view)
if (this.args.onSelect) {
// Similar logic for select if needed, but we usually close.
this.args.onSelect(null);
// Similar logic for select if needed, but we usually close.
this.args.onSelect(null);
}
// Close sidebar after delete
@@ -80,7 +81,8 @@ export default class PlacesSidebar extends Component {
} else {
// It's a fresh POI -> Save it
const placeData = {
title: place.osmTags.name || place.osmTags['name:en'] || 'Untitled Place',
title:
place.osmTags.name || place.osmTags['name:en'] || 'Untitled Place',
lat: place.lat,
lon: place.lon,
tags: [],
@@ -115,6 +117,27 @@ export default class PlacesSidebar extends Component {
}
}
@action
async updateBookmark(updatedPlace) {
try {
const savedPlace = await this.storage.updatePlace(updatedPlace);
console.log('Place updated:', savedPlace.title);
// Notify parent to refresh map/lists
if (this.args.onBookmarkChange) {
this.args.onBookmarkChange();
}
// Update local view
if (this.args.onUpdate) {
this.args.onUpdate(savedPlace);
}
} catch (e) {
console.error('Failed to update place:', e);
alert('Failed to update place: ' + e.message);
}
}
<template>
<div class="sidebar">
<div class="sidebar-header">
@@ -123,16 +146,15 @@ export default class PlacesSidebar extends Component {
type="button"
class="back-btn"
{{on "click" this.clearSelection}}
></button>
<h2>Details</h2>
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
{{else}}
<h2>Nearby Places</h2>
{{/if}}
<button
type="button"
class="close-btn"
{{on "click" @onClose}}
>×</button>
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
@name="x"
@size={{20}}
@color="#333"
/></button>
</div>
<div class="sidebar-content">
@@ -140,6 +162,7 @@ export default class PlacesSidebar extends Component {
<PlaceDetails
@place={{@selectedPlace}}
@onToggleSave={{this.toggleSave}}
@onSave={{this.updateBookmark}}
/>
{{else}}
{{#if @places}}

View File

@@ -0,0 +1,55 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import Icon from '#components/icon';
export default class SettingsPane extends Component {
constructor() {
super(...arguments);
}
<template>
<div class="sidebar settings-pane">
<div class="sidebar-header">
<h2>Marco</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" />
</button>
</div>
<div class="sidebar-content">
<section class="settings-section">
<h3>Settings</h3>
<p>
<em>App settings/preferences go here.</em>
</p>
</section>
<section class="settings-section">
<h3>About</h3>
<p>
<strong>Marco</strong> (as in <a
href="https://en.wikipedia.org/wiki/Marco_Polo"
target="_blank" rel="noopener">Marco Polo</a>) is an unhosted maps application
that respects your privacy and choices.
</p>
<p>
Connect your own <a href="https://remotestorage.io/"
target="_blank" rel="noopener">remote storage</a> to sync place bookmarks across
devices.
</p>
<ul class="link-list">
<li>
<a href="https://gitea.kosmos.org/raucao/marco" target="_blank" rel="noopener">
Source Code
</a> (<a href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License" target="_blank" rel="noopener">AGPL</a>)
</li>
<li>
<a href="https://openstreetmap.org/copyright" target="_blank" rel="noopener">
Map Data © OpenStreetMap
</a>
</li>
</ul>
</section>
</div>
</div>
</template>
}

View File

@@ -0,0 +1,66 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import Icon from '#components/icon';
import { on } from '@ember/modifier';
export default class UserMenuComponent extends Component {
@action
connectRS() {
this.args.onClose();
this.args.storage.connect();
}
@action
disconnectRS() {
this.args.storage.disconnect();
}
<template>
<div class="user-menu-popover">
<div class="user-status">
{{#if @storage.connected}}
Connected as
<strong>{{@storage.userAddress}}</strong>
{{else}}
Not connected
{{/if}}
</div>
<ul class="account-list">
<li class="account-item">
<div class="account-info">
<Icon @name="server" @size={{18}} />
<span>RemoteStorage</span>
</div>
{{#if @storage.connected}}
<button
class="btn-text text-danger"
type="button"
{{on "click" this.disconnectRS}}
>Disconnect</button>
{{else}}
<button
class="btn-text text-primary"
type="button"
{{on "click" this.connectRS}}
>Connect</button>
{{/if}}
</li>
<li class="account-item disabled">
<div class="account-info">
<Icon @name="globe" @size={{18}} />
<span>OpenStreetMap</span>
</div>
</li>
<li class="account-item disabled">
<div class="account-info">
<Icon @name="zap" @size={{18}} />
<span>Nostr</span>
</div>
</li>
</ul>
</div>
</template>
}

View File

@@ -23,7 +23,8 @@ export default class OsmService extends Service {
out center;
`.trim();
const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(
const url = `https://overpass.bke.ro/api/interpreter?data=${encodeURIComponent(
// const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(
query
)}`;
@@ -58,6 +59,7 @@ out center;
async fetchWithRetry(url, options = {}, retries = 3) {
try {
// eslint-disable-next-line warp-drive/no-external-request-patterns
const res = await fetch(url, options);
if (!res.ok && retries > 0 && [502, 503, 504, 429].includes(res.status)) {
@@ -99,7 +101,8 @@ out center;
`.trim();
}
const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(
const url = `https://overpass.bke.ro/api/interpreter?data=${encodeURIComponent(
// const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(
query
)}`;
const res = await this.fetchWithRetry(url);

View File

@@ -1,19 +1,25 @@
import Service from '@ember/service';
import RemoteStorage from 'remotestoragejs';
import Places from '@remotestorage/module-places';
import Widget from 'remotestorage-widget';
import { tracked } from '@glimmer/tracking';
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
import { debounce } from '@ember/runloop';
import { action } from '@ember/object';
import { debounceTask } from 'ember-lifeline';
import Geohash from 'latlon-geohash';
export default class StorageService extends Service {
rs;
widget;
@tracked placesInView = [];
@tracked savedPlaces = [];
@tracked loadedPrefixes = [];
@tracked currentBbox = null;
@tracked version = 0; // Shared version tracker for bookmarks
@tracked initialSyncDone = false;
@tracked connected = false;
@tracked userAddress = null;
@tracked isWidgetOpen = false;
constructor() {
super(...arguments);
@@ -24,19 +30,37 @@ export default class StorageService extends Service {
});
this.rs.access.claim('places', 'rw');
// Caching strategy:
this.rs.caching.enable('/places/');
window.remoteStorage = this.rs;
// const widget = new Widget(this.rs);
// widget.attach();
this.widget = new Widget(this.rs, {
leaveOpen: true,
skipInitial: true,
});
// We don't attach immediately; we'll attach when the user clicks Connect
this.rs.on('ready', () => {
// console.debug('[rs] client ready');
});
this.rs.on('sync-done', (result) => {
this.rs.on('connected', () => {
console.debug('Remote storage connected');
this.connected = true;
this.userAddress = this.rs.remote.userAddress;
});
this.rs.on('disconnected', () => {
console.debug('Remote storage disconnected');
this.connected = false;
this.userAddress = null;
this.placesInView = [];
this.savedPlaces = [];
this.loadedPrefixes = [];
this.initialSyncDone = false;
});
this.rs.on('sync-done', () => {
// console.debug('[rs] sync done:', result);
if (!this.initialSyncDone) {
this.initialSyncDone = true;
@@ -46,7 +70,7 @@ export default class StorageService extends Service {
this.rs.scope('/places/').on('change', (event) => {
// console.debug(event);
this.handlePlaceChange(event);
debounce(this, this.reloadCurrentView, 200);
debounceTask(this, 'reloadCurrentView', 200);
});
}
@@ -96,7 +120,7 @@ export default class StorageService extends Service {
notifyChange() {
this.version++;
debounce(this, this.reloadCurrentView, 200);
debounceTask(this, 'reloadCurrentView', 200);
}
reloadCurrentView() {
@@ -157,7 +181,7 @@ export default class StorageService extends Service {
// If the hash is in the set of reloaded prefixes, we discard the old version
// (because the 'places' array contains the authoritative new state for this prefix)
return !prefixSet.has(hash);
} catch (e) {
} catch {
return true; // Keep malformed/unknown places safe
}
});
@@ -192,12 +216,50 @@ export default class StorageService extends Service {
async storePlace(placeData) {
const savedPlace = await this.places.store(placeData);
this.savedPlaces = [...this.savedPlaces, savedPlace];
// Only append if not already there (handlePlaceChange might also fire)
if (!this.savedPlaces.some((p) => p.id === savedPlace.id)) {
this.savedPlaces = [...this.savedPlaces, savedPlace];
}
return savedPlace;
}
async updatePlace(placeData) {
const savedPlace = await this.places.store(placeData);
// Update local list
const index = this.savedPlaces.findIndex((p) => p.id === savedPlace.id);
if (index !== -1) {
const newPlaces = [...this.savedPlaces];
newPlaces[index] = savedPlace;
this.savedPlaces = newPlaces;
}
return savedPlace;
}
async removePlace(place) {
await this.places.remove(place.id, place.geohash);
this.savedPlaces = this.savedPlaces.filter(p => p.id !== place.id);
this.savedPlaces = this.savedPlaces.filter((p) => p.id !== place.id);
}
@action
connect() {
this.isWidgetOpen = true;
// Check if widget is already attached
if (!document.querySelector('.rs-widget')) {
// Attach to our specific container
this.widget.attach('rs-widget-container');
}
}
@action
closeWidget() {
this.isWidgetOpen = false;
}
@action
disconnect() {
this.rs.disconnect();
this.isWidgetOpen = false;
}
}

View File

@@ -8,6 +8,7 @@ body {
body {
margin: 0;
font-family: 'Noto Serif', serif;
}
#root,
@@ -20,14 +21,165 @@ body {
background: #f8f9fa;
-webkit-tap-highlight-color: transparent;
outline: none; /* Prevent focus outline on click */
position: absolute;
inset: 0;
}
/* Ensure RS widget is above the map */
#remotestorage-widget {
/* Ensure RS widget is above the map but potentially hidden initially if needed */
#rs-widget-container {
position: absolute;
top: 10px;
top: 60px; /* Below header */
right: 10px;
z-index: 1000;
z-index: 4000;
display: none; /* Hidden by default */
}
#rs-widget-container.visible {
display: block;
}
.rs-backdrop {
position: fixed;
inset: 0;
z-index: 3999; /* Below widget container but above everything else */
/* background: rgb(0 0 0 / 20%); Optional: dim background */
}
/* App Header */
.app-header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 60px;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 3000; /* Above sidebar (2000) and map */
pointer-events: none; /* Let clicks pass through to map where transparent */
}
.header-left,
.header-right {
pointer-events: auto; /* Re-enable clicks for buttons */
}
.icon-btn {
background: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
cursor: pointer;
transition: transform 0.1s;
}
.icon-btn:active {
transform: scale(0.95);
}
.user-btn {
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.user-avatar-placeholder {
width: 40px;
height: 40px;
background: #333;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
}
/* User Menu Popover */
.user-menu-container {
position: relative;
}
.user-menu-popover {
position: absolute;
top: 100%;
right: 0;
margin-top: 10px;
width: 280px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
padding: 1rem;
z-index: 3001;
}
.menu-backdrop {
position: fixed;
inset: 0;
z-index: 3000; /* Below popover but above everything else */
/* background: rgb(0 0 0 / 10%); Optional dimming */
}
.user-status {
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid #eee;
color: #666;
font-size: 0.9rem;
}
.account-list {
list-style: none;
padding: 0;
margin: 0;
}
.account-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
}
.account-item.disabled {
opacity: 0.5;
pointer-events: none;
}
.account-info {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 500;
}
.btn-text {
background: none;
border: none;
padding: 0;
font-weight: 600;
cursor: pointer;
font-size: 0.9rem;
}
.text-primary {
color: #007bff;
}
.text-danger {
color: #dc3545;
}
.btn-text:hover {
text-decoration: underline;
}
/* Sidebar Styles */
@@ -39,12 +191,24 @@ body {
width: 300px;
background: white;
color: #333;
z-index: 2000;
z-index: 3100; /* Higher than Header (3000) */
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
display: flex;
flex-direction: column;
}
/* Settings Pane Mobile Overrides */
@media (width <= 768px) {
.settings-pane.sidebar {
width: 100%;
right: 0;
border-radius: 16px 16px 0 0;
height: 66vh;
top: auto;
bottom: 0;
}
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid #eee;
@@ -62,54 +226,106 @@ body {
padding: 1rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0 0.5rem;
.edit-form {
margin: -1rem;
margin-bottom: 1rem;
background: #f8f9fa;
padding: 1rem;
border-bottom: 1px solid #eee;
}
.back-btn {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
margin-right: 0.5rem;
.form-group {
margin-bottom: 0.75rem;
}
.place-details {
}
.place-details h3 {
font-size: 1.2rem;
margin-top: 0;
margin-bottom: 0.5rem;
}
.place-details .place-type {
.form-group label {
display: block;
font-size: 0.85rem;
color: #666;
font-size: 0.9rem;
text-transform: capitalize;
margin: 0 0 1rem 0;
margin-bottom: 0.25rem;
}
.place-details .place-description {
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-family: inherit;
font-size: 0.95rem;
box-sizing: border-box; /* Ensure padding doesn't overflow width */
}
.form-control:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgb(0 123 255 / 10%);
}
.edit-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.settings-section {
margin-bottom: 2rem;
}
.settings-section h3 {
font-size: 1rem;
font-weight: bold;
color: #666;
margin: 0 0 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-full {
width: 100%;
}
.btn-primary {
background: #007bff;
color: white;
border: none;
padding: 0.75rem 1.5rem;
padding: 0.75rem;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
width: 100%;
font-size: 1rem;
}
.btn-primary:hover {
background: #0056b3;
background: #0069d9;
}
.meta-info a {
color: #007bff;
text-decoration: none;
padding-bottom: 4rem;
}
.meta-info a:hover {
text-decoration: underline;
}
.link-list {
list-style: none;
padding: 0;
margin: 0;
}
.link-list li {
margin-bottom: 0.5rem;
}
.link-list a {
color: #007bff;
text-decoration: none;
font-size: 0.95rem;
}
.link-list a:hover {
text-decoration: underline;
}
.places-list {
@@ -148,44 +364,113 @@ body {
text-transform: capitalize;
}
.meta-info {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #eee;
.back-btn {
background: none;
border: none;
cursor: pointer;
padding: 0 0.5rem;
margin-left: -0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn {
background: none;
border: none;
cursor: pointer;
padding: 0 0.5rem;
margin-right: -0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.place-details h3 {
font-size: 1.2rem;
margin-top: 0;
margin-bottom: 0.5rem;
}
.place-details .place-type {
color: #666;
font-size: 0.9rem;
text-align: left;
text-transform: capitalize;
margin: 0 0 1rem;
}
.meta-info p {
margin: 0.75rem 0;
line-height: 1.4;
word-break: break-word; /* Prevent long URLs from breaking layout */
.place-details .place-description {
margin-bottom: 1.5rem;
}
.meta-info strong {
font-weight: bold;
.place-details .actions {
padding-bottom: 0.3rem;
display: flex;
flex-direction: row;
gap: 1rem;
}
.meta-info a {
color: #007bff;
text-decoration: none;
.btn {
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.meta-info a:hover {
text-decoration: underline;
.btn-sm {
padding: 0.4rem 1rem !important;
font-size: 0.9rem !important;
}
.meta-divider {
border: 0;
border-top: 1px dashed #ddd;
margin: 1rem 0;
.btn-outline {
background: transparent;
color: #333;
border: 1px solid #ccc;
}
.btn-outline:hover {
border: 1px solid #898989;
}
.btn-secondary {
color: #333;
border: 1px solid rgb(255 204 51 / 20%);
background: rgb(255 204 51 / 30%);
}
.btn-secondary:hover {
background: rgb(255 204 51 / 40%);
}
.btn-blue {
background: #007bff;
color: white;
border: none;
}
.btn-blue:hover {
background: #0056b3;
}
.btn-green {
background: #198754;
color: white;
border: none;
}
.btn-green:hover {
background: #157347;
}
/* Map Search Pulse Animation */
.search-pulse {
border-radius: 50%;
border: 2px solid rgba(255, 204, 51, 0.8); /* Gold/Yellow to match markers */
background: rgba(255, 204, 51, 0.2);
border: 2px solid rgb(255 204 51 / 80%); /* Gold/Yellow to match markers */
background: rgb(255 204 51 / 20%);
position: absolute;
transform: translate(-50%, -50%);
pointer-events: none;
@@ -199,8 +484,8 @@ body {
}
.search-pulse.blue {
border-color: rgba(51, 153, 204, 0.8);
background: rgba(51, 153, 204, 0.2);
border-color: rgb(51 153 204 / 80%);
background: rgb(51 153 204 / 20%);
}
@keyframes pulse {
@@ -208,6 +493,7 @@ body {
transform: translate(-50%, -50%) scale(0.8);
opacity: 0.8;
}
100% {
transform: translate(-50%, -50%) scale(1.4);
opacity: 0;
@@ -216,13 +502,20 @@ body {
/* Locate Control */
.ol-control.ol-locate {
top: 5em; /* Position below zoom controls (usually at .5em or similar) */
right: 0.5em;
left: auto;
inset: auto 0.5em 2.5em auto;
}
.ol-touch .ol-control.ol-locate {
top: 5.5em; /* Adjust for touch devices where controls might be larger */
inset: auto auto 3.5em;
}
/* Rotate Control */
.ol-rotate {
inset: auto 0.5em 5em auto;
}
.ol-touch .ol-rotate {
inset: auto auto 6em;
}
span.icon {
@@ -236,7 +529,7 @@ span.icon {
.icon svg {
width: 100%;
height: 100%;
stroke: currentColor;
stroke: currentcolor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
@@ -253,6 +546,7 @@ span.icon {
/* Selected Pin Animation */
.selected-pin-container {
position: absolute;
/* Center the bottom tip of the pin at the coordinate */
transform: translate(-50%, -100%);
pointer-events: none; /* Let clicks pass through to the map features below if needed */
@@ -261,14 +555,14 @@ span.icon {
.selected-pin-container.active {
display: block;
animation: dropIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
animation: drop-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}
.selected-pin {
width: 40px;
height: 40px;
color: #ea4335; /* Google Red */
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
filter: drop-shadow(0 4px 6px rgb(0 0 0 / 30%));
}
.selected-pin svg {
@@ -283,7 +577,7 @@ span.icon {
.selected-pin-shadow {
width: 10px;
height: 4px;
background: rgba(0, 0, 0, 0.3);
background: rgb(0 0 0 / 30%);
border-radius: 50%;
position: absolute;
bottom: 0;
@@ -291,45 +585,45 @@ span.icon {
transform: translateX(-50%);
z-index: -1;
opacity: 0;
animation: shadowFade 0.5s 0.2s forwards;
animation: shadow-fade 0.5s 0.2s forwards;
}
@keyframes dropIn {
@keyframes drop-in {
0% {
transform: translate(-50%, -200%) scale(0);
opacity: 0;
}
60% {
opacity: 1;
}
100% {
transform: translate(-50%, -100%) scale(1);
opacity: 1;
}
}
@keyframes shadowFade {
@keyframes shadow-fade {
to {
opacity: 1;
}
}
@media (max-width: 768px) {
@media (width <= 768px) {
.sidebar {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 50vh;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
box-shadow: 0 -2px 10px rgb(0 0 0 / 10%);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
inset: auto 0 0;
}
.sidebar-content {
overflow-y: auto;
overscroll-behavior: contain; /* Prevent scroll chaining */
/* Ensure content doesn't get hidden behind bottom safe areas on mobile */
padding-bottom: env(safe-area-inset-bottom, 20px);
}

View File

@@ -2,17 +2,21 @@ import Component from '@glimmer/component';
import { pageTitle } from 'ember-page-title';
import Map from '#components/map';
import PlacesSidebar from '#components/places-sidebar';
import AppHeader from '#components/app-header';
import SettingsPane from '#components/settings-pane';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { eq } from 'ember-truth-helpers';
import { and } from 'ember-truth-helpers';
import { on } from '@ember/modifier';
export default class ApplicationComponent extends Component {
@service storage;
@service router;
@tracked nearbyPlaces = null;
@tracked isSettingsOpen = false;
// @tracked bookmarksVersion = 0; // Moved to storage service
get isSidebarOpen() {
@@ -52,6 +56,16 @@ export default class ApplicationComponent extends Component {
}
}
@action
toggleSettings() {
this.isSettingsOpen = !this.isSettingsOpen;
}
@action
closeSettings() {
this.isSettingsOpen = false;
}
@action
selectFromList(place) {
if (place) {
@@ -74,6 +88,21 @@ export default class ApplicationComponent extends Component {
<template>
{{pageTitle "M/\RCO"}}
<AppHeader @onToggleMenu={{this.toggleSettings}} />
<div
id="rs-widget-container"
class={{if this.storage.isWidgetOpen "visible"}}
></div>
{{#if this.storage.isWidgetOpen}}
<div
class="rs-backdrop"
role="button"
{{on "click" this.storage.closeWidget}}
></div>
{{/if}}
<Map
@onPlacesFound={{this.showPlaces}}
@isSidebarOpen={{this.isSidebarOpen}}
@@ -88,6 +117,10 @@ export default class ApplicationComponent extends Component {
/>
{{/if}}
{{#if this.isSettingsOpen}}
<SettingsPane @onClose={{this.closeSettings}} />
{{/if}}
{{outlet}}
</template>
}

View File

@@ -38,8 +38,11 @@ export default class PlaceTemplate extends Component {
// 3. If not saved, check our local "optimistic" state (from handleUpdate)
// This handles the "unsaved" state immediately after deletion before any other sync
if (this.localPlace && (this.localPlace.osmId === id || this.localPlace.id === id)) {
return this.localPlace;
if (
this.localPlace &&
(this.localPlace.osmId === id || this.localPlace.id === id)
) {
return this.localPlace;
}
// 4. Fallback to the route model (which might be the stale "saved" object from when the route loaded)
@@ -50,10 +53,10 @@ export default class PlaceTemplate extends Component {
...model,
id: undefined,
createdAt: undefined,
geohash: undefined
geohash: undefined,
};
}
return model;
}

View File

@@ -41,6 +41,7 @@ export function getGeohashPrefixesInBbox(bbox) {
try {
const hash = Geohash.encode(cLat, cLon, 4);
prefixes.add(hash);
// eslint-disable-next-line no-unused-vars
} catch (e) {
// Ignore invalid coords if any
}
@@ -50,16 +51,28 @@ export function getGeohashPrefixesInBbox(bbox) {
// Ensure corners are definitely included (floating point steps might miss slightly)
try {
prefixes.add(Geohash.encode(minLat, minLon, 4));
} catch (e) {}
// eslint-disable-next-line no-unused-vars
} catch (e) {
/* ignore */
}
try {
prefixes.add(Geohash.encode(maxLat, maxLon, 4));
} catch (e) {}
// eslint-disable-next-line no-unused-vars
} catch (e) {
/* ignore */
}
try {
prefixes.add(Geohash.encode(minLat, maxLon, 4));
} catch (e) {}
// eslint-disable-next-line no-unused-vars
} catch (e) {
/* ignore */
}
try {
prefixes.add(Geohash.encode(maxLat, minLon, 4));
} catch (e) {}
// eslint-disable-next-line no-unused-vars
} catch (e) {
/* ignore */
}
return Array.from(prefixes);
}

View File

@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.4.1",
"version": "1.6.1",
"private": true,
"description": "Small description for marco goes here",
"repository": "",
@@ -93,5 +93,8 @@
},
"ember": {
"edition": "octane"
},
"dependencies": {
"ember-lifeline": "^7.0.0"
}
}

21
pnpm-lock.yaml generated
View File

@@ -7,6 +7,10 @@ settings:
importers:
.:
dependencies:
ember-lifeline:
specifier: ^7.0.0
version: 7.0.0(@ember/test-helpers@5.4.1(@babel/core@7.28.6))
devDependencies:
'@babel/core':
specifier: ^7.28.5
@@ -2254,6 +2258,15 @@ packages:
'@typescript-eslint/parser':
optional: true
ember-lifeline@7.0.0:
resolution: {integrity: sha512-2l51NzgH5vjN972zgbs+32rnXnnEFKB7qsSpJF+lBI4V5TG6DMy4SfowC72ZEuAtS58OVfwITbOO+RnM21EdpA==}
engines: {node: 16.* || >= 18}
peerDependencies:
'@ember/test-helpers': '>= 1.0.0'
peerDependenciesMeta:
'@ember/test-helpers':
optional: true
ember-modifier@4.2.2:
resolution: {integrity: sha512-pPYBAGyczX0hedGWQFQOEiL9s45KS9efKxJxUQkMLjQyh+1Uef1mcmAGsdw2KmvNupITkE/nXxmVO1kZ9tt3ag==}
@@ -6762,6 +6775,14 @@ snapshots:
- eslint
- typescript
ember-lifeline@7.0.0(@ember/test-helpers@5.4.1(@babel/core@7.28.6)):
dependencies:
'@embroider/addon-shim': 1.10.2
optionalDependencies:
'@ember/test-helpers': 5.4.1(@babel/core@7.28.6)
transitivePeerDependencies:
- supports-color
ember-modifier@4.2.2(@babel/core@7.28.6):
dependencies:
'@embroider/addon-shim': 1.10.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

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script type="module" crossorigin src="/assets/main-DXDcwTAg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-B-vHK2y6.css">
<script type="module" crossorigin src="/assets/main-CSdnGzLO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CrGC4Dlj.css">
</head>
<body>
</body>