Compare commits

..

15 Commits

Author SHA1 Message Date
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
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
33 changed files with 893 additions and 159 deletions

View File

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

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

@@ -109,32 +109,6 @@ export default class AppMenuSettingsNostr extends Component {
<span>Nostr</span> <span>Nostr</span>
</summary> </summary>
<div class="details-content form-layout"> <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"> <div class="form-group">
<label for="new-read-relay">Read Relays</label> <label for="new-read-relay">Read Relays</label>
<ul class="relay-list"> <ul class="relay-list">
@@ -225,6 +199,32 @@ export default class AppMenuSettingsNostr extends Component {
{{/if}} {{/if}}
</div> </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"> <div class="form-group">
<label>Cached data</label> <label>Cached data</label>
<button <button

View File

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

View File

@@ -8,6 +8,7 @@ import Icon from './icon';
import fadeInImage from '../modifiers/fade-in-image'; import fadeInImage from '../modifiers/fade-in-image';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
import { modifier } from 'ember-modifier'; import { modifier } from 'ember-modifier';
import config from 'marco/config/environment';
export default class PhotoCarousel extends Component { export default class PhotoCarousel extends Component {
@tracked canScrollLeft = false; @tracked canScrollLeft = false;
@@ -55,6 +56,8 @@ export default class PhotoCarousel extends Component {
} }
}); });
isProgrammaticScroll = false;
scrollToNewPhoto = modifier((element, [eventId]) => { scrollToNewPhoto = modifier((element, [eventId]) => {
if (eventId && eventId !== this.lastEventId) { if (eventId && eventId !== this.lastEventId) {
const isInitial = !this.lastEventId; const isInitial = !this.lastEventId;
@@ -65,6 +68,9 @@ export default class PhotoCarousel extends Component {
return; return;
} }
this.internalEventId = eventId;
this.isProgrammaticScroll = true;
const scrollAction = () => { const scrollAction = () => {
const targetSlide = element.querySelector( const targetSlide = element.querySelector(
`[data-event-id="${eventId}"]` `[data-event-id="${eventId}"]`
@@ -78,11 +84,18 @@ export default class PhotoCarousel extends Component {
// Restore smooth scroll after the jump // Restore smooth scroll after the jump
setTimeout(() => { setTimeout(() => {
element.style.scrollBehavior = originalScrollBehavior; element.style.scrollBehavior = originalScrollBehavior;
this.isProgrammaticScroll = false;
}, 50); }, 50);
} else { } else {
// Use native CSS smooth scrolling for subsequent clicks // Use native CSS smooth scrolling for subsequent clicks
element.scrollLeft = targetSlide.offsetLeft; 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; 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 // Set up intersection observer to track which photo is currently "most" visible
intersectionObserver = new IntersectionObserver( intersectionObserver = new IntersectionObserver(
(entries) => { (entries) => {
if (this.isProgrammaticScroll) return;
for (let entry of entries) { for (let entry of entries) {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) { if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
const eventId = entry.target.dataset.eventId; const eventId = entry.target.dataset.eventId;

View File

@@ -1,22 +1,100 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
import { modifier } from 'ember-modifier';
import { fn } from '@ember/helper'; import { fn } from '@ember/helper';
import { service } from '@ember/service';
import { modifier } from 'ember-modifier';
import { task } from 'ember-concurrency'; import { task } from 'ember-concurrency';
import { EventFactory } from 'applesauce-core'; import { EventFactory } from 'applesauce-factory';
import Icon from '#components/icon'; import config from 'marco/config/environment';
import DropdownMenu from './dropdown-menu';
import PhotoCarousel from './photo-carousel'; 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 { export default class PhotoGallery extends Component {
get isTesting() {
return config.environment === 'test';
}
get destinationElement() {
return document.getElementById('modal-portal') || document.body;
}
@service toast; @service toast;
@service nostrAuth; @service nostrAuth;
@service nostrData; @service nostrData;
@service nostrRelay; @service nostrRelay;
@service blossom; @service blossom;
@service settings;
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0]; @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]) => { bindKeyboard = modifier((element, [handler]) => {
document.addEventListener('keydown', handler); document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler);
@@ -174,67 +258,38 @@ export default class PhotoGallery extends Component {
}); });
<template> <template>
<div {{#if this.isTesting}}
class="photo-gallery-overlay" <GalleryContent
role="dialog" @handleBackgroundClick={{this.handleBackgroundClick}}
tabindex="-1" @bindKeyboard={{this.bindKeyboard}}
{{on "click" this.handleBackgroundClick}} @handleKeydown={{this.handleKeydown}}
{{this.bindKeyboard this.handleKeydown}} @copyEventId={{this.copyEventId}}
> @canDeletePhoto={{this.canDeletePhoto}}
{{! template-lint-disable no-invalid-interactive }} @deletePhotoTask={{this.deletePhotoTask}}
<div class="photo-gallery-content"> @handleClose={{this.handleClose}}
<div class="actions-btn-container"> @photos={{@photos}}
<DropdownMenu @currentPhoto={{this.currentPhoto}}
@iconSize={{24}} @handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
@triggerIcon="more-horizontal" @placeName={{@placeName}}
@iconColor="white" @selectPhoto={{this.selectPhoto}}
as |closeMenu| />
> {{else}}
<button {{#in-element this.destinationElement}}
class="dropdown-item" <GalleryContent
type="button" @handleBackgroundClick={{this.handleBackgroundClick}}
{{on "click" (fn this.copyEventId closeMenu)}} @bindKeyboard={{this.bindKeyboard}}
>Copy Photo Event ID</button> @handleKeydown={{this.handleKeydown}}
{{#if this.isCreator}} @copyEventId={{this.copyEventId}}
<button @canDeletePhoto={{this.canDeletePhoto}}
class="dropdown-item text-danger" @deletePhotoTask={{this.deletePhotoTask}}
type="button" @handleClose={{this.handleClose}}
{{on "click" (fn this.deletePhotoTask.perform closeMenu)}} @photos={{@photos}}
>Delete Photo</button> @currentPhoto={{this.currentPhoto}}
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
@placeName={{@placeName}}
@selectPhoto={{this.selectPhoto}}
/>
{{/in-element}}
{{/if}} {{/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>
</template> </template>
} }

View File

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

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

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

View File

@@ -1843,6 +1843,42 @@ button.create-place {
font-size: 1.2rem; 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 { .alert {
padding: 0.5rem; padding: 0.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;

View File

@@ -7,3 +7,8 @@ export function humanizeOsmTag(text) {
w.replace(/^\w/, (c) => c.toUpperCase()) 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 // Safety check to avoid infinite loops or massive arrays if bbox is weird
if (Math.abs(maxLat - minLat) > 20 || Math.abs(maxLon - minLon) > 20) { 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.' 'BBox too large for 4-char geohash scanning, aborting fine scan.'
); );
return []; 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 checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
import chevronLeft from 'feather-icons/dist/icons/chevron-left.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 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 clock from 'feather-icons/dist/icons/clock.svg?raw';
import database from 'feather-icons/dist/icons/database.svg?raw'; import database from 'feather-icons/dist/icons/database.svg?raw';
import edit from 'feather-icons/dist/icons/edit.svg?raw'; import edit from 'feather-icons/dist/icons/edit.svg?raw';
@@ -146,6 +147,7 @@ const ICONS = {
climbing_wall: climbingWall, climbing_wall: climbingWall,
check, check,
'alert-circle': alertCircle, 'alert-circle': alertCircle,
'alert-triangle': alertTriangle,
'classical-building': classicalBuilding, 'classical-building': classicalBuilding,
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag, 'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
'classical-building-with-flag': classicalBuildingWithFlag, 'classical-building-with-flag': classicalBuildingWithFlag,

View File

@@ -30,6 +30,11 @@ export function parsePlacePhotos(events) {
const allPhotos = []; const allPhotos = [];
for (const event of sortedEvents) { for (const event of sortedEvents) {
const eventTagValues = event.tags
.filter((t) => t[0] === 't')
.map((t) => t[1])
.filter(Boolean);
// Find all imeta tags // Find all imeta tags
const imetas = event.tags.filter((t) => t[0] === 'imeta'); const imetas = event.tags.filter((t) => t[0] === 'imeta');
for (const imeta of imetas) { for (const imeta of imetas) {
@@ -70,6 +75,7 @@ export function parsePlacePhotos(events) {
isLandscape, isLandscape,
aspectRatio, aspectRatio,
placeIdentifier, 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"> <link rel="stylesheet" href="/app/styles/app.css">
</head> </head>
<body> <body>
<div id="modal-portal"></div>
<script type="module"> <script type="module">
import Application from './app/app'; import Application from './app/app';
import environment from './app/config/environment'; import environment from './app/config/environment';

View File

@@ -1,6 +1,6 @@
{ {
"name": "marco", "name": "marco",
"version": "1.21.2", "version": "1.23.0",
"private": true, "private": true,
"description": "Unhosted maps app", "description": "Unhosted maps app",
"repository": { "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

View File

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

View File

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

View File

@@ -16,7 +16,12 @@
</div> </div>
</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> <script type="module">import "ember-testing";</script>

View File

@@ -29,6 +29,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
this.nostrData = this.owner.lookup('service:nostrData'); this.nostrData = this.owner.lookup('service:nostrData');
this.nostrRelay = this.owner.lookup('service:nostrRelay'); this.nostrRelay = this.owner.lookup('service:nostrRelay');
this.toast = this.owner.lookup('service:toast'); this.toast = this.owner.lookup('service:toast');
this.settings = this.owner.lookup('service:settings');
this.photos = [ this.photos = [
{ {
@@ -50,6 +51,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
hooks.afterEach(function () { hooks.afterEach(function () {
sinon.restore(); sinon.restore();
localStorage.removeItem('marco:settings');
}); });
test('it does not show delete button if user is not creator', async function (assert) { 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( await render(
<template> <template>
<div id="test-container"> <div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery <PhotoGallery
@photos={{this.photos}} @photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}} @selectedPhoto={{this.selectedPhoto}}
@@ -76,13 +79,15 @@ module('Integration | Component | photo-gallery', function (hooks) {
.doesNotExist('Delete button is hidden for non-creator'); .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.nostrAuth.pubkey = 'userA'; // Matches photo1's pubkey
this.settings.update('experimentalEnablePhotoDeletion', true); // Enable the setting
this.selectedPhoto = this.photos[0]; this.selectedPhoto = this.photos[0];
await render( await render(
<template> <template>
<div id="test-container"> <div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery <PhotoGallery
@photos={{this.photos}} @photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}} @selectedPhoto={{this.selectedPhoto}}
@@ -97,12 +102,12 @@ module('Integration | Component | photo-gallery', function (hooks) {
assert.dom('.dropdown-popover').exists('Dropdown opened'); assert.dom('.dropdown-popover').exists('Dropdown opened');
assert assert
.dom('.dropdown-item.text-danger') .dom('.dropdown-item.text-danger')
.exists('Delete button is visible for creator'); .exists('Delete button is visible for creator when setting is enabled');
assert.dom('.dropdown-item.text-danger').hasText('Delete Photo');
}); });
test('it handles cancellation of deletion', async function (assert) { test('it handles cancellation of deletion', async function (assert) {
this.nostrAuth.pubkey = 'userA'; this.nostrAuth.pubkey = 'userA';
this.settings.update('experimentalEnablePhotoDeletion', true);
this.selectedPhoto = this.photos[0]; this.selectedPhoto = this.photos[0];
const confirmStub = sinon.stub(window, 'confirm').returns(false); const confirmStub = sinon.stub(window, 'confirm').returns(false);
@@ -111,6 +116,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
await render( await render(
<template> <template>
<div id="test-container"> <div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery <PhotoGallery
@photos={{this.photos}} @photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}} @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) { test('it performs full deletion flow when confirmed', async function (assert) {
this.nostrAuth.pubkey = 'userA'; this.nostrAuth.pubkey = 'userA';
this.settings.update('experimentalEnablePhotoDeletion', true);
// Override the mock's getter just for this test // Override the mock's getter just for this test
Object.defineProperty(this.nostrAuth, 'signer', { Object.defineProperty(this.nostrAuth, 'signer', {
configurable: true, configurable: true,
@@ -157,6 +164,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
await render( await render(
<template> <template>
<div id="test-container"> <div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery <PhotoGallery
@photos={{this.photos}} @photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}} @selectedPhoto={{this.selectedPhoto}}
@@ -230,6 +238,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
await render( await render(
<template> <template>
<div id="test-container"> <div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery <PhotoGallery
@photos={{this.photos}} @photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}} @selectedPhoto={{this.selectedPhoto}}
@@ -269,6 +278,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
await render( await render(
<template> <template>
<div id="test-container"> <div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery <PhotoGallery
@photos={{this.photos}} @photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}} @selectedPhoto={{this.selectedPhoto}}
@@ -277,31 +287,30 @@ module('Integration | Component | photo-gallery', function (hooks) {
</template> </template>
); );
// Let carousel settle assert
.dom('.photo-gallery-content')
.hasAttribute('data-current-event-id', 'event1');
// Right Arrow // Right Arrow
await triggerKeyEvent(document, 'keydown', 'ArrowRight'); 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 assert
.dom('.thumbnail-strip-container .carousel-slide.active img') .dom('.photo-gallery-content')
.hasAttribute('data-src', 'photo2.jpg'); .hasAttribute('data-current-event-id', 'event2');
// Right Arrow again // Right Arrow again
await triggerKeyEvent(document, 'keydown', 'ArrowRight'); await triggerKeyEvent(document, 'keydown', 'ArrowRight');
assert assert
.dom('.thumbnail-strip-container .carousel-slide.active img') .dom('.photo-gallery-content')
.hasAttribute('data-src', 'photo3.jpg'); .hasAttribute('data-current-event-id', 'event3');
// Left Arrow // Left Arrow
await triggerKeyEvent(document, 'keydown', 'ArrowLeft'); await triggerKeyEvent(document, 'keydown', 'ArrowLeft');
assert assert
.dom('.thumbnail-strip-container .carousel-slide.active img') .dom('.photo-gallery-content')
.hasAttribute('data-src', 'photo2.jpg'); .hasAttribute('data-current-event-id', 'event2');
}); });
test('escape key closes gallery', async function (assert) { test('escape key closes gallery', async function (assert) {
@@ -314,6 +323,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
await render( await render(
<template> <template>
<div id="test-container"> <div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery <PhotoGallery
@photos={{this.photos}} @photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}} @selectedPhoto={{this.selectedPhoto}}

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 { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers'; 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 SearchBox from 'marco/components/search-box';
import Service from '@ember/service'; import Service from '@ember/service';
@@ -208,18 +208,22 @@ module('Integration | Component | search-box', function (hooks) {
); );
// Type "Resta" to trigger "Restaurants" category match // Type "Resta" to trigger "Restaurants" category match
await focus('.search-input');
await fillIn('.search-input', 'Resta'); await fillIn('.search-input', 'Resta');
// Wait for debounce (300ms) + execution await waitFor('.search-result-item');
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
await delay(400);
// The first result should be the category match const resultItems = Array.from(
assert.dom('.search-result-item').exists({ count: 1 }); this.element.querySelectorAll('.search-result-item')
assert.dom('.result-title').hasText('Restaurants'); );
const categoryResult = resultItems.find((item) =>
item.textContent.includes('Restaurants')
);
assert.ok(categoryResult, 'Restaurants category result is shown');
// Click the result // Click the result
await click('.search-result-item'); await click(categoryResult);
// Assert transition with lat/lon from map center // Assert transition with lat/lon from map center
assert.verifySteps([ assert.verifySteps([

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'));
});
});