Merge pull request 'Improve app menu sidebar, add separate subsections, more content' (#31) from feature/app_menu into master
All checks were successful
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s

Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
2026-03-17 11:52:42 +00:00
9 changed files with 405 additions and 74 deletions

View File

@@ -2,6 +2,7 @@ import { on } from '@ember/modifier';
import Icon from '#components/icon';
<template>
{{! template-lint-disable no-nested-interactive }}
<div class="sidebar-header">
<button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" />
@@ -13,7 +14,7 @@ import Icon from '#components/icon';
</div>
<div class="sidebar-content">
<section class="settings-section">
<section class="about-section">
<p>
<strong>Marco</strong>
(as in
@@ -33,31 +34,119 @@ import Icon from '#components/icon';
>remote storage</a>
to sync place bookmarks across apps and 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>
<details>
<summary>
<Icon @name="gift" @size={{20}} />
<span>Open Source</span>
</summary>
<div class="details-content">
<table>
<thead>
<tr>
<th>Source</th>
<th>License</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<a
href="https://gitea.kosmos.org/raucao/marco"
target="_blank"
rel="noopener"
>
Marco App
</a>
</td>
<td>
<a
href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License"
target="_blank"
rel="noopener"
>
<abbr title="GNU Affero General Public License">AGPL</abbr>
</a>
</td>
</tr>
<tr>
<td>
<a
href="https://openstreetmap.org/copyright"
target="_blank"
rel="noopener"
>
Map Data
</a>
</td>
<td>
<a
href="https://opendatacommons.org/licenses/odbl/"
target="_blank"
rel="noopener"
>
<abbr
title="Open Data Commons Open Database License"
>ODbL</abbr>
</a>
</td>
</tr>
<tr>
<td>
<a
href="https://github.com/feathericons/feather"
target="_blank"
rel="noopener"
>
Feather Icons
</a>
</td>
<td>
<a
href="https://en.wikipedia.org/wiki/MIT_License"
target="_blank"
rel="noopener"
>
<abbr title="MIT License">MIT</abbr>
</a>
</td>
</tr>
</tbody>
</table>
</div>
</details>
<details>
<summary>
<Icon @name="heart" @size={{20}} @color="#e5533d" />
<span>Contribute</span>
</summary>
<div class="details-content">
<p>
<strong>Most impactful:</strong>
Add and improve data for points of interest in
<a
href="https://www.openstreetmap.org"
target="_blank"
rel="noopener"
>OpenStreetMap</a>.
</p>
<p>
<strong>Most appreciated:</strong>
Use this app as much as you can and
<a
href="https://community.remotestorage.io/t/marco-an-unhosted-maps-app/941"
target="_blank"
rel="noopener"
>submit feedback</a>
about your experience, problems, feature wishes, etc.
</p>
<p>
<strong>Most supportive:</strong>
Tell others about this app, on social media, in blog posts,
educational videos, etc.
</p>
</div>
</details>
</section>
</div>
</template>

View File

@@ -18,6 +18,11 @@ export default class AppMenuSettings extends Component {
this.settings.updateMapKinetic(event.target.value === 'true');
}
@action
updatePhotonApi(event) {
this.settings.updatePhotonApi(event.target.value);
}
<template>
<div class="sidebar-header">
<button type="button" class="back-btn" {{on "click" @onBack}}>
@@ -72,6 +77,23 @@ export default class AppMenuSettings extends Component {
{{/each}}
</select>
</div>
<div class="form-group">
<label for="photon-api">Photon API Provider</label>
<select
id="photon-api"
class="form-control"
{{on "change" this.updatePhotonApi}}
>
{{#each this.settings.photonApis as |api|}}
<option
value={{api.url}}
selected={{if (eq api.url this.settings.photonApi) "selected"}}
>
{{api.name}}
</option>
{{/each}}
</select>
</div>
</section>
</div>
</template>

View File

@@ -499,10 +499,9 @@ export default class MapComponent extends Component {
}
if (options.preventZoom) {
// If we are preventing zoom (e.g. user clicked a bookmark), we still need to center
// but without changing the zoom level.
// We use animateToSmartCenter without a second argument (zoom=null).
this.animateToSmartCenter(coords);
// If we are preventing zoom (e.g. user clicked a bookmark), we rely on visibility check.
// This avoids unnecessary panning if the place is already visible.
this.handlePinVisibility(coords, { maintainZoom: true });
} else if (selected.bbox) {
this.zoomToBbox(selected.bbox);
} else {
@@ -547,7 +546,10 @@ export default class MapComponent extends Component {
}
// Desktop: Sidebar covers left side (approx 400px)
else if (this.args.isSidebarOpen) {
const sidebarWidth = 400;
const sidebarWidthVar = getComputedStyle(document.documentElement)
.getPropertyValue('--sidebar-width')
.trim();
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
const visibleWidth = size[0] - sidebarWidth;
// Left padding: Sidebar + 15% of visible width
@@ -566,14 +568,15 @@ export default class MapComponent extends Component {
});
}
handlePinVisibility(coords) {
handlePinVisibility(coords, options = {}) {
if (!this.mapInstance) return;
const view = this.mapInstance.getView();
const currentZoom = view.getZoom();
// If too far out (e.g. world view), zoom in to neighborhood level (16)
if (currentZoom < 16) {
// UNLESS we want to maintain the current zoom
if (!options.maintainZoom && currentZoom < 16) {
this.animateToSmartCenter(coords, 16);
return;
}
@@ -590,8 +593,12 @@ export default class MapComponent extends Component {
pixel[1] > size[1];
if (isOffScreen) {
this.animateToSmartCenter(coords);
// If off-screen, center it smartly (considering sidebar/bottom sheet)
// Pass maintainZoom to prevent zoom reset if desired
const zoom = options.maintainZoom ? null : 16;
this.animateToSmartCenter(coords, zoom);
} else {
// If on-screen, only pan if obscured by UI
this.panIfObscured(coords);
}
}
@@ -627,6 +634,28 @@ export default class MapComponent extends Component {
// To move the camera South (Lower Y), we subtract.
targetCenter = [coords[0], coords[1] - offsetMapUnits];
}
// Desktop: Check if sidebar is open
else if (this.args.isSidebarOpen) {
const sidebarWidthVar = getComputedStyle(document.documentElement)
.getPropertyValue('--sidebar-width')
.trim();
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
// We want the pin to be in the center of the remaining space.
// Remaining space starts at x = sidebarWidth.
// Center of remaining space = sidebarWidth + (totalWidth - sidebarWidth) / 2
// = sidebarWidth/2 + totalWidth/2
// Map Center is totalWidth/2
// Offset = sidebarWidth/2 (to the right)
const offsetPixels = sidebarWidth / 2;
const offsetMapUnits = offsetPixels * resolution;
// We want pin at center + offset.
// So map center must be pin - offset.
// X increases to the right.
targetCenter = [coords[0] - offsetMapUnits, coords[1]];
}
const animationOptions = {
center: targetCenter,
@@ -645,33 +674,73 @@ export default class MapComponent extends Component {
if (!this.mapInstance) return;
const size = this.mapInstance.getSize();
// Check if mobile (width <= 768px matches CSS)
if (size[0] > 768) return;
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
if (!pixel) return;
const height = size[1];
const view = this.mapInstance.getView();
const center = view.getCenter();
const resolution = view.getResolution();
// Sidebar covers the bottom 50%
const splitPoint = height / 2;
// Default targets (current position)
let targetPixelX = pixel[0];
let targetPixelY = pixel[1];
let needsPan = false;
// 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;
// 1. Mobile Bottom Sheet Logic (Screen <= 768px)
if (size[0] <= 768) {
const height = size[1];
const splitPoint = height / 2;
const view = this.mapInstance.getView();
const center = view.getCenter();
const resolution = view.getResolution();
// If in bottom half
if (pixel[1] > splitPoint) {
targetPixelY = height * 0.25; // Target: Center of top half
needsPan = true;
}
}
// 2. Desktop Sidebar Logic (Screen > 768px + Sidebar Open)
else if (this.args.isSidebarOpen) {
const sidebarWidthVar = getComputedStyle(document.documentElement)
.getPropertyValue('--sidebar-width')
.trim();
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
// 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];
// If under sidebar
if (pixel[0] < sidebarWidth) {
const visibleWidth = size[0] - sidebarWidth;
targetPixelX = sidebarWidth + visibleWidth / 2; // Target: Center of visible area
needsPan = true;
}
}
// 3. Header Logic (Any screen size)
// Check if the (potentially new) target Y is under the header
const headerHeight = 60;
const minTopDistance = headerHeight + 20; // 80px
if (targetPixelY < minTopDistance) {
targetPixelY = minTopDistance + 30; // Move it to ~110px, clear of header
needsPan = true;
}
if (needsPan) {
const deltaPixelX = pixel[0] - targetPixelX;
const deltaPixelY = pixel[1] - targetPixelY;
// X: Camera moves same direction as we want the world to move? No.
// If we want pin to move RIGHT (pixel increases), Camera must move LEFT (X decreases).
// deltaPixelX = current - target. If current < target (want move right), delta is negative.
// center + negative = decrease. Correct.
const newCenterX = center[0] + deltaPixelX * resolution;
// Y: Camera moves opposite direction to world relative to pixel coords.
// Pixel Y increases DOWN. Map Y increases UP.
// If we want pin to move DOWN (pixel increases), Camera must move UP (Y increases).
// deltaPixelY = current - target. If current < target (want move down), delta is negative.
// center - negative = increase. Correct.
const newCenterY = center[1] - deltaPixelY * resolution;
view.animate({
center: newCenter,
center: [newCenterX, newCenterY],
duration: 500,
easing: (t) => t * (2 - t), // Ease-out
});

View File

@@ -286,7 +286,7 @@ export default class PlaceDetails extends Component {
>
<Icon
@name="bookmark"
@color={{if this.isSaved "currentColor" "#007bff"}}
@color={{if this.isSaved "currentColor" "var(--link-color)"}}
/>
{{if this.isSaved "Saved" "Save"}}
</button>
@@ -307,7 +307,7 @@ export default class PlaceDetails extends Component {
title="Edit"
{{on "click" this.startEditing}}
>
<Icon @name="edit" @color="#007bff" />
<Icon @name="edit" @color="var(--link-color)" />
Edit
</button>
{{/if}}

View File

@@ -226,7 +226,7 @@ export default class PlacesSidebar extends Component {
class="btn btn-outline create-place"
{{on "click" this.createNewPlace}}
>
<Icon @name="plus" @size={{18}} @color="#007bff" />
<Icon @name="plus" @size={{18}} @color="var(--link-color)" />
Create new place
</button>
{{/if}}

View File

@@ -1,9 +1,13 @@
import Service from '@ember/service';
import Service, { service } from '@ember/service';
import { getPlaceType } from '../utils/osm';
import { humanizeOsmTag } from '../utils/format-text';
export default class PhotonService extends Service {
baseUrl = 'https://photon.komoot.io/api/';
@service settings;
get baseUrl() {
return this.settings.photonApi;
}
async search(query, lat, lon, limit = 10) {
if (!query || query.length < 2) return [];

View File

@@ -4,6 +4,7 @@ import { tracked } from '@glimmer/tracking';
export default class SettingsService extends Service {
@tracked overpassApi = 'https://overpass-api.de/api/interpreter';
@tracked mapKinetic = true;
@tracked photonApi = 'https://photon.komoot.io/api/';
overpassApis = [
{
@@ -24,6 +25,13 @@ export default class SettingsService extends Service {
// },
];
photonApis = [
{
name: 'photon.komoot.io',
url: 'https://photon.komoot.io/api/',
},
];
constructor() {
super(...arguments);
this.loadSettings();
@@ -59,4 +67,8 @@ export default class SettingsService extends Service {
this.mapKinetic = enabled;
localStorage.setItem('marco:map-kinetic', String(enabled));
}
updatePhotonApi(url) {
this.photonApi = url;
}
}

View File

@@ -3,6 +3,9 @@
:root {
--default-list-color: #fc3;
--hover-bg: #f8f9fa;
--sidebar-width: 360px;
--link-color: #2a7fff;
--link-color-visited: #6a4fbf;
}
html,
@@ -185,7 +188,7 @@ body {
}
.text-primary {
color: #007bff;
color: var(--link-color);
}
.text-danger {
@@ -202,7 +205,7 @@ body {
top: 0;
left: 0;
bottom: 0;
width: 300px;
width: var(--sidebar-width);
background: white;
z-index: 3100; /* Higher than Header (3000) */
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
@@ -285,6 +288,82 @@ body {
height: 20px;
}
.sidebar-content details {
margin: 0 -1rem; /* Top margin, negative side margins to span full width */
}
.sidebar-content details summary {
list-style: none; /* Hide default triangle */
display: flex;
align-items: center;
gap: 0.8rem;
padding: 1rem;
padding-left: 1.4rem;
cursor: pointer;
font-size: 0.95rem;
color: #333;
transition: background-color 0.2s;
}
.sidebar-content details summary::-webkit-details-marker {
display: none; /* Hide default triangle in WebKit */
}
.sidebar-content details summary:hover {
background-color: var(--hover-bg);
}
.sidebar-content details summary .icon {
width: 20px;
height: 20px;
}
.sidebar-content details summary::after {
content: '';
width: 20px;
height: 20px;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'/%3E%3C/svg%3E");
background-size: 20px 20px;
background-repeat: no-repeat;
background-position: center;
margin-left: auto;
transition: transform 0.2s ease;
}
.sidebar-content details[open] summary::after {
transform: rotate(90deg);
}
.sidebar-content details .details-content {
padding: 0 1.4rem 1rem;
animation: details-slide-down 0.2s ease-out;
}
.sidebar-content details .link-list {
padding: 0;
margin: 0;
}
@keyframes details-slide-down {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sidebar-content details .link-list li {
margin-bottom: 0.5rem;
}
.sidebar-content details .link-list li:last-child {
margin-bottom: 0;
}
.edit-form {
margin: -1rem;
margin-bottom: 1rem;
@@ -312,12 +391,25 @@ body {
font-family: inherit;
font-size: 0.95rem;
box-sizing: border-box; /* Ensure padding doesn't overflow width */
color: #333;
background-color: #fff;
}
.form-control:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgb(0 123 255 / 10%);
border-color: var(--link-color);
box-shadow: 0 0 0 2px rgb(42 127 255 / 10%);
}
select.form-control {
appearance: none;
background-color: #fff;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 16px 16px;
padding-right: 2.5rem;
cursor: pointer;
}
.edit-actions {
@@ -335,12 +427,21 @@ body {
margin-top: 1rem;
}
.settings-section p a {
color: #007bff;
.about-section {
margin-bottom: 2rem;
font-size: 0.95rem;
}
.about-section a {
color: var(--link-color);
text-decoration: none;
}
.settings-section p a:hover {
.about-section a:visited {
color: var(--link-color-visited);
}
.about-section a:hover {
text-decoration: underline;
}
@@ -349,7 +450,7 @@ body {
}
.btn-primary {
background: #007bff;
background: var(--link-color);
color: white;
border: none;
padding: 0.75rem;
@@ -378,7 +479,7 @@ body {
}
.meta-info a {
color: #007bff;
color: var(--link-color);
text-decoration: none;
}
@@ -386,6 +487,37 @@ body {
text-decoration: underline;
}
.sidebar-content table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.sidebar-content table th,
.sidebar-content table td {
padding: 0.5rem 0;
text-align: left;
}
.sidebar-content table th {
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
color: #898989;
}
.sidebar-content table td {
border-bottom: 1px solid #f9f9f9;
}
.sidebar-content table tr:last-child td {
border-bottom: none;
}
abbr[title] {
text-decoration: underline dotted;
}
.link-list {
list-style: none;
padding: 0;
@@ -397,7 +529,7 @@ body {
}
.link-list a {
color: #007bff;
color: var(--link-color);
text-decoration: none;
font-size: 0.95rem;
}
@@ -522,7 +654,7 @@ body {
}
.btn-blue {
background: #007bff;
background: var(--link-color);
color: white;
border: none;
}
@@ -767,15 +899,14 @@ span.icon {
display: block;
}
/* Sidebar is open (Desktop: Left 300px) */
/* Sidebar is open (Desktop: Left var(--sidebar-width)) */
/* We want to center in the remaining space (width - 300px) */
/* We want to center in the remaining space (width - var(--sidebar-width)) */
/* Center X = 300 + (width - 300) / 2 = 300 + width/2 - 150 = width/2 + 150 */
/* Center X = var(--sidebar-width) + (width - var(--sidebar-width)) / 2 = var(--sidebar-width)/2 + 50% */
/* So shift left by 150px from center */
.map-container.sidebar-open .map-crosshair {
left: calc(50% + 150px);
left: calc(50% + var(--sidebar-width) / 2);
}
@media (width <= 768px) {
@@ -1063,7 +1194,7 @@ button.create-place {
}
.place-lists-manager input[type='checkbox'] {
accent-color: #007bff;
accent-color: var(--link-color);
width: 16px;
height: 16px;
cursor: pointer;

View File

@@ -5,7 +5,9 @@ import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
import clock from 'feather-icons/dist/icons/clock.svg?raw';
import edit from 'feather-icons/dist/icons/edit.svg?raw';
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
import gift from 'feather-icons/dist/icons/gift.svg?raw';
import globe from 'feather-icons/dist/icons/globe.svg?raw';
import heart from 'feather-icons/dist/icons/heart.svg?raw';
import home from 'feather-icons/dist/icons/home.svg?raw';
import info from 'feather-icons/dist/icons/info.svg?raw';
import instagram from 'feather-icons/dist/icons/instagram.svg?raw';
@@ -35,7 +37,9 @@ const ICONS = {
clock,
edit,
facebook,
gift,
globe,
heart,
home,
info,
instagram,