Add user/accounts menu, RS connect
This commit is contained in:
parent
721fe5f01d
commit
f28be0c994
59
app/components/app-header.gjs
Normal file
59
app/components/app-header.gjs
Normal file
@ -0,0 +1,59 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from '#components/icon';
|
||||
import UserMenu from '#components/user-menu';
|
||||
|
||||
export default class AppHeaderComponent extends Component {
|
||||
@service storage;
|
||||
@tracked isUserMenuOpen = false;
|
||||
|
||||
@action
|
||||
toggleUserMenu() {
|
||||
this.isUserMenuOpen = !this.isUserMenuOpen;
|
||||
}
|
||||
|
||||
@action
|
||||
closeUserMenu() {
|
||||
this.isUserMenuOpen = false;
|
||||
}
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<button class="icon-btn" type="button" aria-label="Menu">
|
||||
<Icon @name="menu" @size={{24}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="user-menu-container">
|
||||
<button
|
||||
class="user-btn"
|
||||
type="button"
|
||||
aria-label="User Menu"
|
||||
{{on "click" this.toggleUserMenu}}
|
||||
>
|
||||
<div class="user-avatar-placeholder">
|
||||
<Icon @name="user" @size={{20}} @color="white" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{{#if this.isUserMenuOpen}}
|
||||
<UserMenu
|
||||
@storage={{this.storage}}
|
||||
@onClose={{this.closeUserMenu}}
|
||||
/>
|
||||
<div
|
||||
class="menu-backdrop"
|
||||
{{on "click" this.closeUserMenu}}
|
||||
role="button"
|
||||
></div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
}
|
||||
@ -2,31 +2,43 @@ import Component from '@glimmer/component';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
|
||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
||||
import home from 'feather-icons/dist/icons/home.svg?raw';
|
||||
import logIn from 'feather-icons/dist/icons/log-in.svg?raw';
|
||||
import logOut from 'feather-icons/dist/icons/log-out.svg?raw';
|
||||
import map from 'feather-icons/dist/icons/map.svg?raw';
|
||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||
|
||||
const ICONS = {
|
||||
'arrow-left': arrowLeft,
|
||||
activity,
|
||||
bookmark,
|
||||
clock,
|
||||
globe,
|
||||
home,
|
||||
'log-in': logIn,
|
||||
'log-out': logOut,
|
||||
map,
|
||||
'map-pin': mapPin,
|
||||
menu,
|
||||
navigation,
|
||||
phone,
|
||||
server,
|
||||
settings,
|
||||
user,
|
||||
x
|
||||
x,
|
||||
zap,
|
||||
};
|
||||
|
||||
export default class IconComponent extends Component {
|
||||
|
||||
66
app/components/user-menu.gjs
Normal file
66
app/components/user-menu.gjs
Normal file
@ -0,0 +1,66 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import Icon from '#components/icon';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class UserMenuComponent extends Component {
|
||||
@action
|
||||
connectRS() {
|
||||
this.args.onClose();
|
||||
this.args.storage.connect();
|
||||
}
|
||||
|
||||
@action
|
||||
disconnectRS() {
|
||||
this.args.storage.disconnect();
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="user-menu-popover">
|
||||
<div class="user-status">
|
||||
{{#if @storage.connected}}
|
||||
Connected as
|
||||
<strong>{{@storage.userAddress}}</strong>
|
||||
{{else}}
|
||||
Not connected
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<ul class="account-list">
|
||||
<li class="account-item">
|
||||
<div class="account-info">
|
||||
<Icon @name="server" @size={{18}} />
|
||||
<span>RemoteStorage</span>
|
||||
</div>
|
||||
{{#if @storage.connected}}
|
||||
<button
|
||||
class="btn-text text-danger"
|
||||
type="button"
|
||||
{{on "click" this.disconnectRS}}
|
||||
>Disconnect</button>
|
||||
{{else}}
|
||||
<button
|
||||
class="btn-text text-primary"
|
||||
type="button"
|
||||
{{on "click" this.connectRS}}
|
||||
>Connect</button>
|
||||
{{/if}}
|
||||
</li>
|
||||
|
||||
<li class="account-item disabled">
|
||||
<div class="account-info">
|
||||
<Icon @name="globe" @size={{18}} />
|
||||
<span>OpenStreetMap</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="account-item disabled">
|
||||
<div class="account-info">
|
||||
<Icon @name="zap" @size={{18}} />
|
||||
<span>Nostr</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@ -1,19 +1,25 @@
|
||||
import Service from '@ember/service';
|
||||
import RemoteStorage from 'remotestoragejs';
|
||||
import Places from '@remotestorage/module-places';
|
||||
import Widget from 'remotestorage-widget';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||
import { action } from '@ember/object';
|
||||
import { debounce } from '@ember/runloop';
|
||||
import Geohash from 'latlon-geohash';
|
||||
|
||||
export default class StorageService extends Service {
|
||||
rs;
|
||||
widget;
|
||||
@tracked placesInView = [];
|
||||
@tracked savedPlaces = [];
|
||||
@tracked loadedPrefixes = [];
|
||||
@tracked currentBbox = null;
|
||||
@tracked version = 0; // Shared version tracker for bookmarks
|
||||
@tracked initialSyncDone = false;
|
||||
@tracked connected = false;
|
||||
@tracked userAddress = null;
|
||||
@tracked isWidgetOpen = false;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
@ -29,14 +35,38 @@ export default class StorageService extends Service {
|
||||
|
||||
window.remoteStorage = this.rs;
|
||||
|
||||
// const widget = new Widget(this.rs);
|
||||
// widget.attach();
|
||||
this.widget = new Widget(this.rs, {
|
||||
leaveOpen: true,
|
||||
skipInitial: true,
|
||||
});
|
||||
// We don't attach immediately; we'll attach when the user clicks Connect
|
||||
|
||||
this.rs.on('ready', () => {
|
||||
// console.debug('[rs] client ready');
|
||||
});
|
||||
|
||||
this.rs.on('sync-done', (result) => {
|
||||
this.rs.on('connected', () => {
|
||||
console.debug('Remote storage connected');
|
||||
this.connected = true;
|
||||
this.userAddress = this.rs.remote.userAddress;
|
||||
|
||||
// Close widget after successful connection (respecting autoCloseAfter)
|
||||
setTimeout(() => {
|
||||
this.isWidgetOpen = false;
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
this.rs.on('disconnected', () => {
|
||||
console.debug('Remote storage disconnected');
|
||||
this.connected = false;
|
||||
this.userAddress = null;
|
||||
this.placesInView = [];
|
||||
this.savedPlaces = [];
|
||||
this.loadedPrefixes = [];
|
||||
this.initialSyncDone = false;
|
||||
});
|
||||
|
||||
this.rs.on('sync-done', () => {
|
||||
// console.debug('[rs] sync done:', result);
|
||||
if (!this.initialSyncDone) {
|
||||
this.initialSyncDone = true;
|
||||
@ -157,7 +187,7 @@ export default class StorageService extends Service {
|
||||
// If the hash is in the set of reloaded prefixes, we discard the old version
|
||||
// (because the 'places' array contains the authoritative new state for this prefix)
|
||||
return !prefixSet.has(hash);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return true; // Keep malformed/unknown places safe
|
||||
}
|
||||
});
|
||||
@ -198,6 +228,28 @@ export default class StorageService extends Service {
|
||||
|
||||
async removePlace(place) {
|
||||
await this.places.remove(place.id, place.geohash);
|
||||
this.savedPlaces = this.savedPlaces.filter(p => p.id !== place.id);
|
||||
this.savedPlaces = this.savedPlaces.filter((p) => p.id !== place.id);
|
||||
}
|
||||
|
||||
@action
|
||||
connect() {
|
||||
this.isWidgetOpen = true;
|
||||
|
||||
// Check if widget is already attached
|
||||
if (!document.querySelector('.rs-widget')) {
|
||||
// Attach to our specific container
|
||||
this.widget.attach('rs-widget-container');
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
closeWidget() {
|
||||
this.isWidgetOpen = false;
|
||||
}
|
||||
|
||||
@action
|
||||
disconnect() {
|
||||
this.rs.disconnect();
|
||||
this.isWidgetOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,12 +22,161 @@ body {
|
||||
outline: none; /* Prevent focus outline on click */
|
||||
}
|
||||
|
||||
/* Ensure RS widget is above the map */
|
||||
#remotestorage-widget {
|
||||
/* Ensure RS widget is above the map but potentially hidden initially if needed */
|
||||
#rs-widget-container {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
top: 60px; /* Below header */
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
z-index: 4000;
|
||||
display: none; /* Hidden by default */
|
||||
}
|
||||
|
||||
#rs-widget-container.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rs-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3999; /* Below widget container but above everything else */
|
||||
/* background: rgba(0,0,0,0.2); Optional: dim background */
|
||||
}
|
||||
|
||||
/* App Header */
|
||||
.app-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 3000; /* Above sidebar (2000) and map */
|
||||
pointer-events: none; /* Let clicks pass through to map where transparent */
|
||||
}
|
||||
|
||||
.header-left,
|
||||
.header-right {
|
||||
pointer-events: auto; /* Re-enable clicks for buttons */
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.icon-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.user-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.user-avatar-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #333;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* User Menu Popover */
|
||||
.user-menu-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-popover {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 10px;
|
||||
width: 280px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 1rem;
|
||||
z-index: 3001;
|
||||
}
|
||||
|
||||
.menu-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3000; /* Below popover but above everything else */
|
||||
/* background: rgba(0,0,0,0.1); Optional dimming */
|
||||
}
|
||||
|
||||
.user-status {
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.account-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.account-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.account-item.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.account-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: #007bff;
|
||||
}
|
||||
.text-danger {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Sidebar Styles */
|
||||
|
||||
@ -2,11 +2,13 @@ import Component from '@glimmer/component';
|
||||
import { pageTitle } from 'ember-page-title';
|
||||
import Map from '#components/map';
|
||||
import PlacesSidebar from '#components/places-sidebar';
|
||||
import AppHeader from '#components/app-header';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { eq } from 'ember-truth-helpers';
|
||||
import { and } from 'ember-truth-helpers';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class ApplicationComponent extends Component {
|
||||
@service storage;
|
||||
@ -74,6 +76,21 @@ export default class ApplicationComponent extends Component {
|
||||
<template>
|
||||
{{pageTitle "M/\RCO"}}
|
||||
|
||||
<AppHeader />
|
||||
|
||||
<div
|
||||
id="rs-widget-container"
|
||||
class={{if this.storage.isWidgetOpen "visible"}}
|
||||
></div>
|
||||
|
||||
{{#if this.storage.isWidgetOpen}}
|
||||
<div
|
||||
class="rs-backdrop"
|
||||
role="button"
|
||||
{{on "click" this.storage.closeWidget}}
|
||||
></div>
|
||||
{{/if}}
|
||||
|
||||
<Map
|
||||
@onPlacesFound={{this.showPlaces}}
|
||||
@isSidebarOpen={{this.isSidebarOpen}}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user