Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
f60dacac80
|
|||
|
9a02363515
|
|||
|
f28be0c994
|
|||
|
721fe5f01d
|
|||
|
518685b7dc
|
|||
|
262e5b61a8
|
|||
|
f87d8bdda9
|
|||
|
f17f8ca17b
|
|||
|
026d1c4712
|
|||
|
6bd55843bb
|
|||
|
33a6469a19
|
|||
|
6d7bea411a
|
|||
|
7b01bb1118
|
59
app/components/app-header.gjs
Normal file
59
app/components/app-header.gjs
Normal file
@@ -0,0 +1,59 @@
|
||||
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">
|
||||
<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>
|
||||
}
|
||||
@@ -1,26 +1,44 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
|
||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
||||
import home from 'feather-icons/dist/icons/home.svg?raw';
|
||||
import logIn from 'feather-icons/dist/icons/log-in.svg?raw';
|
||||
import logOut from 'feather-icons/dist/icons/log-out.svg?raw';
|
||||
import map from 'feather-icons/dist/icons/map.svg?raw';
|
||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||
|
||||
const ICONS = {
|
||||
'arrow-left': arrowLeft,
|
||||
activity,
|
||||
bookmark,
|
||||
clock,
|
||||
globe,
|
||||
home,
|
||||
'log-in': logIn,
|
||||
'log-out': logOut,
|
||||
map,
|
||||
mapPin,
|
||||
'map-pin': mapPin,
|
||||
menu,
|
||||
navigation,
|
||||
phone,
|
||||
server,
|
||||
settings,
|
||||
user,
|
||||
settings
|
||||
x,
|
||||
zap,
|
||||
};
|
||||
|
||||
export default class IconComponent extends Component {
|
||||
|
||||
@@ -61,7 +61,6 @@ export default class MapComponent extends Component {
|
||||
zIndex: 10, // Ensure it sits above the map tiles
|
||||
});
|
||||
|
||||
|
||||
// Default view settings
|
||||
let center = [99.05738, 7.55087];
|
||||
let zoom = 13.0;
|
||||
@@ -95,7 +94,11 @@ export default class MapComponent extends Component {
|
||||
target: element,
|
||||
layers: [openfreemap, bookmarkLayer],
|
||||
view: view,
|
||||
controls: defaultControls({ zoom: false, rotate: true, attribution: true }),
|
||||
controls: defaultControls({
|
||||
zoom: false,
|
||||
rotate: true,
|
||||
attribution: true,
|
||||
}),
|
||||
});
|
||||
|
||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
|
||||
@@ -210,7 +213,9 @@ export default class MapComponent extends Component {
|
||||
geolocation.un('change:position', zoomToLocation);
|
||||
locateListenerKey = null;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// Hide pulse
|
||||
if (this.locationOverlayElement) {
|
||||
@@ -261,7 +266,8 @@ export default class MapComponent extends Component {
|
||||
else if (accuracy) {
|
||||
const viewportWidthMeters = 6.325 * accuracy;
|
||||
const minDimensionPixels = Math.min(size[0], size[1]);
|
||||
const requiredResolutionMeters = viewportWidthMeters / minDimensionPixels;
|
||||
const requiredResolutionMeters =
|
||||
viewportWidthMeters / minDimensionPixels;
|
||||
const metersPerMapUnit = getPointResolution(
|
||||
view.getProjection(),
|
||||
1,
|
||||
@@ -443,23 +449,23 @@ export default class MapComponent extends Component {
|
||||
|
||||
// If the pin is in the bottom half (y > splitPoint), it is obscured
|
||||
if (pixel[1] > splitPoint) {
|
||||
// Target position: Center of top half = height * 0.25
|
||||
const targetY = height * 0.25;
|
||||
const deltaY = pixel[1] - targetY;
|
||||
// Target position: Center of top half = height * 0.25
|
||||
const targetY = height * 0.25;
|
||||
const deltaY = pixel[1] - targetY;
|
||||
|
||||
const view = this.mapInstance.getView();
|
||||
const center = view.getCenter();
|
||||
const resolution = view.getResolution();
|
||||
const view = this.mapInstance.getView();
|
||||
const center = view.getCenter();
|
||||
const resolution = view.getResolution();
|
||||
|
||||
// Move the map center SOUTH (decrease Y) to move the pin UP (decrease pixel Y)
|
||||
const deltaMapUnits = deltaY * resolution;
|
||||
const newCenter = [center[0], center[1] - deltaMapUnits];
|
||||
// Move the map center SOUTH (decrease Y) to move the pin UP (decrease pixel Y)
|
||||
const deltaMapUnits = deltaY * resolution;
|
||||
const newCenter = [center[0], center[1] - deltaMapUnits];
|
||||
|
||||
view.animate({
|
||||
center: newCenter,
|
||||
duration: 500,
|
||||
easing: (t) => t * (2 - t) // Ease-out
|
||||
});
|
||||
view.animate({
|
||||
center: newCenter,
|
||||
duration: 500,
|
||||
easing: (t) => t * (2 - t), // Ease-out
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,7 +531,7 @@ export default class MapComponent extends Component {
|
||||
|
||||
const viewState = {
|
||||
center: currentCenter,
|
||||
zoom: currentZoom
|
||||
zoom: currentZoom,
|
||||
};
|
||||
|
||||
localStorage.setItem('marco:map-view', JSON.stringify(viewState));
|
||||
@@ -545,7 +551,9 @@ export default class MapComponent extends Component {
|
||||
|
||||
if (features && features.length > 0) {
|
||||
console.debug(`Found ${features.length} features in map layer:`);
|
||||
for (const f of features) { console.debug(f) }
|
||||
for (const f of features) {
|
||||
console.debug(f);
|
||||
}
|
||||
const bookmarkFeature = features.find((f) => f.get('isBookmark'));
|
||||
if (bookmarkFeature) {
|
||||
clickedBookmark = bookmarkFeature.get('originalPlace');
|
||||
|
||||
@@ -67,6 +67,11 @@ export default class PlaceDetails extends Component {
|
||||
return this.place.url || this.tags.website || this.tags['contact:website'];
|
||||
}
|
||||
|
||||
get websiteDomain() {
|
||||
const url = new URL(this.website);
|
||||
return url.hostname;
|
||||
}
|
||||
|
||||
get openingHours() {
|
||||
return this.tags.opening_hours;
|
||||
}
|
||||
@@ -74,10 +79,10 @@ export default class PlaceDetails extends Component {
|
||||
get cuisine() {
|
||||
if (!this.tags.cuisine) return null;
|
||||
return this.tags.cuisine
|
||||
.split(';')
|
||||
.map(c => capitalize.compute([c]))
|
||||
.map(c => c.replace('_', ' '))
|
||||
.join(', ');
|
||||
.split(';')
|
||||
.map((c) => capitalize.compute([c]))
|
||||
.map((c) => c.replace('_', ' '))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
get wikipedia() {
|
||||
@@ -106,6 +111,10 @@ export default class PlaceDetails extends Component {
|
||||
return `https://www.openstreetmap.org/${type}/${id}`;
|
||||
}
|
||||
|
||||
get gmapsUrl() {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${this.name}&query=${this.place.lat},${this.place.lon}`;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="place-details">
|
||||
<h3>{{this.name}}</h3>
|
||||
@@ -114,21 +123,30 @@ export default class PlaceDetails extends Component {
|
||||
</p>
|
||||
{{#if this.place.description}}
|
||||
<p class="place-description">
|
||||
{{this.place.description}}
|
||||
{{this.place.description}}
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class={{if this.place.createdAt "btn-secondary" "btn-primary"}}
|
||||
class={{if
|
||||
this.place.createdAt
|
||||
"btn btn-secondary"
|
||||
"btn btn-outline"
|
||||
}}
|
||||
{{on "click" (fn @onToggleSave this.place)}}
|
||||
>
|
||||
{{if this.place.createdAt "Saved ✓" "Save"}}
|
||||
<Icon
|
||||
@name="bookmark"
|
||||
@color={{if this.place.createdAt "currentColor" "#007bff"}}
|
||||
/>
|
||||
{{if this.place.createdAt "Saved" "Save"}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="meta-info">
|
||||
|
||||
{{#if this.cuisine}}
|
||||
<p>
|
||||
<strong>Cuisine:</strong>
|
||||
@@ -153,18 +171,27 @@ export default class PlaceDetails extends Component {
|
||||
{{#if this.website}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="globe" @title="Website" />
|
||||
<span><a href={{this.website}} target="_blank" rel="noopener noreferrer">Website</a></span>
|
||||
<span><a
|
||||
href={{this.website}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{this.websiteDomain}}</a></span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.wikipedia}}
|
||||
<p>
|
||||
<strong>Wikipedia:</strong>
|
||||
<a href="https://wikipedia.org/wiki/{{this.wikipedia}}" target="_blank" rel="noopener noreferrer">Article</a>
|
||||
<a
|
||||
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Article</a>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<hr class="meta-divider">
|
||||
</div>
|
||||
<div class="meta-info">
|
||||
|
||||
{{#if this.address}}
|
||||
<p class="content-with-icon">
|
||||
@@ -174,7 +201,7 @@ export default class PlaceDetails extends Component {
|
||||
{{/if}}
|
||||
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="mapPin" @title="Geo link" />
|
||||
<Icon @name="map-pin" @title="Geo link" />
|
||||
<span>
|
||||
<a href={{this.geoLink}} target="_blank" rel="noopener noreferrer">
|
||||
{{this.visibleGeoLink}}
|
||||
@@ -192,6 +219,16 @@ export default class PlaceDetails extends Component {
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="map" @title="OSM ID" />
|
||||
<span>
|
||||
<a href={{this.gmapsUrl}} target="_blank" rel="noopener noreferrer">
|
||||
Google Maps
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import or from 'ember-truth-helpers/helpers/or';
|
||||
import PlaceDetails from './place-details';
|
||||
import Icon from './icon';
|
||||
|
||||
export default class PlacesSidebar extends Component {
|
||||
@service storage;
|
||||
@@ -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
|
||||
async toggleSave(place) {
|
||||
if (!place) return;
|
||||
|
||||
if (place.createdAt) {
|
||||
// It's a saved bookmark -> Delete it
|
||||
if (confirm(`Delete "${place.title}"?`)) {
|
||||
try {
|
||||
if (place.id && place.geohash) {
|
||||
await this.storage.places.remove(place.id, place.geohash);
|
||||
console.log('Place deleted:', place.title);
|
||||
await this.storage.removePlace(place);
|
||||
console.log('Place deleted:', place.title);
|
||||
|
||||
// Notify parent to refresh map bookmarks
|
||||
if (this.args.onBookmarkChange) {
|
||||
this.args.onBookmarkChange();
|
||||
}
|
||||
// Notify parent to refresh map bookmarks
|
||||
if (this.args.onBookmarkChange) {
|
||||
this.args.onBookmarkChange();
|
||||
}
|
||||
|
||||
// Update selection to the new saved place object
|
||||
// This updates the local UI state immediately without a route refresh
|
||||
if (this.args.onUpdate) {
|
||||
// When deleting, we revert to a "fresh" object or just close.
|
||||
// Since we close the sidebar below, we might not strictly need to update local state,
|
||||
// but it's good practice.
|
||||
// Reconstruct the "original" place without ID/Geohash/CreatedAt
|
||||
const freshPlace = {
|
||||
...place,
|
||||
id: undefined,
|
||||
geohash: undefined,
|
||||
createdAt: undefined
|
||||
};
|
||||
this.args.onUpdate(freshPlace);
|
||||
}
|
||||
// Update selection to the new saved place object
|
||||
// This updates the local UI state immediately without a route refresh
|
||||
if (this.args.onUpdate) {
|
||||
// When deleting, we revert to a "fresh" object or just close.
|
||||
// Since we close the sidebar below, we might not strictly need to update local state,
|
||||
// but it's good practice.
|
||||
// Reconstruct the "original" place without ID/Geohash/CreatedAt
|
||||
const freshPlace = {
|
||||
...place,
|
||||
id: undefined,
|
||||
geohash: undefined,
|
||||
createdAt: undefined,
|
||||
};
|
||||
this.args.onUpdate(freshPlace);
|
||||
}
|
||||
|
||||
// Also fire onSelect if it exists (for list view)
|
||||
if (this.args.onSelect) {
|
||||
// Similar logic for select if needed, but we usually close.
|
||||
this.args.onSelect(null);
|
||||
}
|
||||
// Also fire onSelect if it exists (for list view)
|
||||
if (this.args.onSelect) {
|
||||
// Similar logic for select if needed, but we usually close.
|
||||
this.args.onSelect(null);
|
||||
}
|
||||
|
||||
// Close sidebar after delete
|
||||
if (this.args.onClose) {
|
||||
this.args.onClose();
|
||||
}
|
||||
} else {
|
||||
alert('Cannot delete: Missing ID or Geohash');
|
||||
// Close sidebar after delete
|
||||
if (this.args.onClose) {
|
||||
this.args.onClose();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete:', e);
|
||||
@@ -104,7 +81,8 @@ export default class PlacesSidebar extends Component {
|
||||
} else {
|
||||
// It's a fresh POI -> Save it
|
||||
const placeData = {
|
||||
title: place.osmTags.name || place.osmTags['name:en'] || 'Untitled Place',
|
||||
title:
|
||||
place.osmTags.name || place.osmTags['name:en'] || 'Untitled Place',
|
||||
lat: place.lat,
|
||||
lon: place.lon,
|
||||
tags: [],
|
||||
@@ -115,7 +93,7 @@ export default class PlacesSidebar extends Component {
|
||||
};
|
||||
|
||||
try {
|
||||
const savedPlace = await this.storage.places.store(placeData);
|
||||
const savedPlace = await this.storage.storePlace(placeData);
|
||||
console.log('Place saved:', placeData.title);
|
||||
|
||||
// Notify parent to refresh map bookmarks
|
||||
@@ -147,23 +125,22 @@ export default class PlacesSidebar extends Component {
|
||||
type="button"
|
||||
class="back-btn"
|
||||
{{on "click" this.clearSelection}}
|
||||
>←</button>
|
||||
<h2>Details</h2>
|
||||
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
||||
{{else}}
|
||||
<h2>Nearby Places</h2>
|
||||
{{/if}}
|
||||
<button
|
||||
type="button"
|
||||
class="close-btn"
|
||||
{{on "click" @onClose}}
|
||||
>×</button>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
||||
@name="x"
|
||||
@size={{20}}
|
||||
@color="#333"
|
||||
/></button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
{{#if @selectedPlace}}
|
||||
<PlaceDetails
|
||||
@place={{@selectedPlace}}
|
||||
@onToggleSave={{this.toggleSave}}
|
||||
<PlaceDetails
|
||||
@place={{@selectedPlace}}
|
||||
@onToggleSave={{this.toggleSave}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#if @places}}
|
||||
|
||||
66
app/components/user-menu.gjs
Normal file
66
app/components/user-menu.gjs
Normal 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>
|
||||
}
|
||||
@@ -9,17 +9,14 @@ export default class PlaceRoute extends Route {
|
||||
async model(params) {
|
||||
const id = params.place_id;
|
||||
|
||||
// Check for explicit OSM prefixes
|
||||
if (id.startsWith('osm:node:') || id.startsWith('osm:way:')) {
|
||||
const [, type, osmId] = id.split(':');
|
||||
console.log(`Fetching explicit OSM ${type}:`, osmId);
|
||||
return this.loadOsmPlace(osmId, type);
|
||||
}
|
||||
|
||||
// Wait for storage sync before checking bookmarks
|
||||
await this.waitForSync();
|
||||
|
||||
// 1. Try to find in local bookmarks
|
||||
let bookmark = this.storage.findPlaceById(id);
|
||||
|
||||
if (bookmark) {
|
||||
@@ -27,9 +24,8 @@ export default class PlaceRoute extends Route {
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
// 2. Fallback: Fetch from OSM (assuming generic ID or old format)
|
||||
console.log('Not in bookmarks, fetching from OSM:', id);
|
||||
return this.loadOsmPlace(id);
|
||||
console.warn('Not in bookmarks:', id);
|
||||
return null;
|
||||
}
|
||||
|
||||
async waitForSync() {
|
||||
|
||||
@@ -23,7 +23,8 @@ export default class OsmService extends Service {
|
||||
out center;
|
||||
`.trim();
|
||||
|
||||
const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(
|
||||
const url = `https://overpass.bke.ro/api/interpreter?data=${encodeURIComponent(
|
||||
// const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(
|
||||
query
|
||||
)}`;
|
||||
|
||||
@@ -99,7 +100,8 @@ out center;
|
||||
`.trim();
|
||||
}
|
||||
|
||||
const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(
|
||||
const url = `https://overpass.bke.ro/api/interpreter?data=${encodeURIComponent(
|
||||
// const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(
|
||||
query
|
||||
)}`;
|
||||
const res = await this.fetchWithRetry(url);
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import Service from '@ember/service';
|
||||
import RemoteStorage from 'remotestoragejs';
|
||||
import Places from '@remotestorage/module-places';
|
||||
import Widget from 'remotestorage-widget';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||
import { action } from '@ember/object';
|
||||
import { debounce } from '@ember/runloop';
|
||||
import Geohash from 'latlon-geohash';
|
||||
|
||||
export default class StorageService extends Service {
|
||||
rs;
|
||||
widget;
|
||||
@tracked placesInView = [];
|
||||
@tracked savedPlaces = [];
|
||||
@tracked loadedPrefixes = [];
|
||||
@tracked currentBbox = null;
|
||||
@tracked version = 0; // Shared version tracker for bookmarks
|
||||
@tracked initialSyncDone = false;
|
||||
@tracked connected = false;
|
||||
@tracked userAddress = null;
|
||||
@tracked isWidgetOpen = false;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
@@ -29,14 +35,38 @@ export default class StorageService extends Service {
|
||||
|
||||
window.remoteStorage = this.rs;
|
||||
|
||||
// const widget = new Widget(this.rs);
|
||||
// widget.attach();
|
||||
this.widget = new Widget(this.rs, {
|
||||
leaveOpen: true,
|
||||
skipInitial: true,
|
||||
});
|
||||
// We don't attach immediately; we'll attach when the user clicks Connect
|
||||
|
||||
this.rs.on('ready', () => {
|
||||
// console.debug('[rs] client ready');
|
||||
});
|
||||
|
||||
this.rs.on('sync-done', (result) => {
|
||||
this.rs.on('connected', () => {
|
||||
console.debug('Remote storage connected');
|
||||
this.connected = true;
|
||||
this.userAddress = this.rs.remote.userAddress;
|
||||
|
||||
// Close widget after successful connection (respecting autoCloseAfter)
|
||||
setTimeout(() => {
|
||||
this.isWidgetOpen = false;
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -157,7 +187,7 @@ export default class StorageService extends Service {
|
||||
// If the hash is in the set of reloaded prefixes, we discard the old version
|
||||
// (because the 'places' array contains the authoritative new state for this prefix)
|
||||
return !prefixSet.has(hash);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return true; // Keep malformed/unknown places safe
|
||||
}
|
||||
});
|
||||
@@ -178,12 +208,48 @@ export default class StorageService extends Service {
|
||||
}
|
||||
|
||||
findPlaceById(id) {
|
||||
// Search by internal ID first
|
||||
let place = this.savedPlaces.find((p) => p.id === id);
|
||||
if (!id) return undefined;
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,161 @@ body {
|
||||
outline: none; /* Prevent focus outline on click */
|
||||
}
|
||||
|
||||
/* Ensure RS widget is above the map */
|
||||
#remotestorage-widget {
|
||||
/* Ensure RS widget is above the map but potentially hidden initially if needed */
|
||||
#rs-widget-container {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
top: 60px; /* Below header */
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
z-index: 4000;
|
||||
display: none; /* Hidden by default */
|
||||
}
|
||||
|
||||
#rs-widget-container.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rs-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3999; /* Below widget container but above everything else */
|
||||
/* background: rgba(0,0,0,0.2); 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 rgba(0, 0, 0, 0.2);
|
||||
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 rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* 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 rgba(0, 0, 0, 0.15);
|
||||
padding: 1rem;
|
||||
z-index: 3001;
|
||||
}
|
||||
|
||||
.menu-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3000; /* Below popover but above everything else */
|
||||
/* background: rgba(0,0,0,0.1); 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 */
|
||||
@@ -62,20 +211,26 @@ body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
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 {
|
||||
@@ -97,21 +252,65 @@ body {
|
||||
.place-details .place-description {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
.place-details .actions {
|
||||
padding-bottom: 0.3rem;
|
||||
/* display: flex; */
|
||||
/* flex-direction: row; */
|
||||
/* gap: 1rem; */
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
/* width: 50%; */
|
||||
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.3);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 204, 51, 0.4);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.places-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -149,13 +348,16 @@ body {
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.meta-info p:first-child {
|
||||
margin-top: 1.2rem;
|
||||
padding-top: 1.2rem;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.meta-info p {
|
||||
margin: 0.75rem 0;
|
||||
line-height: 1.4;
|
||||
@@ -169,18 +371,13 @@ body {
|
||||
.meta-info a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.meta-info a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.meta-divider {
|
||||
border: 0;
|
||||
border-top: 1px dashed #ddd;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Map Search Pulse Animation */
|
||||
.search-pulse {
|
||||
border-radius: 50%;
|
||||
@@ -216,13 +413,28 @@ body {
|
||||
|
||||
/* Locate Control */
|
||||
.ol-control.ol-locate {
|
||||
top: 5em; /* Position below zoom controls (usually at .5em or similar) */
|
||||
top: auto;
|
||||
bottom: 2.5em;
|
||||
right: 0.5em;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.ol-touch .ol-control.ol-locate {
|
||||
top: 5.5em; /* Adjust for touch devices where controls might be larger */
|
||||
top: auto;
|
||||
bottom: 3.5em;
|
||||
}
|
||||
|
||||
/* Rotate Control */
|
||||
.ol-rotate {
|
||||
top: auto;
|
||||
bottom: 5em;
|
||||
right: 0.5em;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.ol-touch .ol-rotate {
|
||||
top: auto;
|
||||
bottom: 6em;
|
||||
}
|
||||
|
||||
span.icon {
|
||||
|
||||
@@ -2,11 +2,13 @@ import Component from '@glimmer/component';
|
||||
import { pageTitle } from 'ember-page-title';
|
||||
import Map from '#components/map';
|
||||
import PlacesSidebar from '#components/places-sidebar';
|
||||
import AppHeader from '#components/app-header';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { eq } from 'ember-truth-helpers';
|
||||
import { and } from 'ember-truth-helpers';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class ApplicationComponent extends Component {
|
||||
@service storage;
|
||||
@@ -74,6 +76,21 @@ export default class ApplicationComponent extends Component {
|
||||
<template>
|
||||
{{pageTitle "M/\RCO"}}
|
||||
|
||||
<AppHeader />
|
||||
|
||||
<div
|
||||
id="rs-widget-container"
|
||||
class={{if this.storage.isWidgetOpen "visible"}}
|
||||
></div>
|
||||
|
||||
{{#if this.storage.isWidgetOpen}}
|
||||
<div
|
||||
class="rs-backdrop"
|
||||
role="button"
|
||||
{{on "click" this.storage.closeWidget}}
|
||||
></div>
|
||||
{{/if}}
|
||||
|
||||
<Map
|
||||
@onPlacesFound={{this.showPlaces}}
|
||||
@isSidebarOpen={{this.isSidebarOpen}}
|
||||
|
||||
@@ -23,20 +23,40 @@ export default class PlaceTemplate extends Component {
|
||||
// Let's use a modifier or just sync it.
|
||||
|
||||
get place() {
|
||||
// If we have a manually updated place (from save), use it.
|
||||
// Otherwise use the route model.
|
||||
// We need to ensure we reset `localPlace` when navigating to a NEW place.
|
||||
// Comparing IDs is a safe bet.
|
||||
|
||||
// 1. Resolve the ID from the model (OSM ID or internal ID)
|
||||
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 (
|
||||
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.
|
||||
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.
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"description": "Small description for marco goes here",
|
||||
"repository": "",
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
release/assets/main-DPNrocGB.js
Normal file
2
release/assets/main-DPNrocGB.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-h3_9Qqi3.css
Normal file
1
release/assets/main-h3_9Qqi3.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="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-IB7GaxzK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-B-vHK2y6.css">
|
||||
<script type="module" crossorigin src="/assets/main-DPNrocGB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-h3_9Qqi3.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user