Refactor app menu, add place lists

Unify sidebar, make everything route-based
This commit is contained in:
2026-06-30 12:05:08 +02:00
parent c11882adfb
commit ad9c489102
22 changed files with 445 additions and 79 deletions

View File

@@ -19,6 +19,12 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
<div class="sidebar-content"> <div class="sidebar-content">
<ul class="app-menu"> <ul class="app-menu">
<li>
<button type="button" {{on "click" @onSavedPlaces}}>
<Icon @name="bookmark" @size={{20}} />
<span>Saved places</span>
</button>
</li>
<li> <li>
<button type="button" {{on "click" (fn @onNavigate "settings")}}> <button type="button" {{on "click" (fn @onNavigate "settings")}}>
<Icon @name="settings" @size={{20}} /> <Icon @name="settings" @size={{20}} />

View File

@@ -2,6 +2,7 @@ import Component from '@glimmer/component';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { fn } from '@ember/helper'; import { fn } from '@ember/helper';
import { service } from '@ember/service';
import eq from 'ember-truth-helpers/helpers/eq'; import eq from 'ember-truth-helpers/helpers/eq';
import AppMenuHome from './home'; import AppMenuHome from './home';
@@ -9,6 +10,7 @@ import AppMenuSettings from './settings';
import AppMenuAbout from './about'; import AppMenuAbout from './about';
export default class AppMenu extends Component { export default class AppMenu extends Component {
@service router;
@tracked currentView = 'menu'; // 'menu', 'settings', 'about' @tracked currentView = 'menu'; // 'menu', 'settings', 'about'
@action @action
@@ -16,10 +18,19 @@ export default class AppMenu extends Component {
this.currentView = view; this.currentView = view;
} }
@action
goToSavedPlaces() {
this.router.transitionTo('lists.index');
}
<template> <template>
<div class="sidebar app-menu-pane"> <div class="sidebar app-menu-pane">
{{#if (eq this.currentView "menu")}} {{#if (eq this.currentView "menu")}}
<AppMenuHome @onNavigate={{this.setView}} @onClose={{@onClose}} /> <AppMenuHome
@onNavigate={{this.setView}}
@onClose={{@onClose}}
@onSavedPlaces={{this.goToSavedPlaces}}
/>
{{else if (eq this.currentView "settings")}} {{else if (eq this.currentView "settings")}}
<AppMenuSettings <AppMenuSettings

View File

@@ -103,7 +103,7 @@ export default class PlaceListsManager extends Component {
checked={{this.isSaved}} checked={{this.isSaved}}
{{on "change" this.toggleSaved}} {{on "change" this.toggleSaved}}
/> />
<span class="list-color"></span> <span class="list-color-dot"></span>
<span class="list-name">Saved places</span> <span class="list-name">Saved places</span>
</label> </label>
</div> </div>
@@ -122,7 +122,7 @@ export default class PlaceListsManager extends Component {
/> />
{{! template-lint-disable no-inline-styles }} {{! template-lint-disable no-inline-styles }}
<span <span
class="list-color" class="list-color-dot"
style={{this.styleFor list.color}} style={{this.styleFor list.color}}
></span> ></span>
<span class="list-name">{{list.title}}</span> <span class="list-name">{{list.title}}</span>

View File

@@ -9,6 +9,7 @@ import PlaceDetails from './place-details';
import Icon from './icon'; import Icon from './icon';
import humanizeOsmTag from '../helpers/humanize-osm-tag'; import humanizeOsmTag from '../helpers/humanize-osm-tag';
import { getLocalizedName, getPlaceType } from '../utils/osm'; import { getLocalizedName, getPlaceType } from '../utils/osm';
import restoreScroll from '../modifiers/restore-scroll';
export default class PlacesSidebar extends Component { export default class PlacesSidebar extends Component {
@service storage; @service storage;
@@ -168,7 +169,17 @@ export default class PlacesSidebar extends Component {
{{on "click" this.clearSelection}} {{on "click" this.clearSelection}}
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button> ><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
{{else}} {{else}}
{{#if this.isNearbySearch}} {{#if @onBack}}
<button type="button" class="back-btn" {{on "click" @onBack}}><Icon
@name="arrow-left"
@size={{20}}
@color="#333"
/></button>
{{/if}}
{{#if @title}}
<h2><Icon @name="bookmark" @size={{20}} @color="#333" />
{{@title}}</h2>
{{else if this.isNearbySearch}}
<h2><Icon @name="target" @size={{20}} @color="#ea4335" /> <h2><Icon @name="target" @size={{20}} @color="#ea4335" />
Nearby</h2> Nearby</h2>
{{else}} {{else}}
@@ -182,7 +193,7 @@ export default class PlacesSidebar extends Component {
/></button> /></button>
</div> </div>
<div class="sidebar-content"> <div class="sidebar-content" {{restoreScroll @scrollTop}}>
{{#if @selectedPlace}} {{#if @selectedPlace}}
<PlaceDetails <PlaceDetails
@place={{@selectedPlace}} @place={{@selectedPlace}}

View File

@@ -0,0 +1,10 @@
import { modifier } from 'ember-modifier';
export default modifier((element, [scrollTop]) => {
if (element && typeof scrollTop === 'number' && scrollTop > 0) {
// Restore inside requestAnimationFrame to guarantee layout rendering is ready
requestAnimationFrame(() => {
element.scrollTop = scrollTop;
});
}
});

View File

@@ -10,6 +10,10 @@ Router.map(function () {
this.route('place', { path: '/place/:place_id' }); this.route('place', { path: '/place/:place_id' });
this.route('place.new', { path: '/place/new' }); this.route('place.new', { path: '/place/new' });
this.route('search'); this.route('search');
this.route('menu');
this.route('lists', function () {
this.route('list', { path: '/:list_id' });
});
this.route('oauth', function () { this.route('oauth', function () {
this.route('osm-callback', { path: '/osm/callback' }); this.route('osm-callback', { path: '/osm/callback' });
}); });

View File

@@ -6,5 +6,6 @@ export default class IndexRoute extends Route {
activate() { activate() {
this.mapUi.clearSearchResults(); this.mapUi.clearSearchResults();
this.mapUi.hideSidebar();
} }
} }

10
app/routes/lists.js Normal file
View File

@@ -0,0 +1,10 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ListsRoute extends Route {
@service mapUi;
activate() {
this.mapUi.showSidebar();
}
}

View File

@@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class ListsIndexRoute extends Route {}

17
app/routes/lists/list.js Normal file
View File

@@ -0,0 +1,17 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ListsListRoute extends Route {
@service storage;
async model(params) {
const listId = params.list_id;
try {
const places = await this.storage.getPlacesInList(listId);
return { listId, places };
} catch (e) {
console.error('Failed to load places in list', listId, e);
return { listId, places: [] };
}
}
}

10
app/routes/menu.js Normal file
View File

@@ -0,0 +1,10 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class MenuRoute extends Route {
@service mapUi;
activate() {
this.mapUi.showSidebar();
}
}

View File

@@ -108,6 +108,7 @@ export default class PlaceRoute extends Route {
this.mapUi.clearSelection(); this.mapUi.clearSelection();
// Reset the "return to search" flag so it doesn't persist to subsequent navigations // Reset the "return to search" flag so it doesn't persist to subsequent navigations
this.mapUi.returnToSearch = false; this.mapUi.returnToSearch = false;
this.mapUi.returnToRoute = null;
} }
async loadOsmPlace(id, type = null) { async loadOsmPlace(id, type = null) {

View File

@@ -1,5 +1,6 @@
import Service, { service } from '@ember/service'; import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class MapUiService extends Service { export default class MapUiService extends Service {
@service nostrData; @service nostrData;
@@ -9,6 +10,7 @@ export default class MapUiService extends Service {
@tracked isCreating = false; @tracked isCreating = false;
@tracked creationCoordinates = null; @tracked creationCoordinates = null;
@tracked returnToSearch = false; @tracked returnToSearch = false;
@tracked returnToRoute = null;
@tracked currentCenter = null; @tracked currentCenter = null;
@tracked currentBounds = null; @tracked currentBounds = null;
@tracked currentZoom = null; @tracked currentZoom = null;
@@ -19,13 +21,33 @@ export default class MapUiService extends Service {
@tracked currentSearch = null; @tracked currentSearch = null;
@tracked loadingState = null; @tracked loadingState = null;
@tracked isSidebarVisible = false; @tracked isSidebarVisible = false;
@tracked isSidebarOpening = false;
scrollPositions = {};
@action
saveScrollPosition(key, value) {
this.scrollPositions[key] = value;
}
@action
getScrollPosition(key) {
return this.scrollPositions[key] || 0;
}
showSidebar() { showSidebar() {
this.isSidebarVisible = true; if (!this.isSidebarVisible) {
this.isSidebarVisible = true;
this.isSidebarOpening = true;
setTimeout(() => {
this.isSidebarOpening = false;
}, 250);
}
} }
hideSidebar() { hideSidebar() {
this.isSidebarVisible = false; this.isSidebarVisible = false;
this.isSidebarOpening = false;
} }
selectPlace(place, options = {}) { selectPlace(place, options = {}) {

View File

@@ -270,6 +270,11 @@ export default class StorageService extends Service {
} }
} }
async getPlacesInList(listId) {
if (!this.places || !this.places.lists) return [];
return this.places.lists.getPlaces(listId);
}
async loadPlacesInBounds(bbox) { async loadPlacesInBounds(bbox) {
// 1. Calculate required prefixes // 1. Calculate required prefixes
const requiredPrefixes = getGeohashPrefixesInBbox(bbox); const requiredPrefixes = getGeohashPrefixesInBbox(bbox);

View File

@@ -1,8 +1,10 @@
/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */ /* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */
:root { :root {
--default-list-color: #fc3; --body-text-color: #333;
--primary-background-color: #fff;
--hover-bg: #f8f9fa; --hover-bg: #f8f9fa;
--divider-color: #eee;
--sidebar-width: 350px; --sidebar-width: 350px;
--link-color: #2a7fff; --link-color: #2a7fff;
--link-color-visited: #6a4fbf; --link-color-visited: #6a4fbf;
@@ -10,6 +12,7 @@
--marker-color-dark: #b31412; --marker-color-dark: #b31412;
--danger-color: var(--marker-color-primary); --danger-color: var(--marker-color-primary);
--danger-color-dark: var(--marker-color-dark); --danger-color-dark: var(--marker-color-dark);
--default-list-color: #fc3;
} }
html, html,
@@ -31,7 +34,7 @@ body {
font-family: 'Noto Sans', sans-serif; font-family: 'Noto Sans', sans-serif;
font-size: 16px; font-size: 16px;
font-weight: normal; font-weight: normal;
color: #333; color: var(--body-text-color);
} }
#root, #root,
@@ -383,7 +386,7 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 1rem 0; padding: 1rem 0;
border-bottom: 1px solid #eee; border-bottom: 1px solid var(--divider-color);
} }
.account-item:last-child { .account-item:last-child {
@@ -461,6 +464,9 @@ body {
flex-direction: column; flex-direction: column;
overflow: hidden; /* Ensure flex children are contained */ overflow: hidden; /* Ensure flex children are contained */
will-change: transform; will-change: transform;
}
.sidebar-opening .sidebar {
animation: sidebar-slide-in-left 0.18s cubic-bezier(0.16, 1, 0.3, 1) forwards; animation: sidebar-slide-in-left 0.18s cubic-bezier(0.16, 1, 0.3, 1) forwards;
} }
@@ -481,7 +487,7 @@ body {
.sidebar-header { .sidebar-header {
padding: 1rem; padding: 1rem;
border-bottom: 1px solid #eee; border-bottom: 1px solid var(--divider-color);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -528,7 +534,7 @@ body {
padding-left: 1.4rem; padding-left: 1.4rem;
background: none; background: none;
border: none; border: none;
color: #333; color: var(--body-text-color);
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
font-size: 0.95rem; font-size: 0.95rem;
@@ -559,7 +565,7 @@ body {
padding-left: 1.4rem; padding-left: 1.4rem;
cursor: pointer; cursor: pointer;
font-size: 0.95rem; font-size: 0.95rem;
color: #333; color: var(--body-text-color);
transition: background-color 0.2s; transition: background-color 0.2s;
} }
@@ -630,7 +636,7 @@ body {
width: 24px; width: 24px;
height: 24px; height: 24px;
border-radius: 50%; border-radius: 50%;
background-color: #fff; background-color: var(--primary-background-color);
border: 1px solid var(--danger-color); border: 1px solid var(--danger-color);
color: var(--danger-color); color: var(--danger-color);
cursor: pointer; cursor: pointer;
@@ -646,7 +652,7 @@ body {
.btn-remove-relay:hover, .btn-remove-relay:hover,
.btn-remove-relay:active { .btn-remove-relay:active {
background-color: var(--danger-color); background-color: var(--danger-color);
color: #fff; color: var(--primary-background-color);
} }
.add-relay-input { .add-relay-input {
@@ -676,7 +682,7 @@ body {
margin-bottom: 1rem; margin-bottom: 1rem;
background: var(--hover-bg); background: var(--hover-bg);
padding: 1rem; padding: 1rem;
border-bottom: 1px solid #eee; border-bottom: 1px solid var(--divider-color);
} }
.form-group { .form-group {
@@ -698,8 +704,8 @@ body {
font-family: inherit; font-family: inherit;
font-size: 1rem; font-size: 1rem;
box-sizing: border-box; /* Ensure padding doesn't overflow width */ box-sizing: border-box; /* Ensure padding doesn't overflow width */
color: #333; color: var(--body-text-color);
background-color: #fff; background-color: var(--primary-background-color);
} }
.form-control:focus { .form-control:focus {
@@ -710,7 +716,7 @@ body {
select.form-control { select.form-control {
appearance: none; appearance: none;
background-color: #fff; background-color: var(--primary-background-color);
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-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-repeat: no-repeat;
background-position: right 0.75rem center; background-position: right 0.75rem center;
@@ -786,7 +792,7 @@ select.form-control {
.meta-info p:first-child { .meta-info p:first-child {
margin-top: 1.2rem; margin-top: 1.2rem;
padding-top: 1.2rem; padding-top: 1.2rem;
border-top: 1px solid #eee; border-top: 1px solid var(--divider-color);
} }
.meta-info a, .meta-info a,
@@ -845,9 +851,9 @@ abbr[title] {
width: 100%; width: 100%;
text-align: left; text-align: left;
border: none; border: none;
border-bottom: 1px solid #eee; border-bottom: 1px solid var(--divider-color);
background: #fff; background: var(--primary-background-color);
color: #333; color: var(--body-text-color);
padding: 1rem; padding: 1rem;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
@@ -1018,7 +1024,7 @@ abbr[title] {
.photo-carousel.inline .photo-carousel-track { .photo-carousel.inline .photo-carousel-track {
scroll-snap-type: none; scroll-snap-type: none;
gap: 2px; gap: 2px;
background-color: #fff; background-color: var(--primary-background-color);
} }
.photo-carousel.inline .carousel-slide { .photo-carousel.inline .carousel-slide {
@@ -1097,7 +1103,7 @@ abbr[title] {
.btn-outline { .btn-outline {
background: transparent; background: transparent;
color: #333; color: var(--body-text-color);
border: 1px solid #ccc; border: 1px solid #ccc;
} }
@@ -1106,7 +1112,7 @@ abbr[title] {
} }
.btn-secondary { .btn-secondary {
color: #333; color: var(--body-text-color);
border: 1px solid rgb(255 204 51 / 20%); border: 1px solid rgb(255 204 51 / 20%);
background: rgb(255 204 51 / 30%); background: rgb(255 204 51 / 30%);
} }
@@ -1363,7 +1369,7 @@ span.icon {
width: 24px; width: 24px;
height: 24px; height: 24px;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
color: #333; color: var(--body-text-color);
pointer-events: none; pointer-events: none;
z-index: 2000; z-index: 2000;
display: none; display: none;
@@ -1499,7 +1505,7 @@ button.create-place {
min-width: 0; min-width: 0;
height: 100%; height: 100%;
font-size: 1rem; font-size: 1rem;
color: #333; color: var(--body-text-color);
outline: none; outline: none;
width: 100%; width: 100%;
padding: 0 4px; padding: 0 4px;
@@ -1530,7 +1536,7 @@ button.create-place {
.search-submit-btn:hover { .search-submit-btn:hover {
background: rgb(0 0 0 / 5%); background: rgb(0 0 0 / 5%);
color: #333; color: var(--body-text-color);
} }
.search-clear-btn { .search-clear-btn {
@@ -1548,7 +1554,7 @@ button.create-place {
.search-clear-btn:hover { .search-clear-btn:hover {
background: rgb(0 0 0 / 5%); background: rgb(0 0 0 / 5%);
color: #333; color: var(--body-text-color);
} }
/* Search Results Popover */ /* Search Results Popover */
@@ -1617,7 +1623,7 @@ button.create-place {
.result-title { .result-title {
font-weight: 500; font-weight: 500;
color: #333; color: var(--body-text-color);
font-size: 0.95rem; font-size: 0.95rem;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -1669,7 +1675,7 @@ button.create-place {
cursor: pointer; cursor: pointer;
margin: 0; margin: 0;
font-size: 0.95rem; font-size: 0.95rem;
color: #333; color: var(--body-text-color);
} }
.place-lists-manager input[type='checkbox'] { .place-lists-manager input[type='checkbox'] {
@@ -1679,7 +1685,8 @@ button.create-place {
cursor: pointer; cursor: pointer;
} }
.place-lists-manager .list-color { /* Shared List Color Dot */
.list-color-dot {
width: 12px; width: 12px;
height: 12px; height: 12px;
background-color: var(--default-list-color); background-color: var(--default-list-color);
@@ -1690,7 +1697,7 @@ button.create-place {
.place-lists-manager .divider { .place-lists-manager .divider {
height: 1px; height: 1px;
background: #eee; background: var(--divider-color);
margin: 0.5rem 0; margin: 0.5rem 0;
} }
@@ -1733,7 +1740,7 @@ button.create-place {
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 16px; /* Pill shape */ border-radius: 16px; /* Pill shape */
font-size: 0.9rem; font-size: 0.9rem;
color: #333; color: var(--body-text-color);
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
box-shadow: 0 1px 3px rgb(0 0 0 / 10%); box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
@@ -1745,7 +1752,7 @@ button.create-place {
} }
.category-chip:active { .category-chip:active {
background: #eee; background: var(--divider-color);
} }
.category-chip:disabled { .category-chip:disabled {
@@ -1880,7 +1887,7 @@ button.create-place {
.photo-tag-chip { .photo-tag-chip {
background: #f8f9fa; background: #f8f9fa;
color: #333; color: var(--body-text-color);
border: none; border: none;
border-radius: 16px; border-radius: 16px;
padding: 6px 12px; padding: 6px 12px;
@@ -1890,7 +1897,7 @@ button.create-place {
} }
.photo-tag-chip:hover { .photo-tag-chip:hover {
background: #eee; background: var(--divider-color);
} }
.photo-tag-chip.is-selected { .photo-tag-chip.is-selected {
@@ -2121,7 +2128,7 @@ button.create-place {
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
font-size: 0.95rem; font-size: 0.95rem;
color: #333; color: var(--body-text-color);
white-space: nowrap; white-space: nowrap;
} }
@@ -2163,3 +2170,47 @@ button.create-place {
transform: translateY(0); transform: translateY(0);
} }
} }
/* Lists Index Sidebar Menu */
.lists-index-item {
width: 100%;
text-align: left;
border: none;
border-bottom: 1px solid var(--divider-color);
background: var(--primary-background-color);
color: var(--body-text-color);
padding: 1rem;
cursor: pointer;
transition: background 0.2s;
font-family: inherit;
display: flex;
align-items: center;
justify-content: space-between;
}
.lists-index-item:hover {
background: var(--hover-bg);
}
.lists-index-item-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.lists-index-name {
font-size: 0.95rem;
font-weight: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lists-index-count {
color: #666;
font-size: 0.85rem;
margin: 0;
white-space: nowrap;
flex-shrink: 0;
}

View File

@@ -2,12 +2,9 @@ import Component from '@glimmer/component';
import { pageTitle } from 'ember-page-title'; import { pageTitle } from 'ember-page-title';
import Map from '#components/map'; import Map from '#components/map';
import AppHeader from '#components/app-header'; import AppHeader from '#components/app-header';
import AppMenu from '#components/app-menu/index';
import Toast from '#components/toast'; import Toast from '#components/toast';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { or } from 'ember-truth-helpers';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
export default class ApplicationComponent extends Component { export default class ApplicationComponent extends Component {
@@ -15,16 +12,17 @@ export default class ApplicationComponent extends Component {
@service mapUi; @service mapUi;
@service router; @service router;
@tracked isAppMenuOpen = false;
get isSidebarOpen() { get isSidebarOpen() {
// We consider the sidebar "open" if we are in search or place routes AND it's visible. // We consider the sidebar "open" if we are in search, menu, lists or place routes AND it's visible.
// This helps the map know if it should shift the center or adjust view. // This helps the map know if it should shift the center or adjust view.
const name = this.router.currentRouteName;
return ( return (
this.mapUi.isSidebarVisible && this.mapUi.isSidebarVisible &&
(this.router.currentRouteName === 'place' || (name === 'place' ||
this.router.currentRouteName === 'place.new' || name === 'place.new' ||
this.router.currentRouteName === 'search') name === 'search' ||
name === 'menu' ||
name.startsWith('lists'))
); );
} }
@@ -37,24 +35,27 @@ export default class ApplicationComponent extends Component {
@action @action
toggleAppMenu() { toggleAppMenu() {
this.isAppMenuOpen = !this.isAppMenuOpen; if (this.router.currentRouteName === 'menu') {
} this.router.transitionTo('index');
} else {
@action this.router.transitionTo('menu');
closeAppMenu() { }
this.isAppMenuOpen = false;
} }
@action @action
handleOutsideClick() { handleOutsideClick() {
if (this.isAppMenuOpen) { const name = this.router.currentRouteName;
this.closeAppMenu(); if (
} else if ( name === 'search' ||
this.router.currentRouteName === 'search' || name === 'place' ||
this.router.currentRouteName === 'place' name === 'menu' ||
name.startsWith('lists')
) { ) {
this.mapUi.clearSelection(); this.mapUi.clearSelection();
this.mapUi.hideSidebar(); this.mapUi.hideSidebar();
if (name === 'menu' || name.startsWith('lists')) {
this.router.transitionTo('index');
}
} }
} }
@@ -66,32 +67,30 @@ export default class ApplicationComponent extends Component {
<template> <template>
{{pageTitle "Marco"}} {{pageTitle "Marco"}}
<AppHeader @onToggleMenu={{this.toggleAppMenu}} /> <div class={{if this.mapUi.isSidebarOpening "sidebar-opening"}}>
<AppHeader @onToggleMenu={{this.toggleAppMenu}} />
<div
id="rs-widget-container"
class={{if this.storage.isWidgetOpen "visible"}}
></div>
{{#if this.storage.isWidgetOpen}}
<div <div
class="rs-backdrop" id="rs-widget-container"
role="button" class={{if this.storage.isWidgetOpen "visible"}}
{{on "click" this.storage.closeWidget}}
></div> ></div>
{{/if}}
<Map {{#if this.storage.isWidgetOpen}}
@isSidebarOpen={{or this.isSidebarOpen this.isAppMenuOpen}} <div
@onOutsideClick={{this.handleOutsideClick}} class="rs-backdrop"
/> role="button"
{{on "click" this.storage.closeWidget}}
></div>
{{/if}}
{{#if this.isAppMenuOpen}} <Map
<AppMenu @onClose={{this.closeAppMenu}} /> @isSidebarOpen={{this.isSidebarOpen}}
{{/if}} @onOutsideClick={{this.handleOutsideClick}}
/>
<Toast /> <Toast />
{{outlet}} {{outlet}}
</div>
</template> </template>
} }

1
app/templates/lists.gjs Normal file
View File

@@ -0,0 +1 @@
<template>{{outlet}}</template>

View File

@@ -0,0 +1,80 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { on } from '@ember/modifier';
import Icon from '#components/icon';
import { htmlSafe } from '@ember/template';
export default class ListsIndexTemplate extends Component {
@service storage;
@service router;
@service mapUi;
styleFor(color) {
return htmlSafe(`background-color: ${color}`);
}
@action
selectList(listId) {
this.router.transitionTo('lists.list', listId);
}
@action
close() {
this.router.transitionTo('index');
}
@action
backToMenu() {
this.router.transitionTo('menu');
}
<template>
{{#if this.mapUi.isSidebarVisible}}
<div class="sidebar">
<div class="sidebar-header">
<button type="button" class="back-btn" {{on "click" this.backToMenu}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" />
</button>
<h2><Icon @name="bookmark" @size={{20}} @color="#333" />
Saved places</h2>
<button type="button" class="close-btn" {{on "click" this.close}}>
<Icon @name="x" @size={{20}} @color="#333" />
</button>
</div>
<div class="sidebar-content">
<ul class="places-list">
{{#each this.storage.lists as |list|}}
<li>
<button
type="button"
class="lists-index-item"
{{on "click" (fn this.selectList list.id)}}
>
<div class="lists-index-item-left">
{{! template-lint-disable no-inline-styles }}
<span
class="list-color-dot"
style={{this.styleFor list.color}}
></span>
<div class="lists-index-name">{{list.title}}</div>
</div>
<div class="lists-index-count">
{{#if list.placeRefs.length}}
{{list.placeRefs.length}}
places
{{else}}
empty
{{/if}}
</div>
</button>
</li>
{{/each}}
</ul>
</div>
</div>
{{/if}}
</template>
}

View File

@@ -0,0 +1,98 @@
import Component from '@glimmer/component';
import PlacesSidebar from '#components/places-sidebar';
import { service } from '@ember/service';
import { action } from '@ember/object';
export default class ListsListTemplate extends Component {
@service router;
@service mapUi;
@service storage;
get listId() {
return this.args.model?.listId;
}
get scrollTop() {
return this.mapUi.getScrollPosition(`list-${this.listId}`);
}
get listTitle() {
const list = this.storage.lists.find((l) => l.id === this.listId);
return list ? list.title : 'Saved places';
}
get places() {
const modelPlaces = this.args.model?.places || [];
const currentList = this.storage.lists.find((l) => l.id === this.listId);
const placeRefsIds = new Set(
currentList?.placeRefs?.map((ref) => ref.id) || []
);
// Filter live tracked savedPlaces that are in this list
const livePlaces = this.storage.savedPlaces.filter((p) =>
placeRefsIds.has(p.id)
);
const merged = [];
const seen = new Set();
// Process live state first to reflect deletions/edits immediately
livePlaces.forEach((p) => {
merged.push(p);
seen.add(p.id);
});
// Supplement with any model-fetched places that are still valid but not in live state yet
modelPlaces.forEach((p) => {
if (placeRefsIds.has(p.id) && !seen.has(p.id)) {
merged.push(p);
seen.add(p.id);
}
});
return merged;
}
@action
selectPlace(place) {
if (place) {
const sidebarContent = document.querySelector('.sidebar-content');
if (sidebarContent) {
this.mapUi.saveScrollPosition(
`list-${this.listId}`,
sidebarContent.scrollTop
);
}
this.mapUi.returnToRoute = {
name: 'lists.list',
model: this.listId,
};
this.mapUi.showSidebar();
this.mapUi.preventNextZoom = true;
this.router.transitionTo('place', place);
}
}
@action
close() {
this.router.transitionTo('index');
}
@action
backToLists() {
this.router.transitionTo('lists.index');
}
<template>
{{#if this.mapUi.isSidebarVisible}}
<PlacesSidebar
@places={{this.places}}
@title={{this.listTitle}}
@scrollTop={{this.scrollTop}}
@onSelect={{this.selectPlace}}
@onClose={{this.close}}
@onBack={{this.backToLists}}
/>
{{/if}}
</template>
}

15
app/templates/menu.gjs Normal file
View File

@@ -0,0 +1,15 @@
import Component from '@glimmer/component';
import AppMenu from '#components/app-menu/index';
import { service } from '@ember/service';
import { action } from '@ember/object';
export default class MenuTemplate extends Component {
@service router;
@action
close() {
this.router.transitionTo('index');
}
<template><AppMenu @onClose={{this.close}} /></template>
}

View File

@@ -77,8 +77,14 @@ export default class PlaceTemplate extends Component {
navigateBack(place) { navigateBack(place) {
// The sidebar calls this with null when "Back" is clicked. // The sidebar calls this with null when "Back" is clicked.
if (place === null) { if (place === null) {
// If we have an active route context (e.g. lists), return to it
if (this.mapUi.returnToRoute) {
this.mapUi.showSidebar();
const { name, model } = this.mapUi.returnToRoute;
this.router.transitionTo(name, model);
}
// If we have an active search context, return to it (UP navigation) // If we have an active search context, return to it (UP navigation)
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) { else if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
this.mapUi.showSidebar(); this.mapUi.showSidebar();
this.router.transitionTo('search', { this.router.transitionTo('search', {
queryParams: this.mapUi.currentSearch, queryParams: this.mapUi.currentSearch,

View File

@@ -10,6 +10,10 @@ export default class SearchTemplate extends Component {
@action @action
selectPlace(place) { selectPlace(place) {
if (place) { if (place) {
const sidebarContent = document.querySelector('.sidebar-content');
if (sidebarContent) {
this.mapUi.saveScrollPosition('search', sidebarContent.scrollTop);
}
this.mapUi.returnToSearch = true; this.mapUi.returnToSearch = true;
this.mapUi.showSidebar(); this.mapUi.showSidebar();
this.mapUi.preventNextZoom = true; this.mapUi.preventNextZoom = true;
@@ -28,6 +32,7 @@ export default class SearchTemplate extends Component {
{{#if this.mapUi.isSidebarVisible}} {{#if this.mapUi.isSidebarVisible}}
<PlacesSidebar <PlacesSidebar
@places={{this.mapUi.searchResults}} @places={{this.mapUi.searchResults}}
@scrollTop={{this.mapUi.getScrollPosition "search"}}
@onSelect={{this.selectPlace}} @onSelect={{this.selectPlace}}
@onClose={{this.close}} @onClose={{this.close}}
/> />