Compare commits
20 Commits
14827fce3e
...
dev/map_ti
| Author | SHA1 | Date | |
|---|---|---|---|
|
f0a19e30b8
|
|||
|
59bc5ca046
|
|||
|
ef4bb8f51a
|
|||
|
f82a797720
|
|||
|
f9cb22ee0e
|
|||
|
a77ea0c97d
|
|||
|
208b77a294
|
|||
|
ea3e4dd0dc
|
|||
|
2c2a3e2a4c
|
|||
|
d266bb92bd
|
|||
|
200100686d
|
|||
|
70d2fe1c6c
|
|||
|
6329ad986d
|
|||
|
bcfa81494e
|
|||
|
bc42694707
|
|||
|
4390b7d699
|
|||
|
7bab8dfa09
|
|||
|
51c9555273
|
|||
|
632efeeab5
|
|||
|
deeea9961f
|
@@ -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>
|
||||
|
||||
49
app/components/app-menu/settings/experimental.gjs
Normal file
49
app/components/app-menu/settings/experimental.gjs
Normal 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>
|
||||
}
|
||||
@@ -109,32 +109,6 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
<span>Nostr</span>
|
||||
</summary>
|
||||
<div class="details-content form-layout">
|
||||
<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 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
|
||||
|
||||
@@ -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;
|
||||
@@ -286,9 +287,26 @@ export default class MapComponent extends Component {
|
||||
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
|
||||
this.mapUi.updateZoom(view.getZoom());
|
||||
|
||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', {
|
||||
webfonts: 'data:text/css,',
|
||||
});
|
||||
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';
|
||||
|
||||
@@ -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}}
|
||||
>
|
||||
<div
|
||||
class="modal-content"
|
||||
role="document"
|
||||
tabindex="0"
|
||||
{{on "click" this.stopProp}}
|
||||
{{#if this.isTesting}}
|
||||
<ModalContent
|
||||
@close={{this.close}}
|
||||
@stopProp={{this.stopProp}}
|
||||
@disableClose={{@disableClose}}
|
||||
>
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -55,6 +56,8 @@ export default class PhotoCarousel extends Component {
|
||||
}
|
||||
});
|
||||
|
||||
isProgrammaticScroll = false;
|
||||
|
||||
scrollToNewPhoto = modifier((element, [eventId]) => {
|
||||
if (eventId && eventId !== this.lastEventId) {
|
||||
const isInitial = !this.lastEventId;
|
||||
@@ -65,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}"]`
|
||||
@@ -78,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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -111,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;
|
||||
|
||||
@@ -1,22 +1,100 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { on } from '@ember/modifier';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import { service } from '@ember/service';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { EventFactory } from 'applesauce-core';
|
||||
import Icon from '#components/icon';
|
||||
import { EventFactory } from 'applesauce-factory';
|
||||
import config from 'marco/config/environment';
|
||||
import DropdownMenu from './dropdown-menu';
|
||||
import PhotoCarousel from './photo-carousel';
|
||||
import DropdownMenu from '#components/dropdown-menu';
|
||||
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];
|
||||
|
||||
@@ -28,6 +106,12 @@ export default class PhotoGallery extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
get canDeletePhoto() {
|
||||
return (
|
||||
this.isCreator && this.settings.experimentalEnablePhotoDeletion === true
|
||||
);
|
||||
}
|
||||
|
||||
bindKeyboard = modifier((element, [handler]) => {
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
@@ -174,67 +258,38 @@ export default class PhotoGallery extends Component {
|
||||
});
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="photo-gallery-overlay"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
{{on "click" this.handleBackgroundClick}}
|
||||
{{this.bindKeyboard this.handleKeydown}}
|
||||
>
|
||||
{{! template-lint-disable no-invalid-interactive }}
|
||||
<div class="photo-gallery-content">
|
||||
<div class="actions-btn-container">
|
||||
<DropdownMenu
|
||||
@iconSize={{24}}
|
||||
@triggerIcon="more-horizontal"
|
||||
@iconColor="white"
|
||||
as |closeMenu|
|
||||
>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
type="button"
|
||||
{{on "click" (fn this.copyEventId closeMenu)}}
|
||||
>Copy Photo Event ID</button>
|
||||
{{#if this.isCreator}}
|
||||
<button
|
||||
class="dropdown-item text-danger"
|
||||
type="button"
|
||||
{{on "click" (fn this.deletePhotoTask.perform closeMenu)}}
|
||||
>Delete Photo</button>
|
||||
{{/if}}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
@photos={{@photos}}
|
||||
@scrollToEventId={{this.currentPhoto.eventId}}
|
||||
@onVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||
@name={{@placeName}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="thumbnail-strip-container">
|
||||
<PhotoCarousel
|
||||
@variant="gallery-thumbnails"
|
||||
@photos={{@photos}}
|
||||
@scrollToEventId={{this.currentPhoto.eventId}}
|
||||
@onPhotoClick={{this.selectPhoto}}
|
||||
@name={{@placeName}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{#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}}
|
||||
@currentPhoto={{this.currentPhoto}}
|
||||
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||
@placeName={{@placeName}}
|
||||
@selectPhoto={{this.selectPhoto}}
|
||||
/>
|
||||
{{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}}
|
||||
@currentPhoto={{this.currentPhoto}}
|
||||
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||
@placeName={{@placeName}}
|
||||
@selectPhoto={{this.selectPhoto}}
|
||||
/>
|
||||
{{/in-element}}
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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) => {
|
||||
return (
|
||||
p.title?.toLowerCase().includes(queryLower) ||
|
||||
p.description?.toLowerCase().includes(queryLower)
|
||||
);
|
||||
});
|
||||
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) => {
|
||||
|
||||
6
app/helpers/capitalize.js
Normal file
6
app/helpers/capitalize.js
Normal 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
6
app/helpers/includes.js
Normal 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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -1843,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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';
|
||||
@@ -146,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,
|
||||
|
||||
@@ -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) {
|
||||
@@ -70,6 +75,7 @@ export function parsePlacePhotos(events) {
|
||||
isLandscape,
|
||||
aspectRatio,
|
||||
placeIdentifier,
|
||||
tags: eventTagValues,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
32
app/utils/photo-tag-suggestions.js
Normal file
32
app/utils/photo-tag-suggestions.js
Normal 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;
|
||||
}
|
||||
95
app/utils/poi-category-matcher.js
Normal file
95
app/utils/poi-category-matcher.js
Normal 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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.21.2",
|
||||
"version": "1.24.0",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
|
||||
25
release/assets/main-CLZV93ov.js
Normal file
25
release/assets/main-CLZV93ov.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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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-CjxGWim8.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-M5C-HUrg.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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -38,6 +38,7 @@ class MockOsmService extends Service {
|
||||
}
|
||||
|
||||
class MockStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = [];
|
||||
findPlaceById() {
|
||||
return null;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
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 = [
|
||||
{
|
||||
@@ -50,6 +51,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
hooks.afterEach(function () {
|
||||
sinon.restore();
|
||||
localStorage.removeItem('marco:settings');
|
||||
});
|
||||
|
||||
test('it does not show delete button if user is not creator', async function (assert) {
|
||||
@@ -59,6 +61,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
await render(
|
||||
<template>
|
||||
<div id="test-container">
|
||||
<div id="modal-portal"></div>
|
||||
<PhotoGallery
|
||||
@photos={{this.photos}}
|
||||
@selectedPhoto={{this.selectedPhoto}}
|
||||
@@ -76,13 +79,15 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
.doesNotExist('Delete button is hidden for non-creator');
|
||||
});
|
||||
|
||||
test('it shows delete button if user is creator', async function (assert) {
|
||||
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}}
|
||||
@@ -97,12 +102,12 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
assert.dom('.dropdown-popover').exists('Dropdown opened');
|
||||
assert
|
||||
.dom('.dropdown-item.text-danger')
|
||||
.exists('Delete button is visible for creator');
|
||||
assert.dom('.dropdown-item.text-danger').hasText('Delete Photo');
|
||||
.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);
|
||||
@@ -111,6 +116,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
await render(
|
||||
<template>
|
||||
<div id="test-container">
|
||||
<div id="modal-portal"></div>
|
||||
<PhotoGallery
|
||||
@photos={{this.photos}}
|
||||
@selectedPhoto={{this.selectedPhoto}}
|
||||
@@ -128,6 +134,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
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,
|
||||
@@ -157,6 +164,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
await render(
|
||||
<template>
|
||||
<div id="test-container">
|
||||
<div id="modal-portal"></div>
|
||||
<PhotoGallery
|
||||
@photos={{this.photos}}
|
||||
@selectedPhoto={{this.selectedPhoto}}
|
||||
@@ -230,6 +238,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
await render(
|
||||
<template>
|
||||
<div id="test-container">
|
||||
<div id="modal-portal"></div>
|
||||
<PhotoGallery
|
||||
@photos={{this.photos}}
|
||||
@selectedPhoto={{this.selectedPhoto}}
|
||||
@@ -269,6 +278,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
await render(
|
||||
<template>
|
||||
<div id="test-container">
|
||||
<div id="modal-portal"></div>
|
||||
<PhotoGallery
|
||||
@photos={{this.photos}}
|
||||
@selectedPhoto={{this.selectedPhoto}}
|
||||
@@ -277,31 +287,30 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
</template>
|
||||
);
|
||||
|
||||
// Let carousel settle
|
||||
assert
|
||||
.dom('.photo-gallery-content')
|
||||
.hasAttribute('data-current-event-id', 'event1');
|
||||
|
||||
// Right Arrow
|
||||
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
|
||||
|
||||
// Let's just assert that currentPhoto was updated internally, which trickles down.
|
||||
// The actual DOM update for the main image might be tricky if the carousel relies on scroll events.
|
||||
// We can at least check if the thumbnail selection changed, as that is directly driven by currentPhoto
|
||||
assert
|
||||
.dom('.thumbnail-strip-container .carousel-slide.active img')
|
||||
.hasAttribute('data-src', 'photo2.jpg');
|
||||
.dom('.photo-gallery-content')
|
||||
.hasAttribute('data-current-event-id', 'event2');
|
||||
|
||||
// Right Arrow again
|
||||
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
|
||||
|
||||
assert
|
||||
.dom('.thumbnail-strip-container .carousel-slide.active img')
|
||||
.hasAttribute('data-src', 'photo3.jpg');
|
||||
.dom('.photo-gallery-content')
|
||||
.hasAttribute('data-current-event-id', 'event3');
|
||||
|
||||
// Left Arrow
|
||||
await triggerKeyEvent(document, 'keydown', 'ArrowLeft');
|
||||
|
||||
assert
|
||||
.dom('.thumbnail-strip-container .carousel-slide.active img')
|
||||
.hasAttribute('data-src', 'photo2.jpg');
|
||||
.dom('.photo-gallery-content')
|
||||
.hasAttribute('data-current-event-id', 'event2');
|
||||
});
|
||||
|
||||
test('escape key closes gallery', async function (assert) {
|
||||
@@ -314,6 +323,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
await render(
|
||||
<template>
|
||||
<div id="test-container">
|
||||
<div id="modal-portal"></div>
|
||||
<PhotoGallery
|
||||
@photos={{this.photos}}
|
||||
@selectedPhoto={{this.selectedPhoto}}
|
||||
|
||||
100
tests/integration/components/place-photo-upload-test.gjs
Normal file
100
tests/integration/components/place-photo-upload-test.gjs
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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"]']);
|
||||
});
|
||||
});
|
||||
|
||||
144
tests/unit/utils/nostr-test.js
Normal file
144
tests/unit/utils/nostr-test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
30
tests/unit/utils/photo-tag-suggestions-test.js
Normal file
30
tests/unit/utils/photo-tag-suggestions-test.js
Normal 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, []);
|
||||
});
|
||||
});
|
||||
38
tests/unit/utils/poi-category-matcher-test.js
Normal file
38
tests/unit/utils/poi-category-matcher-test.js
Normal 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'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user