Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
262e5b61a8
|
|||
|
f87d8bdda9
|
|||
|
f17f8ca17b
|
|||
|
026d1c4712
|
|||
|
6bd55843bb
|
|||
|
33a6469a19
|
|||
|
6d7bea411a
|
|||
|
7b01bb1118
|
|||
|
84d4f9cbbf
|
|||
|
f7e7480e51
|
|||
|
6e87ef3573
|
|||
|
86b85e9a0b
|
|||
|
2a203e8e82
|
|||
|
b08dcedd13
|
|||
|
5267ffdd5c
|
|||
|
deae2260b1
|
|||
|
3c5b4d9b98
|
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
# compiled output
|
# compiled output
|
||||||
/dist/
|
/dist/
|
||||||
|
/release/
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
/coverage/
|
/coverage/
|
||||||
|
|||||||
@@ -3,3 +3,4 @@
|
|||||||
|
|
||||||
# compiled output
|
# compiled output
|
||||||
/dist/
|
/dist/
|
||||||
|
/release/
|
||||||
|
|||||||
@@ -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,6 +53,7 @@ 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.
|
||||||
@@ -59,6 +64,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 +74,25 @@ 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.
|
||||||
|
|
||||||
## 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/map.gjs`: Map rendering, interaction, and mobile auto-panning.
|
||||||
- `app/components/map.gjs`: Map rendering and interaction.
|
- `app/templates/application.gjs`: Root template handling place selection logic.
|
||||||
- `app/services/storage.js`: Data sync 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. **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.
|
2. **Edit Bookmarks:** Allow users to edit the title and description of saved places.
|
||||||
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
|
3. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, and address unused variables/runloop usage.
|
||||||
4. **Testing:** Add automated tests for the geohash coverage and retry logic.
|
4. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
|
||||||
|
5. **Testing:** Add automated tests for the geohash coverage and retry logic.
|
||||||
|
|
||||||
## Technical Constraints
|
## Technical Constraints
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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 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 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';
|
||||||
@@ -8,19 +10,23 @@ 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 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 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';
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
|
'arrow-left': arrowLeft,
|
||||||
|
bookmark,
|
||||||
clock,
|
clock,
|
||||||
globe,
|
globe,
|
||||||
home,
|
home,
|
||||||
map,
|
map,
|
||||||
mapPin,
|
'map-pin': mapPin,
|
||||||
navigation,
|
navigation,
|
||||||
phone,
|
phone,
|
||||||
|
settings,
|
||||||
user,
|
user,
|
||||||
settings
|
x
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class IconComponent extends Component {
|
export default class IconComponent extends Component {
|
||||||
@@ -33,7 +39,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() {
|
||||||
|
|||||||
@@ -114,7 +114,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 +123,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';
|
||||||
|
|
||||||
@@ -353,21 +353,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,25 +434,27 @@ 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,
|
||||||
@@ -405,8 +465,8 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
// 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 +476,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 +515,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 {
|
||||||
@@ -484,6 +544,8 @@ 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');
|
||||||
|
|||||||
@@ -67,6 +67,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;
|
||||||
}
|
}
|
||||||
@@ -106,6 +111,10 @@ 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>
|
<h3>{{this.name}}</h3>
|
||||||
@@ -121,14 +130,16 @@ export default class PlaceDetails extends Component {
|
|||||||
<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>
|
||||||
</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,7 +164,7 @@ 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}}
|
||||||
|
|
||||||
@@ -164,7 +175,8 @@ export default class PlaceDetails extends Component {
|
|||||||
</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 +186,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 +204,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>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -115,7 +92,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
|
||||||
@@ -147,8 +124,7 @@ 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}}
|
||||||
@@ -156,14 +132,14 @@ export default class PlacesSidebar extends Component {
|
|||||||
type="button"
|
type="button"
|
||||||
class="close-btn"
|
class="close-btn"
|
||||||
{{on "click" @onClose}}
|
{{on "click" @onClose}}
|
||||||
>×</button>
|
><Icon @name="x" @size={{20}} @color="#333" /></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}}
|
||||||
/>
|
/>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{#if @places}}
|
{{#if @places}}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ export default class OsmService extends Service {
|
|||||||
out center;
|
out center;
|
||||||
`.trim();
|
`.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
|
query
|
||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
@@ -99,7 +100,8 @@ out center;
|
|||||||
`.trim();
|
`.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
|
query
|
||||||
)}`;
|
)}`;
|
||||||
const res = await this.fetchWithRetry(url);
|
const res = await this.fetchWithRetry(url);
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import Geohash from 'latlon-geohash';
|
|||||||
|
|
||||||
export default class StorageService extends Service {
|
export default class StorageService extends Service {
|
||||||
rs;
|
rs;
|
||||||
|
@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;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(...arguments);
|
super(...arguments);
|
||||||
@@ -31,14 +33,63 @@ export default class StorageService extends Service {
|
|||||||
// widget.attach();
|
// widget.attach();
|
||||||
|
|
||||||
this.rs.on('ready', () => {
|
this.rs.on('ready', () => {
|
||||||
// this.loadAllPlaces();
|
// console.debug('[rs] client ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rs.on('sync-done', (result) => {
|
||||||
|
// 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) => {
|
||||||
|
// console.debug(event);
|
||||||
|
this.handlePlaceChange(event);
|
||||||
debounce(this, this.reloadCurrentView, 200);
|
debounce(this, 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;
|
||||||
}
|
}
|
||||||
@@ -98,7 +149,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
|
||||||
@@ -112,27 +163,41 @@ export default class StorageService extends Service {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 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);
|
||||||
|
this.savedPlaces = [...this.savedPlaces, savedPlace];
|
||||||
|
return savedPlace;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePlace(place) {
|
||||||
|
await this.places.remove(place.id, place.geohash);
|
||||||
|
this.savedPlaces = this.savedPlaces.filter(p => p.id !== place.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,20 +62,26 @@ body {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn {
|
.back-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 1.2rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-right: 0.5rem;
|
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 {
|
.place-details {
|
||||||
@@ -97,21 +103,65 @@ body {
|
|||||||
.place-details .place-description {
|
.place-details .place-description {
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.place-details .actions {
|
||||||
background: #007bff;
|
padding-bottom: 0.3rem;
|
||||||
color: white;
|
/* display: flex; */
|
||||||
border: none;
|
/* flex-direction: row; */
|
||||||
|
/* gap: 1rem; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 100%;
|
/* width: 50%; */
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: #333;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
border: 1px solid #898989;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
color: #333;
|
||||||
|
border: 1px solid rgba(255, 204, 51, 0.2);
|
||||||
|
background: rgba(255, 204, 51, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border: 1px solid #898989;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-blue {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-blue:hover {
|
||||||
background: #0056b3;
|
background: #0056b3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-green {
|
||||||
|
background: #198754;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-green:hover {
|
||||||
|
background: #157347;
|
||||||
|
}
|
||||||
|
|
||||||
.places-list {
|
.places-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -149,13 +199,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta-info {
|
.meta-info {
|
||||||
margin-top: 1.5rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid #eee;
|
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta-info p:first-child {
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
padding-top: 1.2rem;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
.meta-info p {
|
.meta-info p {
|
||||||
margin: 0.75rem 0;
|
margin: 0.75rem 0;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -169,18 +222,13 @@ body {
|
|||||||
.meta-info a {
|
.meta-info a {
|
||||||
color: #007bff;
|
color: #007bff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
padding-bottom: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-info a:hover {
|
.meta-info a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-divider {
|
|
||||||
border: 0;
|
|
||||||
border-top: 1px dashed #ddd;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Map Search Pulse Animation */
|
/* Map Search Pulse Animation */
|
||||||
.search-pulse {
|
.search-pulse {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|||||||
@@ -23,20 +23,37 @@ 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;
|
||||||
if (
|
const id = model.osmId || model.id;
|
||||||
this.localPlace &&
|
|
||||||
(this.localPlace.id === model.id || this.localPlace.osmId === model.osmId)
|
// 2. Check the storage service for a LIVE version of this bookmark
|
||||||
) {
|
// This is the most critical fix: Storage is the source of truth.
|
||||||
// If the local place is "richer" (has createdAt), prefer it.
|
// Since `this.storage.savedPlaces` is @tracked, this getter will re-compute
|
||||||
if (this.localPlace.createdAt && !model.createdAt) return this.localPlace;
|
// whenever a bookmark is added or removed.
|
||||||
// If we deleted it (local has no createdAt, model might?) - wait, if we delete, we close sidebar.
|
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 (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)
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.3.2",
|
"version": "1.4.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Small description for marco goes here",
|
"description": "Small description for marco goes here",
|
||||||
"repository": "",
|
"repository": "",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
2
release/assets/main-CmTTrT7_.js
Normal file
2
release/assets/main-CmTTrT7_.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
release/assets/main-GCk-xMVE.css
Normal file
1
release/assets/main-GCk-xMVE.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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-CmTTrT7_.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-B-vHK2y6.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-GCk-xMVE.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
11
tests/unit/routes/place-test.js
Normal file
11
tests/unit/routes/place-test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user