36 Commits

Author SHA1 Message Date
da64ae1572 1.7.0 2026-01-24 21:07:40 +07:00
1a96f95c82 Hide settings pane on outside click, render above places pane 2026-01-24 21:06:50 +07:00
911e6ddf38 Add setting for Overpass API provider 2026-01-24 20:47:55 +07:00
e61dc00725 Restore some lost styles 2026-01-24 20:47:42 +07:00
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
6d7bea411a 1.4.1 2026-01-23 10:21:25 +07:00
7b01bb1118 Fix place store/remove behavior 2026-01-23 10:21:02 +07:00
84d4f9cbbf 1.4.0 2026-01-22 17:35:06 +07:00
f7e7480e51 Pan map to bring loaded place into view if necessary 2026-01-22 17:34:19 +07:00
6e87ef3573 Load all saved place into memory
Fixes launching the app with a place URL directly, and will be useful
for search etc. later.
2026-01-22 17:23:50 +07:00
86b85e9a0b Ignore release dir for linting etc. 2026-01-22 16:52:26 +07:00
2a203e8e82 Add initialSyncDone property to storage service
Allows us to know when the first sync cycle has been completed
2026-01-22 16:40:02 +07:00
b08dcedd13 Slightly brighter icon color 2026-01-22 16:39:26 +07:00
5267ffdd5c Log map features on click 2026-01-22 14:54:01 +07:00
deae2260b1 Fix occasional exception on mobiles 2026-01-22 14:40:35 +07:00
3c5b4d9b98 Update status doc 2026-01-21 22:00:34 +07:00
30 changed files with 1294 additions and 262 deletions

View File

@@ -3,6 +3,7 @@
# compiled output # compiled output
/dist/ /dist/
/release/
# misc # misc
/coverage/ /coverage/

View File

@@ -3,3 +3,4 @@
# compiled output # compiled output
/dist/ /dist/
/release/

View File

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

View File

@@ -1,6 +1,6 @@
# Project Status: Marco # Project Status: Marco
**Last Updated:** Wed Jan 21 2026 **Last Updated:** Sat Jan 24 2026
## Project Context ## Project Context
@@ -19,7 +19,10 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- **Optimization:** Added **10px hit tolerance** for easier tapping on mobile devices. - **Optimization:** Added **10px hit tolerance** for easier tapping on mobile devices.
- **Visuals:** Increased bookmark marker size (Radius 9px) and added a subtle drop shadow. - **Visuals:** Increased bookmark marker size (Radius 9px) and added a subtle drop shadow.
- **Feedback:** Implemented a "pulse" animation (via OpenLayers Overlay) at the click location to visualize the search radius (30m/50m). - **Feedback:** Implemented a "pulse" animation (via OpenLayers Overlay) at the click location to visualize the search radius (30m/50m).
- **Mobile UX:** Disabled browser tap highlights (`-webkit-tap-highlight-color: transparent`) to prevent blue flashing on Android. - **Mobile UX:**
- Disabled browser tap highlights (`-webkit-tap-highlight-color: transparent`) to prevent blue flashing on Android.
- Disabled "pull-to-refresh" (`overscroll-behavior: none`) on the body to prevent accidental reloads while keeping the sidebar scrollable (`contain`).
- **Auto-Pan:** On mobile screens, if a selected pin is obscured by the bottom sheet, the map automatically pans to center the pin in the visible top half of the screen.
- **Geolocation ("Locate Me"):** - **Geolocation ("Locate Me"):**
- Implemented a "Locate Me" button with robust tracking logic. - Implemented a "Locate Me" button with robust tracking logic.
- **Dynamic Zoom:** Automatically zooms to a level where the accuracy circle covers ~10% of the map (fallback logic handles missing accuracy data). - **Dynamic Zoom:** Automatically zooms to a level where the accuracy circle covers ~10% of the map (fallback logic handles missing accuracy data).
@@ -27,6 +30,7 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- **Auto-Stop:** Pulse and tracking automatically stop when high accuracy (≤20m) is achieved or after a 10s timeout. - **Auto-Stop:** Pulse and tracking automatically stop when high accuracy (≤20m) is achieved or after a 10s timeout.
- **Persistence:** Saves and restores map center and zoom level using `localStorage` (key: `marco:map-view`). - **Persistence:** Saves and restores map center and zoom level using `localStorage` (key: `marco:map-view`).
- **Controls:** Enabled standard OpenLayers Rotate control (re-north) and custom Locate control. - **Controls:** Enabled standard OpenLayers Rotate control (re-north) and custom Locate control.
- **Pin Animation:** Selected pins are highlighted with a custom **Red Pin** overlay that drops in with an animation. The center dot is styled as a solid dark red circle (`#b31412`).
### 2. RemoteStorage Module (`@remotestorage/module-places`) ### 2. RemoteStorage Module (`@remotestorage/module-places`)
@@ -49,9 +53,13 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- **Reliability:** Implemented `fetchWithRetry` to handle HTTP 504/502/503 timeouts and 429 rate limits, in addition to network errors. - **Reliability:** Implemented `fetchWithRetry` to handle HTTP 504/502/503 timeouts and 429 rate limits, in addition to network errors.
- **UI Components:** - **UI Components:**
- `places-sidebar.gjs`: Displays a list of nearby POIs. - `places-sidebar.gjs`: Displays a list of nearby POIs.
- **Layout:** Responsive design that transforms into a **Bottom Sheet** (50% height) on mobile screens (`<=768px`) with rounded corners and upward shadow.
- `place-details.gjs`: Dedicated component for displaying rich place information. - `place-details.gjs`: Dedicated component for displaying rich place information.
- **Features:** Icons (via `feather-icons`), Address, Phone, Website, Opening Hours, Cuisine, Wikipedia. - **Features:** Icons (via `feather-icons`), Address, Phone, Website, Opening Hours, Cuisine, Wikipedia.
- **Layout:** Polished UI with distinct sections for Actions and Meta info. - **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:** - **Geo Utils:**
- `app/utils/geo.js`: Haversine distance calculations. - `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. - `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
@@ -59,6 +67,7 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
### 4. Routing & Data Optimization ### 4. Routing & Data Optimization
- **Explicit URLs:** Implemented routing support for specific OSM entities via `/place/osm:node:<id>` and `/place/osm:way:<id>`, distinguishing them from local bookmarks (ULIDs). - **Explicit URLs:** Implemented routing support for specific OSM entities via `/place/osm:node:<id>` and `/place/osm:way:<id>`, distinguishing them from local bookmarks (ULIDs).
- **Smart Linking:** The `showPlaces` action intercepts search results and automatically resolves them to existing **Bookmarks** if a match is found (via `storage.findPlaceById`). This ensures the app navigates to the persistent Bookmark URL (ULID) and correctly reflects the "Saved" status in the UI instead of treating it as a new generic OSM place.
- **Data Normalization:** Refactored `OsmService` to return normalized objects (`osmTags`, `osmType`) for all queries. This ensures consistent data structures between fresh Overpass results and saved bookmarks throughout the app. - **Data Normalization:** Refactored `OsmService` to return normalized objects (`osmTags`, `osmType`) for all queries. This ensures consistent data structures between fresh Overpass results and saved bookmarks throughout the app.
- **Performance:** Optimized navigation to prevent redundant network requests. Clicking a map pin passes the existing data object to the route, skipping the `model` hook (no re-fetch) while maintaining correct deep-linkable URLs via a custom `serialize` hook in `PlaceRoute`. - **Performance:** Optimized navigation to prevent redundant network requests. Clicking a map pin passes the existing data object to the route, skipping the `model` hook (no re-fetch) while maintaining correct deep-linkable URLs via a custom `serialize` hook in `PlaceRoute`.
@@ -68,24 +77,24 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- **Workflow:** - **Workflow:**
1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`. 1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`.
2. User clicks map -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass). 2. User clicks map -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass).
3. **Navigation:** Selected place is passed to the route (`transitionTo` with model), updating the URL to `/place/<id>` or `/place/osm:<type>:<id>` without re-fetching data. 3. **Navigation:** Selected place is checked against bookmarks; if found, it uses the Bookmark object. Otherwise, it uses the OSM object.
4. Sidebar displays details via `<PlaceDetails>` component. 4. Sidebar displays details via `<PlaceDetails>` component (Bottom sheet on mobile).
5. User clicks "Save Bookmark" -> Stores JSON in RemoteStorage. 5. User clicks "Save Bookmark" -> Stores JSON in RemoteStorage.
6. RemoteStorage change event -> Debounced reload updates the map reactive-ly. 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 ## Files Currently in Focus
- `app/components/place-details.gjs`: UI logic for place info. - `app/styles/app.css`: Responsive sidebar styles and mobile optimizations.
- `app/routes/place.js`: Routing logic. - `app/components/place-details.gjs`: Place display and editing logic.
- `app/components/map.gjs`: Map rendering and interaction. - `app/services/storage.js`: Data sync and update logic.
- `app/services/storage.js`: Data sync logic.
## Next Steps & Pending Tasks ## Next Steps & Pending Tasks
1. **App Header:** Implement a transparent header bar with the App Logo (left) and Login/User Info (right). 1. **Collections/Lists:** Implement ability to organize bookmarks into lists/collections.
2. **Edit Bookmarks:** Allow users to edit the title and description of saved places. 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). 3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
4. **Testing:** Add automated tests for the geohash coverage and retry logic. 4. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
## Technical Constraints ## 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 Component from '@glimmer/component';
import { htmlSafe } from '@ember/template'; 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 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 globe from 'feather-icons/dist/icons/globe.svg?raw';
import home from 'feather-icons/dist/icons/home.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 map from 'feather-icons/dist/icons/map.svg?raw';
import mapPin from 'feather-icons/dist/icons/map-pin.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 navigation from 'feather-icons/dist/icons/navigation.svg?raw';
import phone from 'feather-icons/dist/icons/phone.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 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 = { const ICONS = {
'arrow-left': arrowLeft,
activity,
bookmark,
clock, clock,
edit,
globe, globe,
home, home,
'log-in': logIn,
'log-out': logOut,
map, map,
mapPin, 'map-pin': mapPin,
menu,
navigation, navigation,
phone, phone,
server,
settings,
user, user,
settings x,
zap,
}; };
export default class IconComponent extends Component { export default class IconComponent extends Component {
@@ -33,7 +53,7 @@ export default class IconComponent extends Component {
} }
get color() { get color() {
return this.args.color || '#888'; return this.args.color || '#898989';
} }
get style() { get style() {

View File

@@ -16,7 +16,6 @@ import Geolocation from 'ol/Geolocation.js';
import { Style, Circle, Fill, Stroke } from 'ol/style.js'; import { Style, Circle, Fill, Stroke } from 'ol/style.js';
import { apply } from 'ol-mapbox-style'; import { apply } from 'ol-mapbox-style';
import { getDistance } from '../utils/geo'; import { getDistance } from '../utils/geo';
import Icon from '../components/icon';
export default class MapComponent extends Component { export default class MapComponent extends Component {
@service osm; @service osm;
@@ -61,7 +60,6 @@ export default class MapComponent extends Component {
zIndex: 10, // Ensure it sits above the map tiles zIndex: 10, // Ensure it sits above the map tiles
}); });
// Default view settings // Default view settings
let center = [99.05738, 7.55087]; let center = [99.05738, 7.55087];
let zoom = 13.0; let zoom = 13.0;
@@ -95,7 +93,11 @@ export default class MapComponent extends Component {
target: element, target: element,
layers: [openfreemap, bookmarkLayer], layers: [openfreemap, bookmarkLayer],
view: view, 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'); apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
@@ -114,7 +116,7 @@ export default class MapComponent extends Component {
// Using JS creation to ensure it's cleanly managed by OpenLayers // Using JS creation to ensure it's cleanly managed by OpenLayers
this.selectedPinElement = document.createElement('div'); this.selectedPinElement = document.createElement('div');
this.selectedPinElement.className = 'selected-pin-container'; this.selectedPinElement.className = 'selected-pin-container';
// Create the icon structure inside // Create the icon structure inside
const pinIcon = document.createElement('div'); const pinIcon = document.createElement('div');
pinIcon.className = 'selected-pin'; pinIcon.className = 'selected-pin';
@@ -123,7 +125,7 @@ export default class MapComponent extends Component {
// Feather icons are globally available if we used the script, but we are using the module approach. // Feather icons are globally available if we used the script, but we are using the module approach.
// Simple SVG for Map Pin: // Simple SVG for Map Pin:
pinIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3" style="fill: #b31412; stroke: none;"></circle></svg>`; pinIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3" style="fill: #b31412; stroke: none;"></circle></svg>`;
const pinShadow = document.createElement('div'); const pinShadow = document.createElement('div');
pinShadow.className = 'selected-pin-shadow'; pinShadow.className = 'selected-pin-shadow';
@@ -210,7 +212,9 @@ export default class MapComponent extends Component {
geolocation.un('change:position', zoomToLocation); geolocation.un('change:position', zoomToLocation);
locateListenerKey = null; locateListenerKey = null;
} }
} catch (e) { /* ignore */ } } catch {
/* ignore */
}
// Hide pulse // Hide pulse
if (this.locationOverlayElement) { if (this.locationOverlayElement) {
@@ -261,7 +265,8 @@ export default class MapComponent extends Component {
else if (accuracy) { else if (accuracy) {
const viewportWidthMeters = 6.325 * accuracy; const viewportWidthMeters = 6.325 * accuracy;
const minDimensionPixels = Math.min(size[0], size[1]); const minDimensionPixels = Math.min(size[0], size[1]);
const requiredResolutionMeters = viewportWidthMeters / minDimensionPixels; const requiredResolutionMeters =
viewportWidthMeters / minDimensionPixels;
const metersPerMapUnit = getPointResolution( const metersPerMapUnit = getPointResolution(
view.getProjection(), view.getProjection(),
1, 1,
@@ -353,21 +358,79 @@ export default class MapComponent extends Component {
if (selected && selected.lat && selected.lon) { if (selected && selected.lat && selected.lon) {
const coords = fromLonLat([selected.lon, selected.lat]); const coords = fromLonLat([selected.lon, selected.lat]);
this.selectedPinOverlay.setPosition(coords); this.selectedPinOverlay.setPosition(coords);
// Reset animation by removing/adding class // Reset animation by removing/adding class
this.selectedPinElement.classList.remove('active'); this.selectedPinElement.classList.remove('active');
// Force reflow // Force reflow
void this.selectedPinElement.offsetWidth; void this.selectedPinElement.offsetWidth;
this.selectedPinElement.classList.add('active'); this.selectedPinElement.classList.add('active');
this.panIfObscured(coords); this.handlePinVisibility(coords);
} else { } else {
this.selectedPinElement.classList.remove('active'); this.selectedPinElement.classList.remove('active');
// Hide it effectively by moving it away or just relying on display:none in CSS // Hide it effectively by moving it away or just relying on display:none in CSS
this.selectedPinOverlay.setPosition(undefined); this.selectedPinOverlay.setPosition(undefined);
} }
}); });
handlePinVisibility(coords) {
if (!this.mapInstance) return;
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
const size = this.mapInstance.getSize();
// Check if off-screen (not rendered or outside bounds)
const isOffScreen =
!pixel ||
pixel[0] < 0 ||
pixel[0] > size[0] ||
pixel[1] < 0 ||
pixel[1] > size[1];
if (isOffScreen) {
this.animateToSmartCenter(coords);
} else {
this.panIfObscured(coords);
}
}
animateToSmartCenter(coords) {
if (!this.mapInstance) return;
const size = this.mapInstance.getSize();
const view = this.mapInstance.getView();
const resolution = view.getResolution();
let targetCenter = coords;
// Check if mobile (width <= 768px matches CSS)
if (size[0] <= 768) {
// On mobile, the bottom 50% is covered by the sheet.
// We want the pin to be in the center of the TOP 50% (visible area).
// That means the pin should be at y = height * 0.25 (25% down from top).
// The map center is at y = height * 0.50.
// So the pin is "above" the center by 25% of the height in pixels.
// To put the pin there, the map center needs to be "below" the pin by that amount.
const height = size[1];
const offsetPixels = height * 0.25; // Distance from desired pin pos to map center
const offsetMapUnits = offsetPixels * resolution;
// Shift center SOUTH (decrease Y)
// Note: In Web Mercator (EPSG:3857), Y increases North.
// So to look "lower", we decrease Y? No wait.
// If we move the camera South (decrease Y), the features move North (Up) on screen.
// We want the Pin (fixed lat/lon) to be Higher up on screen.
// So we must move the Camera South (Lower Y).
targetCenter = [coords[0], coords[1] - offsetMapUnits];
}
view.animate({
center: targetCenter,
duration: 1000,
easing: (t) => t * (2 - t), // Ease-out
});
}
panIfObscured(coords) { panIfObscured(coords) {
if (!this.mapInstance) return; if (!this.mapInstance) return;
@@ -376,37 +439,39 @@ export default class MapComponent extends Component {
if (size[0] > 768) return; if (size[0] > 768) return;
const pixel = this.mapInstance.getPixelFromCoordinate(coords); const pixel = this.mapInstance.getPixelFromCoordinate(coords);
if (!pixel) return;
const height = size[1]; const height = size[1];
// Sidebar covers the bottom 50% // Sidebar covers the bottom 50%
const splitPoint = height / 2; const splitPoint = height / 2;
// If the pin is in the bottom half (y > splitPoint), it is obscured // If the pin is in the bottom half (y > splitPoint), it is obscured
if (pixel[1] > splitPoint) { if (pixel[1] > splitPoint) {
// Target position: Center of top half = height * 0.25 // Target position: Center of top half = height * 0.25
const targetY = height * 0.25; const targetY = height * 0.25;
const deltaY = pixel[1] - targetY; const deltaY = pixel[1] - targetY;
const view = this.mapInstance.getView(); const view = this.mapInstance.getView();
const center = view.getCenter(); const center = view.getCenter();
const resolution = view.getResolution(); const resolution = view.getResolution();
// Move the map center SOUTH (decrease Y) to move the pin UP (decrease pixel Y) // Move the map center SOUTH (decrease Y) to move the pin UP (decrease pixel Y)
const deltaMapUnits = deltaY * resolution; const deltaMapUnits = deltaY * resolution;
const newCenter = [center[0], center[1] - deltaMapUnits]; const newCenter = [center[0], center[1] - deltaMapUnits];
view.animate({ view.animate({
center: newCenter, center: newCenter,
duration: 500, duration: 500,
easing: (t) => t * (2 - t) // Ease-out easing: (t) => t * (2 - t), // Ease-out
}); });
} }
} }
// Re-fetch bookmarks when the version changes (triggered by parent action or service) // Re-fetch bookmarks when the version changes (triggered by parent action or service)
updateBookmarks = modifier(() => { updateBookmarks = modifier(() => {
// Depend on the tracked storage.savedPlaces to automatically update when they change // Depend on the tracked storage.placesInView to automatically update when they change
const places = this.storage.savedPlaces; const places = this.storage.placesInView;
this.loadBookmarks(places); this.loadBookmarks(places);
}); });
@@ -416,13 +481,13 @@ export default class MapComponent extends Component {
if (!places || places.length === 0) { if (!places || places.length === 0) {
// Fallback or explicit check if we have tracked property usage? // Fallback or explicit check if we have tracked property usage?
// The service updates 'savedPlaces'. We should probably use that if we want reactiveness. // The service updates 'placesInView'. We should probably use that if we want reactiveness.
places = this.storage.savedPlaces; places = this.storage.placesInView;
} }
// Previously: const places = await this.storage.places.getPlaces(); // Previously: const places = await this.storage.places.getPlaces();
// We no longer want to fetch everything blindly. // We no longer want to fetch everything blindly.
// We rely on 'savedPlaces' being updated by handleMapMove calling storage.loadPlacesInBounds. // We rely on 'placesInView' being updated by handleMapMove calling storage.loadPlacesInBounds.
this.bookmarkSource.clear(); this.bookmarkSource.clear();
@@ -455,7 +520,7 @@ export default class MapComponent extends Component {
const bbox = { minLat, minLon, maxLat, maxLon }; const bbox = { minLat, minLon, maxLat, maxLon };
await this.storage.loadPlacesInBounds(bbox); await this.storage.loadPlacesInBounds(bbox);
this.loadBookmarks(this.storage.savedPlaces); this.loadBookmarks(this.storage.placesInView);
// Persist view to localStorage // Persist view to localStorage
try { try {
@@ -465,7 +530,7 @@ export default class MapComponent extends Component {
const viewState = { const viewState = {
center: currentCenter, center: currentCenter,
zoom: currentZoom zoom: currentZoom,
}; };
localStorage.setItem('marco:map-view', JSON.stringify(viewState)); localStorage.setItem('marco:map-view', JSON.stringify(viewState));
@@ -484,6 +549,10 @@ export default class MapComponent extends Component {
let selectedFeatureType = null; let selectedFeatureType = null;
if (features && features.length > 0) { if (features && features.length > 0) {
console.debug(`Found ${features.length} features in map layer:`);
for (const f of features) {
console.debug(f);
}
const bookmarkFeature = features.find((f) => f.get('isBookmark')); const bookmarkFeature = features.find((f) => f.get('isBookmark'));
if (bookmarkFeature) { if (bookmarkFeature) {
clickedBookmark = bookmarkFeature.get('originalPlace'); clickedBookmark = bookmarkFeature.get('originalPlace');
@@ -624,7 +693,6 @@ export default class MapComponent extends Component {
{{this.setupMap}} {{this.setupMap}}
{{this.updateBookmarks}} {{this.updateBookmarks}}
{{this.updateSelectedPin}} {{this.updateSelectedPin}}
style="position: absolute; inset: 0;"
></div> ></div>
</template> </template>
} }

View File

@@ -4,7 +4,19 @@ import { on } from '@ember/modifier';
import capitalize from '../helpers/capitalize'; import capitalize from '../helpers/capitalize';
import Icon from '../components/icon'; import Icon from '../components/icon';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class PlaceDetails extends Component { export default class PlaceDetails extends Component {
@tracked isEditing = false;
@tracked editTitle = '';
@tracked editDescription = '';
constructor() {
super(...arguments);
this.resetEditFields();
}
get place() { get place() {
return this.args.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() { get type() {
return ( return (
this.tags.amenity || this.tags.amenity ||
@@ -67,6 +120,11 @@ export default class PlaceDetails extends Component {
return this.place.url || this.tags.website || this.tags['contact:website']; return this.place.url || this.tags.website || this.tags['contact:website'];
} }
get websiteDomain() {
const url = new URL(this.website);
return url.hostname;
}
get openingHours() { get openingHours() {
return this.tags.opening_hours; return this.tags.opening_hours;
} }
@@ -74,10 +132,10 @@ export default class PlaceDetails extends Component {
get cuisine() { get cuisine() {
if (!this.tags.cuisine) return null; if (!this.tags.cuisine) return null;
return this.tags.cuisine return this.tags.cuisine
.split(';') .split(';')
.map(c => capitalize.compute([c])) .map((c) => capitalize.compute([c]))
.map(c => c.replace('_', ' ')) .map((c) => c.replace('_', ' '))
.join(', '); .join(', ');
} }
get wikipedia() { get wikipedia() {
@@ -106,29 +164,83 @@ export default class PlaceDetails extends Component {
return `https://www.openstreetmap.org/${type}/${id}`; 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> <template>
<div class="place-details"> <div class="place-details">
<h3>{{this.name}}</h3> {{#if this.isEditing}}
<p class="place-type"> <form class="edit-form" {{on "submit" this.saveChanges}}>
{{this.type}} <div class="form-group">
</p> <label for="edit-title">Title</label>
{{#if this.place.description}} <input
<p class="place-description"> id="edit-title"
{{this.place.description}} 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> </p>
{{#if this.place.description}}
<p class="place-description">
{{this.place.description}}
</p>
{{/if}}
{{/if}} {{/if}}
<div class="actions"> <div class="actions">
<button <button
type="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)}} {{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> </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>
<div class="meta-info"> <div class="meta-info">
{{#if this.cuisine}} {{#if this.cuisine}}
<p> <p>
<strong>Cuisine:</strong> <strong>Cuisine:</strong>
@@ -153,18 +265,27 @@ export default class PlaceDetails extends Component {
{{#if this.website}} {{#if this.website}}
<p class="content-with-icon"> <p class="content-with-icon">
<Icon @name="globe" @title="Website" /> <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> </p>
{{/if}} {{/if}}
{{#if this.wikipedia}} {{#if this.wikipedia}}
<p> <p>
<strong>Wikipedia:</strong> <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> </p>
{{/if}} {{/if}}
<hr class="meta-divider"> </div>
<div class="meta-info">
{{#if this.address}} {{#if this.address}}
<p class="content-with-icon"> <p class="content-with-icon">
@@ -174,7 +295,7 @@ export default class PlaceDetails extends Component {
{{/if}} {{/if}}
<p class="content-with-icon"> <p class="content-with-icon">
<Icon @name="mapPin" @title="Geo link" /> <Icon @name="map-pin" @title="Geo link" />
<span> <span>
<a href={{this.geoLink}} target="_blank" rel="noopener noreferrer"> <a href={{this.geoLink}} target="_blank" rel="noopener noreferrer">
{{this.visibleGeoLink}} {{this.visibleGeoLink}}
@@ -192,6 +313,16 @@ export default class PlaceDetails extends Component {
</span> </span>
</p> </p>
{{/if}} {{/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>
</div> </div>
</template> </template>

View File

@@ -5,6 +5,7 @@ import { on } from '@ember/modifier';
import { fn } from '@ember/helper'; import { fn } from '@ember/helper';
import or from 'ember-truth-helpers/helpers/or'; import or from 'ember-truth-helpers/helpers/or';
import PlaceDetails from './place-details'; import PlaceDetails from './place-details';
import Icon from './icon';
export default class PlacesSidebar extends Component { export default class PlacesSidebar extends Component {
@service storage; @service storage;
@@ -31,70 +32,46 @@ export default class PlacesSidebar extends Component {
} }
} }
get geoLink() {
if (!this.args.selectedPlace) return '#';
const p = this.args.selectedPlace;
// geo:lat,lon?q=lat,lon(Label)
const label = encodeURIComponent(
p.title ||
p.tags?.name ||
p.tags?.['name:en'] ||
'Location'
);
return `geo:${p.lat},${p.lon}?q=${p.lat},${p.lon}(${label})`;
}
get visibleGeoLink() {
if (!this.args.selectedPlace) return '';
const p = this.args.selectedPlace;
return `geo:${p.lat},${p.lon}`;
}
@action @action
async toggleSave(place) { async toggleSave(place) {
if (!place) return; if (!place) return;
if (place.createdAt) { if (place.createdAt) {
// It's a saved bookmark -> Delete it
if (confirm(`Delete "${place.title}"?`)) { if (confirm(`Delete "${place.title}"?`)) {
try { try {
if (place.id && place.geohash) { await this.storage.removePlace(place);
await this.storage.places.remove(place.id, place.geohash); console.log('Place deleted:', place.title);
console.log('Place deleted:', place.title);
// Notify parent to refresh map bookmarks // Notify parent to refresh map bookmarks
if (this.args.onBookmarkChange) { if (this.args.onBookmarkChange) {
this.args.onBookmarkChange(); this.args.onBookmarkChange();
} }
// Update selection to the new saved place object // Update selection to the new saved place object
// This updates the local UI state immediately without a route refresh // This updates the local UI state immediately without a route refresh
if (this.args.onUpdate) { if (this.args.onUpdate) {
// When deleting, we revert to a "fresh" object or just close. // 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, // Since we close the sidebar below, we might not strictly need to update local state,
// but it's good practice. // but it's good practice.
// Reconstruct the "original" place without ID/Geohash/CreatedAt // Reconstruct the "original" place without ID/Geohash/CreatedAt
const freshPlace = { const freshPlace = {
...place, ...place,
id: undefined, id: undefined,
geohash: undefined, geohash: undefined,
createdAt: undefined createdAt: undefined,
}; };
this.args.onUpdate(freshPlace); 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) { if (this.args.onSelect) {
// Similar logic for select if needed, but we usually close. // Similar logic for select if needed, but we usually close.
this.args.onSelect(null); this.args.onSelect(null);
} }
// Close sidebar after delete // Close sidebar after delete
if (this.args.onClose) { if (this.args.onClose) {
this.args.onClose(); this.args.onClose();
}
} else {
alert('Cannot delete: Missing ID or Geohash');
} }
} catch (e) { } catch (e) {
console.error('Failed to delete:', e); console.error('Failed to delete:', e);
@@ -104,7 +81,8 @@ export default class PlacesSidebar extends Component {
} else { } else {
// It's a fresh POI -> Save it // It's a fresh POI -> Save it
const placeData = { 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, lat: place.lat,
lon: place.lon, lon: place.lon,
tags: [], tags: [],
@@ -115,7 +93,7 @@ export default class PlacesSidebar extends Component {
}; };
try { try {
const savedPlace = await this.storage.places.store(placeData); const savedPlace = await this.storage.storePlace(placeData);
console.log('Place saved:', placeData.title); console.log('Place saved:', placeData.title);
// Notify parent to refresh map bookmarks // Notify parent to refresh map bookmarks
@@ -139,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> <template>
<div class="sidebar"> <div class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
@@ -147,23 +146,23 @@ export default class PlacesSidebar extends Component {
type="button" type="button"
class="back-btn" class="back-btn"
{{on "click" this.clearSelection}} {{on "click" this.clearSelection}}
></button> ><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
<h2>Details</h2>
{{else}} {{else}}
<h2>Nearby Places</h2> <h2>Nearby Places</h2>
{{/if}} {{/if}}
<button <button type="button" class="close-btn" {{on "click" @onClose}}><Icon
type="button" @name="x"
class="close-btn" @size={{20}}
{{on "click" @onClose}} @color="#333"
>×</button> /></button>
</div> </div>
<div class="sidebar-content"> <div class="sidebar-content">
{{#if @selectedPlace}} {{#if @selectedPlace}}
<PlaceDetails <PlaceDetails
@place={{@selectedPlace}} @place={{@selectedPlace}}
@onToggleSave={{this.toggleSave}} @onToggleSave={{this.toggleSave}}
@onSave={{this.updateBookmark}}
/> />
{{else}} {{else}}
{{#if @places}} {{#if @places}}

View File

@@ -0,0 +1,75 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { service } from '@ember/service';
import { action } from '@ember/object';
import Icon from '#components/icon';
import eq from 'ember-truth-helpers/helpers/eq';
export default class SettingsPane extends Component {
@service settings;
@action
updateApi(event) {
this.settings.updateOverpassApi(event.target.value);
}
<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>
<div class="form-group">
<label for="overpass-api">Overpass API Provider</label>
<select
id="overpass-api"
class="form-control"
{{on "change" this.updateApi}}
>
{{#each this.settings.overpassApis as |api|}}
<option
value={{api.url}}
selected={{if (eq api.url this.settings.overpassApi) "selected"}}
>
{{api.name}}
</option>
{{/each}}
</select>
</div>
</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

@@ -9,14 +9,14 @@ export default class PlaceRoute extends Route {
async model(params) { async model(params) {
const id = params.place_id; const id = params.place_id;
// Check for explicit OSM prefixes
if (id.startsWith('osm:node:') || id.startsWith('osm:way:')) { if (id.startsWith('osm:node:') || id.startsWith('osm:way:')) {
const [, type, osmId] = id.split(':'); const [, type, osmId] = id.split(':');
console.log(`Fetching explicit OSM ${type}:`, osmId); console.log(`Fetching explicit OSM ${type}:`, osmId);
return this.loadOsmPlace(osmId, type); return this.loadOsmPlace(osmId, type);
} }
// 1. Try to find in local bookmarks await this.waitForSync();
let bookmark = this.storage.findPlaceById(id); let bookmark = this.storage.findPlaceById(id);
if (bookmark) { if (bookmark) {
@@ -24,9 +24,24 @@ export default class PlaceRoute extends Route {
return bookmark; return bookmark;
} }
// 2. Fallback: Fetch from OSM (assuming generic ID or old format) console.warn('Not in bookmarks:', id);
console.log('Not in bookmarks, fetching from OSM:', id); return null;
return this.loadOsmPlace(id); }
async waitForSync() {
if (this.storage.initialSyncDone) return;
console.log('Waiting for initial storage sync...');
const timeout = 5000;
const start = Date.now();
while (!this.storage.initialSyncDone) {
if (Date.now() - start > timeout) {
console.warn('Timed out waiting for initial sync');
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
} }
afterModel(model) { afterModel(model) {

View File

@@ -1,6 +1,8 @@
import Service from '@ember/service'; import Service, { service } from '@ember/service';
export default class OsmService extends Service { export default class OsmService extends Service {
@service settings;
controller = null; controller = null;
async getNearbyPois(lat, lon, radius = 50) { async getNearbyPois(lat, lon, radius = 50) {
@@ -23,9 +25,7 @@ export default class OsmService extends Service {
out center; out center;
`.trim(); `.trim();
const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent( const url = `${this.settings.overpassApi}?data=${encodeURIComponent(query)}`;
query
)}`;
try { try {
const res = await this.fetchWithRetry(url, { signal }); const res = await this.fetchWithRetry(url, { signal });
@@ -58,6 +58,7 @@ out center;
async fetchWithRetry(url, options = {}, retries = 3) { async fetchWithRetry(url, options = {}, retries = 3) {
try { try {
// eslint-disable-next-line warp-drive/no-external-request-patterns
const res = await fetch(url, options); const res = await fetch(url, options);
if (!res.ok && retries > 0 && [502, 503, 504, 429].includes(res.status)) { if (!res.ok && retries > 0 && [502, 503, 504, 429].includes(res.status)) {
@@ -99,9 +100,7 @@ out center;
`.trim(); `.trim();
} }
const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent( const url = `${this.settings.overpassApi}?data=${encodeURIComponent(query)}`;
query
)}`;
const res = await this.fetchWithRetry(url); const res = await this.fetchWithRetry(url);
if (!res.ok) throw new Error('Overpass request failed'); if (!res.ok) throw new Error('Overpass request failed');
const data = await res.json(); const data = await res.json();

32
app/services/settings.js Normal file
View File

@@ -0,0 +1,32 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class SettingsService extends Service {
@tracked overpassApi = 'https://overpass.bke.ro/api/interpreter';
overpassApis = [
{ name: 'bke.ro', url: 'https://overpass.bke.ro/api/interpreter' },
{ name: 'overpass-api.de', url: 'https://overpass-api.de/api/interpreter' },
{
name: 'private.coffee',
url: 'https://overpass.private.coffee/api/interpreter',
},
];
constructor() {
super(...arguments);
this.loadSettings();
}
loadSettings() {
const savedApi = localStorage.getItem('marco-overpass-api');
if (savedApi) {
this.overpassApi = savedApi;
}
}
updateOverpassApi(url) {
this.overpassApi = url;
localStorage.setItem('marco-overpass-api', url);
}
}

View File

@@ -1,17 +1,25 @@
import Service from '@ember/service'; import Service from '@ember/service';
import RemoteStorage from 'remotestoragejs'; import RemoteStorage from 'remotestoragejs';
import Places from '@remotestorage/module-places'; import Places from '@remotestorage/module-places';
import Widget from 'remotestorage-widget';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage'; 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'; import Geohash from 'latlon-geohash';
export default class StorageService extends Service { export default class StorageService extends Service {
rs; rs;
widget;
@tracked placesInView = [];
@tracked savedPlaces = []; @tracked savedPlaces = [];
@tracked loadedPrefixes = []; @tracked loadedPrefixes = [];
@tracked currentBbox = null; @tracked currentBbox = null;
@tracked version = 0; // Shared version tracker for bookmarks @tracked version = 0; // Shared version tracker for bookmarks
@tracked initialSyncDone = false;
@tracked connected = false;
@tracked userAddress = null;
@tracked isWidgetOpen = false;
constructor() { constructor() {
super(...arguments); super(...arguments);
@@ -22,30 +30,97 @@ export default class StorageService extends Service {
}); });
this.rs.access.claim('places', 'rw'); this.rs.access.claim('places', 'rw');
// Caching strategy:
this.rs.caching.enable('/places/'); this.rs.caching.enable('/places/');
window.remoteStorage = this.rs; window.remoteStorage = this.rs;
// const widget = new Widget(this.rs); this.widget = new Widget(this.rs, {
// widget.attach(); leaveOpen: true,
skipInitial: true,
});
// We don't attach immediately; we'll attach when the user clicks Connect
this.rs.on('ready', () => { this.rs.on('ready', () => {
// this.loadAllPlaces(); // console.debug('[rs] client ready');
});
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;
}
}); });
this.rs.scope('/places/').on('change', (event) => { this.rs.scope('/places/').on('change', (event) => {
debounce(this, this.reloadCurrentView, 200); // console.debug(event);
this.handlePlaceChange(event);
debounceTask(this, 'reloadCurrentView', 200);
}); });
} }
handlePlaceChange(event) {
const { newValue, relativePath } = event;
// Remove old entry if exists
// The relativePath is like "geohash/geohash/ULID" or just "ULID" depending on structure.
// Our structure is <2-char>/<2-char>/<id>.
// But let's rely on the ID inside the object if possible, or extract from path.
// We can't easily identify the ID from just relativePath without parsing logic if it's nested.
// However, for deletions (newValue is undefined), we might need the ID.
// Fortunately, our objects (newValue) contain the ID.
// If it's a deletion, we need to find the object in our array to remove it.
// Since we don't have the ID in newValue (it's null), we rely on `relativePath`.
// Let's assume the filename is the ID.
const pathParts = relativePath.split('/');
const id = pathParts[pathParts.length - 1];
if (!newValue) {
// Deletion
this.savedPlaces = this.savedPlaces.filter((p) => p.id !== id);
} else {
// Add or Update
// Ensure the object has the ID (it should)
const place = { ...newValue, id };
// Update existing or add new
const index = this.savedPlaces.findIndex((p) => p.id === id);
if (index !== -1) {
// Replace
const newPlaces = [...this.savedPlaces];
newPlaces[index] = place;
this.savedPlaces = newPlaces;
} else {
// Add
this.savedPlaces = [...this.savedPlaces, place];
}
}
}
get places() { get places() {
return this.rs.places; return this.rs.places;
} }
notifyChange() { notifyChange() {
this.version++; this.version++;
debounce(this, this.reloadCurrentView, 200); debounceTask(this, 'reloadCurrentView', 200);
} }
reloadCurrentView() { reloadCurrentView() {
@@ -98,7 +173,7 @@ export default class StorageService extends Service {
// Identify existing places that belong to the reloaded prefixes and remove them // Identify existing places that belong to the reloaded prefixes and remove them
const prefixSet = new Set(prefixes); const prefixSet = new Set(prefixes);
const keptPlaces = this.savedPlaces.filter((place) => { const keptPlaces = this.placesInView.filter((place) => {
if (!place.lat || !place.lon) return false; if (!place.lat || !place.lon) return false;
try { try {
// Calculate 4-char geohash for the existing place // Calculate 4-char geohash for the existing place
@@ -106,33 +181,85 @@ export default class StorageService extends Service {
// If the hash is in the set of reloaded prefixes, we discard the old version // 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) // (because the 'places' array contains the authoritative new state for this prefix)
return !prefixSet.has(hash); return !prefixSet.has(hash);
} catch (e) { } catch {
return true; // Keep malformed/unknown places safe return true; // Keep malformed/unknown places safe
} }
}); });
// Merge the kept places (from other areas) with the fresh places (from these areas) // Merge the kept places (from other areas) with the fresh places (from these areas)
this.savedPlaces = [...keptPlaces, ...places]; this.placesInView = [...keptPlaces, ...places];
} else { } else {
// Full reload // Full reload
this.savedPlaces = places; this.placesInView = places;
} }
} else { } else {
if (!prefixes) this.savedPlaces = []; if (!prefixes) this.placesInView = [];
} }
console.log('Loaded saved places:', this.savedPlaces.length); console.log('Loaded saved places:', this.placesInView.length);
} catch (e) { } catch (e) {
console.error('Failed to load places:', e); console.error('Failed to load places:', e);
} }
} }
findPlaceById(id) { findPlaceById(id) {
// Search by internal ID first if (!id) return undefined;
let place = this.savedPlaces.find((p) => p.id === id); const strId = String(id);
// Search by internal ID first (loose comparison via string cast)
let place = this.savedPlaces.find((p) => p.id && String(p.id) === strId);
if (place) return place; if (place) return place;
// Then search by OSM ID // Then search by OSM ID
place = this.savedPlaces.find((p) => p.osmId === id); place = this.savedPlaces.find((p) => p.osmId && String(p.osmId) === strId);
return place; return place;
} }
async storePlace(placeData) {
const savedPlace = await this.places.store(placeData);
// 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);
}
@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 { body {
margin: 0; margin: 0;
font-family: 'Noto Serif', serif;
} }
#root, #root,
@@ -20,14 +21,165 @@ body {
background: #f8f9fa; background: #f8f9fa;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
outline: none; /* Prevent focus outline on click */ outline: none; /* Prevent focus outline on click */
position: absolute;
inset: 0;
} }
/* Ensure RS widget is above the map */ /* Ensure RS widget is above the map but potentially hidden initially if needed */
#remotestorage-widget { #rs-widget-container {
position: absolute; position: absolute;
top: 10px; top: 60px; /* Below header */
right: 10px; 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 */ /* Sidebar Styles */
@@ -39,12 +191,28 @@ body {
width: 300px; width: 300px;
background: white; background: white;
color: #333; color: #333;
z-index: 2000; z-index: 3100; /* Higher than Header (3000) */
box-shadow: 2px 0 5px rgb(0 0 0 / 10%); box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.settings-pane.sidebar {
z-index: 3200; /* Higher than Places Sidebar (3100) */
}
/* 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 { .sidebar-header {
padding: 1rem; padding: 1rem;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
@@ -62,54 +230,120 @@ body {
padding: 1rem; padding: 1rem;
} }
.close-btn { .edit-form {
background: none; margin: -1rem;
border: none; margin-bottom: 1rem;
font-size: 1.5rem; background: #f8f9fa;
cursor: pointer; padding: 1rem;
padding: 0 0.5rem; border-bottom: 1px solid #eee;
} }
.back-btn { .form-group {
background: none; margin-bottom: 0.75rem;
border: none;
font-size: 1.2rem;
cursor: pointer;
margin-right: 0.5rem;
} }
.place-details { .form-group label {
} display: block;
font-size: 0.85rem;
.place-details h3 {
font-size: 1.2rem;
margin-top: 0;
margin-bottom: 0.5rem;
}
.place-details .place-type {
color: #666; color: #666;
font-size: 0.9rem; margin-bottom: 0.25rem;
text-transform: capitalize;
margin: 0 0 1rem 0;
} }
.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;
}
.settings-section .form-group {
margin-top: 1rem;
}
.btn-full {
width: 100%;
} }
.btn-primary { .btn-primary {
background: #007bff; background: #007bff;
color: white; color: white;
border: none; border: none;
padding: 0.75rem 1.5rem; padding: 0.75rem;
border-radius: 4px; border-radius: 4px;
font-weight: 500;
cursor: pointer; cursor: pointer;
width: 100%;
font-size: 1rem;
} }
.btn-primary:hover { .btn-primary:hover {
background: #0056b3; background: #0069d9;
}
.meta-info {
font-size: 0.9rem;
}
.meta-info p:first-child {
margin-top: 1.2rem;
padding-top: 1.2rem;
border-top: 1px solid #eee;
}
.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 { .places-list {
@@ -148,44 +382,113 @@ body {
text-transform: capitalize; text-transform: capitalize;
} }
.meta-info { .back-btn {
margin-top: 1.5rem; background: none;
padding-top: 1rem; border: none;
border-top: 1px solid #eee; 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; font-size: 0.9rem;
text-align: left; text-transform: capitalize;
margin: 0 0 1rem;
} }
.meta-info p { .place-details .place-description {
margin: 0.75rem 0; margin-bottom: 1.5rem;
line-height: 1.4;
word-break: break-word; /* Prevent long URLs from breaking layout */
} }
.meta-info strong { .place-details .actions {
font-weight: bold; padding-bottom: 0.3rem;
display: flex;
flex-direction: row;
gap: 1rem;
} }
.meta-info a { .btn {
color: #007bff; padding: 0.75rem 1.5rem;
text-decoration: none; border-radius: 4px;
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
} }
.meta-info a:hover { .btn-sm {
text-decoration: underline; padding: 0.4rem 1rem !important;
font-size: 0.9rem !important;
} }
.meta-divider { .btn-outline {
border: 0; background: transparent;
border-top: 1px dashed #ddd; color: #333;
margin: 1rem 0; 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 */ /* Map Search Pulse Animation */
.search-pulse { .search-pulse {
border-radius: 50%; border-radius: 50%;
border: 2px solid rgba(255, 204, 51, 0.8); /* Gold/Yellow to match markers */ border: 2px solid rgb(255 204 51 / 80%); /* Gold/Yellow to match markers */
background: rgba(255, 204, 51, 0.2); background: rgb(255 204 51 / 20%);
position: absolute; position: absolute;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
pointer-events: none; pointer-events: none;
@@ -199,8 +502,8 @@ body {
} }
.search-pulse.blue { .search-pulse.blue {
border-color: rgba(51, 153, 204, 0.8); border-color: rgb(51 153 204 / 80%);
background: rgba(51, 153, 204, 0.2); background: rgb(51 153 204 / 20%);
} }
@keyframes pulse { @keyframes pulse {
@@ -208,6 +511,7 @@ body {
transform: translate(-50%, -50%) scale(0.8); transform: translate(-50%, -50%) scale(0.8);
opacity: 0.8; opacity: 0.8;
} }
100% { 100% {
transform: translate(-50%, -50%) scale(1.4); transform: translate(-50%, -50%) scale(1.4);
opacity: 0; opacity: 0;
@@ -216,13 +520,20 @@ body {
/* Locate Control */ /* Locate Control */
.ol-control.ol-locate { .ol-control.ol-locate {
top: 5em; /* Position below zoom controls (usually at .5em or similar) */ inset: auto 0.5em 2.5em auto;
right: 0.5em;
left: auto;
} }
.ol-touch .ol-control.ol-locate { .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 { span.icon {
@@ -236,7 +547,7 @@ span.icon {
.icon svg { .icon svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
stroke: currentColor; stroke: currentcolor;
fill: none; fill: none;
stroke-width: 2; stroke-width: 2;
stroke-linecap: round; stroke-linecap: round;
@@ -253,6 +564,7 @@ span.icon {
/* Selected Pin Animation */ /* Selected Pin Animation */
.selected-pin-container { .selected-pin-container {
position: absolute; position: absolute;
/* Center the bottom tip of the pin at the coordinate */ /* Center the bottom tip of the pin at the coordinate */
transform: translate(-50%, -100%); transform: translate(-50%, -100%);
pointer-events: none; /* Let clicks pass through to the map features below if needed */ pointer-events: none; /* Let clicks pass through to the map features below if needed */
@@ -261,14 +573,14 @@ span.icon {
.selected-pin-container.active { .selected-pin-container.active {
display: block; 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 { .selected-pin {
width: 40px; width: 40px;
height: 40px; height: 40px;
color: #ea4335; /* Google Red */ 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 { .selected-pin svg {
@@ -283,7 +595,7 @@ span.icon {
.selected-pin-shadow { .selected-pin-shadow {
width: 10px; width: 10px;
height: 4px; height: 4px;
background: rgba(0, 0, 0, 0.3); background: rgb(0 0 0 / 30%);
border-radius: 50%; border-radius: 50%;
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@@ -291,45 +603,45 @@ span.icon {
transform: translateX(-50%); transform: translateX(-50%);
z-index: -1; z-index: -1;
opacity: 0; opacity: 0;
animation: shadowFade 0.5s 0.2s forwards; animation: shadow-fade 0.5s 0.2s forwards;
} }
@keyframes dropIn { @keyframes drop-in {
0% { 0% {
transform: translate(-50%, -200%) scale(0); transform: translate(-50%, -200%) scale(0);
opacity: 0; opacity: 0;
} }
60% { 60% {
opacity: 1; opacity: 1;
} }
100% { 100% {
transform: translate(-50%, -100%) scale(1); transform: translate(-50%, -100%) scale(1);
opacity: 1; opacity: 1;
} }
} }
@keyframes shadowFade { @keyframes shadow-fade {
to { to {
opacity: 1; opacity: 1;
} }
} }
@media (max-width: 768px) { @media (width <= 768px) {
.sidebar { .sidebar {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%; width: 100%;
height: 50vh; 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-left-radius: 16px;
border-top-right-radius: 16px; border-top-right-radius: 16px;
inset: auto 0 0;
} }
.sidebar-content { .sidebar-content {
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; /* Prevent scroll chaining */ overscroll-behavior: contain; /* Prevent scroll chaining */
/* Ensure content doesn't get hidden behind bottom safe areas on mobile */ /* Ensure content doesn't get hidden behind bottom safe areas on mobile */
padding-bottom: env(safe-area-inset-bottom, 20px); 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 { pageTitle } from 'ember-page-title';
import Map from '#components/map'; import Map from '#components/map';
import PlacesSidebar from '#components/places-sidebar'; import PlacesSidebar from '#components/places-sidebar';
import AppHeader from '#components/app-header';
import SettingsPane from '#components/settings-pane';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { eq } from 'ember-truth-helpers'; import { eq } from 'ember-truth-helpers';
import { and } from 'ember-truth-helpers'; import { and, or } from 'ember-truth-helpers';
import { on } from '@ember/modifier';
export default class ApplicationComponent extends Component { export default class ApplicationComponent extends Component {
@service storage; @service storage;
@service router; @service router;
@tracked nearbyPlaces = null; @tracked nearbyPlaces = null;
@tracked isSettingsOpen = false;
// @tracked bookmarksVersion = 0; // Moved to storage service // @tracked bookmarksVersion = 0; // Moved to storage service
get isSidebarOpen() { get isSidebarOpen() {
@@ -52,6 +56,16 @@ export default class ApplicationComponent extends Component {
} }
} }
@action
toggleSettings() {
this.isSettingsOpen = !this.isSettingsOpen;
}
@action
closeSettings() {
this.isSettingsOpen = false;
}
@action @action
selectFromList(place) { selectFromList(place) {
if (place) { if (place) {
@@ -60,6 +74,15 @@ export default class ApplicationComponent extends Component {
} }
} }
@action
handleOutsideClick() {
if (this.isSettingsOpen) {
this.closeSettings();
} else {
this.closeSidebar();
}
}
@action @action
closeSidebar() { closeSidebar() {
this.nearbyPlaces = null; this.nearbyPlaces = null;
@@ -74,10 +97,25 @@ export default class ApplicationComponent extends Component {
<template> <template>
{{pageTitle "M/\RCO"}} {{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 <Map
@onPlacesFound={{this.showPlaces}} @onPlacesFound={{this.showPlaces}}
@isSidebarOpen={{this.isSidebarOpen}} @isSidebarOpen={{or this.isSidebarOpen this.isSettingsOpen}}
@onOutsideClick={{this.closeSidebar}} @onOutsideClick={{this.handleOutsideClick}}
/> />
{{#if (and (eq this.router.currentRouteName "index") this.nearbyPlaces)}} {{#if (and (eq this.router.currentRouteName "index") this.nearbyPlaces)}}
@@ -88,6 +126,10 @@ export default class ApplicationComponent extends Component {
/> />
{{/if}} {{/if}}
{{#if this.isSettingsOpen}}
<SettingsPane @onClose={{this.closeSettings}} />
{{/if}}
{{outlet}} {{outlet}}
</template> </template>
} }

View File

@@ -23,20 +23,40 @@ export default class PlaceTemplate extends Component {
// Let's use a modifier or just sync it. // Let's use a modifier or just sync it.
get place() { get place() {
// If we have a manually updated place (from save), use it. // 1. Resolve the ID from the model (OSM ID or internal ID)
// Otherwise use the route model.
// We need to ensure we reset `localPlace` when navigating to a NEW place.
// Comparing IDs is a safe bet.
const model = this.args.model; const model = this.args.model;
const id = model.osmId || model.id;
// 2. Check the storage service for a LIVE version of this bookmark
// This is the most critical fix: Storage is the source of truth.
// Since `this.storage.savedPlaces` is @tracked, this getter will re-compute
// whenever a bookmark is added or removed.
const saved = this.storage.findPlaceById(id);
if (saved) {
return saved;
}
// 3. If not saved, check our local "optimistic" state (from handleUpdate)
// This handles the "unsaved" state immediately after deletion before any other sync
if ( if (
this.localPlace && this.localPlace &&
(this.localPlace.id === model.id || this.localPlace.osmId === model.osmId) (this.localPlace.osmId === id || this.localPlace.id === id)
) { ) {
// If the local place is "richer" (has createdAt), prefer it. return this.localPlace;
if (this.localPlace.createdAt && !model.createdAt) return this.localPlace;
// If we deleted it (local has no createdAt, model might?) - wait, if we delete, we close sidebar.
} }
// 4. Fallback to the route model (which might be the stale "saved" object from when the route loaded)
// If the model *has* a createdAt but we didn't find it in step 2 (storage),
// it means it was deleted. We must return a sanitized version.
if (model.createdAt) {
return {
...model,
id: undefined,
createdAt: undefined,
geohash: undefined,
};
}
return model; return model;
} }

View File

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

View File

@@ -30,7 +30,7 @@ const esmParserOptions = {
}; };
export default defineConfig([ export default defineConfig([
globalIgnores(['dist/', 'coverage/', '!**/.*']), globalIgnores(['dist/', 'coverage/', 'release/', '!**/.*']),
js.configs.recommended, js.configs.recommended,
eslintConfigPrettier, eslintConfigPrettier,
ember.configs.base, ember.configs.base,

View File

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

21
pnpm-lock.yaml generated
View File

@@ -7,6 +7,10 @@ settings:
importers: importers:
.: .:
dependencies:
ember-lifeline:
specifier: ^7.0.0
version: 7.0.0(@ember/test-helpers@5.4.1(@babel/core@7.28.6))
devDependencies: devDependencies:
'@babel/core': '@babel/core':
specifier: ^7.28.5 specifier: ^7.28.5
@@ -2254,6 +2258,15 @@ packages:
'@typescript-eslint/parser': '@typescript-eslint/parser':
optional: true 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: ember-modifier@4.2.2:
resolution: {integrity: sha512-pPYBAGyczX0hedGWQFQOEiL9s45KS9efKxJxUQkMLjQyh+1Uef1mcmAGsdw2KmvNupITkE/nXxmVO1kZ9tt3ag==} resolution: {integrity: sha512-pPYBAGyczX0hedGWQFQOEiL9s45KS9efKxJxUQkMLjQyh+1Uef1mcmAGsdw2KmvNupITkE/nXxmVO1kZ9tt3ag==}
@@ -6762,6 +6775,14 @@ snapshots:
- eslint - eslint
- typescript - 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): ember-modifier@4.2.2(@babel/core@7.28.6):
dependencies: dependencies:
'@embroider/addon-shim': 1.10.2 '@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="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<script type="module" crossorigin src="/assets/main-D-uLmO86.js"></script> <script type="module" crossorigin src="/assets/main-Dpm1fpXl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-B-vHK2y6.css"> <link rel="stylesheet" crossorigin href="/assets/main-B9HZHSjP.css">
</head> </head>
<body> <body>
</body> </body>

View File

@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
module('Unit | Route | place', function (hooks) {
setupTest(hooks);
test('it exists', function (assert) {
let route = this.owner.lookup('route:place');
assert.ok(route);
});
});