Compare commits

..

33 Commits

Author SHA1 Message Date
f0a19e30b8 Don't load actual map tiles in tests
Some checks failed
CI / Lint (pull_request) Successful in 31s
CI / Test (pull_request) Failing after 1m6s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
We don't need remote tiles for testing our functionality
2026-06-06 12:34:59 +04:00
59bc5ca046 1.24.0
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 55s
2026-06-06 12:12:06 +04:00
ef4bb8f51a Merge pull request 'Include saved places in search results' (#59) from feature/search_saved_places into master
All checks were successful
CI / Lint (push) Successful in 33s
CI / Test (push) Successful in 56s
Reviewed-on: #59
2026-06-06 08:03:32 +00:00
f82a797720 Include list names in search results for saved places
All checks were successful
CI / Lint (pull_request) Successful in 32s
CI / Test (pull_request) Successful in 55s
Release Drafter / Update release notes draft (pull_request) Successful in 16s
2026-06-06 12:00:48 +04:00
f9cb22ee0e Include saved places in search results 2026-06-06 11:47:27 +04:00
a77ea0c97d 1.23.0
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 54s
2026-06-05 18:57:28 +04:00
208b77a294 Merge pull request 'Optionally add suggested tags to place photos' (#58) from feature/photo_tags into master
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 54s
Reviewed-on: #58
2026-06-05 14:51:15 +00:00
ea3e4dd0dc Fix warning when running tests
All checks were successful
CI / Lint (pull_request) Successful in 32s
CI / Test (pull_request) Successful in 52s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-06-05 18:46:10 +04:00
2c2a3e2a4c Fix flaky test
All checks were successful
CI / Lint (pull_request) Successful in 32s
CI / Test (pull_request) Successful in 55s
Wait for specific actions/elements
2026-06-05 18:37:40 +04:00
d266bb92bd Use data attribute to determine current gallery photo in test 2026-06-05 18:33:08 +04:00
200100686d Optionally add tag to place photo
Some checks failed
CI / Lint (pull_request) Successful in 52s
CI / Test (pull_request) Failing after 56s
2026-06-05 18:24:36 +04:00
70d2fe1c6c 1.22.0
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 54s
2026-05-13 12:28:39 +02:00
6329ad986d Fix unnecessary browser console warnings 2026-05-13 12:27:16 +02:00
bcfa81494e Merge pull request 'Delete own photos, fetch/sync remote deletions' (#55) from feature/delete_own_photos into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 1m0s
Reviewed-on: #55
2026-05-13 10:08:22 +00:00
bc42694707 Move form group to different place
All checks were successful
CI / Lint (pull_request) Successful in 30s
CI / Test (pull_request) Successful in 1m0s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-05-13 12:03:43 +02:00
4390b7d699 Add settings for experimental features
All checks were successful
CI / Lint (pull_request) Successful in 31s
CI / Test (pull_request) Successful in 59s
2026-05-13 11:57:41 +02:00
7bab8dfa09 Fix arrow key scrolling in photo gallery 2026-05-13 11:02:19 +02:00
51c9555273 Fix flaky photo gallery carousel tests and refactor overlays
* Fixed a race condition in `photo-carousel` where programmatic scrolling
  (e.g., keyboard navigation) would conflict with `IntersectionObserver`
  callbacks, causing the current photo to revert mid-scroll. Added an
  `isProgrammaticScroll` flag to temporarily suppress observer updates
  during these scrolls.
* Added explicit timeouts in `photo-gallery-test.gjs` to allow the carousel
  animations to settle between keyboard events.
* Refactored `Modal` and `PhotoGallery` components to use `{{in-element}}`
  to render their contents into a top-level `#modal-portal` div. This prevents
  z-index and overflow clipping issues.
* Updated `index.html` to include the `#modal-portal` div.
2026-05-13 10:31:45 +02:00
632efeeab5 1.21.3
All checks were successful
CI / Lint (push) Successful in 34s
CI / Test (push) Successful in 57s
2026-05-08 11:43:57 +02:00
14827fce3e Delete own photos
Some checks failed
CI / Lint (pull_request) Successful in 30s
CI / Test (pull_request) Failing after 57s
2026-05-05 12:07:33 +02:00
deeea9961f Merge pull request 'Add photo actions, fix portrait photos using thumb URLs' (#54) from feature/photo_actions into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 56s
Reviewed-on: #54
2026-05-05 09:49:25 +00:00
7a109c9ba5 Fix portrait photos using thumb for full size variant
All checks were successful
CI / Lint (pull_request) Successful in 32s
CI / Test (pull_request) Successful in 55s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-05-05 10:16:12 +02:00
10aae3c9b3 Add photo action for copying event ID 2026-05-05 09:56:12 +02:00
b492e2aa89 Add dropdown component, photo actions menu 2026-05-05 09:49:20 +02:00
4c4a53ae42 1.21.2
All checks were successful
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 55s
2026-05-05 07:18:37 +02:00
a240a5d199 Serve dev on all IPs 2026-05-05 07:17:30 +02:00
0332cf4c3c Merge pull request 'Hide quick-search pills on low zoom levels' (#53) from ui/pills_on_zoom into master
All checks were successful
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 54s
Reviewed-on: #53
2026-05-05 05:14:54 +00:00
59c447fe1f Hide quick-search pills on low zoom levels
All checks were successful
CI / Lint (pull_request) Successful in 30s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Successful in 7s
2026-05-05 07:10:28 +02:00
1140ecfe41 Add buttons for opening signer app, copying connect link
All checks were successful
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 59s
2026-05-05 07:02:08 +02:00
60936ed2f5 1.21.1
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 56s
2026-04-27 21:51:55 +01:00
ca82a029bc Fix app menu section layout 2026-04-27 21:50:47 +01:00
0630aed73d Fix modals 2026-04-27 21:50:36 +01:00
f27a636529 1.21.0
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 58s
2026-04-27 21:26:23 +01:00
47 changed files with 1948 additions and 221 deletions

View File

@@ -15,6 +15,7 @@ export default class AppHeaderComponent extends Component {
@service settings;
@service nostrAuth;
@service nostrData;
@service mapUi;
@tracked isUserMenuOpen = false;
@tracked searchQuery = '';
@@ -22,6 +23,11 @@ export default class AppHeaderComponent extends Component {
return !!this.searchQuery;
}
get showQuickSearch() {
const zoom = this.mapUi.currentZoom ?? 13;
return this.settings.showQuickSearchButtons && zoom >= 12;
}
@action
toggleUserMenu() {
this.isUserMenuOpen = !this.isUserMenuOpen;
@@ -54,7 +60,7 @@ export default class AppHeaderComponent extends Component {
/>
</div>
{{#if this.settings.showQuickSearchButtons}}
{{#if this.showQuickSearch}}
<div class="header-center {{if this.hasQuery 'searching'}}">
<CategoryChips @onSelect={{this.handleChipSelect}} />
</div>

View File

@@ -6,6 +6,7 @@ import Icon from '#components/icon';
import AppMenuSettingsMapUi from './settings/map-ui';
import AppMenuSettingsApis from './settings/apis';
import AppMenuSettingsNostr from './settings/nostr';
import AppMenuSettingsExperimental from './settings/experimental';
export default class AppMenuSettings extends Component {
@service settings;
@@ -35,6 +36,7 @@ export default class AppMenuSettings extends Component {
<AppMenuSettingsMapUi @onChange={{this.updateSetting}} />
<AppMenuSettingsApis @onChange={{this.updateSetting}} />
<AppMenuSettingsNostr @onChange={{this.updateSetting}} />
<AppMenuSettingsExperimental @onChange={{this.updateSetting}} />
</section>
</div>
</template>

View File

@@ -15,7 +15,7 @@ export default class AppMenuSettingsApis extends Component {
<Icon @name="server" @size={{20}} />
<span>API Providers</span>
</summary>
<div class="details-content">
<div class="details-content form-layout">
<div class="form-group">
<label for="overpass-api">Overpass API Provider</label>
<select

View File

@@ -0,0 +1,49 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { service } from '@ember/service';
import { fn } from '@ember/helper';
import Icon from '#components/icon';
export default class AppMenuSettingsExperimental extends Component {
@service settings;
<template>
{{! template-lint-disable no-nested-interactive }}
<details>
<summary>
<Icon @name="alert-triangle" @size={{20}} />
<span>Experimental</span>
</summary>
<div class="details-content form-layout">
<div class="form-group">
<label for="experimental-enable-photo-deletion">Enable photo deletion
(own photos)</label>
<select
id="experimental-enable-photo-deletion"
class="form-control"
{{on "change" (fn @onChange "experimentalEnablePhotoDeletion")}}
>
<option
value="true"
selected={{if
this.settings.experimentalEnablePhotoDeletion
"selected"
}}
>
On
</option>
<option
value="false"
selected={{unless
this.settings.experimentalEnablePhotoDeletion
"selected"
}}
>
Off
</option>
</select>
</div>
</div>
</details>
</template>
}

View File

@@ -14,7 +14,7 @@ export default class AppMenuSettingsMapUi extends Component {
<Icon @name="map" @size={{20}} />
<span>Map & UI</span>
</summary>
<div class="details-content">
<div class="details-content form-layout">
<div class="form-group">
<label for="show-quick-search">Quick search buttons visible</label>
<select

View File

@@ -108,33 +108,7 @@ export default class AppMenuSettingsNostr extends Component {
<Icon @name="zap" @size={{20}} />
<span>Nostr</span>
</summary>
<div class="details-content">
<div class="form-group">
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
servers</label>
<select
id="nostr-photo-fallback-uploads"
class="form-control"
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
>
<option
value="true"
selected={{if this.settings.nostrPhotoFallbackUploads "selected"}}
>
Yes
</option>
<option
value="false"
selected={{unless
this.settings.nostrPhotoFallbackUploads
"selected"
}}
>
No
</option>
</select>
</div>
<div class="details-content form-layout">
<div class="form-group">
<label for="new-read-relay">Read Relays</label>
<ul class="relay-list">
@@ -225,6 +199,32 @@ export default class AppMenuSettingsNostr extends Component {
{{/if}}
</div>
<div class="form-group">
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
servers</label>
<select
id="nostr-photo-fallback-uploads"
class="form-control"
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
>
<option
value="true"
selected={{if this.settings.nostrPhotoFallbackUploads "selected"}}
>
Yes
</option>
<option
value="false"
selected={{unless
this.settings.nostrPhotoFallbackUploads
"selected"
}}
>
No
</option>
</select>
</div>
<div class="form-group">
<label>Cached data</label>
<button

View File

@@ -0,0 +1,53 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import Icon from '#components/icon';
export default class DropdownMenu extends Component {
@tracked isOpen = false;
@action
toggleMenu(e) {
e?.stopPropagation();
this.isOpen = !this.isOpen;
}
@action
closeMenu(e) {
e?.stopPropagation();
this.isOpen = false;
}
get triggerIcon() {
return this.args.triggerIcon || 'more-vertical';
}
<template>
<div class="dropdown-menu-container">
<button
class="dropdown-trigger-btn btn-press"
type="button"
title={{@triggerTitle}}
{{on "click" this.toggleMenu}}
>
<Icon
@name={{this.triggerIcon}}
@size={{@iconSize}}
@color={{@iconColor}}
/>
</button>
{{#if this.isOpen}}
<div class="dropdown-popover {{@popoverClass}}">
{{yield this.closeMenu}}
</div>
<div
class="menu-backdrop"
{{on "click" this.closeMenu}}
role="button"
></div>
{{/if}}
</div>
</template>
}

View File

@@ -20,6 +20,7 @@ import { Style, Circle, Fill, Stroke, Icon } from 'ol/style.js';
import { apply } from 'ol-mapbox-style';
import { getIcon } from '../utils/icons';
import { getIconNameForTags } from '../utils/osm-icons';
import config from 'marco/config/environment';
export default class MapComponent extends Component {
@service osm;
@@ -284,10 +285,28 @@ export default class MapComponent extends Component {
// Initialize the UI service with the map center
const initialCenter = toLonLat(view.getCenter());
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
this.mapUi.updateZoom(view.getZoom());
if (config.environment === 'test') {
apply(this.mapInstance, {
version: 8,
name: 'Test Style',
sources: {},
layers: [
{
id: 'background',
type: 'background',
paint: {
'background-color': '#f8f4f0',
},
},
],
});
} else {
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', {
webfonts: 'data:text/css,',
});
}
this.searchOverlayElement = document.createElement('div');
this.searchOverlayElement.className = 'search-pulse';
@@ -1046,6 +1065,7 @@ export default class MapComponent extends Component {
const view = this.mapInstance.getView();
const center = toLonLat(view.getCenter());
this.mapUi.updateCenter(center[1], center[0]);
this.mapUi.updateZoom(view.getZoom());
// If in creation mode, update the coordinates in the service AND the URL
if (this.mapUi.isCreating) {

View File

@@ -1,9 +1,39 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import config from 'marco/config/environment';
import Icon from './icon';
const ModalContent = <template>
<div class="modal-overlay" role="dialog" tabindex="-1" {{on "click" @close}}>
<div
class="modal-content"
role="document"
tabindex="0"
{{on "click" @stopProp}}
>
<button
type="button"
class="close-modal-btn btn-text {{if @disableClose 'disabled'}}"
disabled={{@disableClose}}
{{on "click" @close}}
>
<Icon @name="x" @size={{24}} @color="currentColor" />
</button>
{{yield}}
</div>
</div>
</template>;
export default class Modal extends Component {
get isTesting() {
return config.environment === 'test';
}
get destinationElement() {
return document.getElementById('modal-portal') || document.body;
}
@action
stopProp(e) {
e.stopPropagation();
@@ -18,28 +48,24 @@ export default class Modal extends Component {
}
<template>
<div
class="modal-overlay"
role="dialog"
tabindex="-1"
{{on "click" this.close}}
{{#if this.isTesting}}
<ModalContent
@close={{this.close}}
@stopProp={{this.stopProp}}
@disableClose={{@disableClose}}
>
<div
class="modal-content"
role="document"
tabindex="0"
{{on "click" this.stopProp}}
>
<button
type="button"
class="close-modal-btn btn-text {{if @disableClose 'disabled'}}"
disabled={{@disableClose}}
{{on "click" this.close}}
>
<Icon @name="x" @size={{24}} @color="currentColor" />
</button>
{{yield}}
</div>
</div>
</ModalContent>
{{else}}
{{#in-element this.destinationElement}}
<ModalContent
@close={{this.close}}
@stopProp={{this.stopProp}}
@disableClose={{@disableClose}}
>
{{yield}}
</ModalContent>
{{/in-element}}
{{/if}}
</template>
}

View File

@@ -41,6 +41,40 @@ export default class NostrConnectComponent extends Component {
}
}
@action
async copyConnectUri() {
const text = this.nostrAuth.connectUri;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (!successful) {
throw new Error('Fallback copy failed');
}
}
this.toast.show('Connection link copied to clipboard');
} catch (err) {
console.error('Failed to copy text: ', err);
alert('Failed to copy link');
}
}
<template>
<div class="nostr-connect-modal">
<h2>Connect with Nostr</h2>
@@ -59,7 +93,7 @@ export default class NostrConnectComponent extends Component {
class="btn btn-outline"
type="button"
disabled
title="No Nostr extension found in your browser."
title="No Nostr extension found in your browser"
>
Browser Extension (Not Found)
</button>
@@ -79,9 +113,20 @@ export default class NostrConnectComponent extends Component {
{{#if this.nostrAuth.isMobile}}
<p>Waiting for you to approve the connection in your mobile signer
app...</p>
<div class="mobile-connect-actions">
<a href={{this.nostrAuth.connectUri}} class="btn btn-primary">
Open Signer App
</a>
<button
class="btn btn-outline"
type="button"
{{on "click" this.copyConnectUri}}
>
Copy Connection Link
</button>
</div>
{{else}}
<p>Scan this QR code with a compatible Nostr signer app (like
Amber):</p>
<p>Scan this QR code with a Nostr signer app (like Amber):</p>
<div class="qr-code-container">
<canvas {{qrCode this.nostrAuth.connectUri}}></canvas>
</div>

View File

@@ -8,6 +8,7 @@ import Icon from './icon';
import fadeInImage from '../modifiers/fade-in-image';
import { on } from '@ember/modifier';
import { modifier } from 'ember-modifier';
import config from 'marco/config/environment';
export default class PhotoCarousel extends Component {
@tracked canScrollLeft = false;
@@ -33,6 +34,14 @@ export default class PhotoCarousel extends Component {
return !this.canScrollRight;
}
get isGalleryMain() {
return this.args.variant === 'gallery-main';
}
get isGalleryThumbnails() {
return this.args.variant === 'gallery-thumbnails';
}
get variantClass() {
return this.args.variant || 'inline';
}
@@ -47,6 +56,8 @@ export default class PhotoCarousel extends Component {
}
});
isProgrammaticScroll = false;
scrollToNewPhoto = modifier((element, [eventId]) => {
if (eventId && eventId !== this.lastEventId) {
const isInitial = !this.lastEventId;
@@ -57,6 +68,9 @@ export default class PhotoCarousel extends Component {
return;
}
this.internalEventId = eventId;
this.isProgrammaticScroll = true;
const scrollAction = () => {
const targetSlide = element.querySelector(
`[data-event-id="${eventId}"]`
@@ -70,11 +84,18 @@ export default class PhotoCarousel extends Component {
// Restore smooth scroll after the jump
setTimeout(() => {
element.style.scrollBehavior = originalScrollBehavior;
this.isProgrammaticScroll = false;
}, 50);
} else {
// Use native CSS smooth scrolling for subsequent clicks
element.scrollLeft = targetSlide.offsetLeft;
// Clear programmatic scroll flag after a delay to let scroll finish
setTimeout(() => {
this.isProgrammaticScroll = false;
}, 500);
}
} else {
this.isProgrammaticScroll = false;
}
};
@@ -103,10 +124,16 @@ export default class PhotoCarousel extends Component {
}
let intersectionObserver;
if (this.args.onVisiblePhotoChange && window.IntersectionObserver) {
if (
this.args.onVisiblePhotoChange &&
window.IntersectionObserver &&
config.environment !== 'test'
) {
// Set up intersection observer to track which photo is currently "most" visible
intersectionObserver = new IntersectionObserver(
(entries) => {
if (this.isProgrammaticScroll) return;
for (let entry of entries) {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
const eventId = entry.target.dataset.eventId;
@@ -205,6 +232,23 @@ export default class PhotoCarousel extends Component {
/>
{{/if}}
{{#if this.isGalleryMain}}
<img
data-src={{photo.url}}
class="place-header-photo
{{if photo.isLandscape 'landscape' 'portrait'}}"
alt={{@name}}
{{fadeInImage photo.url}}
/>
{{else if this.isGalleryThumbnails}}
<img
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
class="place-header-photo
{{if photo.isLandscape 'landscape' 'portrait'}}"
alt={{@name}}
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
/>
{{else}}
{{#if photo.isLandscape}}
<picture>
{{#if photo.thumbUrl}}
@@ -229,6 +273,7 @@ export default class PhotoCarousel extends Component {
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
/>
{{/if}}
{{/if}}
</div>
{{/each}}

View File

@@ -1,13 +1,122 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import Icon from './icon';
import { fn } from '@ember/helper';
import { service } from '@ember/service';
import { modifier } from 'ember-modifier';
import { task } from 'ember-concurrency';
import { EventFactory } from 'applesauce-factory';
import config from 'marco/config/environment';
import DropdownMenu from './dropdown-menu';
import PhotoCarousel from './photo-carousel';
import Icon from './icon';
const GalleryContent = <template>
<div
class="photo-gallery-overlay"
role="dialog"
tabindex="-1"
{{on "click" @handleBackgroundClick}}
{{@bindKeyboard @handleKeydown}}
>
{{! template-lint-disable no-invalid-interactive }}
<div
class="photo-gallery-content"
data-current-event-id={{@currentPhoto.eventId}}
>
<div class="actions-btn-container">
<DropdownMenu
@iconSize={{24}}
@triggerIcon="more-horizontal"
@iconColor="white"
as |closeMenu|
>
<button
class="dropdown-item"
type="button"
{{on "click" (fn @copyEventId closeMenu)}}
>Copy Photo Event ID</button>
{{#if @canDeletePhoto}}
<button
class="dropdown-item text-danger"
type="button"
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
>Delete Photo</button>
{{/if}}
</DropdownMenu>
</div>
<button
type="button"
class="close-btn btn-text"
{{on "click" @handleClose}}
aria-label="Close gallery"
title="Close"
>
<Icon @name="x" @size={{24}} @color="white" />
</button>
<div class="main-photo-container">
<PhotoCarousel
@variant="gallery-main"
@photos={{@photos}}
@scrollToEventId={{@currentPhoto.eventId}}
@onVisiblePhotoChange={{@handleVisiblePhotoChange}}
@name={{@placeName}}
/>
</div>
<div class="thumbnail-strip-container">
<PhotoCarousel
@variant="gallery-thumbnails"
@photos={{@photos}}
@scrollToEventId={{@currentPhoto.eventId}}
@onPhotoClick={{@selectPhoto}}
@name={{@placeName}}
/>
</div>
</div>
</div>
</template>;
export default class PhotoGallery extends Component {
get isTesting() {
return config.environment === 'test';
}
get destinationElement() {
return document.getElementById('modal-portal') || document.body;
}
@service toast;
@service nostrAuth;
@service nostrData;
@service nostrRelay;
@service blossom;
@service settings;
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
get isCreator() {
return (
this.currentPhoto?.pubkey &&
this.nostrAuth.pubkey &&
this.currentPhoto.pubkey === this.nostrAuth.pubkey
);
}
get canDeletePhoto() {
return (
this.isCreator && this.settings.experimentalEnablePhotoDeletion === true
);
}
bindKeyboard = modifier((element, [handler]) => {
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
});
@action
handleClose() {
if (this.args.onClose) {
@@ -21,7 +130,8 @@ export default class PhotoGallery extends Component {
if (
e.target.closest('.thumbnail-strip-container') ||
e.target.closest('.carousel-nav-btn') ||
e.target.closest('.close-btn')
e.target.closest('.close-btn') ||
e.target.closest('.actions-btn-container')
) {
return;
}
@@ -41,45 +151,145 @@ export default class PhotoGallery extends Component {
}
}
@action
handleKeydown(e) {
if (!this.args.photos || this.args.photos.length === 0) return;
if (e.key === 'Escape') {
this.handleClose();
return;
}
const currentIndex = this.args.photos.indexOf(this.currentPhoto);
if (currentIndex === -1) return;
if (e.key === 'ArrowLeft' && currentIndex > 0) {
this.currentPhoto = this.args.photos[currentIndex - 1];
} else if (
e.key === 'ArrowRight' &&
currentIndex < this.args.photos.length - 1
) {
this.currentPhoto = this.args.photos[currentIndex + 1];
}
}
@action
async copyEventId(closeMenu) {
if (this.currentPhoto?.eventId) {
try {
await navigator.clipboard.writeText(this.currentPhoto.eventId);
this.toast.show('Event ID copied to clipboard');
} catch (err) {
console.error('Failed to copy event ID:', err);
this.toast.show('Failed to copy event ID');
}
}
closeMenu();
}
deletePhotoTask = task(async (closeMenu) => {
if (
!confirm(
'Are you sure you want to delete this photo? This cannot be undone.'
)
) {
if (closeMenu) closeMenu();
return;
}
try {
const eventId = this.currentPhoto.eventId;
// Publish Nostr kind: 5 deletion event first so we don't end up with dead blossom links on a failure
const factory = new EventFactory({ signer: this.nostrAuth.signer });
const tags = [['e', eventId]];
if (this.currentPhoto.placeIdentifier) {
tags.push(['i', this.currentPhoto.placeIdentifier]);
}
const template = {
kind: 5,
created_at: Math.floor(Date.now() / 1000),
content: 'Deleted photo',
tags,
};
const event = await factory.sign(template);
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
// Remove from local store by adding the kind 5 to it
this.nostrData.store.add(event);
// Now that the event is published, try to delete from Blossom
const hashRegex = /[0-9a-f]{64}/i;
if (this.currentPhoto.url) {
const match = this.currentPhoto.url.match(hashRegex);
if (match) {
try {
await this.blossom.delete(match[0]);
} catch (e) {
console.warn('Failed to delete main image from blossom:', e);
}
}
}
if (this.currentPhoto.thumbUrl) {
const match = this.currentPhoto.thumbUrl.match(hashRegex);
if (match) {
try {
await this.blossom.delete(match[0]);
} catch (e) {
console.warn('Failed to delete thumb image from blossom:', e);
}
}
}
this.toast.show('Photo deleted successfully');
if (closeMenu) closeMenu();
this.handleClose();
} catch (e) {
console.error('Failed to delete photo:', e);
this.toast.show('Failed to delete photo: ' + e.message);
if (closeMenu) closeMenu();
}
});
<template>
<div
class="photo-gallery-overlay"
role="dialog"
tabindex="-1"
{{on "click" this.handleBackgroundClick}}
>
{{! template-lint-disable no-invalid-interactive }}
<div class="photo-gallery-content">
<button
type="button"
class="close-btn btn-text"
{{on "click" this.handleClose}}
aria-label="Close gallery"
title="Close"
>
<Icon @name="x" @size={{24}} @color="white" />
</button>
<div class="main-photo-container">
<PhotoCarousel
@variant="gallery-main"
{{#if this.isTesting}}
<GalleryContent
@handleBackgroundClick={{this.handleBackgroundClick}}
@bindKeyboard={{this.bindKeyboard}}
@handleKeydown={{this.handleKeydown}}
@copyEventId={{this.copyEventId}}
@canDeletePhoto={{this.canDeletePhoto}}
@deletePhotoTask={{this.deletePhotoTask}}
@handleClose={{this.handleClose}}
@photos={{@photos}}
@scrollToEventId={{this.currentPhoto.eventId}}
@onVisiblePhotoChange={{this.handleVisiblePhotoChange}}
@name={{@placeName}}
@currentPhoto={{this.currentPhoto}}
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
@placeName={{@placeName}}
@selectPhoto={{this.selectPhoto}}
/>
</div>
<div class="thumbnail-strip-container">
<PhotoCarousel
@variant="gallery-thumbnails"
{{else}}
{{#in-element this.destinationElement}}
<GalleryContent
@handleBackgroundClick={{this.handleBackgroundClick}}
@bindKeyboard={{this.bindKeyboard}}
@handleKeydown={{this.handleKeydown}}
@copyEventId={{this.copyEventId}}
@canDeletePhoto={{this.canDeletePhoto}}
@deletePhotoTask={{this.deletePhotoTask}}
@handleClose={{this.handleClose}}
@photos={{@photos}}
@scrollToEventId={{this.currentPhoto.eventId}}
@onPhotoClick={{this.selectPhoto}}
@name={{@placeName}}
@currentPhoto={{this.currentPhoto}}
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
@placeName={{@placeName}}
@selectPhoto={{this.selectPhoto}}
/>
</div>
</div>
</div>
{{/in-element}}
{{/if}}
</template>
}

View File

@@ -29,6 +29,14 @@ export default class PlaceDetails extends Component {
@tracked isConnectingNostr = false;
@tracked isGalleryOpen = false;
@tracked selectedGalleryPhoto = null;
@tracked isPhotoUploadModalOpen = false;
@tracked isNostrConnectModalOpen = false;
@tracked newlyUploadedPhotoId = null;
@action
handleUploadStateChange(isActive) {
this.isPhotoUploadActive = isActive;
}
@action
openPhotoUploadModal(e) {

View File

@@ -8,6 +8,10 @@ import { task } from 'ember-concurrency';
import Geohash from 'latlon-geohash';
import PlacePhotoUploadItem from './place-photo-upload-item';
import Icon from '#components/icon';
import { getSuggestedPhotoTags } from '../utils/photo-tag-suggestions';
import capitalize from '../helpers/capitalize';
import includes from '../helpers/includes';
import { fn } from '@ember/helper';
import { or, not } from 'ember-truth-helpers';
export default class PlacePhotoUpload extends Component {
@@ -22,6 +26,7 @@ export default class PlacePhotoUpload extends Component {
@tracked error = '';
@tracked isPublishing = false;
@tracked isDragging = false;
@tracked selectedTags = [];
get place() {
return this.args.place || {};
@@ -37,6 +42,10 @@ export default class PlacePhotoUpload extends Component {
);
}
get suggestedTags() {
return getSuggestedPhotoTags(this.place);
}
@action
handleFileSelect(event) {
this.addFile(event.target.files[0]);
@@ -93,11 +102,22 @@ export default class PlacePhotoUpload extends Component {
}
this.file = null;
this.uploadedPhoto = null;
this.selectedTags = [];
if (this.args.onUploadStateChange) {
this.args.onUploadStateChange(false);
}
}
@action
toggleTag(tag) {
if (this.selectedTags.includes(tag)) {
this.selectedTags = [];
return;
}
this.selectedTags = [tag];
}
deletePhotoTask = task(async (photoData) => {
try {
if (photoData.hash) {
@@ -139,6 +159,10 @@ export default class PlacePhotoUpload extends Component {
const tags = [['i', `osm:${osmType}:${osmId}`]];
for (const tag of this.selectedTags) {
tags.push(['t', tag]);
}
if (lat && lon) {
tags.push(['g', Geohash.encode(lat, lon, 4)]);
tags.push(['g', Geohash.encode(lat, lon, 6)]);
@@ -227,6 +251,26 @@ export default class PlacePhotoUpload extends Component {
/>
</div>
{{#if this.suggestedTags.length}}
<div class="photo-tag-suggestions">
<p class="photo-tag-suggestions-title">
Choose a tag/category (optional):
</p>
<div class="photo-tag-suggestions-list">
{{#each this.suggestedTags as |tag|}}
<button
type="button"
class="photo-tag-chip
{{if (includes this.selectedTags tag) 'is-selected'}}"
{{on "click" (fn this.toggleTag tag)}}
>
{{capitalize tag}}
</button>
{{/each}}
</div>
</div>
{{/if}}
<button
type="button"
class="btn btn-primary btn-publish"

View File

@@ -13,6 +13,7 @@ import { eq, or } from 'ember-truth-helpers';
export default class SearchBoxComponent extends Component {
@service photon;
@service osm;
@service storage;
@service router;
@service mapUi;
@service map; // Assuming we might need map context, but mostly we use router
@@ -50,6 +51,29 @@ export default class SearchBoxComponent extends Component {
this.searchTask.perform(value);
}
formatSavedPlace(place) {
const listNames = (place._listIds || [])
.map((id) => this.storage.lists?.find((l) => l.id === id)?.title)
.filter(Boolean)
.join(', ');
const description = listNames
? `Saved place (${listNames})`
: 'Saved place';
return {
source: 'saved',
id: place.id,
title: place.title,
icon: 'bookmark',
description,
osmId: place.osmId,
osmType: place.osmType,
lat: place.lat,
lon: place.lon,
};
}
searchTask = task({ restartable: true }, async (term) => {
await timeout(300);
@@ -76,8 +100,29 @@ export default class SearchBoxComponent extends Component {
icon: 'search',
}));
// Filter saved places (minimum 3 characters)
let savedMatches = [];
if (q.length >= 3) {
savedMatches = this.storage.savedPlaces
.filter((p) => p.title && p.title.toLowerCase().includes(q))
.map((p) => this.formatSavedPlace(p));
}
const results = await this.photon.search(query, lat, lon);
this.results = [...categoryMatches, ...results];
// Deduplicate Photon results that are already in saved matches
const savedOsmIds = new Set(
savedMatches.map((s) => s.osmId).filter(Boolean)
);
const filteredPhotonResults = results.filter(
(r) => !savedOsmIds.has(r.osmId)
);
this.results = [
...categoryMatches,
...savedMatches,
...filteredPhotonResults,
];
} catch (e) {
console.error('Search failed', e);
this.results = [];
@@ -156,8 +201,12 @@ export default class SearchBoxComponent extends Component {
}
this.results = []; // Hide popover
// If it has an OSM ID, go to place details
if (place.osmId) {
// If it's a custom saved place without an OSM ID, go to place details via internal ID
if (place.source === 'saved' && place.id && !place.osmId) {
this.router.transitionTo('place', place.id);
}
// If it has an OSM ID, go to place details via OSM ID
else if (place.osmId) {
// Format: osm:node:123
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
const id = `osm:${place.osmType}:${place.osmId}`;

View File

@@ -78,14 +78,17 @@ export default class SearchController extends Controller {
// Search with Photon (using lat/lon for bias if available)
pois = await this.photon.search(params.q, lat, lon);
// Search local bookmarks by name
// Search local bookmarks by name (minimum 3 characters)
const queryLower = params.q.toLowerCase();
const localMatches = this.storage.savedPlaces.filter((p) => {
let localMatches = [];
if (queryLower.length >= 3) {
localMatches = this.storage.savedPlaces.filter((p) => {
return (
p.title?.toLowerCase().includes(queryLower) ||
p.description?.toLowerCase().includes(queryLower)
);
});
}
// Merge local matches
localMatches.forEach((local) => {

View File

@@ -0,0 +1,6 @@
import { helper } from '@ember/component/helper';
import { capitalize as format } from '../utils/format-text';
export default helper(function capitalize([text]) {
return format(text);
});

6
app/helpers/includes.js Normal file
View File

@@ -0,0 +1,6 @@
import { helper } from '@ember/component/helper';
export default helper(function includes([collection, value]) {
if (!Array.isArray(collection)) return false;
return collection.includes(value);
});

View File

@@ -11,6 +11,7 @@ export default class MapUiService extends Service {
@tracked returnToSearch = false;
@tracked currentCenter = null;
@tracked currentBounds = null;
@tracked currentZoom = null;
@tracked searchBoxHasFocus = false;
@tracked selectionOptions = {};
@tracked preventNextZoom = false;
@@ -81,6 +82,10 @@ export default class MapUiService extends Service {
this.currentCenter = { lat, lon };
}
updateZoom(zoom) {
this.currentZoom = zoom;
}
updateBounds(bounds) {
this.currentBounds = bounds;
}

View File

@@ -55,10 +55,11 @@ export default class NostrDataService extends Service {
this._stopPersisting = persistEventsToCache(
this.store,
async (events) => {
// Only cache profiles, mailboxes, blossom servers, and place photos
// Only cache profiles, mailboxes, blossom servers, and place photos, and deletions
const toCache = events.filter(
(e) =>
e.kind === 0 ||
e.kind === 5 ||
e.kind === 10002 ||
e.kind === 10063 ||
e.kind === 360
@@ -215,7 +216,7 @@ export default class NostrDataService extends Service {
const cachedEvents = await this.cache.query([
{
kinds: [360],
kinds: [360, 5],
'#i': [entityId],
},
]);
@@ -236,7 +237,7 @@ export default class NostrDataService extends Service {
this.nostrRelay.pool
.request(this.activeReadRelays, [
{
kinds: [360],
kinds: [360, 5],
'#i': [entityId],
},
])

View File

@@ -9,6 +9,7 @@ const DEFAULT_SETTINGS = {
nostrPhotoFallbackUploads: false,
nostrReadRelays: null,
nostrWriteRelays: null,
experimentalEnablePhotoDeletion: false,
};
export default class SettingsService extends Service {
@@ -20,6 +21,8 @@ export default class SettingsService extends Service {
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
@tracked experimentalEnablePhotoDeletion =
DEFAULT_SETTINGS.experimentalEnablePhotoDeletion;
overpassApis = [
{
@@ -108,6 +111,8 @@ export default class SettingsService extends Service {
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
this.nostrReadRelays = finalSettings.nostrReadRelays;
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
this.experimentalEnablePhotoDeletion =
finalSettings.experimentalEnablePhotoDeletion;
// Save to ensure migrated settings are stored in the new format
this.saveSettings();
@@ -122,6 +127,7 @@ export default class SettingsService extends Service {
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
nostrReadRelays: this.nostrReadRelays,
nostrWriteRelays: this.nostrWriteRelays,
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
};
localStorage.setItem('marco:settings', JSON.stringify(settings));
}

View File

@@ -594,6 +594,9 @@ body {
padding: 0 1.4rem 1rem;
animation: details-slide-down 0.2s ease-out;
font-size: 0.9rem;
}
.sidebar-content details .details-content.form-layout {
display: flex;
flex-direction: column;
gap: 16px;
@@ -1081,6 +1084,7 @@ abbr[title] {
align-items: center;
justify-content: center;
gap: 0.5rem;
text-decoration: none;
}
.btn:disabled {
@@ -1777,6 +1781,13 @@ button.create-place {
margin-top: 1.5rem;
}
.mobile-connect-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
.nostr-connect-status {
margin-top: 1.5rem;
text-align: center;
@@ -1832,6 +1843,42 @@ button.create-place {
font-size: 1.2rem;
}
.photo-tag-suggestions {
margin: 1rem 0 1.5rem;
}
.photo-tag-suggestions-title {
color: #898989;
margin: 0 0 0.75rem;
font-weight: normal;
font-size: 0.9rem;
}
.photo-tag-suggestions-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.photo-tag-chip {
background: #f8f9fa;
color: #333;
border: none;
border-radius: 16px;
padding: 6px 12px;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.1s;
}
.photo-tag-chip:hover {
background: #eee;
}
.photo-tag-chip.is-selected {
background: rgb(255 204 51 / 30%);
}
.alert {
padding: 0.5rem;
margin-bottom: 1rem;
@@ -2016,3 +2063,63 @@ button.create-place {
.photo-carousel.gallery-thumbnails .carousel-nav-btn {
display: none;
}
/* Dropdown Menu Component */
.dropdown-menu-container {
position: relative;
display: inline-block;
}
.dropdown-trigger-btn {
background: transparent;
border: none;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.dropdown-popover {
position: absolute;
top: 100%;
left: 0;
margin-top: 5px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
padding: 0.5rem 0;
z-index: 3001;
min-width: 150px;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.dropdown-item {
background: transparent;
border: none;
padding: 0.5rem 1rem;
text-align: left;
cursor: pointer;
font-size: 0.95rem;
color: #333;
white-space: nowrap;
}
.dropdown-item:hover {
background: #f0f0f0;
}
/* Actions button in photo gallery */
.photo-gallery-overlay .actions-btn-container {
position: absolute;
top: 0.5rem;
left: 0.5rem;
width: 48px;
height: 48px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -7,3 +7,8 @@ export function humanizeOsmTag(text) {
w.replace(/^\w/, (c) => c.toUpperCase())
);
}
export function capitalize(text) {
if (typeof text !== 'string' || !text) return '';
return text.charAt(0).toUpperCase() + text.slice(1);
}

View File

@@ -23,7 +23,7 @@ export function getGeohashPrefixesInBbox(bbox) {
// Safety check to avoid infinite loops or massive arrays if bbox is weird
if (Math.abs(maxLat - minLat) > 20 || Math.abs(maxLon - minLon) > 20) {
console.warn(
console.debug(
'BBox too large for 4-char geohash scanning, aborting fine scan.'
);
return [];

View File

@@ -6,6 +6,7 @@ import featherCamera from 'feather-icons/dist/icons/camera.svg?raw';
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw';
import alertTriangle from 'feather-icons/dist/icons/alert-triangle.svg?raw';
import clock from 'feather-icons/dist/icons/clock.svg?raw';
import database from 'feather-icons/dist/icons/database.svg?raw';
import edit from 'feather-icons/dist/icons/edit.svg?raw';
@@ -22,6 +23,8 @@ import mail from 'feather-icons/dist/icons/mail.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 moreHorizontal from 'feather-icons/dist/icons/more-horizontal.svg?raw';
import moreVertical from 'feather-icons/dist/icons/more-vertical.svg?raw';
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
import phone from 'feather-icons/dist/icons/phone.svg?raw';
import plus from 'feather-icons/dist/icons/plus.svg?raw';
@@ -81,10 +84,6 @@ import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
import memorialStoneWithInscription from '@waysidemapping/pinhead/dist/icons/memorial_stone_with_inscription.svg?raw';
import mobilePhoneWithKeypadAndAntenna from '@waysidemapping/pinhead/dist/icons/mobile_phone_with_keypad_and_antenna.svg?raw';
import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
@@ -148,6 +147,7 @@ const ICONS = {
climbing_wall: climbingWall,
check,
'alert-circle': alertCircle,
'alert-triangle': alertTriangle,
'classical-building': classicalBuilding,
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
'classical-building-with-flag': classicalBuildingWithFlag,
@@ -193,11 +193,9 @@ const ICONS = {
mail,
map,
'map-pin': mapPin,
'market-stall': marketStall,
'memorial-stone-with-inscription': memorialStoneWithInscription,
menu,
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
'molar-tooth': molarTooth,
'more-horizontal': moreHorizontal,
'more-vertical': moreVertical,
navigation,
'needle-and-spool-of-thread': needleAndSpoolOfThread,
nostrich,

View File

@@ -30,6 +30,11 @@ export function parsePlacePhotos(events) {
const allPhotos = [];
for (const event of sortedEvents) {
const eventTagValues = event.tags
.filter((t) => t[0] === 't')
.map((t) => t[1])
.filter(Boolean);
// Find all imeta tags
const imetas = event.tags.filter((t) => t[0] === 'imeta');
for (const imeta of imetas) {
@@ -38,6 +43,7 @@ export function parsePlacePhotos(events) {
let blurhash = null;
let isLandscape = false;
let aspectRatio = 16 / 9; // default
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
for (const tag of imeta.slice(1)) {
if (tag.startsWith('url ')) {
@@ -68,6 +74,8 @@ export function parsePlacePhotos(events) {
blurhash,
isLandscape,
aspectRatio,
placeIdentifier,
tags: eventTagValues,
});
}
}

View File

@@ -0,0 +1,32 @@
import { POI_CATEGORIES } from './poi-categories';
import { getMatchingPoiCategoryIds } from './poi-category-matcher';
export const CATEGORY_TAGS = {
restaurants: ['food', 'menu', 'vibe', 'front'],
coffee: ['food', 'menu', 'vibe', 'front'],
groceries: ['front', 'food'],
'things-to-do': ['architecture', 'amenities', 'vibe', 'front'],
accommodation: ['rooms', 'amenities', 'food', 'vibe', 'front'],
};
export function getSuggestedPhotoTags(place) {
const osmTags = place?.osmTags || place?.tags || {};
const categoryIds = getMatchingPoiCategoryIds(osmTags, POI_CATEGORIES);
const suggested = [];
for (const categoryId of categoryIds) {
const tags = CATEGORY_TAGS[categoryId];
if (!Array.isArray(tags)) continue;
for (const tag of tags) {
if (!suggested.includes(tag)) {
suggested.push(tag);
}
}
}
if (suggested.length === 0) {
return [];
}
return suggested;
}

View File

@@ -0,0 +1,95 @@
export function getMatchingPoiCategories(osmTags, categories) {
if (!Array.isArray(categories) || !osmTags) return [];
return categories.filter((category) => {
if (!Array.isArray(category.filter)) return false;
return category.filter.some((filterStr) =>
matchesFilter(osmTags, filterStr)
);
});
}
export function getMatchingPoiCategoryIds(osmTags, categories) {
return getMatchingPoiCategories(osmTags, categories).map((c) => c.id);
}
function matchesFilter(osmTags, filterStr) {
const clauses = parseOverpassClauses(filterStr);
if (clauses.length === 0) return false;
return clauses.every((clause) => matchesClause(osmTags, clause));
}
function parseOverpassClauses(filterStr) {
if (!filterStr) return [];
const matches = filterStr.match(/\[[^\]]+\]/g);
if (!matches) return [];
return matches
.map((raw) => parseClause(raw.slice(1, -1).trim()))
.filter(Boolean);
}
function parseClause(content) {
const presenceMatch = content.match(/^"([^"]+)"$/);
if (presenceMatch) {
return { type: 'presence', key: presenceMatch[1] };
}
const equalsMatch = content.match(/^"([^"]+)"\s*=\s*"([^"]*)"$/);
if (equalsMatch) {
return { type: 'equals', key: equalsMatch[1], value: equalsMatch[2] };
}
const regexMatch = content.match(/^"([^"]+)"\s*~\s*"([^"]*)"$/);
if (regexMatch) {
return {
type: 'regex',
key: regexMatch[1],
pattern: regexMatch[2],
regex: new RegExp(regexMatch[2]),
};
}
const notRegexMatch = content.match(/^"([^"]+)"\s*!~\s*"([^"]*)"$/);
if (notRegexMatch) {
return {
type: 'not-regex',
key: notRegexMatch[1],
pattern: notRegexMatch[2],
regex: new RegExp(notRegexMatch[2]),
};
}
return null;
}
function matchesClause(osmTags, clause) {
const tagValues = getTagValues(osmTags, clause.key);
switch (clause.type) {
case 'presence':
return tagValues.length > 0;
case 'equals':
return tagValues.some((value) => value === clause.value);
case 'regex':
return tagValues.some((value) => clause.regex.test(value));
case 'not-regex':
return (
tagValues.length === 0 ||
!tagValues.some((value) => clause.regex.test(value))
);
default:
return false;
}
}
function getTagValues(osmTags, key) {
if (!osmTags || !key) return [];
const rawValue = osmTags[key];
if (rawValue === undefined || rawValue === null) return [];
return String(rawValue)
.split(';')
.map((value) => value.trim())
.filter(Boolean);
}

View File

@@ -42,6 +42,7 @@
<link rel="stylesheet" href="/app/styles/app.css">
</head>
<body>
<div id="modal-portal"></div>
<script type="module">
import Application from './app/app';
import environment from './app/config/environment';

View File

@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.20.5",
"version": "1.24.0",
"private": true,
"description": "Unhosted maps app",
"repository": {

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

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

View File

@@ -39,9 +39,10 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-Dhq0XoTm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CHuW_yI-.css">
<script type="module" crossorigin src="/assets/main-CLZV93ov.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-COnSXoPt.css">
</head>
<body>
<div id="modal-portal"></div>
</body>
</html>

View File

@@ -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) {

View File

@@ -38,6 +38,7 @@ class MockOsmService extends Service {
}
class MockStorageService extends Service {
initialSyncDone = true;
savedPlaces = [];
findPlaceById() {
return null;

View File

@@ -16,7 +16,12 @@
</div>
</div>
<script src="/testem.js" integrity="" data-embroider-ignore></script>
<script
src="/testem.js"
integrity=""
data-embroider-ignore
vite-ignore
></script>
<script type="module">import "ember-testing";</script>

View File

@@ -0,0 +1,339 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click, triggerKeyEvent } from '@ember/test-helpers';
import Service from '@ember/service';
import PhotoGallery from 'marco/components/photo-gallery';
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
import sinon from 'sinon';
class MockBlossomService extends Service {
async delete() {
return true;
}
}
class MockToastService extends Service {
show() {}
}
module('Integration | Component | photo-gallery', function (hooks) {
setupRenderingTest(hooks);
setupNostrMocks(hooks);
hooks.beforeEach(function () {
this.owner.register('service:blossom', MockBlossomService);
this.owner.register('service:toast', MockToastService);
this.blossom = this.owner.lookup('service:blossom');
this.nostrAuth = this.owner.lookup('service:nostrAuth');
this.nostrData = this.owner.lookup('service:nostrData');
this.nostrRelay = this.owner.lookup('service:nostrRelay');
this.toast = this.owner.lookup('service:toast');
this.settings = this.owner.lookup('service:settings');
this.photos = [
{
eventId: 'event1',
pubkey: 'userA',
placeIdentifier: 'osm:node:12345',
url: 'https://example.com/a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.jpg',
thumbUrl:
'https://example.com/b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.jpg',
},
{
eventId: 'event2',
pubkey: 'userB',
placeIdentifier: 'osm:node:12345',
url: 'photo2.jpg',
},
];
});
hooks.afterEach(function () {
sinon.restore();
localStorage.removeItem('marco:settings');
});
test('it does not show delete button if user is not creator', async function (assert) {
this.nostrAuth.pubkey = 'userB'; // Different from photo1's pubkey
this.selectedPhoto = this.photos[0];
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
// Open dropdown
await click('.dropdown-trigger-btn');
assert.dom('.dropdown-popover').exists('Dropdown opened');
assert
.dom('.dropdown-item.text-danger')
.doesNotExist('Delete button is hidden for non-creator');
});
test('it shows delete button if user is creator and setting is enabled', async function (assert) {
this.nostrAuth.pubkey = 'userA'; // Matches photo1's pubkey
this.settings.update('experimentalEnablePhotoDeletion', true); // Enable the setting
this.selectedPhoto = this.photos[0];
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
// Open dropdown
await click('.dropdown-trigger-btn');
assert.dom('.dropdown-popover').exists('Dropdown opened');
assert
.dom('.dropdown-item.text-danger')
.exists('Delete button is visible for creator when setting is enabled');
});
test('it handles cancellation of deletion', async function (assert) {
this.nostrAuth.pubkey = 'userA';
this.settings.update('experimentalEnablePhotoDeletion', true);
this.selectedPhoto = this.photos[0];
const confirmStub = sinon.stub(window, 'confirm').returns(false);
const blossomSpy = sinon.spy(this.blossom, 'delete');
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
await click('.dropdown-trigger-btn');
await click('.dropdown-item.text-danger');
assert.ok(confirmStub.calledOnce, 'confirmation dialog was shown');
assert.ok(blossomSpy.notCalled, 'blossom.delete was NOT called');
});
test('it performs full deletion flow when confirmed', async function (assert) {
this.nostrAuth.pubkey = 'userA';
this.settings.update('experimentalEnablePhotoDeletion', true);
// Override the mock's getter just for this test
Object.defineProperty(this.nostrAuth, 'signer', {
configurable: true,
get: () => ({
signEvent: async (e) => ({
...e,
id: 'signed-id',
sig: 'sig',
pubkey: 'userA',
}),
getPublicKey: async () => 'userA',
}),
});
this.selectedPhoto = this.photos[0];
let closed = false;
this.handleClose = () => {
closed = true;
};
const confirmStub = sinon.stub(window, 'confirm').returns(true);
const blossomStub = sinon.stub(this.blossom, 'delete').resolves();
const publishStub = sinon.stub(this.nostrRelay, 'publish').resolves();
const storeStub = sinon.stub(this.nostrData.store, 'add');
const toastSpy = sinon.spy(this.toast, 'show');
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
@onClose={{this.handleClose}}
/>
</div>
</template>
);
await click('.dropdown-trigger-btn');
await click('.dropdown-item.text-danger');
assert.ok(confirmStub.calledOnce, 'confirmation dialog was shown');
// Check blossom deletions
assert.ok(
blossomStub.calledTwice,
'blossom.delete was called twice (main + thumb)'
);
assert.strictEqual(
blossomStub.firstCall.args[0],
'a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
'extracted correct hash for main image'
);
assert.strictEqual(
blossomStub.secondCall.args[0],
'b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
'extracted correct hash for thumb image'
);
// Check Nostr kind 5
assert.ok(publishStub.calledOnce, 'nostrRelay.publish was called');
const publishedEvent = publishStub.firstCall.args[1];
assert.strictEqual(publishedEvent.kind, 5, 'published event is kind 5');
assert.deepEqual(
publishedEvent.tags[0],
['e', 'event1'],
'event tags reference the deleted photo'
);
assert.deepEqual(
publishedEvent.tags[1],
['i', 'osm:node:12345'],
'event tags include the place identifier'
);
// Check store update
assert.ok(storeStub.calledOnce, 'nostrData.store.add was called');
assert.strictEqual(
storeStub.firstCall.args[0].kind,
5,
'added kind 5 event to local store'
);
// Check UX
assert.ok(
toastSpy.calledWith('Photo deleted successfully'),
'success toast was shown'
);
assert.ok(closed, 'gallery was closed after deletion');
});
test('it copies event id to clipboard', async function (assert) {
this.nostrAuth.pubkey = 'userA';
this.selectedPhoto = this.photos[0];
const clipboardStub = sinon
.stub(navigator.clipboard, 'writeText')
.resolves();
const toastSpy = sinon.spy(this.toast, 'show');
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
await click('.dropdown-trigger-btn');
// Find the copy button (it should be the first one)
const items = document.querySelectorAll('.dropdown-item');
let copyBtn;
items.forEach((item) => {
if (item.textContent.includes('Copy Photo Event ID')) {
copyBtn = item;
}
});
await click(copyBtn);
assert.ok(clipboardStub.calledWith('event1'), 'copied correct event id');
assert.ok(
toastSpy.calledWith('Event ID copied to clipboard'),
'success toast was shown'
);
});
test('keyboard navigation changes photos', async function (assert) {
this.photos = [
{ eventId: 'event1', url: 'photo1.jpg' },
{ eventId: 'event2', url: 'photo2.jpg' },
{ eventId: 'event3', url: 'photo3.jpg' },
];
this.selectedPhoto = this.photos[0];
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
assert
.dom('.photo-gallery-content')
.hasAttribute('data-current-event-id', 'event1');
// Right Arrow
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
assert
.dom('.photo-gallery-content')
.hasAttribute('data-current-event-id', 'event2');
// Right Arrow again
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
assert
.dom('.photo-gallery-content')
.hasAttribute('data-current-event-id', 'event3');
// Left Arrow
await triggerKeyEvent(document, 'keydown', 'ArrowLeft');
assert
.dom('.photo-gallery-content')
.hasAttribute('data-current-event-id', 'event2');
});
test('escape key closes gallery', async function (assert) {
this.selectedPhoto = this.photos[0];
let closed = false;
this.handleClose = () => {
closed = true;
};
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
@onClose={{this.handleClose}}
/>
</div>
</template>
);
await triggerKeyEvent(document, 'keydown', 'Escape');
assert.ok(closed, 'gallery was closed on escape key');
});
});

View File

@@ -0,0 +1,100 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click, triggerEvent } from '@ember/test-helpers';
import Service from '@ember/service';
import PlacePhotoUpload from 'marco/components/place-photo-upload';
module('Integration | Component | place-photo-upload', function (hooks) {
setupRenderingTest(hooks);
class MockNostrAuthService extends Service {
get isConnected() {
return true;
}
get signer() {
return null;
}
}
hooks.beforeEach(function () {
this.owner.register('service:nostrAuth', MockNostrAuthService);
});
async function selectFile(element, file) {
const input = element.querySelector('#photo-upload-input');
Object.defineProperty(input, 'files', {
value: [file],
configurable: true,
});
await triggerEvent(input, 'change');
}
test('it shows tag suggestions when they exist after upload selection', async function (assert) {
this.place = {
title: 'Cafe Alpha',
osmId: '123',
osmType: 'node',
osmTags: { amenity: 'cafe' },
};
await render(
<template><PlacePhotoUpload @place={{this.place}} /></template>
);
assert.dom('.photo-tag-suggestions').doesNotExist();
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
await selectFile(this.element, file);
assert.dom('.photo-tag-suggestions').exists();
assert.dom('.photo-tag-chip').exists();
assert.dom('.photo-tag-chip').includesText('Food');
});
test('it only allows one selected tag at a time', async function (assert) {
this.place = {
title: 'Cafe Alpha',
osmId: '123',
osmType: 'node',
osmTags: { amenity: 'cafe' },
};
await render(
<template><PlacePhotoUpload @place={{this.place}} /></template>
);
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
await selectFile(this.element, file);
const chips = this.element.querySelectorAll('.photo-tag-chip');
assert.ok(chips.length > 1, 'multiple tag chips are rendered');
await click(chips[0]);
assert.dom('.photo-tag-chip.is-selected').exists({ count: 1 });
assert.dom(chips[0]).hasClass('is-selected');
await click(chips[1]);
assert.dom('.photo-tag-chip.is-selected').exists({ count: 1 });
assert.dom(chips[1]).hasClass('is-selected');
assert.dom(chips[0]).doesNotHaveClass('is-selected');
});
test('it hides tag suggestions when no tags are suggested', async function (assert) {
this.place = {
title: 'Office Beta',
osmId: '456',
osmType: 'node',
osmTags: { office: 'lawyer' },
};
await render(
<template><PlacePhotoUpload @place={{this.place}} /></template>
);
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
await selectFile(this.element, file);
assert.dom('.photo-tag-suggestions').doesNotExist();
});
});

View File

@@ -1,6 +1,6 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, fillIn, click, waitFor } from '@ember/test-helpers';
import { render, fillIn, click, waitFor, focus } from '@ember/test-helpers';
import SearchBox from 'marco/components/search-box';
import Service from '@ember/service';
@@ -208,22 +208,301 @@ module('Integration | Component | search-box', function (hooks) {
);
// Type "Resta" to trigger "Restaurants" category match
await focus('.search-input');
await fillIn('.search-input', 'Resta');
// Wait for debounce (300ms) + execution
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
await delay(400);
await waitFor('.search-result-item');
// The first result should be the category match
assert.dom('.search-result-item').exists({ count: 1 });
assert.dom('.result-title').hasText('Restaurants');
const resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
const categoryResult = resultItems.find((item) =>
item.textContent.includes('Restaurants')
);
assert.ok(categoryResult, 'Restaurants category result is shown');
// Click the result
await click('.search-result-item');
await click(categoryResult);
// Assert transition with lat/lon from map center
assert.verifySteps([
'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}',
]);
});
test('it includes, deduplicates, and prioritizes saved places in search results', async function (assert) {
// Mock MapUi Service
class MockMapUiService extends Service {
currentCenter = { lat: 52.52, lon: 13.405 };
setSearchBoxFocus() {}
}
this.owner.register('service:map-ui', MockMapUiService);
// Mock Router Service
class MockRouterService extends Service {
transitionTo(routeName, id) {
assert.step(`transitionTo: ${routeName} ["${id}"]`);
}
}
this.owner.register('service:router', MockRouterService);
// Mock Storage Service
class MockStorageService extends Service {
lists = [{ id: 'favs', title: 'Favorites' }];
savedPlaces = [
{
title: 'Awesome Coffee',
lat: 52.5,
lon: 13.4,
osmId: '999',
osmType: 'node',
_listIds: ['favs'],
},
];
}
this.owner.register('service:storage', MockStorageService);
// Mock Photon Service
class MockPhotonService extends Service {
async search(query) {
if (query === 'coffee') {
return [
{
title: 'Awesome Coffee',
osmId: '999',
osmType: 'node',
description: 'Duplicate to be removed',
},
{
title: 'Other Coffee',
osmId: '888',
osmType: 'node',
description: 'A different coffee shop',
},
];
}
return [];
}
}
this.owner.register('service:photon', MockPhotonService);
this.noop = () => {};
await render(
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
);
// Type "coffee" to trigger matches in Category, Saved, and Photon
await fillIn('.search-input', 'coffee');
await waitFor('.search-results-popover', { timeout: 2000 });
const resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Should be exactly 3 items:
// 1. Category (Coffee)
// 2. Saved (Awesome Coffee)
// 3. Photon (Other Coffee)
// (The Photon duplicate of "Awesome Coffee" is removed)
assert.strictEqual(resultItems.length, 3, 'Renders exactly 3 items');
// 1. Category
assert.ok(
resultItems[0].textContent.includes('Coffee'),
'First item is the category match'
);
assert
.dom(resultItems[0].querySelector('.result-icon svg'))
.hasClass('feather-search', 'Category uses search icon');
// 2. Saved Place
assert.ok(
resultItems[1].textContent.includes('Awesome Coffee'),
'Second item is the saved place match'
);
assert.ok(
resultItems[1].textContent.includes('Saved place'),
'Saved place has correct description text'
);
assert
.dom(resultItems[1].querySelector('.result-icon svg'))
.hasClass('feather-bookmark', 'Saved place uses bookmark icon');
// 3. Photon Match
assert.ok(
resultItems[2].textContent.includes('Other Coffee'),
'Third item is the unique photon result'
);
// Click the Saved Place
await click(resultItems[1]);
assert.verifySteps(['transitionTo: place ["osm:node:999"]']);
});
test('it requires 3 or more characters to match saved places', async function (assert) {
// Mock MapUi Service
class MockMapUiService extends Service {
currentCenter = { lat: 52.52, lon: 13.405 };
setSearchBoxFocus() {}
}
this.owner.register('service:map-ui', MockMapUiService);
// Mock Router Service
class MockRouterService extends Service {
transitionTo() {}
}
this.owner.register('service:router', MockRouterService);
// Mock Storage Service
class MockStorageService extends Service {
lists = [{ id: 'favs', title: 'Favorites' }];
savedPlaces = [
{
title: 'Awesome Coffee',
lat: 52.5,
lon: 13.4,
osmId: '999',
osmType: 'node',
_listIds: ['favs'],
},
];
}
this.owner.register('service:storage', MockStorageService);
// Mock Photon Service
class MockPhotonService extends Service {
async search(query) {
if (query === 'aw' || query === 'awe') {
return [
{
title: 'Aww Some Place',
osmId: '111',
osmType: 'node',
},
];
}
return [];
}
}
this.owner.register('service:photon', MockPhotonService);
this.noop = () => {};
await render(
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
);
// Type "aw" (2 characters)
await fillIn('.search-input', 'aw');
await waitFor('.search-results-popover', { timeout: 2000 });
let resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Should only show Photon match since 'aw' is < 3 characters
assert.strictEqual(
resultItems.length,
1,
'Renders exactly 1 item for 2 chars'
);
assert.ok(
resultItems[0].textContent.includes('Aww Some Place'),
'Shows photon match'
);
assert.notOk(
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
'Saved place is NOT shown for 2 char query'
);
// Type "awe" (3 characters)
await fillIn('.search-input', 'awe');
await waitFor('.search-results-popover', { timeout: 2000 });
resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Should now show Saved Place and Photon match
assert.strictEqual(
resultItems.length,
2,
'Renders exactly 2 items for 3 chars'
);
assert.ok(
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
'Saved place is now shown'
);
assert.ok(
resultItems.some((item) =>
item.textContent.includes('Saved place (Favorites)')
),
'List names are appended to the description'
);
});
test('it navigates to internal ID for custom saved places without an OSM ID', async function (assert) {
// Mock MapUi Service
class MockMapUiService extends Service {
currentCenter = { lat: 52.52, lon: 13.405 };
setSearchBoxFocus() {}
}
this.owner.register('service:map-ui', MockMapUiService);
// Mock Router Service
class MockRouterService extends Service {
transitionTo(routeName, id) {
assert.step(`transitionTo: ${routeName} ["${id}"]`);
}
}
this.owner.register('service:router', MockRouterService);
// Mock Storage Service (Custom Place)
class MockStorageService extends Service {
savedPlaces = [
{
id: 'custom-1234',
title: 'My Custom Home',
lat: 52.5,
lon: 13.4,
// Notice NO osmId or osmType
},
];
}
this.owner.register('service:storage', MockStorageService);
// Mock Photon Service
class MockPhotonService extends Service {
async search() {
return [];
}
}
this.owner.register('service:photon', MockPhotonService);
this.noop = () => {};
await render(
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
);
// Type 3 chars to trigger saved place match
await fillIn('.search-input', 'cus');
await waitFor('.search-results-popover', { timeout: 2000 });
const resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Ensure our custom place is rendered
const customResult = resultItems.find((item) =>
item.textContent.includes('My Custom Home')
);
assert.ok(customResult, 'Custom place is rendered');
// Click it
await click(customResult);
// Verify it navigated using the internal ID, NOT a search query
assert.verifySteps(['transitionTo: place ["custom-1234"]']);
});
});

View File

@@ -0,0 +1,144 @@
import { module, test } from 'qunit';
import { normalizeRelayUrl, parsePlacePhotos } from 'marco/utils/nostr';
module('Unit | Utility | nostr', function () {
test('normalizeRelayUrl normalizes protocol, case, and slashes', function (assert) {
assert.strictEqual(normalizeRelayUrl(null), '');
assert.strictEqual(normalizeRelayUrl(''), '');
assert.strictEqual(normalizeRelayUrl(' '), '');
assert.strictEqual(
normalizeRelayUrl('Relay.example.com'),
'wss://relay.example.com'
);
assert.strictEqual(
normalizeRelayUrl('ws://Relay.example.com/'),
'ws://relay.example.com'
);
assert.strictEqual(
normalizeRelayUrl('wss://relay.example.com///'),
'wss://relay.example.com'
);
});
test('parsePlacePhotos includes event t tags on photo objects', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 123,
tags: [
['i', 'osm:node:123'],
['t', 'food'],
['t', 'vibe'],
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 1);
assert.deepEqual(photos[0].tags, ['food', 'vibe']);
});
test('parsePlacePhotos sorts by created_at', function (assert) {
const events = [
{
id: 'event-2',
pubkey: 'pubkey-2',
created_at: 200,
tags: [
['i', 'osm:node:456'],
['imeta', 'url https://example.com/late.jpg', 'dim 600x900'],
],
},
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 100,
tags: [
['i', 'osm:node:123'],
['imeta', 'url https://example.com/early.jpg', 'dim 600x900'],
],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 2);
assert.strictEqual(photos[0].url, 'https://example.com/early.jpg');
assert.strictEqual(photos[1].url, 'https://example.com/late.jpg');
});
test('parsePlacePhotos promotes first landscape photo to index 0', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 100,
tags: [
['imeta', 'url https://example.com/portrait.jpg', 'dim 600x900'],
],
},
{
id: 'event-2',
pubkey: 'pubkey-2',
created_at: 200,
tags: [
['imeta', 'url https://example.com/landscape.jpg', 'dim 1200x600'],
],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 2);
assert.strictEqual(photos[0].url, 'https://example.com/landscape.jpg');
assert.strictEqual(photos[1].url, 'https://example.com/portrait.jpg');
});
test('parsePlacePhotos skips imeta entries without urls', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 100,
tags: [['imeta', 'dim 800x600']],
},
];
const photos = parsePlacePhotos(events);
assert.deepEqual(photos, []);
});
test('parsePlacePhotos returns one photo per event imeta tag', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 100,
tags: [
['i', 'osm:node:123'],
['imeta', 'url https://example.com/photo-1.jpg', 'dim 800x600'],
],
},
{
id: 'event-2',
pubkey: 'pubkey-2',
created_at: 200,
tags: [
['i', 'osm:node:456'],
['imeta', 'url https://example.com/photo-2.jpg', 'dim 600x800'],
],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 2);
assert.strictEqual(photos[0].placeIdentifier, 'osm:node:123');
assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456');
});
});

View File

@@ -0,0 +1,30 @@
import { module, test } from 'qunit';
import { POI_CATEGORIES } from 'marco/utils/poi-categories';
import { getMatchingPoiCategoryIds } from 'marco/utils/poi-category-matcher';
import {
getSuggestedPhotoTags,
CATEGORY_TAGS,
} from 'marco/utils/photo-tag-suggestions';
module('Unit | Utility | photo-tag-suggestions', function () {
test('returns tags for all matching categories with de-duplication', function (assert) {
const place = { osmTags: { amenity: 'cafe' } };
const categoryIds = getMatchingPoiCategoryIds(
place.osmTags,
POI_CATEGORIES
);
assert.ok(categoryIds.includes('restaurants'));
assert.ok(categoryIds.includes('coffee'));
const result = getSuggestedPhotoTags(place);
assert.deepEqual(result, CATEGORY_TAGS.restaurants);
});
test('returns no tags when no category matches', function (assert) {
const place = { osmTags: { office: 'lawyer' } };
const result = getSuggestedPhotoTags(place);
assert.deepEqual(result, []);
});
});

View File

@@ -0,0 +1,38 @@
import { module, test } from 'qunit';
import { POI_CATEGORIES } from 'marco/utils/poi-categories';
import {
getMatchingPoiCategories,
getMatchingPoiCategoryIds,
} from 'marco/utils/poi-category-matcher';
module('Unit | Utility | poi-category-matcher', function () {
test('matches multiple categories from OSM tags', function (assert) {
const tags = { amenity: 'cafe' };
const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES);
assert.ok(categoryIds.includes('restaurants'));
assert.ok(categoryIds.includes('coffee'));
});
test('supports semicolon-separated values', function (assert) {
const tags = { amenity: 'cafe;bar' };
const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES);
assert.ok(categoryIds.includes('coffee'));
});
test('negative regex clause fails if any value matches', function (assert) {
const tags = { amenity: 'cafe', cuisine: 'coffee;irish' };
const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES);
assert.notOk(categoryIds.includes('restaurants'));
});
test('presence clause matches when tag exists', function (assert) {
const tags = { historic: 'castle' };
const categories = getMatchingPoiCategories(tags, POI_CATEGORIES);
const categoryIds = categories.map((category) => category.id);
assert.ok(categoryIds.includes('things-to-do'));
});
});

View File

@@ -4,7 +4,7 @@ import { babel } from '@rollup/plugin-babel';
export default defineConfig({
server: {
host: '127.0.0.1',
host: '0.0.0.0',
},
plugins: [
ember(),