Merge pull request 'Add Collections (place lists) to app menu' (#65) from feature/list-places-in-lists into master
Reviewed-on: #65
This commit was merged in pull request #65.
This commit is contained in:
@@ -3,11 +3,11 @@ import Icon from '#components/icon';
|
||||
|
||||
<template>
|
||||
{{! template-lint-disable no-nested-interactive }}
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header has-back-btn">
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2>About</h2>
|
||||
<h2 class="sidebar-header-text-centered">About</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
|
||||
@@ -19,6 +19,12 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
|
||||
|
||||
<div class="sidebar-content">
|
||||
<ul class="app-menu">
|
||||
<li>
|
||||
<button type="button" {{on "click" @onSavedPlaces}}>
|
||||
<Icon @name="bookmark" @size={{20}} />
|
||||
<span>Collections</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" {{on "click" (fn @onNavigate "settings")}}>
|
||||
<Icon @name="settings" @size={{20}} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { fn } from '@ember/helper';
|
||||
import { service } from '@ember/service';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
|
||||
import AppMenuHome from './home';
|
||||
@@ -9,6 +10,7 @@ import AppMenuSettings from './settings';
|
||||
import AppMenuAbout from './about';
|
||||
|
||||
export default class AppMenu extends Component {
|
||||
@service router;
|
||||
@tracked currentView = 'menu'; // 'menu', 'settings', 'about'
|
||||
|
||||
@action
|
||||
@@ -16,10 +18,19 @@ export default class AppMenu extends Component {
|
||||
this.currentView = view;
|
||||
}
|
||||
|
||||
@action
|
||||
goToSavedPlaces() {
|
||||
this.router.transitionTo('lists.index');
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar app-menu-pane">
|
||||
{{#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")}}
|
||||
<AppMenuSettings
|
||||
|
||||
@@ -21,11 +21,11 @@ export default class AppMenuSettings extends Component {
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header has-back-btn">
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2>Settings</h2>
|
||||
<h2 class="sidebar-header-text-centered">Settings</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
|
||||
@@ -103,7 +103,7 @@ export default class PlaceListsManager extends Component {
|
||||
checked={{this.isSaved}}
|
||||
{{on "change" this.toggleSaved}}
|
||||
/>
|
||||
<span class="list-color"></span>
|
||||
<span class="list-color-dot"></span>
|
||||
<span class="list-name">Saved places</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -122,7 +122,7 @@ export default class PlaceListsManager extends Component {
|
||||
/>
|
||||
{{! template-lint-disable no-inline-styles }}
|
||||
<span
|
||||
class="list-color"
|
||||
class="list-color-dot"
|
||||
style={{this.styleFor list.color}}
|
||||
></span>
|
||||
<span class="list-name">{{list.title}}</span>
|
||||
|
||||
@@ -5,10 +5,13 @@ import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import or from 'ember-truth-helpers/helpers/or';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
import and from 'ember-truth-helpers/helpers/and';
|
||||
import not from 'ember-truth-helpers/helpers/not';
|
||||
import PlaceDetails from './place-details';
|
||||
import Icon from './icon';
|
||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||
import restoreScroll from '../modifiers/restore-scroll';
|
||||
|
||||
export default class PlacesSidebar extends Component {
|
||||
@service storage;
|
||||
@@ -160,7 +163,11 @@ export default class PlacesSidebar extends Component {
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header {{if this.hasHeaderPhoto 'no-border'}}">
|
||||
<div
|
||||
class="sidebar-header
|
||||
{{if this.hasHeaderPhoto 'no-border'}}
|
||||
{{if (and (not @selectedPlace) @onBack) 'has-back-btn'}}"
|
||||
>
|
||||
{{#if @selectedPlace}}
|
||||
<button
|
||||
type="button"
|
||||
@@ -168,11 +175,32 @@ export default class PlacesSidebar extends Component {
|
||||
{{on "click" this.clearSelection}}
|
||||
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
||||
{{else}}
|
||||
{{#if this.isNearbySearch}}
|
||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" />
|
||||
Nearby</h2>
|
||||
{{#if @onBack}}
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}><Icon
|
||||
@name="arrow-left"
|
||||
@size={{20}}
|
||||
@color="#333"
|
||||
/></button>
|
||||
{{/if}}
|
||||
{{#if @onBack}}
|
||||
<h2 class="sidebar-header-text-centered">
|
||||
<span class="sidebar-header-icon-wrapper">
|
||||
<Icon
|
||||
@name="bookmark"
|
||||
@size={{20}}
|
||||
@color={{or @color "#898989"}}
|
||||
/>
|
||||
</span>
|
||||
{{@title}}
|
||||
</h2>
|
||||
{{else}}
|
||||
<h2><Icon @name="search" @size={{20}} @color="#333" /> Results</h2>
|
||||
{{#if this.isNearbySearch}}
|
||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" />
|
||||
Nearby</h2>
|
||||
{{else}}
|
||||
<h2><Icon @name="search" @size={{20}} @color="#333" />
|
||||
Results</h2>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
||||
@@ -182,7 +210,7 @@ export default class PlacesSidebar extends Component {
|
||||
/></button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
|
||||
{{#if @selectedPlace}}
|
||||
<PlaceDetails
|
||||
@place={{@selectedPlace}}
|
||||
@@ -190,54 +218,58 @@ export default class PlacesSidebar extends Component {
|
||||
@onSave={{this.updateBookmark}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#if @places}}
|
||||
<ul class="places-list">
|
||||
{{#each @places as |place|}}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="place-item"
|
||||
{{on "click" (fn this.selectPlace place)}}
|
||||
>
|
||||
<div class="place-name">{{or
|
||||
place.title
|
||||
place.osmTags.name
|
||||
place.osmTags.name:en
|
||||
"Unnamed Place"
|
||||
}}</div>
|
||||
<div class="place-type">
|
||||
{{#if (eq place.source "osm")}}
|
||||
{{humanizeOsmTag place.type}}
|
||||
{{else if (eq place.source "photon")}}
|
||||
{{place.description}}
|
||||
{{else}}
|
||||
{{#if place.osmTags}}
|
||||
{{humanizeOsmTag (getPlaceType place.osmTags)}}
|
||||
{{else if place.description}}
|
||||
{{place.description}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{#if @isLoading}}
|
||||
<div class="sidebar-loading">
|
||||
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
|
||||
</div>
|
||||
{{else}}
|
||||
{{#if this.isNearbySearch}}
|
||||
<p class="empty-state">No places found nearby.</p>
|
||||
{{#if @places}}
|
||||
<ul class="places-list">
|
||||
{{#each @places as |place|}}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="place-item"
|
||||
{{on "click" (fn this.selectPlace place)}}
|
||||
>
|
||||
<div class="place-name">{{or
|
||||
place.title
|
||||
place.osmTags.name
|
||||
place.osmTags.name:en
|
||||
"Unnamed Place"
|
||||
}}</div>
|
||||
<div class="place-type">
|
||||
{{#if (eq place.source "osm")}}
|
||||
{{humanizeOsmTag place.type}}
|
||||
{{else if (eq place.source "photon")}}
|
||||
{{place.description}}
|
||||
{{else if (getPlaceType place.osmTags)}}
|
||||
{{getPlaceType place.osmTags}}
|
||||
{{else}}
|
||||
Saved place
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="empty-state">No results found.</p>
|
||||
{{#if this.isNearbySearch}}
|
||||
<p class="empty-state">No places found nearby.</p>
|
||||
{{else}}
|
||||
<p class="empty-state">No results found.</p>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline create-place"
|
||||
{{on "click" this.createNewPlace}}
|
||||
>
|
||||
<Icon @name="plus" @size={{18}} @color="var(--link-color)" />
|
||||
Create new place
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline create-place"
|
||||
{{on "click" this.createNewPlace}}
|
||||
>
|
||||
<Icon @name="plus" @size={{18}} @color="var(--link-color)" />
|
||||
Create new place
|
||||
</button>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
export default class ListsListController extends Controller {
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@service storage;
|
||||
|
||||
@tracked model;
|
||||
@tracked loadedPlaces = [];
|
||||
|
||||
get listId() {
|
||||
return this.model?.list_id;
|
||||
}
|
||||
|
||||
loadPlacesTask = task({ restartable: true }, async (listId) => {
|
||||
this.loadedPlaces = []; // Clear previous elements immediately to show fresh loader
|
||||
try {
|
||||
this.loadedPlaces = await this.storage.getPlacesInList(listId);
|
||||
} catch (e) {
|
||||
console.error('Failed to load places in list', listId, e);
|
||||
this.loadedPlaces = [];
|
||||
}
|
||||
});
|
||||
|
||||
get scrollTop() {
|
||||
return this.mapUi.getScrollPosition(`list-${this.listId}`);
|
||||
}
|
||||
|
||||
get listColor() {
|
||||
const list = this.storage.lists.find((l) => l.id === this.listId);
|
||||
if (list && list.color) {
|
||||
return list.color;
|
||||
}
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--default-list-color')
|
||||
.trim();
|
||||
}
|
||||
|
||||
get listTitle() {
|
||||
const list = this.storage.lists.find((l) => l.id === this.listId);
|
||||
return list ? list.title : 'Collections';
|
||||
}
|
||||
|
||||
get 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 background-fetched places that are still valid but not in live state yet
|
||||
this.loadedPlaces.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');
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,24 @@ export default class SearchController extends Controller {
|
||||
category = null;
|
||||
|
||||
fetchResultsTask = task({ restartable: true }, async (params) => {
|
||||
// Hide sidebar and clear previous results immediately to signal a new search
|
||||
// 1. Check if the incoming parameters match our currently loaded search
|
||||
const isSameSearch =
|
||||
this.mapUi.currentSearch &&
|
||||
params.q === this.mapUi.currentSearch.q &&
|
||||
params.category === this.mapUi.currentSearch.category &&
|
||||
params.lat === this.mapUi.currentSearch.lat &&
|
||||
params.lon === this.mapUi.currentSearch.lon;
|
||||
|
||||
const hasResults =
|
||||
this.mapUi.searchResults && this.mapUi.searchResults.length > 0;
|
||||
|
||||
// 2. If it's a back navigation to the exact same search, resolve instantly with no animation
|
||||
if (isSameSearch && hasResults) {
|
||||
this.mapUi.showSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Otherwise, this is a brand new search: hide the sidebar and clear previous results immediately to signal a new search
|
||||
this.mapUi.hideSidebar();
|
||||
this.mapUi.clearSearchResults();
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -10,6 +10,10 @@ Router.map(function () {
|
||||
this.route('place', { path: '/place/:place_id' });
|
||||
this.route('place.new', { path: '/place/new' });
|
||||
this.route('search');
|
||||
this.route('menu');
|
||||
this.route('lists', function () {
|
||||
this.route('list', { path: '/:list_id' });
|
||||
});
|
||||
this.route('oauth', function () {
|
||||
this.route('osm-callback', { path: '/osm/callback' });
|
||||
});
|
||||
|
||||
@@ -6,5 +6,6 @@ export default class IndexRoute extends Route {
|
||||
|
||||
activate() {
|
||||
this.mapUi.clearSearchResults();
|
||||
this.mapUi.hideSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class ListsIndexRoute extends Route {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class ListsListRoute extends Route {
|
||||
@service storage;
|
||||
|
||||
model(params) {
|
||||
// Resolve instantly so transition happens in 0ms!
|
||||
return { list_id: params.list_id };
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
console.debug('DEBUG: setupController controller is:', controller);
|
||||
console.debug(
|
||||
'DEBUG: controller.loadPlacesTask is:',
|
||||
controller?.loadPlacesTask
|
||||
);
|
||||
controller.model = model;
|
||||
super.setupController(controller, model);
|
||||
if (controller && controller.loadPlacesTask) {
|
||||
controller.loadPlacesTask.perform(model.list_id);
|
||||
} else {
|
||||
console.error('DEBUG: ERROR! controller.loadPlacesTask is undefined!');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,7 @@ export default class PlaceRoute extends Route {
|
||||
this.mapUi.clearSelection();
|
||||
// Reset the "return to search" flag so it doesn't persist to subsequent navigations
|
||||
this.mapUi.returnToSearch = false;
|
||||
this.mapUi.returnToRoute = null;
|
||||
}
|
||||
|
||||
async loadOsmPlace(id, type = null) {
|
||||
|
||||
+23
-1
@@ -1,5 +1,6 @@
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class MapUiService extends Service {
|
||||
@service nostrData;
|
||||
@@ -9,6 +10,7 @@ export default class MapUiService extends Service {
|
||||
@tracked isCreating = false;
|
||||
@tracked creationCoordinates = null;
|
||||
@tracked returnToSearch = false;
|
||||
@tracked returnToRoute = null;
|
||||
@tracked currentCenter = null;
|
||||
@tracked currentBounds = null;
|
||||
@tracked currentZoom = null;
|
||||
@@ -19,13 +21,33 @@ export default class MapUiService extends Service {
|
||||
@tracked currentSearch = null;
|
||||
@tracked loadingState = null;
|
||||
@tracked isSidebarVisible = false;
|
||||
@tracked isSidebarOpening = false;
|
||||
|
||||
scrollPositions = {};
|
||||
|
||||
@action
|
||||
saveScrollPosition(key, value) {
|
||||
this.scrollPositions[key] = value;
|
||||
}
|
||||
|
||||
@action
|
||||
getScrollPosition(key) {
|
||||
return this.scrollPositions[key] || 0;
|
||||
}
|
||||
|
||||
showSidebar() {
|
||||
this.isSidebarVisible = true;
|
||||
if (!this.isSidebarVisible) {
|
||||
this.isSidebarVisible = true;
|
||||
this.isSidebarOpening = true;
|
||||
setTimeout(() => {
|
||||
this.isSidebarOpening = false;
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
|
||||
hideSidebar() {
|
||||
this.isSidebarVisible = false;
|
||||
this.isSidebarOpening = false;
|
||||
}
|
||||
|
||||
selectPlace(place, options = {}) {
|
||||
|
||||
@@ -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) {
|
||||
// 1. Calculate required prefixes
|
||||
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
||||
|
||||
+135
-40
@@ -1,8 +1,10 @@
|
||||
/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */
|
||||
|
||||
:root {
|
||||
--default-list-color: #fc3;
|
||||
--body-text-color: #333;
|
||||
--primary-background-color: #fff;
|
||||
--hover-bg: #f8f9fa;
|
||||
--divider-color: #eee;
|
||||
--sidebar-width: 350px;
|
||||
--link-color: #2a7fff;
|
||||
--link-color-visited: #6a4fbf;
|
||||
@@ -10,6 +12,7 @@
|
||||
--marker-color-dark: #b31412;
|
||||
--danger-color: var(--marker-color-primary);
|
||||
--danger-color-dark: var(--marker-color-dark);
|
||||
--default-list-color: #fc3;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -31,7 +34,7 @@ body {
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
|
||||
#root,
|
||||
@@ -112,8 +115,6 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
grid-area: search;
|
||||
|
||||
/* Ensure it sits at the start of its grid area */
|
||||
justify-self: start;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -127,7 +128,6 @@ body {
|
||||
|
||||
@media (width > 768px) {
|
||||
.header-left {
|
||||
/* Desktop: Ensure minimum width for search box so it's not squeezed */
|
||||
min-width: 300px;
|
||||
max-width: 350px;
|
||||
}
|
||||
@@ -140,8 +140,6 @@ body {
|
||||
|
||||
.header-center {
|
||||
grid-area: chips;
|
||||
|
||||
/* Desktop: Center the chips block in the available space */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 0; /* Allow shrinking */
|
||||
@@ -156,7 +154,6 @@ body {
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
/* No need to reset min-width/max-width since they are only set in media query above */
|
||||
.header-center {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -383,7 +380,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.account-item:last-child {
|
||||
@@ -461,6 +458,9 @@ body {
|
||||
flex-direction: column;
|
||||
overflow: hidden; /* Ensure flex children are contained */
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.sidebar-opening .sidebar {
|
||||
animation: sidebar-slide-in-left 0.18s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@@ -480,11 +480,13 @@ body {
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
height: 56px; /* Strictly enforce identical vertical height */
|
||||
padding: 0 1rem; /* Keep horizontal padding, remove vertical padding */
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-sizing: border-box; /* Guarantee strict height boundaries */
|
||||
}
|
||||
|
||||
.sidebar-header.no-border {
|
||||
@@ -493,7 +495,7 @@ body {
|
||||
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -528,7 +530,7 @@ body {
|
||||
padding-left: 1.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
@@ -559,7 +561,7 @@ body {
|
||||
padding-left: 1.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
@@ -630,7 +632,7 @@ body {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
background-color: var(--primary-background-color);
|
||||
border: 1px solid var(--danger-color);
|
||||
color: var(--danger-color);
|
||||
cursor: pointer;
|
||||
@@ -646,7 +648,7 @@ body {
|
||||
.btn-remove-relay:hover,
|
||||
.btn-remove-relay:active {
|
||||
background-color: var(--danger-color);
|
||||
color: #fff;
|
||||
color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.add-relay-input {
|
||||
@@ -676,7 +678,7 @@ body {
|
||||
margin-bottom: 1rem;
|
||||
background: var(--hover-bg);
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@@ -698,8 +700,8 @@ body {
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box; /* Ensure padding doesn't overflow width */
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
color: var(--body-text-color);
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
@@ -710,7 +712,7 @@ body {
|
||||
|
||||
select.form-control {
|
||||
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-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
@@ -786,7 +788,7 @@ select.form-control {
|
||||
.meta-info p:first-child {
|
||||
margin-top: 1.2rem;
|
||||
padding-top: 1.2rem;
|
||||
border-top: 1px solid #eee;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.meta-info a,
|
||||
@@ -845,9 +847,9 @@ abbr[title] {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
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;
|
||||
@@ -1018,7 +1020,7 @@ abbr[title] {
|
||||
.photo-carousel.inline .photo-carousel-track {
|
||||
scroll-snap-type: none;
|
||||
gap: 2px;
|
||||
background-color: #fff;
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.photo-carousel.inline .carousel-slide {
|
||||
@@ -1097,7 +1099,7 @@ abbr[title] {
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
@@ -1106,7 +1108,7 @@ abbr[title] {
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
border: 1px solid rgb(255 204 51 / 20%);
|
||||
background: rgb(255 204 51 / 30%);
|
||||
}
|
||||
@@ -1363,7 +1365,7 @@ span.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
pointer-events: none;
|
||||
z-index: 2000;
|
||||
display: none;
|
||||
@@ -1499,7 +1501,7 @@ button.create-place {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
padding: 0 4px;
|
||||
@@ -1530,7 +1532,7 @@ button.create-place {
|
||||
|
||||
.search-submit-btn:hover {
|
||||
background: rgb(0 0 0 / 5%);
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
@@ -1548,7 +1550,7 @@ button.create-place {
|
||||
|
||||
.search-clear-btn:hover {
|
||||
background: rgb(0 0 0 / 5%);
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
|
||||
/* Search Results Popover */
|
||||
@@ -1617,7 +1619,7 @@ button.create-place {
|
||||
|
||||
.result-title {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -1669,7 +1671,7 @@ button.create-place {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
|
||||
.place-lists-manager input[type='checkbox'] {
|
||||
@@ -1679,7 +1681,8 @@ button.create-place {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.place-lists-manager .list-color {
|
||||
/* Shared List Color Dot */
|
||||
.list-color-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--default-list-color);
|
||||
@@ -1690,7 +1693,7 @@ button.create-place {
|
||||
|
||||
.place-lists-manager .divider {
|
||||
height: 1px;
|
||||
background: #eee;
|
||||
background: var(--divider-color);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
@@ -1733,7 +1736,7 @@ button.create-place {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 16px; /* Pill shape */
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
|
||||
@@ -1745,7 +1748,7 @@ button.create-place {
|
||||
}
|
||||
|
||||
.category-chip:active {
|
||||
background: #eee;
|
||||
background: var(--divider-color);
|
||||
}
|
||||
|
||||
.category-chip:disabled {
|
||||
@@ -1880,7 +1883,7 @@ button.create-place {
|
||||
|
||||
.photo-tag-chip {
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 6px 12px;
|
||||
@@ -1890,7 +1893,7 @@ button.create-place {
|
||||
}
|
||||
|
||||
.photo-tag-chip:hover {
|
||||
background: #eee;
|
||||
background: var(--divider-color);
|
||||
}
|
||||
|
||||
.photo-tag-chip.is-selected {
|
||||
@@ -2121,7 +2124,7 @@ button.create-place {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -2163,3 +2166,95 @@ button.create-place {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Centered layout when back button is present */
|
||||
.sidebar-header.has-back-btn {
|
||||
position: relative;
|
||||
justify-content: center; /* Center horizontally */
|
||||
}
|
||||
|
||||
/* Absolute positioning for buttons in centered header */
|
||||
.sidebar-header.has-back-btn .back-btn {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-header.has-back-btn .close-btn {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Centralized Title text */
|
||||
.sidebar-header-text-centered {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.sidebar-header-icon-wrapper {
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
margin-right: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Sidebar Loading State */
|
||||
.sidebar-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 1rem;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,9 @@ import Component from '@glimmer/component';
|
||||
import { pageTitle } from 'ember-page-title';
|
||||
import Map from '#components/map';
|
||||
import AppHeader from '#components/app-header';
|
||||
import AppMenu from '#components/app-menu/index';
|
||||
import Toast from '#components/toast';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { or } from 'ember-truth-helpers';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class ApplicationComponent extends Component {
|
||||
@@ -15,16 +12,17 @@ export default class ApplicationComponent extends Component {
|
||||
@service mapUi;
|
||||
@service router;
|
||||
|
||||
@tracked isAppMenuOpen = false;
|
||||
|
||||
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.
|
||||
const name = this.router.currentRouteName;
|
||||
return (
|
||||
this.mapUi.isSidebarVisible &&
|
||||
(this.router.currentRouteName === 'place' ||
|
||||
this.router.currentRouteName === 'place.new' ||
|
||||
this.router.currentRouteName === 'search')
|
||||
(name === 'place' ||
|
||||
name === 'place.new' ||
|
||||
name === 'search' ||
|
||||
name === 'menu' ||
|
||||
name.startsWith('lists'))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,24 +35,27 @@ export default class ApplicationComponent extends Component {
|
||||
|
||||
@action
|
||||
toggleAppMenu() {
|
||||
this.isAppMenuOpen = !this.isAppMenuOpen;
|
||||
}
|
||||
|
||||
@action
|
||||
closeAppMenu() {
|
||||
this.isAppMenuOpen = false;
|
||||
if (this.router.currentRouteName === 'menu') {
|
||||
this.router.transitionTo('index');
|
||||
} else {
|
||||
this.router.transitionTo('menu');
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleOutsideClick() {
|
||||
if (this.isAppMenuOpen) {
|
||||
this.closeAppMenu();
|
||||
} else if (
|
||||
this.router.currentRouteName === 'search' ||
|
||||
this.router.currentRouteName === 'place'
|
||||
const name = this.router.currentRouteName;
|
||||
if (
|
||||
name === 'search' ||
|
||||
name === 'place' ||
|
||||
name === 'menu' ||
|
||||
name.startsWith('lists')
|
||||
) {
|
||||
this.mapUi.clearSelection();
|
||||
this.mapUi.hideSidebar();
|
||||
if (name === 'menu' || name.startsWith('lists')) {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,32 +67,30 @@ export default class ApplicationComponent extends Component {
|
||||
<template>
|
||||
{{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
|
||||
class="rs-backdrop"
|
||||
role="button"
|
||||
{{on "click" this.storage.closeWidget}}
|
||||
id="rs-widget-container"
|
||||
class={{if this.storage.isWidgetOpen "visible"}}
|
||||
></div>
|
||||
{{/if}}
|
||||
|
||||
<Map
|
||||
@isSidebarOpen={{or this.isSidebarOpen this.isAppMenuOpen}}
|
||||
@onOutsideClick={{this.handleOutsideClick}}
|
||||
/>
|
||||
{{#if this.storage.isWidgetOpen}}
|
||||
<div
|
||||
class="rs-backdrop"
|
||||
role="button"
|
||||
{{on "click" this.storage.closeWidget}}
|
||||
></div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.isAppMenuOpen}}
|
||||
<AppMenu @onClose={{this.closeAppMenu}} />
|
||||
{{/if}}
|
||||
<Map
|
||||
@isSidebarOpen={{this.isSidebarOpen}}
|
||||
@onOutsideClick={{this.handleOutsideClick}}
|
||||
/>
|
||||
|
||||
<Toast />
|
||||
<Toast />
|
||||
|
||||
{{outlet}}
|
||||
{{outlet}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<template>{{outlet}}</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
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) {
|
||||
const finalColor =
|
||||
color ||
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--default-list-color')
|
||||
.trim();
|
||||
return htmlSafe(`background-color: ${finalColor}`);
|
||||
}
|
||||
|
||||
@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 has-back-btn">
|
||||
<button type="button" class="back-btn" {{on "click" this.backToMenu}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2 class="sidebar-header-text-centered">
|
||||
<span class="sidebar-header-icon-wrapper">
|
||||
<Icon @name="bookmark" @size={{20}} @color="#898989" />
|
||||
</span>
|
||||
Collections
|
||||
</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>
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import PlacesSidebar from '#components/places-sidebar';
|
||||
|
||||
<template>
|
||||
{{#if @controller.mapUi.isSidebarVisible}}
|
||||
<PlacesSidebar
|
||||
@places={{@controller.places}}
|
||||
@title={{@controller.listTitle}}
|
||||
@color={{@controller.listColor}}
|
||||
@scrollTop={{@controller.scrollTop}}
|
||||
@isLoading={{@controller.loadPlacesTask.isRunning}}
|
||||
@onSelect={{@controller.selectPlace}}
|
||||
@onClose={{@controller.close}}
|
||||
@onBack={{@controller.backToLists}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
@@ -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>
|
||||
}
|
||||
@@ -77,8 +77,14 @@ export default class PlaceTemplate extends Component {
|
||||
navigateBack(place) {
|
||||
// The sidebar calls this with null when "Back" is clicked.
|
||||
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 (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
else if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
this.mapUi.showSidebar();
|
||||
this.router.transitionTo('search', {
|
||||
queryParams: this.mapUi.currentSearch,
|
||||
|
||||
@@ -10,6 +10,10 @@ export default class SearchTemplate extends Component {
|
||||
@action
|
||||
selectPlace(place) {
|
||||
if (place) {
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
if (sidebarContent) {
|
||||
this.mapUi.saveScrollPosition('search', sidebarContent.scrollTop);
|
||||
}
|
||||
this.mapUi.returnToSearch = true;
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.preventNextZoom = true;
|
||||
@@ -28,6 +32,7 @@ export default class SearchTemplate extends Component {
|
||||
{{#if this.mapUi.isSidebarVisible}}
|
||||
<PlacesSidebar
|
||||
@places={{this.mapUi.searchResults}}
|
||||
@scrollTop={{this.mapUi.getScrollPosition "search"}}
|
||||
@onSelect={{this.selectPlace}}
|
||||
@onClose={{this.close}}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@
|
||||
"@embroider/vite": "^1.5.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@glimmer/component": "^2.0.0",
|
||||
"@remotestorage/module-places": "~1.2.1",
|
||||
"@remotestorage/module-places": "~1.3.0",
|
||||
"@rollup/plugin-babel": "^6.1.0",
|
||||
"@warp-drive/core": "~5.8.0",
|
||||
"@warp-drive/ember": "~5.8.0",
|
||||
|
||||
Generated
+5
-5
@@ -88,8 +88,8 @@ importers:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@remotestorage/module-places':
|
||||
specifier: ~1.2.1
|
||||
version: 1.2.1
|
||||
specifier: ~1.3.0
|
||||
version: 1.3.0
|
||||
'@rollup/plugin-babel':
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1)
|
||||
@@ -1468,8 +1468,8 @@ packages:
|
||||
resolution: {integrity: sha512-4rdu8GPY9TeQwsYp5D2My74dC3dSVS3tghAvisG80ybK4lqa0gvlrglaSTBxogJbxqHRw/NjI/liEtb3+SD+Bw==}
|
||||
engines: {node: '>=18.12'}
|
||||
|
||||
'@remotestorage/module-places@1.2.1':
|
||||
resolution: {integrity: sha512-hNRuhGoG8RS+cieVvDVzXWBEuNPfyeFirhgNH3z1WoKw9ngHdPY6V0sT0vKbsxB8xaODReZfo2ZKHLTmdFunlw==}
|
||||
'@remotestorage/module-places@1.3.0':
|
||||
resolution: {integrity: sha512-VM0CqkIP6IBEpjqJ2DyTrGDOQXc73aXoAFDLIoME5Lo033uTitgn+qKgTGPK/lD4H92mk1+D3W88ECtf9v3mWw==}
|
||||
|
||||
'@rollup/plugin-babel@6.1.0':
|
||||
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
|
||||
@@ -7439,7 +7439,7 @@ snapshots:
|
||||
'@pnpm/error': 1000.0.5
|
||||
find-up: 5.0.0
|
||||
|
||||
'@remotestorage/module-places@1.2.1':
|
||||
'@remotestorage/module-places@1.3.0':
|
||||
dependencies:
|
||||
latlon-geohash: 2.0.0
|
||||
ulid: 3.0.2
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click, waitFor } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
|
||||
class MockOsmService extends Service {
|
||||
async fetchOsmObject() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class MockStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = [
|
||||
{
|
||||
id: 'place-123',
|
||||
title: 'Mountain Trail',
|
||||
geohash: 'u33dc0',
|
||||
osmTags: { name: 'Mountain Trail' },
|
||||
},
|
||||
];
|
||||
lists = [
|
||||
{
|
||||
id: 'to-go',
|
||||
title: 'Want to go',
|
||||
color: '#2e9e4f',
|
||||
placeRefs: [{ id: 'place-123', geohash: 'u33dc0' }],
|
||||
},
|
||||
{ id: 'to-do', title: 'To do', color: '#2a7fff', placeRefs: [] },
|
||||
];
|
||||
|
||||
findPlaceById(id) {
|
||||
if (id === 'place-123') {
|
||||
return this.savedPlaces[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
isPlaceSaved() {
|
||||
return true;
|
||||
}
|
||||
|
||||
loadPlacesInBounds() {
|
||||
return [];
|
||||
}
|
||||
|
||||
getPlacesInList(listId) {
|
||||
if (listId === 'to-go') {
|
||||
return Promise.resolve([this.savedPlaces[0]]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
module('Acceptance | collections navigation', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
});
|
||||
|
||||
test('navigating through the collections menu hierarchy, viewing list places, and going back', async function (assert) {
|
||||
// 1. Visit Home Map
|
||||
await visit('/');
|
||||
assert.strictEqual(currentURL(), '/');
|
||||
|
||||
// 2. Open the App Menu overlay
|
||||
await click('.menu-btn-integrated');
|
||||
assert.dom('.sidebar.app-menu-pane').exists('App menu sidebar is open');
|
||||
assert
|
||||
.dom('.app-menu')
|
||||
.includesText('Collections', 'Menu contains Collections link');
|
||||
|
||||
// 3. Transition to Collections Index (List of lists)
|
||||
await click(document.querySelectorAll('.app-menu button')[0]); // Click "Collections"
|
||||
assert.strictEqual(currentURL(), '/lists', 'Transitions to /lists index');
|
||||
assert
|
||||
.dom('.sidebar-header-text-centered')
|
||||
.includesText('Collections', 'Header is centered and titled Collections');
|
||||
assert
|
||||
.dom('.lists-index-item')
|
||||
.exists({ count: 2 }, 'Renders our 2 mocked list items');
|
||||
|
||||
// 4. Transition to a specific list (Want to go)
|
||||
await click(document.querySelectorAll('.lists-index-item')[0]); // Click "Want to go"
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/lists/to-go',
|
||||
'Transitions instantly to /lists/to-go'
|
||||
);
|
||||
|
||||
// 5. Verify background loading spinner shows up, then results populate
|
||||
await waitFor('.places-list');
|
||||
assert
|
||||
.dom('.places-list .place-name')
|
||||
.hasText('Mountain Trail', 'Renders the saved place from the list');
|
||||
assert
|
||||
.dom('.places-list .place-type')
|
||||
.hasText('Saved place', 'Place type displays Saved place correctly');
|
||||
|
||||
// 6. Click back button in collection list header
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.strictEqual(currentURL(), '/lists', 'Goes back to /lists index');
|
||||
|
||||
// 7. Click back button in collections index header
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.strictEqual(currentURL(), '/menu', 'Goes back to main menu route');
|
||||
|
||||
// 8. Close sidebar
|
||||
await click('.sidebar-header .close-btn');
|
||||
assert.strictEqual(currentURL(), '/', 'Sidebar closed and returned home');
|
||||
});
|
||||
|
||||
test('clicking a place inside a collection sets returnToRoute and returns gracefully on back click', async function (assert) {
|
||||
await visit('/lists/to-go');
|
||||
await waitFor('.places-list');
|
||||
|
||||
// Click on the place item to view details
|
||||
await click('.place-item');
|
||||
assert.ok(
|
||||
currentURL().includes('/place/place-123'),
|
||||
'Transitions to place details route'
|
||||
);
|
||||
|
||||
// Click back from place details
|
||||
await click('.back-btn');
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/lists/to-go',
|
||||
'Returns gracefully back to lists/to-go list view'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { module, test } from 'qunit';
|
||||
import { visit, currentURL, waitFor, triggerEvent } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
import sinon from 'sinon';
|
||||
|
||||
module('Acceptance | map search reset', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
@@ -17,58 +16,10 @@ module('Acceptance | map search reset', function (hooks) {
|
||||
'marco:map-view',
|
||||
JSON.stringify(highZoomState)
|
||||
);
|
||||
|
||||
// Stub window.fetch using Sinon
|
||||
// We want to intercept map style requests and let everything else through
|
||||
this.fetchStub = sinon.stub(window, 'fetch');
|
||||
|
||||
this.fetchStub.callsFake(async (input, init) => {
|
||||
let url = input;
|
||||
if (typeof input === 'object' && input !== null && 'url' in input) {
|
||||
url = input.url;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof url === 'string' &&
|
||||
url.includes('tiles.openfreemap.org/styles/liberty')
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
version: 8,
|
||||
name: 'Liberty',
|
||||
sources: {
|
||||
openmaptiles: {
|
||||
type: 'vector',
|
||||
url: 'https://tiles.openfreemap.org/planet',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
paint: {
|
||||
'background-color': '#123456',
|
||||
},
|
||||
},
|
||||
],
|
||||
glyphs:
|
||||
'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
|
||||
sprite: 'https://tiles.openfreemap.org/sprites/liberty',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Pass through to the original implementation
|
||||
return this.fetchStub.wrappedMethod(input, init);
|
||||
});
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
window.localStorage.removeItem('marco:map-view');
|
||||
// Restore the original fetch
|
||||
this.fetchStub.restore();
|
||||
});
|
||||
|
||||
test('clicking the map clears the category search parameter', async function (assert) {
|
||||
|
||||
@@ -4,6 +4,92 @@ import {
|
||||
setupTest as upstreamSetupTest,
|
||||
} from 'ember-qunit';
|
||||
import { setupNostrMocks } from './mock-nostr';
|
||||
import sinon from 'sinon';
|
||||
|
||||
function setupMapStyleMocks(hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
// Stub window.fetch to capture map-style assets before they hit the network
|
||||
this.fetchStub = sinon.stub(window, 'fetch');
|
||||
|
||||
this.fetchStub.callsFake(async (input, init) => {
|
||||
let url = input;
|
||||
if (typeof input === 'object' && input !== null && 'url' in input) {
|
||||
url = input.url;
|
||||
}
|
||||
|
||||
if (typeof url === 'string' && url.includes('tiles.openfreemap.org')) {
|
||||
// A. Mock Style Sheet
|
||||
if (url.includes('/styles/liberty')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
version: 8,
|
||||
name: 'Mock Style',
|
||||
sources: {
|
||||
openmaptiles: {
|
||||
type: 'vector',
|
||||
tiles: [], // Empty tiles list prevents any map tile fetching completely!
|
||||
},
|
||||
},
|
||||
sprite: 'https://tiles.openfreemap.org/sprites/liberty',
|
||||
glyphs:
|
||||
'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
|
||||
layers: [
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
paint: { 'background-color': '#f8f9fa' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// B. Mock Sprite Atlas JSON
|
||||
if (url.endsWith('.json') && url.includes('/sprites/')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}), // Empty sprite dictionary
|
||||
};
|
||||
}
|
||||
|
||||
// C. Mock Sprite Atlas PNG (Returns a valid 1x1 transparent PNG)
|
||||
if (url.endsWith('.png') && url.includes('/sprites/')) {
|
||||
const bytes = new Uint8Array([
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0,
|
||||
0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73,
|
||||
68, 65, 84, 120, 156, 99, 96, 0, 0, 0, 2, 0, 1, 226, 33, 188, 51, 0,
|
||||
0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
|
||||
]);
|
||||
const blob = new Blob([bytes], { type: 'image/png' });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: async () => blob,
|
||||
};
|
||||
}
|
||||
|
||||
// Catch-all mock for other openfreemap endpoints
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
// Pass through to original fetch (e.g. Photon results, local mock APIs)
|
||||
return this.fetchStub.wrappedMethod(input, init);
|
||||
});
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
if (this.fetchStub && typeof this.fetchStub.restore === 'function') {
|
||||
this.fetchStub.restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// This file exists to provide wrappers around ember-qunit's
|
||||
// test setup functions. This way, you can easily extend the setup that is
|
||||
@@ -12,6 +98,7 @@ import { setupNostrMocks } from './mock-nostr';
|
||||
function setupApplicationTest(hooks, options) {
|
||||
upstreamSetupApplicationTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
setupMapStyleMocks(hooks);
|
||||
|
||||
// Additional setup for application tests can be done here.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user