Compare commits
23 Commits
5afece5f51
...
v1.19.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
5c71523d90
|
|||
|
bea1b97fb7
|
|||
|
6ba1cf31cf
|
|||
|
9cdd021cda
|
|||
|
ef53870b35
|
|||
|
918a794784
|
|||
|
344a3067fa
|
|||
|
ad3e6ea402
|
|||
|
9e2545da7b
|
|||
|
480c97fb9d
|
|||
|
179cf49370
|
|||
|
aea0388267
|
|||
|
e4d02cda26
|
|||
|
27ebbaca60
|
|||
|
cbdd056dcb
|
|||
|
2423b67f94
|
|||
|
2a3ad26eb9
|
|||
|
9d06898b15
|
|||
|
6df43edbf9
|
|||
|
e2fcdce154
|
|||
|
829dff9839
|
|||
|
e7dfed204e
|
|||
|
2dfd411837
|
@@ -1,8 +1,12 @@
|
||||
# This file is committed to git and should not contain any secrets.
|
||||
#
|
||||
#
|
||||
# Vite recommends using .env.local or .env.[mode].local if you need to manage secrets
|
||||
# SEE: https://vite.dev/guide/env-and-mode.html#env-files for more information.
|
||||
|
||||
|
||||
# Default NODE_ENV with vite build --mode=test is production
|
||||
NODE_ENV=development
|
||||
|
||||
# OpenStreetMap OAuth
|
||||
VITE_OSM_CLIENT_ID=jIn8l5mT8FZOGYiIYXG1Yvj_2FZKB9TJ1edZwOJPsRU
|
||||
VITE_OSM_OAUTH_URL=https://www.openstreetmap.org
|
||||
|
||||
3
.env.production
Normal file
3
.env.production
Normal file
@@ -0,0 +1,3 @@
|
||||
# OpenStreetMap OAuth
|
||||
VITE_OSM_CLIENT_ID=jIn8l5mT8FZOGYiIYXG1Yvj_2FZKB9TJ1edZwOJPsRU
|
||||
VITE_OSM_OAUTH_URL=https://www.openstreetmap.org
|
||||
@@ -284,7 +284,9 @@ export default class MapComponent extends Component {
|
||||
const initialCenter = toLonLat(view.getCenter());
|
||||
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
|
||||
|
||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
|
||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', {
|
||||
webfonts: 'data:text/css,',
|
||||
});
|
||||
|
||||
this.searchOverlayElement = document.createElement('div');
|
||||
this.searchOverlayElement.className = 'search-pulse';
|
||||
@@ -392,7 +394,10 @@ export default class MapComponent extends Component {
|
||||
const locateElement = document.createElement('div');
|
||||
locateElement.className = 'ol-control ol-locate';
|
||||
const locateBtn = document.createElement('button');
|
||||
locateBtn.innerHTML = '⊙';
|
||||
locateBtn.style.display = 'flex';
|
||||
locateBtn.style.alignItems = 'center';
|
||||
locateBtn.style.justifyContent = 'center';
|
||||
locateBtn.innerHTML = `<span class="icon" style="width: 14px; height: 14px; display: flex;">${getIcon('navigation')}</span>`;
|
||||
locateBtn.title = 'Locate Me';
|
||||
locateElement.appendChild(locateBtn);
|
||||
|
||||
|
||||
@@ -130,15 +130,24 @@ export default class PlaceDetails extends Component {
|
||||
|
||||
formatMultiLine(val, type) {
|
||||
if (!val) return null;
|
||||
const parts = val
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const parts = [
|
||||
...new Set(
|
||||
val
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
),
|
||||
];
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
if (type === 'phone') {
|
||||
return htmlSafe(
|
||||
parts.map((p) => `<a href="tel:${p}">${p}</a>`).join('<br>')
|
||||
parts
|
||||
.map((p) => {
|
||||
const safeTel = p.replace(/[\s-]+/g, '');
|
||||
return `<a href="tel:${safeTel}">${p}</a>`;
|
||||
})
|
||||
.join('<br>')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,6 +157,17 @@ export default class PlaceDetails extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'whatsapp') {
|
||||
return htmlSafe(
|
||||
parts
|
||||
.map((p) => {
|
||||
const safeTel = p.replace(/[\s-]+/g, '');
|
||||
return `<a href="https://wa.me/${safeTel}" target="_blank" rel="noopener noreferrer">${p}</a>`;
|
||||
})
|
||||
.join('<br>')
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'url') {
|
||||
return htmlSafe(
|
||||
parts
|
||||
@@ -165,8 +185,27 @@ export default class PlaceDetails extends Component {
|
||||
}
|
||||
|
||||
get phone() {
|
||||
const val = this.tags.phone || this.tags['contact:phone'];
|
||||
return this.formatMultiLine(val, 'phone');
|
||||
const rawValues = [
|
||||
this.tags.phone,
|
||||
this.tags['contact:phone'],
|
||||
this.tags.mobile,
|
||||
this.tags['contact:mobile'],
|
||||
].filter(Boolean);
|
||||
|
||||
if (rawValues.length === 0) return null;
|
||||
|
||||
return this.formatMultiLine(rawValues.join(';'), 'phone');
|
||||
}
|
||||
|
||||
get whatsapp() {
|
||||
const rawValues = [
|
||||
this.tags.whatsapp,
|
||||
this.tags['contact:whatsapp'],
|
||||
].filter(Boolean);
|
||||
|
||||
if (rawValues.length === 0) return null;
|
||||
|
||||
return this.formatMultiLine(rawValues.join(';'), 'whatsapp');
|
||||
}
|
||||
|
||||
get email() {
|
||||
@@ -343,6 +382,15 @@ export default class PlaceDetails extends Component {
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.whatsapp}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="whatsapp" @title="WhatsApp" />
|
||||
<span>
|
||||
{{this.whatsapp}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.website}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="globe" @title="Website" />
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import Icon from '#components/icon';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class UserMenuComponent extends Component {
|
||||
@service storage;
|
||||
@service osmAuth;
|
||||
|
||||
@action
|
||||
connectRS() {
|
||||
this.args.onClose();
|
||||
this.args.storage.connect();
|
||||
this.args.storage.showConnectWidget();
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -15,6 +19,17 @@ export default class UserMenuComponent extends Component {
|
||||
this.args.storage.disconnect();
|
||||
}
|
||||
|
||||
@action
|
||||
connectOsm() {
|
||||
this.args.onClose();
|
||||
this.osmAuth.login();
|
||||
}
|
||||
|
||||
@action
|
||||
disconnectOsm() {
|
||||
this.osmAuth.logout();
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="user-menu-popover">
|
||||
<ul class="account-list">
|
||||
@@ -47,15 +62,32 @@ export default class UserMenuComponent extends Component {
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="account-item disabled">
|
||||
<li class="account-item">
|
||||
<div class="account-header">
|
||||
<div class="account-info">
|
||||
<Icon @name="map" @size={{18}} />
|
||||
<span>OpenStreetMap</span>
|
||||
</div>
|
||||
{{#if this.osmAuth.isConnected}}
|
||||
<button
|
||||
class="btn-text text-danger"
|
||||
type="button"
|
||||
{{on "click" this.disconnectOsm}}
|
||||
>Disconnect</button>
|
||||
{{else}}
|
||||
<button
|
||||
class="btn-text text-primary"
|
||||
type="button"
|
||||
{{on "click" this.connectOsm}}
|
||||
>Connect</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="account-status">
|
||||
Coming soon
|
||||
{{#if this.osmAuth.isConnected}}
|
||||
<strong>{{this.osmAuth.userDisplayName}}</strong>
|
||||
{{else}}
|
||||
Not connected
|
||||
{{/if}}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
|
||||
4
app/icons/whatsapp.svg
Normal file
4
app/icons/whatsapp.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="-1.66 0 740.82 740.82" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m630.06 107.66c-69.329-69.387-161.53-107.62-259.76-107.66-202.4 0-367.13 164.67-367.22 367.07-0.027 64.699 16.883 127.86 49.016 183.52l-52.095 190.23 194.67-51.047c53.634 29.244 114.02 44.656 175.48 44.682h0.151c202.38 0 367.13-164.69 367.21-367.09 0.039-98.088-38.121-190.32-107.45-259.71m-259.76 564.8h-0.125c-54.766-0.021-108.48-14.729-155.34-42.529l-11.146-6.613-115.52 30.293 30.834-112.59-7.258-11.543c-30.552-48.58-46.689-104.73-46.665-162.38 0.067-168.23 136.99-305.1 305.34-305.1 81.521 0.031 158.15 31.81 215.78 89.482s89.342 134.33 89.311 215.86c-0.07 168.24-136.99 305.12-305.21 305.12m167.42-228.51c-9.176-4.591-54.286-26.782-62.697-29.843-8.41-3.061-14.526-4.591-20.644 4.592-6.116 9.182-23.7 29.843-29.054 35.964-5.351 6.122-10.703 6.888-19.879 2.296-9.175-4.591-38.739-14.276-73.786-45.526-27.275-24.32-45.691-54.36-51.043-63.542-5.352-9.183-0.569-14.148 4.024-18.72 4.127-4.11 9.175-10.713 13.763-16.07 4.587-5.356 6.116-9.182 9.174-15.303 3.059-6.122 1.53-11.479-0.764-16.07s-20.643-49.739-28.29-68.104c-7.447-17.886-15.012-15.466-20.644-15.746-5.346-0.266-11.469-0.323-17.585-0.323-6.117 0-16.057 2.296-24.468 11.478-8.41 9.183-32.112 31.374-32.112 76.521s32.877 88.763 37.465 94.885c4.587 6.122 64.699 98.771 156.74 138.5 21.891 9.45 38.982 15.093 52.307 19.323 21.981 6.979 41.983 5.994 57.793 3.633 17.628-2.633 54.285-22.19 61.932-43.616 7.646-21.426 7.646-39.791 5.352-43.617-2.293-3.826-8.41-6.122-17.585-10.714" clip-rule="evenodd" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -10,4 +10,7 @@ Router.map(function () {
|
||||
this.route('place', { path: '/place/:place_id' });
|
||||
this.route('place.new', { path: '/place/new' });
|
||||
this.route('search');
|
||||
this.route('oauth', function () {
|
||||
this.route('osm-callback', { path: '/osm/callback' });
|
||||
});
|
||||
});
|
||||
|
||||
17
app/routes/oauth/osm-callback.js
Normal file
17
app/routes/oauth/osm-callback.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class OauthOsmCallbackRoute extends Route {
|
||||
@service osmAuth;
|
||||
@service router;
|
||||
|
||||
async model() {
|
||||
try {
|
||||
await this.osmAuth.handleCallback();
|
||||
} catch (e) {
|
||||
console.error('Failed to handle OSM OAuth callback', e);
|
||||
} finally {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
}
|
||||
}
|
||||
116
app/services/osm-auth.js
Normal file
116
app/services/osm-auth.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { OAuth2AuthCodePkceClient } from 'oauth2-pkce';
|
||||
|
||||
class MarcoOsmAuthStorage {
|
||||
saveState(serializedState) {
|
||||
localStorage.setItem('marco:osm_auth_state', serializedState);
|
||||
}
|
||||
loadState() {
|
||||
const state = localStorage.getItem('marco:osm_auth_state');
|
||||
if (!state) return false;
|
||||
try {
|
||||
JSON.parse(state);
|
||||
return state;
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse OSM auth state', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class OsmAuthService extends Service {
|
||||
@tracked isConnected = false;
|
||||
@tracked userDisplayName = null;
|
||||
|
||||
oauthClient;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
const clientId =
|
||||
import.meta.env.VITE_OSM_CLIENT_ID || 'YOUR_CLIENT_ID_HERE';
|
||||
const oauthUrl =
|
||||
import.meta.env.VITE_OSM_OAUTH_URL || 'https://www.openstreetmap.org';
|
||||
|
||||
const redirectUrl =
|
||||
import.meta.env.VITE_OSM_REDIRECT_URI ||
|
||||
`${window.location.origin}/oauth/osm/callback`;
|
||||
|
||||
this.oauthClient = new OAuth2AuthCodePkceClient(
|
||||
{
|
||||
scopes: ['read_prefs', 'write_api'],
|
||||
authorizationUrl: `${oauthUrl}/oauth2/authorize`,
|
||||
tokenUrl: `${oauthUrl}/oauth2/token`,
|
||||
clientId: clientId,
|
||||
redirectUrl: redirectUrl,
|
||||
storeRefreshToken: true,
|
||||
},
|
||||
new MarcoOsmAuthStorage()
|
||||
);
|
||||
|
||||
this.restoreSession();
|
||||
}
|
||||
|
||||
async restoreSession() {
|
||||
try {
|
||||
await this.oauthClient.ready;
|
||||
} catch (e) {
|
||||
console.warn('oauthClient.ready failed', e);
|
||||
}
|
||||
const isAuthorized = await this.oauthClient.isAuthorized();
|
||||
if (isAuthorized) {
|
||||
this.isConnected = true;
|
||||
const storedName = localStorage.getItem('marco:osm_user_display_name');
|
||||
if (storedName) {
|
||||
this.userDisplayName = storedName;
|
||||
} else {
|
||||
await this.fetchUserInfo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async login() {
|
||||
await this.oauthClient.requestAuthorizationCode();
|
||||
}
|
||||
|
||||
async handleCallback() {
|
||||
await this.oauthClient.receiveCode();
|
||||
await this.oauthClient.getTokens();
|
||||
this.isConnected = true;
|
||||
await this.fetchUserInfo();
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.oauthClient.reset();
|
||||
this.isConnected = false;
|
||||
this.userDisplayName = null;
|
||||
localStorage.removeItem('marco:osm_user_display_name');
|
||||
}
|
||||
|
||||
async fetchUserInfo() {
|
||||
try {
|
||||
const tokens = await this.oauthClient.getTokens();
|
||||
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||
const response = await fetch(
|
||||
'https://api.openstreetmap.org/api/0.6/user/details.json',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.debug('OSM data:', data);
|
||||
const displayName = data.user.display_name;
|
||||
this.userDisplayName = displayName;
|
||||
localStorage.setItem('marco:osm_user_display_name', displayName);
|
||||
} else {
|
||||
console.error('Failed to fetch OSM user info', response.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching OSM user info', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,14 @@ export default class StorageService extends Service {
|
||||
// console.debug('[rs] client ready');
|
||||
});
|
||||
|
||||
this.rs.on('error', (error) => {
|
||||
if (!error) return;
|
||||
console.info('[rs] Error —', `${error.name}: ${error.message}`);
|
||||
if (error.name === 'Unauthorized') {
|
||||
this.showConnectWidget();
|
||||
}
|
||||
});
|
||||
|
||||
this.rs.on('connected', () => {
|
||||
this.connected = true;
|
||||
this.userAddress = this.rs.remote.userAddress;
|
||||
@@ -445,7 +453,7 @@ export default class StorageService extends Service {
|
||||
}
|
||||
|
||||
@action
|
||||
connect() {
|
||||
showConnectWidget() {
|
||||
this.isWidgetOpen = true;
|
||||
|
||||
// Check if widget is already attached
|
||||
|
||||
@@ -14,6 +14,8 @@ html,
|
||||
body {
|
||||
height: 100%;
|
||||
overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
@@ -26,6 +28,7 @@ body {
|
||||
margin: 0;
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@@ -251,6 +254,10 @@ body {
|
||||
margin-left: calc(18px + 0.75rem);
|
||||
}
|
||||
|
||||
.account-status strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.account-item.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
@@ -260,7 +267,6 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
|
||||
@@ -110,6 +110,7 @@ import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/wome
|
||||
import loadingRing from '../icons/270-ring.svg?raw';
|
||||
import nostrich from '../icons/nostrich-2.svg?raw';
|
||||
import remotestorage from '../icons/remotestorage.svg?raw';
|
||||
import whatsapp from '../icons/whatsapp.svg?raw';
|
||||
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||
|
||||
const ICONS = {
|
||||
@@ -218,6 +219,7 @@ const ICONS = {
|
||||
'village-buildings': villageBuildings,
|
||||
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
|
||||
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
||||
whatsapp,
|
||||
wikipedia,
|
||||
parking_p: parkingP,
|
||||
car,
|
||||
@@ -229,6 +231,7 @@ const ICONS = {
|
||||
const FILLED_ICONS = [
|
||||
'fork-and-knife',
|
||||
'wikipedia',
|
||||
'whatsapp',
|
||||
'cup-and-saucer',
|
||||
'coffee-bean',
|
||||
'shopping-basket',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.17.2",
|
||||
"version": "1.19.1",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -87,7 +87,7 @@
|
||||
"prettier-plugin-ember-template-tag": "^2.1.2",
|
||||
"qunit": "^2.25.0",
|
||||
"qunit-dom": "^3.5.0",
|
||||
"remotestorage-widget": "^1.8.0",
|
||||
"remotestorage-widget": "^1.8.1",
|
||||
"remotestoragejs": "2.0.0-beta.8",
|
||||
"sinon": "^21.0.1",
|
||||
"stylelint": "^16.26.1",
|
||||
@@ -104,6 +104,7 @@
|
||||
"dependencies": {
|
||||
"@waysidemapping/pinhead": "^15.20.0",
|
||||
"ember-concurrency": "^5.2.0",
|
||||
"ember-lifeline": "^7.0.0"
|
||||
"ember-lifeline": "^7.0.0",
|
||||
"oauth2-pkce": "^2.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
||||
ember-lifeline:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0(@ember/test-helpers@5.4.1(@babel/core@7.28.6))
|
||||
oauth2-pkce:
|
||||
specifier: ^2.1.3
|
||||
version: 2.1.3
|
||||
devDependencies:
|
||||
'@babel/core':
|
||||
specifier: ^7.28.5
|
||||
@@ -163,8 +166,8 @@ importers:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
remotestorage-widget:
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0
|
||||
specifier: ^1.8.1
|
||||
version: 1.8.1
|
||||
remotestoragejs:
|
||||
specifier: 2.0.0-beta.8
|
||||
version: 2.0.0-beta.8
|
||||
@@ -4150,6 +4153,9 @@ packages:
|
||||
nwsapi@2.2.23:
|
||||
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
|
||||
|
||||
oauth2-pkce@2.1.3:
|
||||
resolution: {integrity: sha512-DcMiG5XUKNabf0qV1GOEPo+zwIdYsO2K1FMhiP8XnSCJbujZLOnSC5BCYiCtD0le+aue2fK6qDDvGjcQNF53jQ==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4558,8 +4564,8 @@ packages:
|
||||
resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==}
|
||||
hasBin: true
|
||||
|
||||
remotestorage-widget@1.8.0:
|
||||
resolution: {integrity: sha512-l8AE2npC2nNkC8bAU6WhWO5Wl4exaXbE2yR82s5Oiqg6h0KN2mYwvLLTQGkp6mSmZTA86e7XaOcNp4lXS8CsBA==}
|
||||
remotestorage-widget@1.8.1:
|
||||
resolution: {integrity: sha512-HxNu2VvIRW3wzkf5fLEzs56ySQ7+YQbRqyp3CKvmw/G+zKhRsmj06HtFoAcm3B14/nJh2SOAv3LyfKuXfUsKPw==}
|
||||
|
||||
remotestoragejs@2.0.0-beta.8:
|
||||
resolution: {integrity: sha512-rtyHTG2VbtiKTRmbwjponRf5VTPJMcHv/ijNid1zX48C0Z0F8ZCBBfkKD2QCxTQyQvCupkWNy3wuIu4HE+AEng==}
|
||||
@@ -10261,6 +10267,8 @@ snapshots:
|
||||
|
||||
nwsapi@2.2.23: {}
|
||||
|
||||
oauth2-pkce@2.1.3: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-hash@1.3.1: {}
|
||||
@@ -10640,7 +10648,7 @@ snapshots:
|
||||
dependencies:
|
||||
jsesc: 3.1.0
|
||||
|
||||
remotestorage-widget@1.8.0: {}
|
||||
remotestorage-widget@1.8.1: {}
|
||||
|
||||
remotestoragejs@2.0.0-beta.8:
|
||||
dependencies:
|
||||
|
||||
File diff suppressed because one or more lines are too long
1
release/assets/main-BF2Ls-fG.css
Normal file
1
release/assets/main-BF2Ls-fG.css
Normal file
File diff suppressed because one or more lines are too long
2
release/assets/main-BVEi_-zb.js
Normal file
2
release/assets/main-BVEi_-zb.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,8 +39,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-B8Ckz4Ru.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-OLSOzTKA.css">
|
||||
<script type="module" crossorigin src="/assets/main-BVEi_-zb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BF2Ls-fG.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
@@ -255,4 +255,83 @@ module('Integration | Component | place-details', function (hooks) {
|
||||
assert.dom('.actions button').hasText('Save');
|
||||
assert.dom('.actions button').doesNotHaveClass('btn-secondary');
|
||||
});
|
||||
|
||||
test('it aggregates phone and mobile tags without duplicates', async function (assert) {
|
||||
const place = {
|
||||
title: 'Phone Shop',
|
||||
osmTags: {
|
||||
phone: '+1-234-567-8900',
|
||||
'contact:phone': '+1-234-567-8900; +1 000 000 0000',
|
||||
mobile: '+1 987 654 3210',
|
||||
'contact:mobile': '+1 987 654 3210',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
// Use specific selector for the phone block since there's no cuisine or opening_hours
|
||||
const metaInfos = Array.from(
|
||||
this.element.querySelectorAll('.meta-info .content-with-icon')
|
||||
);
|
||||
const phoneBlock = metaInfos.find((el) => {
|
||||
const iconSpan = el.querySelector('span.icon[title="Phone"]');
|
||||
return !!iconSpan;
|
||||
});
|
||||
|
||||
assert.ok(phoneBlock, 'Phone block is rendered');
|
||||
|
||||
const links = phoneBlock.querySelectorAll('a[href^="tel:"]');
|
||||
assert.strictEqual(
|
||||
links.length,
|
||||
3,
|
||||
'Rendered exactly 3 unique phone links'
|
||||
);
|
||||
|
||||
assert.strictEqual(links[0].getAttribute('href'), 'tel:+12345678900');
|
||||
assert.strictEqual(links[1].getAttribute('href'), 'tel:+10000000000');
|
||||
assert.strictEqual(links[2].getAttribute('href'), 'tel:+19876543210');
|
||||
|
||||
assert.dom(links[0]).hasText('+1-234-567-8900');
|
||||
assert.dom(links[1]).hasText('+1 000 000 0000');
|
||||
assert.dom(links[2]).hasText('+1 987 654 3210');
|
||||
});
|
||||
|
||||
test('it formats whatsapp tags into wa.me links', async function (assert) {
|
||||
const place = {
|
||||
title: 'Chat Shop',
|
||||
osmTags: {
|
||||
'contact:whatsapp': '+1 234-567 8900',
|
||||
whatsapp: '+44 987 654 321', // Also tests multiple values
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const metaInfos = Array.from(
|
||||
this.element.querySelectorAll('.meta-info .content-with-icon')
|
||||
);
|
||||
const whatsappBlock = metaInfos.find((el) => {
|
||||
const iconSpan = el.querySelector('span.icon[title="WhatsApp"]');
|
||||
return !!iconSpan;
|
||||
});
|
||||
|
||||
assert.ok(whatsappBlock, 'WhatsApp block is rendered');
|
||||
|
||||
const links = whatsappBlock.querySelectorAll('a[href^="https://wa.me/"]');
|
||||
assert.strictEqual(links.length, 2, 'Rendered exactly 2 WhatsApp links');
|
||||
|
||||
// Verify it stripped the dashes and spaces for the wa.me URL
|
||||
assert.strictEqual(
|
||||
links[0].getAttribute('href'),
|
||||
'https://wa.me/+44987654321'
|
||||
);
|
||||
assert.strictEqual(
|
||||
links[1].getAttribute('href'),
|
||||
'https://wa.me/+12345678900'
|
||||
);
|
||||
|
||||
// Verify it kept the dashes and spaces for the visible text
|
||||
assert.dom(links[0]).hasText('+44 987 654 321');
|
||||
assert.dom(links[1]).hasText('+1 234-567 8900');
|
||||
});
|
||||
});
|
||||
|
||||
102
tests/integration/components/user-menu-test.gjs
Normal file
102
tests/integration/components/user-menu-test.gjs
Normal file
@@ -0,0 +1,102 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import UserMenu from 'marco/components/user-menu';
|
||||
|
||||
class MockOsmAuthService extends Service {
|
||||
@tracked isConnected = false;
|
||||
@tracked userDisplayName = null;
|
||||
|
||||
loginCalled = false;
|
||||
logoutCalled = false;
|
||||
|
||||
login() {
|
||||
this.loginCalled = true;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.logoutCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
class MockStorageService extends Service {
|
||||
@tracked connected = false;
|
||||
@tracked userAddress = null;
|
||||
}
|
||||
|
||||
module('Integration | Component | user-menu', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:osmAuth', MockOsmAuthService);
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
this.osmAuth = this.owner.lookup('service:osmAuth');
|
||||
});
|
||||
|
||||
test('it renders disconnected OSM state correctly', async function (assert) {
|
||||
this.osmAuth.isConnected = false;
|
||||
|
||||
this.storageArg = {
|
||||
connected: false,
|
||||
userAddress: null,
|
||||
};
|
||||
this.onClose = () => {};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<UserMenu @storage={{this.storageArg}} @onClose={{this.onClose}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.account-list .account-item:nth-child(2) .account-info')
|
||||
.includesText('OpenStreetMap');
|
||||
assert
|
||||
.dom('.account-list .account-item:nth-child(2) button')
|
||||
.hasText('Connect');
|
||||
assert
|
||||
.dom('.account-list .account-item:nth-child(2) .account-status')
|
||||
.hasText('Not connected');
|
||||
|
||||
await click('.account-list .account-item:nth-child(2) button');
|
||||
assert.true(
|
||||
this.osmAuth.loginCalled,
|
||||
'osmAuth.login() was called when Connect is clicked'
|
||||
);
|
||||
});
|
||||
|
||||
test('it renders connected OSM state correctly', async function (assert) {
|
||||
this.osmAuth.isConnected = true;
|
||||
this.osmAuth.userDisplayName = 'TestMapper';
|
||||
|
||||
this.storageArg = {
|
||||
connected: false,
|
||||
userAddress: null,
|
||||
};
|
||||
this.onClose = () => {};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<UserMenu @storage={{this.storageArg}} @onClose={{this.onClose}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.account-list .account-item:nth-child(2) .account-info')
|
||||
.includesText('OpenStreetMap');
|
||||
assert
|
||||
.dom('.account-list .account-item:nth-child(2) button')
|
||||
.hasText('Disconnect');
|
||||
assert
|
||||
.dom('.account-list .account-item:nth-child(2) .account-status')
|
||||
.hasText('TestMapper');
|
||||
|
||||
await click('.account-list .account-item:nth-child(2) button');
|
||||
assert.true(
|
||||
this.osmAuth.logoutCalled,
|
||||
'osmAuth.logout() was called when Disconnect is clicked'
|
||||
);
|
||||
});
|
||||
});
|
||||
47
tests/unit/routes/oauth/osm-callback-test.js
Normal file
47
tests/unit/routes/oauth/osm-callback-test.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
|
||||
class MockOsmAuthService extends Service {
|
||||
handleCallbackCalled = false;
|
||||
|
||||
async handleCallback() {
|
||||
this.handleCallbackCalled = true;
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
class MockRouterService extends Service {
|
||||
transitionToArgs = [];
|
||||
|
||||
transitionTo(...args) {
|
||||
this.transitionToArgs.push(args);
|
||||
}
|
||||
}
|
||||
|
||||
module('Unit | Route | oauth/osm-callback', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:osmAuth', MockOsmAuthService);
|
||||
this.owner.register('service:router', MockRouterService);
|
||||
});
|
||||
|
||||
test('it handles the callback and transitions to index', async function (assert) {
|
||||
let route = this.owner.lookup('route:oauth/osm-callback');
|
||||
let osmAuth = this.owner.lookup('service:osmAuth');
|
||||
let router = this.owner.lookup('service:router');
|
||||
|
||||
await route.model();
|
||||
|
||||
assert.true(
|
||||
osmAuth.handleCallbackCalled,
|
||||
'handleCallback was called on the osmAuth service'
|
||||
);
|
||||
assert.deepEqual(
|
||||
router.transitionToArgs,
|
||||
[['index']],
|
||||
'router transitioned back to index route'
|
||||
);
|
||||
});
|
||||
});
|
||||
158
tests/unit/services/osm-auth-test.js
Normal file
158
tests/unit/services/osm-auth-test.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'marco/tests/helpers';
|
||||
|
||||
module('Unit | Service | osm-auth', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
let originalLocalStorage;
|
||||
let originalFetch;
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
// Stub localStorage
|
||||
let mockStorage = {};
|
||||
originalLocalStorage = window.localStorage;
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
getItem: (key) => mockStorage[key] || null,
|
||||
setItem: (key, value) => {
|
||||
mockStorage[key] = value.toString();
|
||||
},
|
||||
removeItem: (key) => {
|
||||
delete mockStorage[key];
|
||||
},
|
||||
clear: () => {
|
||||
mockStorage = {};
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Stub fetch
|
||||
originalFetch = window.fetch;
|
||||
window.fetch = async () => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user: { display_name: 'MockedUser' },
|
||||
}),
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
window.localStorage = originalLocalStorage;
|
||||
window.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test('it restores session correctly when logged in', async function (assert) {
|
||||
let service = this.owner.factoryFor('service:osm-auth').create();
|
||||
|
||||
// Stub the underlying oauthClient before it runs restoreSession (actually restoreSession runs in constructor, so we need to intercept it)
|
||||
// Because restoreSession runs in the constructor, we might need to overwrite it after, but it's async.
|
||||
// Let's just create it, let the original restoreSession fail or do nothing, and then we stub and re-call it.
|
||||
|
||||
service.oauthClient.ready = Promise.resolve();
|
||||
service.oauthClient.isAuthorized = async () => true;
|
||||
window.localStorage.setItem('marco:osm_user_display_name', 'CachedName');
|
||||
|
||||
await service.restoreSession();
|
||||
|
||||
assert.true(service.isConnected, 'Service becomes connected');
|
||||
assert.strictEqual(
|
||||
service.userDisplayName,
|
||||
'CachedName',
|
||||
'Restores name from localStorage'
|
||||
);
|
||||
});
|
||||
|
||||
test('it fetches user info when logged in but no cached name', async function (assert) {
|
||||
let service = this.owner.factoryFor('service:osm-auth').create();
|
||||
|
||||
service.oauthClient.ready = Promise.resolve();
|
||||
service.oauthClient.isAuthorized = async () => true;
|
||||
service.oauthClient.getTokens = async () => ({ accessToken: 'fake-token' });
|
||||
// Ensure localStorage is empty for this key
|
||||
window.localStorage.removeItem('marco:osm_user_display_name');
|
||||
|
||||
await service.restoreSession();
|
||||
|
||||
assert.true(service.isConnected, 'Service becomes connected');
|
||||
assert.strictEqual(
|
||||
service.userDisplayName,
|
||||
'MockedUser',
|
||||
'Fetched name from API'
|
||||
);
|
||||
assert.strictEqual(
|
||||
window.localStorage.getItem('marco:osm_user_display_name'),
|
||||
'MockedUser',
|
||||
'Saved name to localStorage'
|
||||
);
|
||||
});
|
||||
|
||||
test('it handles login()', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm-auth');
|
||||
let requestCalled = false;
|
||||
service.oauthClient.requestAuthorizationCode = async () => {
|
||||
requestCalled = true;
|
||||
};
|
||||
|
||||
await service.login();
|
||||
assert.true(
|
||||
requestCalled,
|
||||
'Delegates to oauthClient.requestAuthorizationCode()'
|
||||
);
|
||||
});
|
||||
|
||||
test('it handles logout()', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm-auth');
|
||||
service.isConnected = true;
|
||||
service.userDisplayName = 'ToBeCleared';
|
||||
window.localStorage.setItem('marco:osm_user_display_name', 'ToBeCleared');
|
||||
|
||||
let resetCalled = false;
|
||||
service.oauthClient.reset = async () => {
|
||||
resetCalled = true;
|
||||
};
|
||||
|
||||
await service.logout();
|
||||
|
||||
assert.true(resetCalled, 'Delegates to oauthClient.reset()');
|
||||
assert.false(service.isConnected, 'isConnected is reset');
|
||||
assert.strictEqual(
|
||||
service.userDisplayName,
|
||||
null,
|
||||
'userDisplayName is reset'
|
||||
);
|
||||
assert.strictEqual(
|
||||
window.localStorage.getItem('marco:osm_user_display_name'),
|
||||
null,
|
||||
'localStorage is cleared'
|
||||
);
|
||||
});
|
||||
|
||||
test('it handles the callback flow', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm-auth');
|
||||
let receiveCodeCalled = false;
|
||||
let getTokensCalled = false;
|
||||
|
||||
service.oauthClient.receiveCode = async () => {
|
||||
receiveCodeCalled = true;
|
||||
};
|
||||
service.oauthClient.getTokens = async () => {
|
||||
getTokensCalled = true;
|
||||
return { accessToken: 'fake-token' };
|
||||
};
|
||||
|
||||
await service.handleCallback();
|
||||
|
||||
assert.true(receiveCodeCalled, 'oauthClient.receiveCode was called');
|
||||
assert.true(getTokensCalled, 'oauthClient.getTokens was called');
|
||||
assert.true(service.isConnected, 'Service is connected after callback');
|
||||
assert.strictEqual(
|
||||
service.userDisplayName,
|
||||
'MockedUser',
|
||||
'User info fetched and set during callback'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,9 @@ import { extensions, ember } from '@embroider/vite';
|
||||
import { babel } from '@rollup/plugin-babel';
|
||||
|
||||
export default defineConfig({
|
||||
// server: {
|
||||
// host: '0.0.0.0',
|
||||
// },
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
},
|
||||
plugins: [
|
||||
ember(),
|
||||
// extra plugins here
|
||||
|
||||
Reference in New Issue
Block a user