14 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
23 changed files with 582 additions and 180 deletions

View File

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

View File

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

View File

@@ -23,7 +23,12 @@ export default class AppHeaderComponent extends Component {
<template>
<header class="app-header">
<div class="header-left">
<button class="icon-btn" type="button" aria-label="Menu">
<button
class="icon-btn"
type="button"
aria-label="Menu"
{{on "click" @onToggleMenu}}
>
<Icon @name="menu" @size={{24}} @color="#333" />
</button>
</div>

View File

@@ -5,6 +5,7 @@ import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import activity from 'feather-icons/dist/icons/activity.svg?raw';
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
import clock from 'feather-icons/dist/icons/clock.svg?raw';
import edit from 'feather-icons/dist/icons/edit.svg?raw';
import globe from 'feather-icons/dist/icons/globe.svg?raw';
import home from 'feather-icons/dist/icons/home.svg?raw';
import logIn from 'feather-icons/dist/icons/log-in.svg?raw';
@@ -25,6 +26,7 @@ const ICONS = {
activity,
bookmark,
clock,
edit,
globe,
home,
'log-in': logIn,

View File

@@ -16,7 +16,6 @@ import Geolocation from 'ol/Geolocation.js';
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
import { apply } from 'ol-mapbox-style';
import { getDistance } from '../utils/geo';
import Icon from '../components/icon';
export default class MapComponent extends Component {
@service osm;
@@ -213,7 +212,7 @@ export default class MapComponent extends Component {
geolocation.un('change:position', zoomToLocation);
locateListenerKey = null;
}
} catch (e) {
} catch {
/* ignore */
}
@@ -694,7 +693,6 @@ export default class MapComponent extends Component {
{{this.setupMap}}
{{this.updateBookmarks}}
{{this.updateSelectedPin}}
style="position: absolute; inset: 0;"
></div>
</template>
}

View File

@@ -4,7 +4,19 @@ import { on } from '@ember/modifier';
import capitalize from '../helpers/capitalize';
import Icon from '../components/icon';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class PlaceDetails extends Component {
@tracked isEditing = false;
@tracked editTitle = '';
@tracked editDescription = '';
constructor() {
super(...arguments);
this.resetEditFields();
}
get place() {
return this.args.place || {};
}
@@ -22,6 +34,47 @@ export default class PlaceDetails extends Component {
);
}
@action
resetEditFields() {
this.editTitle = this.name;
this.editDescription = this.place.description || '';
}
@action
startEditing() {
if (!this.place.createdAt) return; // Only allow editing saved places
this.resetEditFields();
this.isEditing = true;
}
@action
cancelEditing() {
this.isEditing = false;
}
@action
async saveChanges(event) {
event.preventDefault();
if (this.args.onSave) {
await this.args.onSave({
...this.place,
title: this.editTitle,
description: this.editDescription,
});
}
this.isEditing = false;
}
@action
updateTitle(e) {
this.editTitle = e.target.value;
}
@action
updateDescription(e) {
this.editDescription = e.target.value;
}
get type() {
return (
this.tags.amenity ||
@@ -117,14 +170,43 @@ export default class PlaceDetails extends Component {
<template>
<div class="place-details">
<h3>{{this.name}}</h3>
<p class="place-type">
{{this.type}}
</p>
{{#if this.place.description}}
<p class="place-description">
{{this.place.description}}
{{#if this.isEditing}}
<form class="edit-form" {{on "submit" this.saveChanges}}>
<div class="form-group">
<label for="edit-title">Title</label>
<input
id="edit-title"
type="text"
value={{this.editTitle}}
{{on "input" this.updateTitle}}
class="form-control"
/>
</div>
<div class="form-group">
<label for="edit-desc">Description</label>
<textarea
id="edit-desc"
value={{this.editDescription}}
{{on "input" this.updateDescription}}
class="form-control"
rows="3"
></textarea>
</div>
<div class="edit-actions">
<button type="submit" class="btn btn-blue btn-sm">Save</button>
<button type="button" class="btn btn-outline btn-sm" {{on "click" this.cancelEditing}}>Cancel</button>
</div>
</form>
{{else}}
<h3>{{this.name}}</h3>
<p class="place-type">
{{this.type}}
</p>
{{#if this.place.description}}
<p class="place-description">
{{this.place.description}}
</p>
{{/if}}
{{/if}}
<div class="actions">
@@ -143,6 +225,18 @@ export default class PlaceDetails extends Component {
/>
{{if this.place.createdAt "Saved" "Save"}}
</button>
{{#if this.place.createdAt}}
<button
type="button"
class="btn btn-outline"
title="Edit"
{{on "click" this.startEditing}}
>
<Icon @name="edit" @color="#007bff" />
Edit
</button>
{{/if}}
</div>
<div class="meta-info">

View File

@@ -117,6 +117,27 @@ export default class PlacesSidebar extends Component {
}
}
@action
async updateBookmark(updatedPlace) {
try {
const savedPlace = await this.storage.updatePlace(updatedPlace);
console.log('Place updated:', savedPlace.title);
// Notify parent to refresh map/lists
if (this.args.onBookmarkChange) {
this.args.onBookmarkChange();
}
// Update local view
if (this.args.onUpdate) {
this.args.onUpdate(savedPlace);
}
} catch (e) {
console.error('Failed to update place:', e);
alert('Failed to update place: ' + e.message);
}
}
<template>
<div class="sidebar">
<div class="sidebar-header">
@@ -141,6 +162,7 @@ export default class PlacesSidebar extends Component {
<PlaceDetails
@place={{@selectedPlace}}
@onToggleSave={{this.toggleSave}}
@onSave={{this.updateBookmark}}
/>
{{else}}
{{#if @places}}

View File

@@ -0,0 +1,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

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

@@ -5,7 +5,7 @@ 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 { debounceTask } from 'ember-lifeline';
import Geohash from 'latlon-geohash';
export default class StorageService extends Service {
@@ -30,7 +30,6 @@ export default class StorageService extends Service {
});
this.rs.access.claim('places', 'rw');
// Caching strategy:
this.rs.caching.enable('/places/');
window.remoteStorage = this.rs;
@@ -49,11 +48,6 @@ export default class StorageService extends Service {
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', () => {
@@ -76,7 +70,7 @@ export default class StorageService extends Service {
this.rs.scope('/places/').on('change', (event) => {
// console.debug(event);
this.handlePlaceChange(event);
debounce(this, this.reloadCurrentView, 200);
debounceTask(this, 'reloadCurrentView', 200);
});
}
@@ -126,7 +120,7 @@ export default class StorageService extends Service {
notifyChange() {
this.version++;
debounce(this, this.reloadCurrentView, 200);
debounceTask(this, 'reloadCurrentView', 200);
}
reloadCurrentView() {
@@ -222,7 +216,23 @@ export default class StorageService extends Service {
async storePlace(placeData) {
const savedPlace = await this.places.store(placeData);
this.savedPlaces = [...this.savedPlaces, savedPlace];
// Only append if not already there (handlePlaceChange might also fire)
if (!this.savedPlaces.some((p) => p.id === savedPlace.id)) {
this.savedPlaces = [...this.savedPlaces, savedPlace];
}
return savedPlace;
}
async updatePlace(placeData) {
const savedPlace = await this.places.store(placeData);
// Update local list
const index = this.savedPlaces.findIndex((p) => p.id === savedPlace.id);
if (index !== -1) {
const newPlaces = [...this.savedPlaces];
newPlaces[index] = savedPlace;
this.savedPlaces = newPlaces;
}
return savedPlace;
}

View File

@@ -8,6 +8,7 @@ body {
body {
margin: 0;
font-family: 'Noto Serif', serif;
}
#root,
@@ -20,6 +21,8 @@ body {
background: #f8f9fa;
-webkit-tap-highlight-color: transparent;
outline: none; /* Prevent focus outline on click */
position: absolute;
inset: 0;
}
/* Ensure RS widget is above the map but potentially hidden initially if needed */
@@ -39,7 +42,8 @@ body {
position: fixed;
inset: 0;
z-index: 3999; /* Below widget container but above everything else */
/* background: rgba(0,0,0,0.2); Optional: dim background */
/* background: rgb(0 0 0 / 20%); Optional: dim background */
}
/* App Header */
@@ -71,7 +75,7 @@ body {
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
cursor: pointer;
transition: transform 0.1s;
}
@@ -95,7 +99,7 @@ body {
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
}
/* User Menu Popover */
@@ -111,19 +115,17 @@ body {
width: 280px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
padding: 1rem;
z-index: 3001;
}
.menu-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
z-index: 3000; /* Below popover but above everything else */
/* background: rgba(0,0,0,0.1); Optional dimming */
/* background: rgb(0 0 0 / 10%); Optional dimming */
}
.user-status {
@@ -171,6 +173,7 @@ body {
.text-primary {
color: #007bff;
}
.text-danger {
color: #dc3545;
}
@@ -188,12 +191,28 @@ body {
width: 300px;
background: white;
color: #333;
z-index: 2000;
z-index: 3100; /* Higher than Header (3000) */
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
display: flex;
flex-direction: column;
}
.settings-pane.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 {
padding: 1rem;
border-bottom: 1px solid #eee;
@@ -211,104 +230,120 @@ body {
padding: 1rem;
}
.back-btn {
background: none;
border: none;
cursor: pointer;
padding: 0 0.5rem;
margin-left: -0.5rem;
display: flex;
align-items: center;
justify-content: center;
.edit-form {
margin: -1rem;
margin-bottom: 1rem;
background: #f8f9fa;
padding: 1rem;
border-bottom: 1px solid #eee;
}
.close-btn {
background: none;
border: none;
cursor: pointer;
padding: 0 0.5rem;
margin-right: -0.5rem;
display: flex;
align-items: center;
justify-content: center;
.form-group {
margin-bottom: 0.75rem;
}
.place-details {
}
.place-details h3 {
font-size: 1.2rem;
margin-top: 0;
margin-bottom: 0.5rem;
}
.place-details .place-type {
.form-group label {
display: block;
font-size: 0.85rem;
color: #666;
font-size: 0.9rem;
text-transform: capitalize;
margin: 0 0 1rem 0;
margin-bottom: 0.25rem;
}
.place-details .place-description {
}
.place-details .actions {
padding-bottom: 0.3rem;
/* display: flex; */
/* flex-direction: row; */
/* gap: 1rem; */
}
.btn {
padding: 0.75rem 1.5rem;
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
/* width: 50%; */
font-size: 1rem;
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;
align-items: center;
justify-content: center;
gap: 0.5rem;
justify-content: flex-end;
}
.btn-outline {
background: transparent;
color: #333;
border: 1px solid #ccc;
.settings-section {
margin-bottom: 2rem;
}
.btn-outline:hover {
border: 1px solid #898989;
.settings-section h3 {
font-size: 1rem;
font-weight: bold;
color: #666;
margin: 0 0 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-secondary {
color: #333;
border: 1px solid rgba(255, 204, 51, 0.2);
background: rgba(255, 204, 51, 0.3);
.settings-section .form-group {
margin-top: 1rem;
}
.btn-secondary:hover {
background: rgba(255, 204, 51, 0.4);
.btn-full {
width: 100%;
}
.btn-blue {
.btn-primary {
background: #007bff;
color: white;
border: none;
padding: 0.75rem;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
}
.btn-blue:hover {
background: #0056b3;
.btn-primary:hover {
background: #0069d9;
}
.btn-green {
background: #198754;
color: white;
border: none;
.meta-info {
font-size: 0.9rem;
}
.btn-green:hover {
background: #157347;
.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 {
@@ -347,42 +382,113 @@ body {
text-transform: capitalize;
}
.meta-info {
.back-btn {
background: none;
border: none;
cursor: pointer;
padding: 0 0.5rem;
margin-left: -0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn {
background: none;
border: none;
cursor: pointer;
padding: 0 0.5rem;
margin-right: -0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.place-details h3 {
font-size: 1.2rem;
margin-top: 0;
margin-bottom: 0.5rem;
}
.place-details .place-type {
color: #666;
font-size: 0.9rem;
text-align: left;
text-transform: capitalize;
margin: 0 0 1rem;
}
.meta-info p:first-child {
margin-top: 1.2rem;
padding-top: 1.2rem;
border-top: 1px solid #eee;
.place-details .place-description {
margin-bottom: 1.5rem;
}
.meta-info p {
margin: 0.75rem 0;
line-height: 1.4;
word-break: break-word; /* Prevent long URLs from breaking layout */
.place-details .actions {
padding-bottom: 0.3rem;
display: flex;
flex-direction: row;
gap: 1rem;
}
.meta-info strong {
font-weight: bold;
.btn {
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.meta-info a {
color: #007bff;
text-decoration: none;
padding-bottom: 4rem;
.btn-sm {
padding: 0.4rem 1rem !important;
font-size: 0.9rem !important;
}
.meta-info a:hover {
text-decoration: underline;
.btn-outline {
background: transparent;
color: #333;
border: 1px solid #ccc;
}
.btn-outline:hover {
border: 1px solid #898989;
}
.btn-secondary {
color: #333;
border: 1px solid rgb(255 204 51 / 20%);
background: rgb(255 204 51 / 30%);
}
.btn-secondary:hover {
background: rgb(255 204 51 / 40%);
}
.btn-blue {
background: #007bff;
color: white;
border: none;
}
.btn-blue:hover {
background: #0056b3;
}
.btn-green {
background: #198754;
color: white;
border: none;
}
.btn-green:hover {
background: #157347;
}
/* Map Search Pulse Animation */
.search-pulse {
border-radius: 50%;
border: 2px solid rgba(255, 204, 51, 0.8); /* Gold/Yellow to match markers */
background: rgba(255, 204, 51, 0.2);
border: 2px solid rgb(255 204 51 / 80%); /* Gold/Yellow to match markers */
background: rgb(255 204 51 / 20%);
position: absolute;
transform: translate(-50%, -50%);
pointer-events: none;
@@ -396,8 +502,8 @@ body {
}
.search-pulse.blue {
border-color: rgba(51, 153, 204, 0.8);
background: rgba(51, 153, 204, 0.2);
border-color: rgb(51 153 204 / 80%);
background: rgb(51 153 204 / 20%);
}
@keyframes pulse {
@@ -405,6 +511,7 @@ body {
transform: translate(-50%, -50%) scale(0.8);
opacity: 0.8;
}
100% {
transform: translate(-50%, -50%) scale(1.4);
opacity: 0;
@@ -413,28 +520,20 @@ body {
/* Locate Control */
.ol-control.ol-locate {
top: auto;
bottom: 2.5em;
right: 0.5em;
left: auto;
inset: auto 0.5em 2.5em auto;
}
.ol-touch .ol-control.ol-locate {
top: auto;
bottom: 3.5em;
inset: auto auto 3.5em;
}
/* Rotate Control */
.ol-rotate {
top: auto;
bottom: 5em;
right: 0.5em;
left: auto;
inset: auto 0.5em 5em auto;
}
.ol-touch .ol-rotate {
top: auto;
bottom: 6em;
inset: auto auto 6em;
}
span.icon {
@@ -448,7 +547,7 @@ span.icon {
.icon svg {
width: 100%;
height: 100%;
stroke: currentColor;
stroke: currentcolor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
@@ -465,6 +564,7 @@ span.icon {
/* Selected Pin Animation */
.selected-pin-container {
position: absolute;
/* Center the bottom tip of the pin at the coordinate */
transform: translate(-50%, -100%);
pointer-events: none; /* Let clicks pass through to the map features below if needed */
@@ -473,14 +573,14 @@ span.icon {
.selected-pin-container.active {
display: block;
animation: dropIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
animation: drop-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}
.selected-pin {
width: 40px;
height: 40px;
color: #ea4335; /* Google Red */
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
filter: drop-shadow(0 4px 6px rgb(0 0 0 / 30%));
}
.selected-pin svg {
@@ -495,7 +595,7 @@ span.icon {
.selected-pin-shadow {
width: 10px;
height: 4px;
background: rgba(0, 0, 0, 0.3);
background: rgb(0 0 0 / 30%);
border-radius: 50%;
position: absolute;
bottom: 0;
@@ -503,45 +603,45 @@ span.icon {
transform: translateX(-50%);
z-index: -1;
opacity: 0;
animation: shadowFade 0.5s 0.2s forwards;
animation: shadow-fade 0.5s 0.2s forwards;
}
@keyframes dropIn {
@keyframes drop-in {
0% {
transform: translate(-50%, -200%) scale(0);
opacity: 0;
}
60% {
opacity: 1;
}
100% {
transform: translate(-50%, -100%) scale(1);
opacity: 1;
}
}
@keyframes shadowFade {
@keyframes shadow-fade {
to {
opacity: 1;
}
}
@media (max-width: 768px) {
@media (width <= 768px) {
.sidebar {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 50vh;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
box-shadow: 0 -2px 10px rgb(0 0 0 / 10%);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
inset: auto 0 0;
}
.sidebar-content {
overflow-y: auto;
overscroll-behavior: contain; /* Prevent scroll chaining */
/* Ensure content doesn't get hidden behind bottom safe areas on mobile */
padding-bottom: env(safe-area-inset-bottom, 20px);
}

View File

@@ -3,11 +3,12 @@ import { pageTitle } from 'ember-page-title';
import Map from '#components/map';
import PlacesSidebar from '#components/places-sidebar';
import AppHeader from '#components/app-header';
import SettingsPane from '#components/settings-pane';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { eq } from 'ember-truth-helpers';
import { and } from 'ember-truth-helpers';
import { and, or } from 'ember-truth-helpers';
import { on } from '@ember/modifier';
export default class ApplicationComponent extends Component {
@@ -15,6 +16,7 @@ export default class ApplicationComponent extends Component {
@service router;
@tracked nearbyPlaces = null;
@tracked isSettingsOpen = false;
// @tracked bookmarksVersion = 0; // Moved to storage service
get isSidebarOpen() {
@@ -54,6 +56,16 @@ export default class ApplicationComponent extends Component {
}
}
@action
toggleSettings() {
this.isSettingsOpen = !this.isSettingsOpen;
}
@action
closeSettings() {
this.isSettingsOpen = false;
}
@action
selectFromList(place) {
if (place) {
@@ -62,6 +74,15 @@ export default class ApplicationComponent extends Component {
}
}
@action
handleOutsideClick() {
if (this.isSettingsOpen) {
this.closeSettings();
} else {
this.closeSidebar();
}
}
@action
closeSidebar() {
this.nearbyPlaces = null;
@@ -76,7 +97,7 @@ export default class ApplicationComponent extends Component {
<template>
{{pageTitle "M/\RCO"}}
<AppHeader />
<AppHeader @onToggleMenu={{this.toggleSettings}} />
<div
id="rs-widget-container"
@@ -93,8 +114,8 @@ export default class ApplicationComponent extends Component {
<Map
@onPlacesFound={{this.showPlaces}}
@isSidebarOpen={{this.isSidebarOpen}}
@onOutsideClick={{this.closeSidebar}}
@isSidebarOpen={{or this.isSidebarOpen this.isSettingsOpen}}
@onOutsideClick={{this.handleOutsideClick}}
/>
{{#if (and (eq this.router.currentRouteName "index") this.nearbyPlaces)}}
@@ -105,6 +126,10 @@ export default class ApplicationComponent extends Component {
/>
{{/if}}
{{#if this.isSettingsOpen}}
<SettingsPane @onClose={{this.closeSettings}} />
{{/if}}
{{outlet}}
</template>
}

View File

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

View File

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

21
pnpm-lock.yaml generated
View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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