Compare commits
4 Commits
v1.21.2
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
14827fce3e
|
|||
|
7a109c9ba5
|
|||
|
10aae3c9b3
|
|||
|
b492e2aa89
|
53
app/components/dropdown-menu.gjs
Normal file
53
app/components/dropdown-menu.gjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from '#components/icon';
|
||||
|
||||
export default class DropdownMenu extends Component {
|
||||
@tracked isOpen = false;
|
||||
|
||||
@action
|
||||
toggleMenu(e) {
|
||||
e?.stopPropagation();
|
||||
this.isOpen = !this.isOpen;
|
||||
}
|
||||
|
||||
@action
|
||||
closeMenu(e) {
|
||||
e?.stopPropagation();
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
get triggerIcon() {
|
||||
return this.args.triggerIcon || 'more-vertical';
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="dropdown-menu-container">
|
||||
<button
|
||||
class="dropdown-trigger-btn btn-press"
|
||||
type="button"
|
||||
title={{@triggerTitle}}
|
||||
{{on "click" this.toggleMenu}}
|
||||
>
|
||||
<Icon
|
||||
@name={{this.triggerIcon}}
|
||||
@size={{@iconSize}}
|
||||
@color={{@iconColor}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{{#if this.isOpen}}
|
||||
<div class="dropdown-popover {{@popoverClass}}">
|
||||
{{yield this.closeMenu}}
|
||||
</div>
|
||||
<div
|
||||
class="menu-backdrop"
|
||||
{{on "click" this.closeMenu}}
|
||||
role="button"
|
||||
></div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -33,6 +33,14 @@ export default class PhotoCarousel extends Component {
|
||||
return !this.canScrollRight;
|
||||
}
|
||||
|
||||
get isGalleryMain() {
|
||||
return this.args.variant === 'gallery-main';
|
||||
}
|
||||
|
||||
get isGalleryThumbnails() {
|
||||
return this.args.variant === 'gallery-thumbnails';
|
||||
}
|
||||
|
||||
get variantClass() {
|
||||
return this.args.variant || 'inline';
|
||||
}
|
||||
@@ -205,29 +213,47 @@ export default class PhotoCarousel extends Component {
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if photo.isLandscape}}
|
||||
<picture>
|
||||
{{#if photo.thumbUrl}}
|
||||
<source
|
||||
media="(max-width: 768px)"
|
||||
data-srcset={{photo.thumbUrl}}
|
||||
/>
|
||||
{{/if}}
|
||||
<img
|
||||
data-src={{photo.url}}
|
||||
class="place-header-photo landscape"
|
||||
alt={{@name}}
|
||||
{{fadeInImage photo.url}}
|
||||
/>
|
||||
</picture>
|
||||
{{else}}
|
||||
{{! Portrait uses thumb everywhere if available }}
|
||||
{{#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 portrait"
|
||||
class="place-header-photo
|
||||
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
||||
alt={{@name}}
|
||||
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#if photo.isLandscape}}
|
||||
<picture>
|
||||
{{#if photo.thumbUrl}}
|
||||
<source
|
||||
media="(max-width: 768px)"
|
||||
data-srcset={{photo.thumbUrl}}
|
||||
/>
|
||||
{{/if}}
|
||||
<img
|
||||
data-src={{photo.url}}
|
||||
class="place-header-photo landscape"
|
||||
alt={{@name}}
|
||||
{{fadeInImage photo.url}}
|
||||
/>
|
||||
</picture>
|
||||
{{else}}
|
||||
{{! Portrait uses thumb everywhere if available }}
|
||||
<img
|
||||
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
||||
class="place-header-photo portrait"
|
||||
alt={{@name}}
|
||||
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
@@ -1,13 +1,38 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from './icon';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { EventFactory } from 'applesauce-core';
|
||||
import Icon from '#components/icon';
|
||||
import PhotoCarousel from './photo-carousel';
|
||||
import DropdownMenu from '#components/dropdown-menu';
|
||||
|
||||
export default class PhotoGallery extends Component {
|
||||
@service toast;
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@service nostrRelay;
|
||||
@service blossom;
|
||||
|
||||
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
|
||||
|
||||
get isCreator() {
|
||||
return (
|
||||
this.currentPhoto?.pubkey &&
|
||||
this.nostrAuth.pubkey &&
|
||||
this.currentPhoto.pubkey === this.nostrAuth.pubkey
|
||||
);
|
||||
}
|
||||
|
||||
bindKeyboard = modifier((element, [handler]) => {
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
});
|
||||
|
||||
@action
|
||||
handleClose() {
|
||||
if (this.args.onClose) {
|
||||
@@ -21,7 +46,8 @@ export default class PhotoGallery extends Component {
|
||||
if (
|
||||
e.target.closest('.thumbnail-strip-container') ||
|
||||
e.target.closest('.carousel-nav-btn') ||
|
||||
e.target.closest('.close-btn')
|
||||
e.target.closest('.close-btn') ||
|
||||
e.target.closest('.actions-btn-container')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -41,15 +67,144 @@ export default class PhotoGallery extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleKeydown(e) {
|
||||
if (!this.args.photos || this.args.photos.length === 0) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
this.handleClose();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = this.args.photos.indexOf(this.currentPhoto);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
if (e.key === 'ArrowLeft' && currentIndex > 0) {
|
||||
this.currentPhoto = this.args.photos[currentIndex - 1];
|
||||
} else if (
|
||||
e.key === 'ArrowRight' &&
|
||||
currentIndex < this.args.photos.length - 1
|
||||
) {
|
||||
this.currentPhoto = this.args.photos[currentIndex + 1];
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async copyEventId(closeMenu) {
|
||||
if (this.currentPhoto?.eventId) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.currentPhoto.eventId);
|
||||
this.toast.show('Event ID copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy event ID:', err);
|
||||
this.toast.show('Failed to copy event ID');
|
||||
}
|
||||
}
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
deletePhotoTask = task(async (closeMenu) => {
|
||||
if (
|
||||
!confirm(
|
||||
'Are you sure you want to delete this photo? This cannot be undone.'
|
||||
)
|
||||
) {
|
||||
if (closeMenu) closeMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const eventId = this.currentPhoto.eventId;
|
||||
|
||||
// Publish Nostr kind: 5 deletion event first so we don't end up with dead blossom links on a failure
|
||||
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||
const tags = [['e', eventId]];
|
||||
|
||||
if (this.currentPhoto.placeIdentifier) {
|
||||
tags.push(['i', this.currentPhoto.placeIdentifier]);
|
||||
}
|
||||
|
||||
const template = {
|
||||
kind: 5,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: 'Deleted photo',
|
||||
tags,
|
||||
};
|
||||
|
||||
const event = await factory.sign(template);
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
|
||||
// Remove from local store by adding the kind 5 to it
|
||||
this.nostrData.store.add(event);
|
||||
|
||||
// Now that the event is published, try to delete from Blossom
|
||||
const hashRegex = /[0-9a-f]{64}/i;
|
||||
|
||||
if (this.currentPhoto.url) {
|
||||
const match = this.currentPhoto.url.match(hashRegex);
|
||||
if (match) {
|
||||
try {
|
||||
await this.blossom.delete(match[0]);
|
||||
} catch (e) {
|
||||
console.warn('Failed to delete main image from blossom:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.currentPhoto.thumbUrl) {
|
||||
const match = this.currentPhoto.thumbUrl.match(hashRegex);
|
||||
if (match) {
|
||||
try {
|
||||
await this.blossom.delete(match[0]);
|
||||
} catch (e) {
|
||||
console.warn('Failed to delete thumb image from blossom:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.toast.show('Photo deleted successfully');
|
||||
|
||||
if (closeMenu) closeMenu();
|
||||
this.handleClose();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete photo:', e);
|
||||
this.toast.show('Failed to delete photo: ' + e.message);
|
||||
if (closeMenu) closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="photo-gallery-overlay"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
{{on "click" this.handleBackgroundClick}}
|
||||
{{this.bindKeyboard this.handleKeydown}}
|
||||
>
|
||||
{{! template-lint-disable no-invalid-interactive }}
|
||||
<div class="photo-gallery-content">
|
||||
<div class="actions-btn-container">
|
||||
<DropdownMenu
|
||||
@iconSize={{24}}
|
||||
@triggerIcon="more-horizontal"
|
||||
@iconColor="white"
|
||||
as |closeMenu|
|
||||
>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
type="button"
|
||||
{{on "click" (fn this.copyEventId closeMenu)}}
|
||||
>Copy Photo Event ID</button>
|
||||
{{#if this.isCreator}}
|
||||
<button
|
||||
class="dropdown-item text-danger"
|
||||
type="button"
|
||||
{{on "click" (fn this.deletePhotoTask.perform closeMenu)}}
|
||||
>Delete Photo</button>
|
||||
{{/if}}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="close-btn btn-text"
|
||||
|
||||
@@ -55,10 +55,11 @@ export default class NostrDataService extends Service {
|
||||
this._stopPersisting = persistEventsToCache(
|
||||
this.store,
|
||||
async (events) => {
|
||||
// Only cache profiles, mailboxes, blossom servers, and place photos
|
||||
// Only cache profiles, mailboxes, blossom servers, and place photos, and deletions
|
||||
const toCache = events.filter(
|
||||
(e) =>
|
||||
e.kind === 0 ||
|
||||
e.kind === 5 ||
|
||||
e.kind === 10002 ||
|
||||
e.kind === 10063 ||
|
||||
e.kind === 360
|
||||
@@ -215,7 +216,7 @@ export default class NostrDataService extends Service {
|
||||
|
||||
const cachedEvents = await this.cache.query([
|
||||
{
|
||||
kinds: [360],
|
||||
kinds: [360, 5],
|
||||
'#i': [entityId],
|
||||
},
|
||||
]);
|
||||
@@ -236,7 +237,7 @@ export default class NostrDataService extends Service {
|
||||
this.nostrRelay.pool
|
||||
.request(this.activeReadRelays, [
|
||||
{
|
||||
kinds: [360],
|
||||
kinds: [360, 5],
|
||||
'#i': [entityId],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -2027,3 +2027,63 @@ button.create-place {
|
||||
.photo-carousel.gallery-thumbnails .carousel-nav-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Dropdown Menu Component */
|
||||
.dropdown-menu-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-trigger-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-popover {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 5px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||
padding: 0.5rem 0;
|
||||
z-index: 3001;
|
||||
min-width: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
/* Actions button in photo gallery */
|
||||
.photo-gallery-overlay .actions-btn-container {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import mail from 'feather-icons/dist/icons/mail.svg?raw';
|
||||
import map from 'feather-icons/dist/icons/map.svg?raw';
|
||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||
import moreHorizontal from 'feather-icons/dist/icons/more-horizontal.svg?raw';
|
||||
import moreVertical from 'feather-icons/dist/icons/more-vertical.svg?raw';
|
||||
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
||||
@@ -81,10 +83,6 @@ import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone
|
||||
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
|
||||
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
|
||||
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
|
||||
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
|
||||
import memorialStoneWithInscription from '@waysidemapping/pinhead/dist/icons/memorial_stone_with_inscription.svg?raw';
|
||||
import mobilePhoneWithKeypadAndAntenna from '@waysidemapping/pinhead/dist/icons/mobile_phone_with_keypad_and_antenna.svg?raw';
|
||||
import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
|
||||
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
|
||||
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
|
||||
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
|
||||
@@ -193,11 +191,9 @@ const ICONS = {
|
||||
mail,
|
||||
map,
|
||||
'map-pin': mapPin,
|
||||
'market-stall': marketStall,
|
||||
'memorial-stone-with-inscription': memorialStoneWithInscription,
|
||||
menu,
|
||||
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
|
||||
'molar-tooth': molarTooth,
|
||||
'more-horizontal': moreHorizontal,
|
||||
'more-vertical': moreVertical,
|
||||
navigation,
|
||||
'needle-and-spool-of-thread': needleAndSpoolOfThread,
|
||||
nostrich,
|
||||
|
||||
@@ -38,6 +38,7 @@ export function parsePlacePhotos(events) {
|
||||
let blurhash = null;
|
||||
let isLandscape = false;
|
||||
let aspectRatio = 16 / 9; // default
|
||||
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
|
||||
|
||||
for (const tag of imeta.slice(1)) {
|
||||
if (tag.startsWith('url ')) {
|
||||
@@ -68,6 +69,7 @@ export function parsePlacePhotos(events) {
|
||||
blurhash,
|
||||
isLandscape,
|
||||
aspectRatio,
|
||||
placeIdentifier,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
329
tests/integration/components/photo-gallery-test.gjs
Normal file
329
tests/integration/components/photo-gallery-test.gjs
Normal file
@@ -0,0 +1,329 @@
|
||||
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.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();
|
||||
});
|
||||
|
||||
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">
|
||||
<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', async function (assert) {
|
||||
this.nostrAuth.pubkey = 'userA'; // Matches photo1's pubkey
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<div id="test-container">
|
||||
<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');
|
||||
assert.dom('.dropdown-item.text-danger').hasText('Delete Photo');
|
||||
});
|
||||
|
||||
test('it handles cancellation of deletion', async function (assert) {
|
||||
this.nostrAuth.pubkey = 'userA';
|
||||
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">
|
||||
<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';
|
||||
// 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">
|
||||
<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">
|
||||
<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">
|
||||
<PhotoGallery
|
||||
@photos={{this.photos}}
|
||||
@selectedPhoto={{this.selectedPhoto}}
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
);
|
||||
|
||||
// Let carousel settle
|
||||
|
||||
// Right Arrow
|
||||
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
|
||||
|
||||
// Let's just assert that currentPhoto was updated internally, which trickles down.
|
||||
// The actual DOM update for the main image might be tricky if the carousel relies on scroll events.
|
||||
// We can at least check if the thumbnail selection changed, as that is directly driven by currentPhoto
|
||||
assert
|
||||
.dom('.thumbnail-strip-container .carousel-slide.active img')
|
||||
.hasAttribute('data-src', 'photo2.jpg');
|
||||
|
||||
// Right Arrow again
|
||||
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
|
||||
|
||||
assert
|
||||
.dom('.thumbnail-strip-container .carousel-slide.active img')
|
||||
.hasAttribute('data-src', 'photo3.jpg');
|
||||
|
||||
// Left Arrow
|
||||
await triggerKeyEvent(document, 'keydown', 'ArrowLeft');
|
||||
|
||||
assert
|
||||
.dom('.thumbnail-strip-container .carousel-slide.active img')
|
||||
.hasAttribute('data-src', 'photo2.jpg');
|
||||
});
|
||||
|
||||
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">
|
||||
<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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user