Compare commits
18 Commits
b492e2aa89
...
v1.23.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
a77ea0c97d
|
|||
|
208b77a294
|
|||
|
ea3e4dd0dc
|
|||
|
2c2a3e2a4c
|
|||
|
d266bb92bd
|
|||
|
200100686d
|
|||
|
70d2fe1c6c
|
|||
|
6329ad986d
|
|||
|
bcfa81494e
|
|||
|
bc42694707
|
|||
|
4390b7d699
|
|||
|
7bab8dfa09
|
|||
|
51c9555273
|
|||
|
632efeeab5
|
|||
|
14827fce3e
|
|||
|
deeea9961f
|
|||
|
7a109c9ba5
|
|||
|
10aae3c9b3
|
@@ -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>
|
||||||
|
|||||||
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>
|
<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
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -33,6 +34,14 @@ export default class PhotoCarousel extends Component {
|
|||||||
return !this.canScrollRight;
|
return !this.canScrollRight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isGalleryMain() {
|
||||||
|
return this.args.variant === 'gallery-main';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isGalleryThumbnails() {
|
||||||
|
return this.args.variant === 'gallery-thumbnails';
|
||||||
|
}
|
||||||
|
|
||||||
get variantClass() {
|
get variantClass() {
|
||||||
return this.args.variant || 'inline';
|
return this.args.variant || 'inline';
|
||||||
}
|
}
|
||||||
@@ -47,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;
|
||||||
@@ -57,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}"]`
|
||||||
@@ -70,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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,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;
|
||||||
@@ -205,6 +232,23 @@ export default class PhotoCarousel extends Component {
|
|||||||
/>
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.isGalleryMain}}
|
||||||
|
<img
|
||||||
|
data-src={{photo.url}}
|
||||||
|
class="place-header-photo
|
||||||
|
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
||||||
|
alt={{@name}}
|
||||||
|
{{fadeInImage photo.url}}
|
||||||
|
/>
|
||||||
|
{{else if this.isGalleryThumbnails}}
|
||||||
|
<img
|
||||||
|
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
||||||
|
class="place-header-photo
|
||||||
|
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
||||||
|
alt={{@name}}
|
||||||
|
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||||
|
/>
|
||||||
|
{{else}}
|
||||||
{{#if photo.isLandscape}}
|
{{#if photo.isLandscape}}
|
||||||
<picture>
|
<picture>
|
||||||
{{#if photo.thumbUrl}}
|
{{#if photo.thumbUrl}}
|
||||||
@@ -229,6 +273,7 @@ export default class PhotoCarousel extends Component {
|
|||||||
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||||
/>
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,122 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { tracked } from '@glimmer/tracking';
|
|
||||||
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 Icon from '#components/icon';
|
import { fn } from '@ember/helper';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { modifier } from 'ember-modifier';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
import { EventFactory } from 'applesauce-factory';
|
||||||
|
import config from 'marco/config/environment';
|
||||||
|
import DropdownMenu from './dropdown-menu';
|
||||||
import PhotoCarousel from './photo-carousel';
|
import 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 nostrAuth;
|
||||||
|
@service nostrData;
|
||||||
|
@service nostrRelay;
|
||||||
|
@service blossom;
|
||||||
|
@service settings;
|
||||||
|
|
||||||
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
|
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
|
||||||
|
|
||||||
|
get isCreator() {
|
||||||
|
return (
|
||||||
|
this.currentPhoto?.pubkey &&
|
||||||
|
this.nostrAuth.pubkey &&
|
||||||
|
this.currentPhoto.pubkey === this.nostrAuth.pubkey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get canDeletePhoto() {
|
||||||
|
return (
|
||||||
|
this.isCreator && this.settings.experimentalEnablePhotoDeletion === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindKeyboard = modifier((element, [handler]) => {
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
|
});
|
||||||
|
|
||||||
@action
|
@action
|
||||||
handleClose() {
|
handleClose() {
|
||||||
if (this.args.onClose) {
|
if (this.args.onClose) {
|
||||||
@@ -43,60 +151,145 @@ export default class PhotoGallery extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleKeydown(e) {
|
||||||
|
if (!this.args.photos || this.args.photos.length === 0) return;
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
this.handleClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = this.args.photos.indexOf(this.currentPhoto);
|
||||||
|
if (currentIndex === -1) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowLeft' && currentIndex > 0) {
|
||||||
|
this.currentPhoto = this.args.photos[currentIndex - 1];
|
||||||
|
} else if (
|
||||||
|
e.key === 'ArrowRight' &&
|
||||||
|
currentIndex < this.args.photos.length - 1
|
||||||
|
) {
|
||||||
|
this.currentPhoto = this.args.photos[currentIndex + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async copyEventId(closeMenu) {
|
||||||
|
if (this.currentPhoto?.eventId) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(this.currentPhoto.eventId);
|
||||||
|
this.toast.show('Event ID copied to clipboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy event ID:', err);
|
||||||
|
this.toast.show('Failed to copy event ID');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePhotoTask = task(async (closeMenu) => {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
'Are you sure you want to delete this photo? This cannot be undone.'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (closeMenu) closeMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventId = this.currentPhoto.eventId;
|
||||||
|
|
||||||
|
// Publish Nostr kind: 5 deletion event first so we don't end up with dead blossom links on a failure
|
||||||
|
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||||
|
const tags = [['e', eventId]];
|
||||||
|
|
||||||
|
if (this.currentPhoto.placeIdentifier) {
|
||||||
|
tags.push(['i', this.currentPhoto.placeIdentifier]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = {
|
||||||
|
kind: 5,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
content: 'Deleted photo',
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
|
||||||
|
const event = await factory.sign(template);
|
||||||
|
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||||
|
|
||||||
|
// Remove from local store by adding the kind 5 to it
|
||||||
|
this.nostrData.store.add(event);
|
||||||
|
|
||||||
|
// Now that the event is published, try to delete from Blossom
|
||||||
|
const hashRegex = /[0-9a-f]{64}/i;
|
||||||
|
|
||||||
|
if (this.currentPhoto.url) {
|
||||||
|
const match = this.currentPhoto.url.match(hashRegex);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
await this.blossom.delete(match[0]);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to delete main image from blossom:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentPhoto.thumbUrl) {
|
||||||
|
const match = this.currentPhoto.thumbUrl.match(hashRegex);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
await this.blossom.delete(match[0]);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to delete thumb image from blossom:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toast.show('Photo deleted successfully');
|
||||||
|
|
||||||
|
if (closeMenu) closeMenu();
|
||||||
|
this.handleClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete photo:', e);
|
||||||
|
this.toast.show('Failed to delete photo: ' + e.message);
|
||||||
|
if (closeMenu) closeMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
<template>
|
<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}}
|
||||||
>
|
@copyEventId={{this.copyEventId}}
|
||||||
{{! template-lint-disable no-invalid-interactive }}
|
@canDeletePhoto={{this.canDeletePhoto}}
|
||||||
<div class="photo-gallery-content">
|
@deletePhotoTask={{this.deletePhotoTask}}
|
||||||
<div class="actions-btn-container">
|
@handleClose={{this.handleClose}}
|
||||||
<DropdownMenu @iconSize={{24}} @triggerIcon="more-horizontal" @iconColor="white" as |closeMenu|>
|
|
||||||
<button
|
|
||||||
class="dropdown-item"
|
|
||||||
type="button"
|
|
||||||
{{on "click" closeMenu}}
|
|
||||||
>Copy Raw Event Data</button>
|
|
||||||
<button
|
|
||||||
class="dropdown-item"
|
|
||||||
type="button"
|
|
||||||
{{on "click" closeMenu}}
|
|
||||||
>Report Photo</button>
|
|
||||||
</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}}
|
@photos={{@photos}}
|
||||||
@scrollToEventId={{this.currentPhoto.eventId}}
|
@currentPhoto={{this.currentPhoto}}
|
||||||
@onVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||||
@name={{@placeName}}
|
@placeName={{@placeName}}
|
||||||
|
@selectPhoto={{this.selectPhoto}}
|
||||||
/>
|
/>
|
||||||
</div>
|
{{else}}
|
||||||
|
{{#in-element this.destinationElement}}
|
||||||
<div class="thumbnail-strip-container">
|
<GalleryContent
|
||||||
<PhotoCarousel
|
@handleBackgroundClick={{this.handleBackgroundClick}}
|
||||||
@variant="gallery-thumbnails"
|
@bindKeyboard={{this.bindKeyboard}}
|
||||||
|
@handleKeydown={{this.handleKeydown}}
|
||||||
|
@copyEventId={{this.copyEventId}}
|
||||||
|
@canDeletePhoto={{this.canDeletePhoto}}
|
||||||
|
@deletePhotoTask={{this.deletePhotoTask}}
|
||||||
|
@handleClose={{this.handleClose}}
|
||||||
@photos={{@photos}}
|
@photos={{@photos}}
|
||||||
@scrollToEventId={{this.currentPhoto.eventId}}
|
@currentPhoto={{this.currentPhoto}}
|
||||||
@onPhotoClick={{this.selectPhoto}}
|
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||||
@name={{@placeName}}
|
@placeName={{@placeName}}
|
||||||
|
@selectPhoto={{this.selectPhoto}}
|
||||||
/>
|
/>
|
||||||
</div>
|
{{/in-element}}
|
||||||
</div>
|
{{/if}}
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
@@ -55,10 +55,11 @@ export default class NostrDataService extends Service {
|
|||||||
this._stopPersisting = persistEventsToCache(
|
this._stopPersisting = persistEventsToCache(
|
||||||
this.store,
|
this.store,
|
||||||
async (events) => {
|
async (events) => {
|
||||||
// Only cache profiles, mailboxes, blossom servers, and place photos
|
// Only cache profiles, mailboxes, blossom servers, and place photos, and deletions
|
||||||
const toCache = events.filter(
|
const toCache = events.filter(
|
||||||
(e) =>
|
(e) =>
|
||||||
e.kind === 0 ||
|
e.kind === 0 ||
|
||||||
|
e.kind === 5 ||
|
||||||
e.kind === 10002 ||
|
e.kind === 10002 ||
|
||||||
e.kind === 10063 ||
|
e.kind === 10063 ||
|
||||||
e.kind === 360
|
e.kind === 360
|
||||||
@@ -215,7 +216,7 @@ export default class NostrDataService extends Service {
|
|||||||
|
|
||||||
const cachedEvents = await this.cache.query([
|
const cachedEvents = await this.cache.query([
|
||||||
{
|
{
|
||||||
kinds: [360],
|
kinds: [360, 5],
|
||||||
'#i': [entityId],
|
'#i': [entityId],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -236,7 +237,7 @@ export default class NostrDataService extends Service {
|
|||||||
this.nostrRelay.pool
|
this.nostrRelay.pool
|
||||||
.request(this.activeReadRelays, [
|
.request(this.activeReadRelays, [
|
||||||
{
|
{
|
||||||
kinds: [360],
|
kinds: [360, 5],
|
||||||
'#i': [entityId],
|
'#i': [entityId],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -38,6 +43,7 @@ export function parsePlacePhotos(events) {
|
|||||||
let blurhash = null;
|
let blurhash = null;
|
||||||
let isLandscape = false;
|
let isLandscape = false;
|
||||||
let aspectRatio = 16 / 9; // default
|
let aspectRatio = 16 / 9; // default
|
||||||
|
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
|
||||||
|
|
||||||
for (const tag of imeta.slice(1)) {
|
for (const tag of imeta.slice(1)) {
|
||||||
if (tag.startsWith('url ')) {
|
if (tag.startsWith('url ')) {
|
||||||
@@ -68,6 +74,8 @@ export function parsePlacePhotos(events) {
|
|||||||
blurhash,
|
blurhash,
|
||||||
isLandscape,
|
isLandscape,
|
||||||
aspectRatio,
|
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">
|
<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';
|
||||||
|
|||||||
@@ -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
25
release/assets/main-DSyq2vVy.js
Normal file
25
release/assets/main-DSyq2vVy.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
339
tests/integration/components/photo-gallery-test.gjs
Normal file
339
tests/integration/components/photo-gallery-test.gjs
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { render, click, triggerKeyEvent } from '@ember/test-helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import PhotoGallery from 'marco/components/photo-gallery';
|
||||||
|
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
|
||||||
|
class MockBlossomService extends Service {
|
||||||
|
async delete() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockToastService extends Service {
|
||||||
|
show() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
module('Integration | Component | photo-gallery', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
setupNostrMocks(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register('service:blossom', MockBlossomService);
|
||||||
|
this.owner.register('service:toast', MockToastService);
|
||||||
|
|
||||||
|
this.blossom = this.owner.lookup('service:blossom');
|
||||||
|
this.nostrAuth = this.owner.lookup('service:nostrAuth');
|
||||||
|
this.nostrData = this.owner.lookup('service:nostrData');
|
||||||
|
this.nostrRelay = this.owner.lookup('service:nostrRelay');
|
||||||
|
this.toast = this.owner.lookup('service:toast');
|
||||||
|
this.settings = this.owner.lookup('service:settings');
|
||||||
|
|
||||||
|
this.photos = [
|
||||||
|
{
|
||||||
|
eventId: 'event1',
|
||||||
|
pubkey: 'userA',
|
||||||
|
placeIdentifier: 'osm:node:12345',
|
||||||
|
url: 'https://example.com/a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.jpg',
|
||||||
|
thumbUrl:
|
||||||
|
'https://example.com/b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 'event2',
|
||||||
|
pubkey: 'userB',
|
||||||
|
placeIdentifier: 'osm:node:12345',
|
||||||
|
url: 'photo2.jpg',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
hooks.afterEach(function () {
|
||||||
|
sinon.restore();
|
||||||
|
localStorage.removeItem('marco:settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it does not show delete button if user is not creator', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = 'userB'; // Different from photo1's pubkey
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
|
||||||
|
assert.dom('.dropdown-popover').exists('Dropdown opened');
|
||||||
|
assert
|
||||||
|
.dom('.dropdown-item.text-danger')
|
||||||
|
.doesNotExist('Delete button is hidden for non-creator');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it shows delete button if user is creator and setting is enabled', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = 'userA'; // Matches photo1's pubkey
|
||||||
|
this.settings.update('experimentalEnablePhotoDeletion', true); // Enable the setting
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
|
||||||
|
assert.dom('.dropdown-popover').exists('Dropdown opened');
|
||||||
|
assert
|
||||||
|
.dom('.dropdown-item.text-danger')
|
||||||
|
.exists('Delete button is visible for creator when setting is enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles cancellation of deletion', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = 'userA';
|
||||||
|
this.settings.update('experimentalEnablePhotoDeletion', true);
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
const confirmStub = sinon.stub(window, 'confirm').returns(false);
|
||||||
|
const blossomSpy = sinon.spy(this.blossom, 'delete');
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
await click('.dropdown-item.text-danger');
|
||||||
|
|
||||||
|
assert.ok(confirmStub.calledOnce, 'confirmation dialog was shown');
|
||||||
|
assert.ok(blossomSpy.notCalled, 'blossom.delete was NOT called');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it performs full deletion flow when confirmed', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = 'userA';
|
||||||
|
this.settings.update('experimentalEnablePhotoDeletion', true);
|
||||||
|
// Override the mock's getter just for this test
|
||||||
|
Object.defineProperty(this.nostrAuth, 'signer', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => ({
|
||||||
|
signEvent: async (e) => ({
|
||||||
|
...e,
|
||||||
|
id: 'signed-id',
|
||||||
|
sig: 'sig',
|
||||||
|
pubkey: 'userA',
|
||||||
|
}),
|
||||||
|
getPublicKey: async () => 'userA',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
this.handleClose = () => {
|
||||||
|
closed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmStub = sinon.stub(window, 'confirm').returns(true);
|
||||||
|
const blossomStub = sinon.stub(this.blossom, 'delete').resolves();
|
||||||
|
const publishStub = sinon.stub(this.nostrRelay, 'publish').resolves();
|
||||||
|
const storeStub = sinon.stub(this.nostrData.store, 'add');
|
||||||
|
const toastSpy = sinon.spy(this.toast, 'show');
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
@onClose={{this.handleClose}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
await click('.dropdown-item.text-danger');
|
||||||
|
|
||||||
|
assert.ok(confirmStub.calledOnce, 'confirmation dialog was shown');
|
||||||
|
|
||||||
|
// Check blossom deletions
|
||||||
|
assert.ok(
|
||||||
|
blossomStub.calledTwice,
|
||||||
|
'blossom.delete was called twice (main + thumb)'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
blossomStub.firstCall.args[0],
|
||||||
|
'a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
|
||||||
|
'extracted correct hash for main image'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
blossomStub.secondCall.args[0],
|
||||||
|
'b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
|
||||||
|
'extracted correct hash for thumb image'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check Nostr kind 5
|
||||||
|
assert.ok(publishStub.calledOnce, 'nostrRelay.publish was called');
|
||||||
|
const publishedEvent = publishStub.firstCall.args[1];
|
||||||
|
assert.strictEqual(publishedEvent.kind, 5, 'published event is kind 5');
|
||||||
|
assert.deepEqual(
|
||||||
|
publishedEvent.tags[0],
|
||||||
|
['e', 'event1'],
|
||||||
|
'event tags reference the deleted photo'
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
publishedEvent.tags[1],
|
||||||
|
['i', 'osm:node:12345'],
|
||||||
|
'event tags include the place identifier'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check store update
|
||||||
|
assert.ok(storeStub.calledOnce, 'nostrData.store.add was called');
|
||||||
|
assert.strictEqual(
|
||||||
|
storeStub.firstCall.args[0].kind,
|
||||||
|
5,
|
||||||
|
'added kind 5 event to local store'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check UX
|
||||||
|
assert.ok(
|
||||||
|
toastSpy.calledWith('Photo deleted successfully'),
|
||||||
|
'success toast was shown'
|
||||||
|
);
|
||||||
|
assert.ok(closed, 'gallery was closed after deletion');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it copies event id to clipboard', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = 'userA';
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
const clipboardStub = sinon
|
||||||
|
.stub(navigator.clipboard, 'writeText')
|
||||||
|
.resolves();
|
||||||
|
const toastSpy = sinon.spy(this.toast, 'show');
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
|
||||||
|
// Find the copy button (it should be the first one)
|
||||||
|
const items = document.querySelectorAll('.dropdown-item');
|
||||||
|
let copyBtn;
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.textContent.includes('Copy Photo Event ID')) {
|
||||||
|
copyBtn = item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await click(copyBtn);
|
||||||
|
|
||||||
|
assert.ok(clipboardStub.calledWith('event1'), 'copied correct event id');
|
||||||
|
assert.ok(
|
||||||
|
toastSpy.calledWith('Event ID copied to clipboard'),
|
||||||
|
'success toast was shown'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard navigation changes photos', async function (assert) {
|
||||||
|
this.photos = [
|
||||||
|
{ eventId: 'event1', url: 'photo1.jpg' },
|
||||||
|
{ eventId: 'event2', url: 'photo2.jpg' },
|
||||||
|
{ eventId: 'event3', url: 'photo3.jpg' },
|
||||||
|
];
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('.photo-gallery-content')
|
||||||
|
.hasAttribute('data-current-event-id', 'event1');
|
||||||
|
|
||||||
|
// Right Arrow
|
||||||
|
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('.photo-gallery-content')
|
||||||
|
.hasAttribute('data-current-event-id', 'event2');
|
||||||
|
|
||||||
|
// Right Arrow again
|
||||||
|
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('.photo-gallery-content')
|
||||||
|
.hasAttribute('data-current-event-id', 'event3');
|
||||||
|
|
||||||
|
// Left Arrow
|
||||||
|
await triggerKeyEvent(document, 'keydown', 'ArrowLeft');
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('.photo-gallery-content')
|
||||||
|
.hasAttribute('data-current-event-id', 'event2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escape key closes gallery', async function (assert) {
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
let closed = false;
|
||||||
|
this.handleClose = () => {
|
||||||
|
closed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
@onClose={{this.handleClose}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
await triggerKeyEvent(document, 'keydown', 'Escape');
|
||||||
|
assert.ok(closed, 'gallery was closed on escape key');
|
||||||
|
});
|
||||||
|
});
|
||||||
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 { 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([
|
||||||
|
|||||||
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