Compare commits

...

25 Commits

Author SHA1 Message Date
14827fce3e Delete own photos
Some checks failed
CI / Lint (pull_request) Successful in 30s
CI / Test (pull_request) Failing after 57s
2026-05-05 12:07:33 +02:00
7a109c9ba5 Fix portrait photos using thumb for full size variant
All checks were successful
CI / Lint (pull_request) Successful in 32s
CI / Test (pull_request) Successful in 55s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-05-05 10:16:12 +02:00
10aae3c9b3 Add photo action for copying event ID 2026-05-05 09:56:12 +02:00
b492e2aa89 Add dropdown component, photo actions menu 2026-05-05 09:49:20 +02:00
4c4a53ae42 1.21.2
All checks were successful
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 55s
2026-05-05 07:18:37 +02:00
a240a5d199 Serve dev on all IPs 2026-05-05 07:17:30 +02:00
0332cf4c3c Merge pull request 'Hide quick-search pills on low zoom levels' (#53) from ui/pills_on_zoom into master
All checks were successful
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 54s
Reviewed-on: #53
2026-05-05 05:14:54 +00:00
59c447fe1f Hide quick-search pills on low zoom levels
All checks were successful
CI / Lint (pull_request) Successful in 30s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Successful in 7s
2026-05-05 07:10:28 +02:00
1140ecfe41 Add buttons for opening signer app, copying connect link
All checks were successful
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 59s
2026-05-05 07:02:08 +02:00
60936ed2f5 1.21.1
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 56s
2026-04-27 21:51:55 +01:00
ca82a029bc Fix app menu section layout 2026-04-27 21:50:47 +01:00
0630aed73d Fix modals 2026-04-27 21:50:36 +01:00
f27a636529 1.21.0
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 58s
2026-04-27 21:26:23 +01:00
995ae95b09 Merge pull request 'Add full-size photo gallery' (#52) from feature/photo-gallery into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 58s
Reviewed-on: #52
2026-04-27 20:24:12 +00:00
0fb320d996 Fix test and linter error
All checks were successful
CI / Lint (pull_request) Successful in 31s
CI / Test (pull_request) Successful in 57s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-04-27 21:20:39 +01:00
4f4ca827b1 Refactor carousel and gallery to share the carousel component
And make the gallery awesome
2026-04-27 21:12:46 +01:00
c1d3f25d50 WIP Add basic photo gallery 2026-04-27 16:45:49 +01:00
2087cfc4f7 Merge pull request 'Various search UI improvements' (#51) from ui/search into master
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 1m0s
Reviewed-on: #51
2026-04-27 14:50:02 +00:00
8572032481 Eliminate race condition in tests
All checks were successful
CI / Lint (pull_request) Successful in 33s
CI / Test (pull_request) Successful in 1m0s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-04-27 15:47:23 +01:00
b4c3f5c88d Improve map centering on mobile
Some checks failed
CI / Lint (pull_request) Successful in 33s
CI / Test (pull_request) Failing after 1m1s
2026-04-27 15:37:13 +01:00
cff19980d5 Refactor search route/loading
* Fetch results asynchronously after app launch
* Hide sidebar and search results when new search is issued
2026-04-27 15:18:17 +01:00
cf251f702b Prevent zoom when opening place from search results 2026-04-27 14:44:35 +01:00
d2eb888dcf Keep search results, only hide sidebar when closed 2026-04-27 14:37:19 +01:00
a0b4a4b3f3 Show toast notification when adding RS account 2026-04-27 12:47:33 +01:00
cb3ee48909 Update rs.js
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 1m0s
2026-04-27 10:32:51 +01:00
43 changed files with 1696 additions and 530 deletions

View File

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

View File

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

View File

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

View File

@@ -108,7 +108,7 @@ export default class AppMenuSettingsNostr extends Component {
<Icon @name="zap" @size={{20}} /> <Icon @name="zap" @size={{20}} />
<span>Nostr</span> <span>Nostr</span>
</summary> </summary>
<div class="details-content"> <div class="details-content form-layout">
<div class="form-group"> <div class="form-group">
<label for="nostr-photo-fallback-uploads">Upload photos to fallback <label for="nostr-photo-fallback-uploads">Upload photos to fallback
servers</label> servers</label>

View File

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

View File

@@ -284,6 +284,7 @@ export default class MapComponent extends Component {
// Initialize the UI service with the map center // Initialize the UI service with the map center
const initialCenter = toLonLat(view.getCenter()); const initialCenter = toLonLat(view.getCenter());
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]); this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
this.mapUi.updateZoom(view.getZoom());
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', { apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', {
webfonts: 'data:text/css,', webfonts: 'data:text/css,',
@@ -782,14 +783,19 @@ export default class MapComponent extends Component {
// Check if mobile (width <= 768px matches CSS) // Check if mobile (width <= 768px matches CSS)
if (size[0] <= 768) { if (size[0] <= 768) {
// On mobile, the bottom 50% is covered by the sheet. // On mobile, the bottom 50% is covered by the sheet.
// We want the pin to be in the center of the TOP 50% (visible area). // We want the pin to be in the center of the TOP 50% (visible area), minus the header.
// That means the pin should be at y = height * 0.25 (25% down from top).
// The map center is at y = height * 0.50.
// So the pin is "above" the center by 25% of the height in pixels.
// To put the pin there, the map center needs to be "below" the pin by that amount.
const height = size[1]; const height = size[1];
const offsetPixels = height * 0.25; // Distance from desired pin pos to map center const headerEl = document.querySelector('.app-header');
const headerHeight = headerEl ? headerEl.offsetHeight : 60;
// Visible area is from headerHeight to height / 2 (bottom sheet covers bottom 50%)
const visibleCenterY = headerHeight + (height / 2 - headerHeight) / 2;
// The map center is at y = height * 0.50.
// So the pin is "above" the center by (height/2 - visibleCenterY) pixels.
// To put the pin there, the map center needs to be "below" the pin by that amount.
const offsetPixels = height / 2 - visibleCenterY; // Distance from desired pin pos to map center
const offsetMapUnits = offsetPixels * resolution; const offsetMapUnits = offsetPixels * resolution;
// Shift center SOUTH (decrease Y). // Shift center SOUTH (decrease Y).
@@ -849,6 +855,9 @@ export default class MapComponent extends Component {
let targetPixelY = pixel[1]; let targetPixelY = pixel[1];
let needsPan = false; let needsPan = false;
const headerEl = document.querySelector('.app-header');
const headerHeight = headerEl ? headerEl.offsetHeight : 60;
// 1. Mobile Bottom Sheet Logic (Screen <= 768px) // 1. Mobile Bottom Sheet Logic (Screen <= 768px)
if (size[0] <= 768) { if (size[0] <= 768) {
const height = size[1]; const height = size[1];
@@ -856,7 +865,7 @@ export default class MapComponent extends Component {
// If in bottom half // If in bottom half
if (pixel[1] > splitPoint) { if (pixel[1] > splitPoint) {
targetPixelY = height * 0.25; // Target: Center of top half targetPixelY = headerHeight + (height / 2 - headerHeight) / 2; // Target: Center of visible area above bottom sheet
needsPan = true; needsPan = true;
} }
} }
@@ -877,11 +886,10 @@ export default class MapComponent extends Component {
// 3. Header Logic (Any screen size) // 3. Header Logic (Any screen size)
// Check if the (potentially new) target Y is under the header // Check if the (potentially new) target Y is under the header
const headerHeight = 60; const minTopDistance = headerHeight + 20; // Provide some padding
const minTopDistance = headerHeight + 20; // 80px
if (targetPixelY < minTopDistance) { if (targetPixelY < minTopDistance) {
targetPixelY = minTopDistance + 30; // Move it to ~110px, clear of header targetPixelY = minTopDistance + 30; // Move it clear of header
needsPan = true; needsPan = true;
} }
@@ -1039,6 +1047,7 @@ export default class MapComponent extends Component {
const view = this.mapInstance.getView(); const view = this.mapInstance.getView();
const center = toLonLat(view.getCenter()); const center = toLonLat(view.getCenter());
this.mapUi.updateCenter(center[1], center[0]); this.mapUi.updateCenter(center[1], center[0]);
this.mapUi.updateZoom(view.getZoom());
// If in creation mode, update the coordinates in the service AND the URL // If in creation mode, update the coordinates in the service AND the URL
if (this.mapUi.isCreating) { if (this.mapUi.isCreating) {
@@ -1144,6 +1153,8 @@ export default class MapComponent extends Component {
this.mapUi.returnToSearch = true; this.mapUi.returnToSearch = true;
} }
this.mapUi.preventNextZoom = true; this.mapUi.preventNextZoom = true;
this.mapUi.selectPlace(place, { preventZoom: true });
this.mapUi.showSidebar();
this.router.transitionTo('place', place); this.router.transitionTo('place', place);
}; };

View File

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

View File

@@ -0,0 +1,298 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { and, eq } from 'ember-truth-helpers';
import Blurhash from './blurhash';
import Icon from './icon';
import fadeInImage from '../modifiers/fade-in-image';
import { on } from '@ember/modifier';
import { modifier } from 'ember-modifier';
export default class PhotoCarousel extends Component {
@tracked canScrollLeft = false;
@tracked canScrollRight = false;
internalEventId = null;
carouselElement = null;
get photos() {
return this.args.photos || [];
}
get showChevrons() {
// Only show chevrons if there's more than one photo
return this.photos.length > 1;
}
get cannotScrollLeft() {
return !this.canScrollLeft;
}
get cannotScrollRight() {
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';
}
lastResetKey = null;
resetScrollPosition = modifier((element, [resetKey]) => {
if (resetKey !== undefined && resetKey !== this.lastResetKey) {
this.lastResetKey = resetKey;
element.scrollLeft = 0;
setTimeout(() => this.updateScrollState(), 50);
}
});
scrollToNewPhoto = modifier((element, [eventId]) => {
if (eventId && eventId !== this.lastEventId) {
const isInitial = !this.lastEventId;
this.lastEventId = eventId;
// Prevent feedback loop if this carousel initiated the change
if (this.internalEventId === eventId) {
return;
}
const scrollAction = () => {
const targetSlide = element.querySelector(
`[data-event-id="${eventId}"]`
);
if (targetSlide) {
if (isInitial) {
const originalScrollBehavior = element.style.scrollBehavior;
element.style.scrollBehavior = 'auto';
element.scrollLeft = targetSlide.offsetLeft;
// Restore smooth scroll after the jump
setTimeout(() => {
element.style.scrollBehavior = originalScrollBehavior;
}, 50);
} else {
// Use native CSS smooth scrolling for subsequent clicks
element.scrollLeft = targetSlide.offsetLeft;
}
}
};
if (isInitial) {
// Execute immediately for the first render to prevent flash
scrollAction();
} else {
// Allow DOM to update first for subsequent clicks
setTimeout(scrollAction, 100);
}
}
});
setupCarousel = modifier((element) => {
this.carouselElement = element;
// Defer the initial calculation slightly to ensure CSS and images have applied
setTimeout(() => {
this.updateScrollState();
}, 50);
let resizeObserver;
if (window.ResizeObserver) {
resizeObserver = new ResizeObserver(() => this.updateScrollState());
resizeObserver.observe(element);
}
let intersectionObserver;
if (this.args.onVisiblePhotoChange && window.IntersectionObserver) {
// Set up intersection observer to track which photo is currently "most" visible
intersectionObserver = new IntersectionObserver(
(entries) => {
for (let entry of entries) {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
const eventId = entry.target.dataset.eventId;
this.internalEventId = eventId;
const photo = this.photos.find((p) => p.eventId === eventId);
if (photo) {
this.args.onVisiblePhotoChange(photo);
}
}
}
},
{
root: element,
threshold: 0.5,
}
);
const slides = element.querySelectorAll('.carousel-slide');
slides.forEach((slide) => intersectionObserver.observe(slide));
}
return () => {
if (resizeObserver) {
resizeObserver.unobserve(element);
}
if (intersectionObserver) {
intersectionObserver.disconnect();
}
};
});
@action
updateScrollState() {
if (!this.carouselElement) return;
const { scrollLeft, scrollWidth, clientWidth } = this.carouselElement;
// tolerance of 1px for floating point rounding issues
this.canScrollLeft = scrollLeft > 1;
this.canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
}
@action
scrollLeft() {
if (!this.carouselElement) return;
this.carouselElement.scrollBy({
left: -this.carouselElement.clientWidth,
behavior: 'smooth',
});
}
@action
scrollRight() {
if (!this.carouselElement) return;
this.carouselElement.scrollBy({
left: this.carouselElement.clientWidth,
behavior: 'smooth',
});
}
@action
handlePhotoClick(photo) {
if (this.args.onPhotoClick) {
this.args.onPhotoClick(photo);
}
}
<template>
{{#if this.photos.length}}
<div class="photo-carousel {{this.variantClass}}">
<div
class="photo-carousel-track"
{{this.setupCarousel}}
{{this.resetScrollPosition @resetKey}}
{{this.scrollToNewPhoto @scrollToEventId}}
{{on "scroll" this.updateScrollState}}
>
{{#each this.photos as |photo|}}
{{! template-lint-disable no-inline-styles no-invalid-interactive }}
<div
class="carousel-slide
{{if photo.isLandscape 'landscape' 'portrait'}}
{{if
(and @scrollToEventId (eq photo.eventId @scrollToEventId))
'active'
}}"
style={{photo.style}}
data-event-id={{photo.eventId}}
{{on "click" (fn this.handlePhotoClick photo)}}
>
{{#if photo.blurhash}}
<Blurhash
@hash={{photo.blurhash}}
@width={{32}}
@height={{18}}
class="place-header-photo-blur"
/>
{{/if}}
{{#if this.isGalleryMain}}
<img
data-src={{photo.url}}
class="place-header-photo
{{if photo.isLandscape 'landscape' 'portrait'}}"
alt={{@name}}
{{fadeInImage photo.url}}
/>
{{else if this.isGalleryThumbnails}}
<img
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
class="place-header-photo
{{if photo.isLandscape 'landscape' 'portrait'}}"
alt={{@name}}
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
/>
{{else}}
{{#if photo.isLandscape}}
<picture>
{{#if photo.thumbUrl}}
<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}}
<div class="carousel-placeholder"></div>
</div>
{{#if this.showChevrons}}
<button
type="button"
class="carousel-nav-btn prev
{{if this.cannotScrollLeft 'disabled'}}"
{{on "click" this.scrollLeft}}
disabled={{this.cannotScrollLeft}}
aria-label="Previous photo"
>
<Icon
@name="chevron-left"
@color="currentColor"
@size={{if (eq @variant "gallery-main") 24 16}}
/>
</button>
<button
type="button"
class="carousel-nav-btn next
{{if this.cannotScrollRight 'disabled'}}"
{{on "click" this.scrollRight}}
disabled={{this.cannotScrollRight}}
aria-label="Next photo"
>
<Icon
@name="chevron-right"
@color="currentColor"
@size={{if (eq @variant "gallery-main") 24 16}}
/>
</button>
{{/if}}
</div>
{{/if}}
</template>
}

View File

@@ -0,0 +1,240 @@
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 { 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) {
this.args.onClose();
}
}
@action
handleBackgroundClick(e) {
// Don't close if clicking on thumbnails, nav buttons, or the close button itself
if (
e.target.closest('.thumbnail-strip-container') ||
e.target.closest('.carousel-nav-btn') ||
e.target.closest('.close-btn') ||
e.target.closest('.actions-btn-container')
) {
return;
}
this.handleClose();
}
@action
selectPhoto(photo) {
this.currentPhoto = photo;
}
@action
handleVisiblePhotoChange(photo) {
if (this.currentPhoto !== photo) {
this.currentPhoto = photo;
}
}
@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"
{{on "click" this.handleClose}}
aria-label="Close gallery"
title="Close"
>
<Icon @name="x" @size={{24}} @color="white" />
</button>
<div class="main-photo-container">
<PhotoCarousel
@variant="gallery-main"
@photos={{@photos}}
@scrollToEventId={{this.currentPhoto.eventId}}
@onVisiblePhotoChange={{this.handleVisiblePhotoChange}}
@name={{@placeName}}
/>
</div>
<div class="thumbnail-strip-container">
<PhotoCarousel
@variant="gallery-thumbnails"
@photos={{@photos}}
@scrollToEventId={{this.currentPhoto.eventId}}
@onPhotoClick={{this.selectPhoto}}
@name={{@placeName}}
/>
</div>
</div>
</div>
</template>
}

View File

@@ -13,7 +13,8 @@ import PlaceListsManager from './place-lists-manager';
import PlacePhotoUpload from './place-photo-upload'; import PlacePhotoUpload from './place-photo-upload';
import NostrConnect from './nostr-connect'; import NostrConnect from './nostr-connect';
import Modal from './modal'; import Modal from './modal';
import PlacePhotosCarousel from './place-photos-carousel'; import PhotoCarousel from './photo-carousel';
import PhotoGallery from './photo-gallery';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object'; import { action } from '@ember/object';
@@ -24,10 +25,13 @@ export default class PlaceDetails extends Component {
@service nostrData; @service nostrData;
@tracked isEditing = false; @tracked isEditing = false;
@tracked showLists = false; @tracked showLists = false;
@tracked isPhotoUploadActive = false;
@tracked isConnectingNostr = false;
@tracked isGalleryOpen = false;
@tracked selectedGalleryPhoto = null;
@tracked isPhotoUploadModalOpen = false; @tracked isPhotoUploadModalOpen = false;
@tracked isNostrConnectModalOpen = false; @tracked isNostrConnectModalOpen = false;
@tracked newlyUploadedPhotoId = null; @tracked newlyUploadedPhotoId = null;
@tracked isPhotoUploadActive = false;
@action @action
handleUploadStateChange(isActive) { handleUploadStateChange(isActive) {
@@ -362,6 +366,18 @@ export default class PlaceDetails extends Component {
return !!this.place.description; return !!this.place.description;
} }
@action
openGallery(photo) {
this.selectedGalleryPhoto = photo;
this.isGalleryOpen = true;
}
@action
closeGallery() {
this.isGalleryOpen = false;
this.selectedGalleryPhoto = null;
}
<template> <template>
<div class="place-details"> <div class="place-details">
{{#if this.isEditing}} {{#if this.isEditing}}
@@ -371,11 +387,13 @@ export default class PlaceDetails extends Component {
@onCancel={{this.cancelEditing}} @onCancel={{this.cancelEditing}}
/> />
{{else}} {{else}}
<PlacePhotosCarousel <PhotoCarousel
@variant="inline"
@photos={{this.photos}} @photos={{this.photos}}
@name={{this.name}} @name={{this.name}}
@resetKey={{this.place.osmId}} @resetKey={{this.place.osmId}}
@scrollToEventId={{this.newlyUploadedPhotoId}} @scrollToEventId={{this.newlyUploadedPhotoId}}
@onPhotoClick={{this.openGallery}}
/> />
<h3>{{this.name}}</h3> <h3>{{this.name}}</h3>
<p class="place-type"> <p class="place-type">
@@ -609,5 +627,14 @@ export default class PlaceDetails extends Component {
<NostrConnect @onConnect={{this.onNostrConnected}} /> <NostrConnect @onConnect={{this.onNostrConnected}} />
</Modal> </Modal>
{{/if}} {{/if}}
{{#if this.isGalleryOpen}}
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedGalleryPhoto}}
@placeName={{this.name}}
@onClose={{this.closeGallery}}
/>
{{/if}}
</template> </template>
} }

View File

@@ -1,189 +0,0 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import Blurhash from './blurhash';
import Icon from './icon';
import fadeInImage from '../modifiers/fade-in-image';
import { on } from '@ember/modifier';
import { modifier } from 'ember-modifier';
export default class PlacePhotosCarousel extends Component {
@tracked canScrollLeft = false;
@tracked canScrollRight = false;
carouselElement = null;
get photos() {
return this.args.photos || [];
}
get showChevrons() {
return this.photos.length > 1;
}
get cannotScrollLeft() {
return !this.canScrollLeft;
}
get cannotScrollRight() {
return !this.canScrollRight;
}
lastResetKey = null;
resetScrollPosition = modifier((element, [resetKey]) => {
if (resetKey !== undefined && resetKey !== this.lastResetKey) {
this.lastResetKey = resetKey;
element.scrollLeft = 0;
setTimeout(() => this.updateScrollState(), 50);
}
});
scrollToNewPhoto = modifier((element, [eventId]) => {
if (eventId && eventId !== this.lastEventId) {
this.lastEventId = eventId;
// Allow DOM to update first since the photo was *just* added to the store
setTimeout(() => {
const targetSlide = element.querySelector(
`[data-event-id="${eventId}"]`
);
if (targetSlide) {
element.scrollLeft = targetSlide.offsetLeft;
}
}, 100);
}
});
setupCarousel = modifier((element) => {
this.carouselElement = element;
// Defer the initial calculation slightly to ensure CSS and images have applied
setTimeout(() => {
this.updateScrollState();
}, 50);
let resizeObserver;
if (window.ResizeObserver) {
resizeObserver = new ResizeObserver(() => this.updateScrollState());
resizeObserver.observe(element);
}
return () => {
if (resizeObserver) {
resizeObserver.unobserve(element);
}
};
});
@action
updateScrollState() {
if (!this.carouselElement) return;
const { scrollLeft, scrollWidth, clientWidth } = this.carouselElement;
// tolerance of 1px for floating point rounding issues
this.canScrollLeft = scrollLeft > 1;
this.canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
}
@action
scrollLeft() {
if (!this.carouselElement) return;
this.carouselElement.scrollBy({
left: -this.carouselElement.clientWidth,
behavior: 'smooth',
});
}
@action
scrollRight() {
if (!this.carouselElement) return;
this.carouselElement.scrollBy({
left: this.carouselElement.clientWidth,
behavior: 'smooth',
});
}
<template>
{{#if this.photos.length}}
<div class="place-photos-carousel-wrapper">
<div
class="place-photos-carousel-track"
{{this.setupCarousel}}
{{this.resetScrollPosition @resetKey}}
{{this.scrollToNewPhoto @scrollToEventId}}
{{on "scroll" this.updateScrollState}}
>
{{#each this.photos as |photo|}}
{{! template-lint-disable no-inline-styles }}
<div
class="carousel-slide
{{if photo.isLandscape 'landscape' 'portrait'}}"
style={{photo.style}}
data-event-id={{photo.eventId}}
>
{{#if photo.blurhash}}
<Blurhash
@hash={{photo.blurhash}}
@width={{32}}
@height={{18}}
class="place-header-photo-blur"
/>
{{/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 }}
<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}}
</div>
{{/each}}
<div class="carousel-placeholder"></div>
</div>
{{#if this.showChevrons}}
<button
type="button"
class="carousel-nav-btn prev
{{if this.cannotScrollLeft 'disabled'}}"
{{on "click" this.scrollLeft}}
disabled={{this.cannotScrollLeft}}
aria-label="Previous photo"
>
<Icon @name="chevron-left" @color="currentColor" />
</button>
<button
type="button"
class="carousel-nav-btn next
{{if this.cannotScrollRight 'disabled'}}"
{{on "click" this.scrollRight}}
disabled={{this.cannotScrollRight}}
aria-label="Next photo"
>
<Icon @name="chevron-right" @color="currentColor" />
</button>
{{/if}}
</div>
{{/if}}
</template>
}

View File

@@ -1,6 +1,16 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { service } from '@ember/service';
import { task } from 'ember-concurrency';
import { getDistance } from '../utils/geo';
export default class SearchController extends Controller { export default class SearchController extends Controller {
@service osm;
@service photon;
@service mapUi;
@service storage;
@service router;
@service toast;
queryParams = ['lat', 'lon', 'q', 'selected', 'category']; queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
lat = null; lat = null;
@@ -8,4 +18,175 @@ export default class SearchController extends Controller {
q = null; q = null;
selected = null; selected = null;
category = null; category = null;
fetchResultsTask = task({ restartable: true }, async (params) => {
// Hide sidebar and clear previous results immediately to signal a new search
this.mapUi.hideSidebar();
this.mapUi.clearSearchResults();
const lat = params.lat ? parseFloat(params.lat) : null;
const lon = params.lon ? parseFloat(params.lon) : null;
let pois = [];
let loadingType = null;
let loadingValue = null;
try {
// Case 0: Category Search (category parameter present)
if (params.category && lat && lon) {
loadingType = 'category';
loadingValue = params.category;
this.mapUi.startLoading(loadingType, loadingValue);
// We need bounds. If we have active map state, use it.
let bounds = this.mapUi.currentBounds;
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
// or just use a fixed box around the center.
if (!bounds) {
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
// Let's take a safe box of ~1km radius.
const delta = 0.01;
bounds = {
minLat: lat - delta,
maxLat: lat + delta,
minLon: lon - delta,
maxLon: lon + delta,
};
}
pois = await this.osm.getCategoryPois(
bounds,
params.category,
lat,
lon
);
// Sort by distance from center
pois = pois
.map((p) => ({
...p,
_distance: getDistance(lat, lon, p.lat, p.lon),
}))
.sort((a, b) => a._distance - b._distance);
}
// Case 1: Text Search (q parameter present)
else if (params.q) {
loadingType = 'text';
loadingValue = params.q;
this.mapUi.startLoading(loadingType, loadingValue);
// Search with Photon (using lat/lon for bias if available)
pois = await this.photon.search(params.q, lat, lon);
// Search local bookmarks by name
const queryLower = params.q.toLowerCase();
const localMatches = this.storage.savedPlaces.filter((p) => {
return (
p.title?.toLowerCase().includes(queryLower) ||
p.description?.toLowerCase().includes(queryLower)
);
});
// Merge local matches
localMatches.forEach((local) => {
const exists = pois.find(
(poi) =>
(local.osmId && poi.osmId === local.osmId) ||
(poi.id && poi.id === local.id)
);
if (!exists) {
pois.push(local);
}
});
}
// Case 2: Nearby Search (lat/lon present, no q)
else if (lat && lon) {
// Nearby search does NOT trigger loading state (pulse is used instead)
const searchRadius = 50; // Default radius
// Fetch POIs from Overpass
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
// Get cached/saved places in search radius
const localMatches = this.storage.savedPlaces.filter((p) => {
const dist = getDistance(lat, lon, p.lat, p.lon);
return dist <= searchRadius;
});
// Merge local matches
localMatches.forEach((local) => {
const exists = pois.find(
(poi) =>
(local.osmId && poi.osmId === local.osmId) ||
(poi.id && poi.id === local.id)
);
if (!exists) {
pois.push(local);
}
});
// Sort by distance from click
pois = pois
.map((p) => {
return {
...p,
_distance: getDistance(lat, lon, p.lat, p.lon),
};
})
.sort((a, b) => a._distance - b._distance);
}
} catch (error) {
console.error('Search request failed.', error);
this.toast.show('Search request failed. Please try again.');
this.mapUi.stopSearch();
return;
} finally {
if (loadingType && loadingValue) {
this.mapUi.stopLoading(loadingType, loadingValue);
}
}
// Check if any of these are already bookmarked
// We resolve them to the bookmark version if they exist
pois = pois.map((p) => {
const saved = this.storage.findPlaceById(p.osmId);
return saved || p;
});
const targetName = params.selected || params.q;
if (targetName && pois.length > 0) {
let matchedPlace = null;
// 1. Exact Name Match
matchedPlace = pois.find(
(p) =>
p.osmTags &&
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
);
// 2. High Proximity Match (<= 10m) - Only if we don't have a name match
// Note: MapComponent had logic for <=20m + type match.
// We might want to pass the 'type' in queryParams if we want to be that precise.
// For now, let's stick to name or very close proximity.
if (!matchedPlace) {
const topCandidate = pois[0];
if (topCandidate._distance <= 10) {
matchedPlace = topCandidate;
}
}
if (matchedPlace) {
// Direct transition!
this.router.replaceWith('place', matchedPlace);
this.mapUi.stopSearch();
return;
}
}
this.mapUi.setSearchResults(pois);
this.mapUi.showSidebar();
this.mapUi.stopSearch();
});
} }

View File

@@ -9,11 +9,28 @@ export default modifier((element, [url]) => {
let observer; let observer;
const hideBlurhash = () => {
const parent = element.parentElement;
const slide =
parent && parent.tagName === 'PICTURE' ? parent.parentElement : parent;
// Only hide the blurhash if we're in the gallery-main view.
// In the inline view, we want to keep the blurhash visible behind portrait photos
// to fill the 16:9 container gracefully.
if (slide && slide.closest('.photo-carousel.gallery-main')) {
const blur = slide.querySelector('.place-header-photo-blur');
if (blur) {
blur.style.opacity = '0';
}
}
};
const handleLoad = () => { const handleLoad = () => {
// Only apply the fade-in animation if it wasn't already loaded instantly // Only apply the fade-in animation if it wasn't already loaded instantly
if (!element.classList.contains('loaded-instant')) { if (!element.classList.contains('loaded-instant')) {
element.classList.add('loaded'); element.classList.add('loaded');
} }
hideBlurhash();
}; };
element.addEventListener('load', handleLoad); element.addEventListener('load', handleLoad);
@@ -33,6 +50,7 @@ export default modifier((element, [url]) => {
if (img.complete) { if (img.complete) {
// Already in browser cache, skip the animation // Already in browser cache, skip the animation
element.classList.add('loaded-instant'); element.classList.add('loaded-instant');
hideBlurhash();
} }
// If this image is inside a <picture> tag, we also need to swap <source> tags // If this image is inside a <picture> tag, we also need to swap <source> tags

View File

@@ -96,6 +96,7 @@ export default class PlaceRoute extends Route {
if (model) { if (model) {
const options = { preventZoom: this.mapUi.preventNextZoom }; const options = { preventZoom: this.mapUi.preventNextZoom };
this.mapUi.selectPlace(model, options); this.mapUi.selectPlace(model, options);
this.mapUi.showSidebar();
this.mapUi.preventNextZoom = false; this.mapUi.preventNextZoom = false;
} }
// Stop the pulse animation if it was running (e.g. redirected from search) // Stop the pulse animation if it was running (e.g. redirected from search)

View File

@@ -22,6 +22,7 @@ export default class PlaceNewRoute extends Route {
this.mapUi.updateCreationCoordinates(model.lat, model.lon); this.mapUi.updateCreationCoordinates(model.lat, model.lon);
} }
this.mapUi.startCreating(); this.mapUi.startCreating();
this.mapUi.showSidebar();
} }
deactivate() { deactivate() {

View File

@@ -1,14 +1,9 @@
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { getDistance } from '../utils/geo';
export default class SearchRoute extends Route { export default class SearchRoute extends Route {
@service osm;
@service photon;
@service mapUi; @service mapUi;
@service storage;
@service router;
@service toast; @service toast;
queryParams = { queryParams = {
@@ -19,186 +14,29 @@ export default class SearchRoute extends Route {
category: { refreshModel: true }, category: { refreshModel: true },
}; };
async model(params) { model(params) {
const lat = params.lat ? parseFloat(params.lat) : null; // Just return params, doing the async fetch in the controller
const lon = params.lon ? parseFloat(params.lon) : null; return params;
let pois = [];
let loadingType = null;
let loadingValue = null;
try {
// Case 0: Category Search (category parameter present)
if (params.category && lat && lon) {
loadingType = 'category';
loadingValue = params.category;
this.mapUi.startLoading(loadingType, loadingValue);
// We need bounds. If we have active map state, use it.
let bounds = this.mapUi.currentBounds;
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
// or just use a fixed box around the center.
if (!bounds) {
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
// Let's take a safe box of ~1km radius.
const delta = 0.01;
bounds = {
minLat: lat - delta,
maxLat: lat + delta,
minLon: lon - delta,
maxLon: lon + delta,
};
}
pois = await this.osm.getCategoryPois(
bounds,
params.category,
lat,
lon
);
// Sort by distance from center
pois = pois
.map((p) => ({
...p,
_distance: getDistance(lat, lon, p.lat, p.lon),
}))
.sort((a, b) => a._distance - b._distance);
}
// Case 1: Text Search (q parameter present)
else if (params.q) {
loadingType = 'text';
loadingValue = params.q;
this.mapUi.startLoading(loadingType, loadingValue);
// Search with Photon (using lat/lon for bias if available)
pois = await this.photon.search(params.q, lat, lon);
// Search local bookmarks by name
const queryLower = params.q.toLowerCase();
const localMatches = this.storage.savedPlaces.filter((p) => {
return (
p.title?.toLowerCase().includes(queryLower) ||
p.description?.toLowerCase().includes(queryLower)
);
});
// Merge local matches
localMatches.forEach((local) => {
const exists = pois.find(
(poi) =>
(local.osmId && poi.osmId === local.osmId) ||
(poi.id && poi.id === local.id)
);
if (!exists) {
pois.push(local);
}
});
}
// Case 2: Nearby Search (lat/lon present, no q)
else if (lat && lon) {
// Nearby search does NOT trigger loading state (pulse is used instead)
const searchRadius = 50; // Default radius
// Fetch POIs from Overpass
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
// Get cached/saved places in search radius
const localMatches = this.storage.savedPlaces.filter((p) => {
const dist = getDistance(lat, lon, p.lat, p.lon);
return dist <= searchRadius;
});
// Merge local matches
localMatches.forEach((local) => {
const exists = pois.find(
(poi) =>
(local.osmId && poi.osmId === local.osmId) ||
(poi.id && poi.id === local.id)
);
if (!exists) {
pois.push(local);
}
});
// Sort by distance from click
pois = pois
.map((p) => {
return {
...p,
_distance: getDistance(lat, lon, p.lat, p.lon),
};
})
.sort((a, b) => a._distance - b._distance);
}
} finally {
if (loadingType && loadingValue) {
this.mapUi.stopLoading(loadingType, loadingValue);
}
}
// Check if any of these are already bookmarked
// We resolve them to the bookmark version if they exist
pois = pois.map((p) => {
const saved = this.storage.findPlaceById(p.osmId);
return saved || p;
});
return pois;
}
afterModel(model, transition) {
const { q, selected } = transition.to.queryParams;
// Heuristic Match Logic (ported from MapComponent)
// If 'selected' is provided (from map click), try to find that specific feature.
// If 'q' is provided (from text search), try to find an exact match to auto-select.
const targetName = selected || q;
if (targetName && model.length > 0) {
let matchedPlace = null;
// 1. Exact Name Match
matchedPlace = model.find(
(p) =>
p.osmTags &&
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
);
// 2. High Proximity Match (<= 10m) - Only if we don't have a name match
// Note: MapComponent had logic for <=20m + type match.
// We might want to pass the 'type' in queryParams if we want to be that precise.
// For now, let's stick to name or very close proximity.
if (!matchedPlace) {
const topCandidate = model[0];
if (topCandidate._distance <= 10) {
matchedPlace = topCandidate;
}
}
if (matchedPlace) {
// Direct transition!
this.router.replaceWith('place', matchedPlace);
return;
}
}
// Stop the pulse animation since search is done (and we are staying here)
this.mapUi.stopSearch();
} }
setupController(controller, model) { setupController(controller, model) {
super.setupController(controller, model); super.setupController(controller, model);
// Ensure pulse is stopped if we reach here
this.mapUi.stopSearch(); // Trigger the background task to fetch results
this.mapUi.setSearchResults(model); controller.fetchResultsTask.perform(model);
// Store current search params to allow "Up" navigation from place details // Store current search params to allow "Up" navigation from place details
const { q, category, lat, lon } = this.paramsFor('search'); const { q, category, lat, lon } = this.paramsFor('search');
this.mapUi.currentSearch = { q, category, lat, lon }; this.mapUi.currentSearch = { q, category, lat, lon };
} }
resetController(controller, isExiting) {
if (isExiting) {
controller.fetchResultsTask.cancelAll();
this.mapUi.stopSearch();
}
}
@action @action
error(error, transition) { error(error, transition) {
this.mapUi.stopSearch(); this.mapUi.stopSearch();
@@ -206,6 +44,6 @@ export default class SearchRoute extends Route {
if (transition) { if (transition) {
transition.abort(); transition.abort();
} }
return false; // Prevent bubble and stop transition return false;
} }
} }

View File

@@ -11,12 +11,22 @@ export default class MapUiService extends Service {
@tracked returnToSearch = false; @tracked returnToSearch = false;
@tracked currentCenter = null; @tracked currentCenter = null;
@tracked currentBounds = null; @tracked currentBounds = null;
@tracked currentZoom = null;
@tracked searchBoxHasFocus = false; @tracked searchBoxHasFocus = false;
@tracked selectionOptions = {}; @tracked selectionOptions = {};
@tracked preventNextZoom = false; @tracked preventNextZoom = false;
@tracked searchResults = []; @tracked searchResults = [];
@tracked currentSearch = null; @tracked currentSearch = null;
@tracked loadingState = null; @tracked loadingState = null;
@tracked isSidebarVisible = false;
showSidebar() {
this.isSidebarVisible = true;
}
hideSidebar() {
this.isSidebarVisible = false;
}
selectPlace(place, options = {}) { selectPlace(place, options = {}) {
this.selectedPlace = place; this.selectedPlace = place;
@@ -72,6 +82,10 @@ export default class MapUiService extends Service {
this.currentCenter = { lat, lon }; this.currentCenter = { lat, lon };
} }
updateZoom(zoom) {
this.currentZoom = zoom;
}
updateBounds(bounds) { updateBounds(bounds) {
this.currentBounds = bounds; this.currentBounds = bounds;
} }

View File

@@ -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],
}, },
]) ])

View File

@@ -11,6 +11,7 @@ import { getLocalizedName } from '../utils/osm';
export default class StorageService extends Service { export default class StorageService extends Service {
@service osm; @service osm;
@service toast;
rs; rs;
widget; widget;
@tracked placesInView = []; @tracked placesInView = [];
@@ -23,10 +24,13 @@ export default class StorageService extends Service {
@tracked connected = false; @tracked connected = false;
@tracked userAddress = null; @tracked userAddress = null;
@tracked isWidgetOpen = false; @tracked isWidgetOpen = false;
isNewConnection = true;
constructor() { constructor() {
super(...arguments); super(...arguments);
this.checkInitialConnectionState();
this.rs = new RemoteStorage({ this.rs = new RemoteStorage({
modules: [Places], modules: [Places],
}); });
@@ -57,6 +61,12 @@ export default class StorageService extends Service {
this.rs.on('connected', () => { this.rs.on('connected', () => {
this.connected = true; this.connected = true;
this.userAddress = this.rs.remote.userAddress; this.userAddress = this.rs.remote.userAddress;
if (this.isNewConnection) {
this.toast.show('Remote storage connected', 3000);
this.isNewConnection = false;
}
this.loadLists(); this.loadLists();
}); });
@@ -72,6 +82,7 @@ export default class StorageService extends Service {
this.loadedPrefixes = []; this.loadedPrefixes = [];
this.lists = []; this.lists = [];
this.initialSyncDone = false; this.initialSyncDone = false;
this.isNewConnection = true;
}); });
this.rs.on('sync-done', () => { this.rs.on('sync-done', () => {
@@ -93,6 +104,31 @@ export default class StorageService extends Service {
}); });
} }
checkInitialConnectionState() {
this.isNewConnection = true;
try {
if (window.localStorage) {
const keys = [
'remotestorage:wireclient',
'remotestorage:dropbox',
'remotestorage:googledrive',
];
for (const key of keys) {
const data = window.localStorage.getItem(key);
if (data) {
const parsed = JSON.parse(data);
if (parsed && parsed.token) {
this.isNewConnection = false;
break;
}
}
}
}
} catch (e) {
console.warn('Failed to check localStorage for existing connection:', e);
}
}
handlePlaceChange(event) { handlePlaceChange(event) {
const { newValue, relativePath } = event; const { newValue, relativePath } = event;

View File

@@ -594,6 +594,9 @@ body {
padding: 0 1.4rem 1rem; padding: 0 1.4rem 1rem;
animation: details-slide-down 0.2s ease-out; animation: details-slide-down 0.2s ease-out;
font-size: 0.9rem; font-size: 0.9rem;
}
.sidebar-content details .details-content.form-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
@@ -890,12 +893,15 @@ abbr[title] {
padding-bottom: 2rem; padding-bottom: 2rem;
} }
.place-photos-carousel-wrapper { .photo-carousel {
position: relative; position: relative;
}
.photo-carousel.inline {
margin: -1rem -1rem 1rem; margin: -1rem -1rem 1rem;
} }
.place-photos-carousel-track { .photo-carousel-track {
display: flex; display: flex;
overflow-x: auto; overflow-x: auto;
scroll-behavior: smooth; scroll-behavior: smooth;
@@ -904,11 +910,12 @@ abbr[title] {
background-color: var(--hover-bg); background-color: var(--hover-bg);
} }
.place-photos-carousel-track::-webkit-scrollbar { .photo-carousel-track::-webkit-scrollbar {
display: none; /* Safari and Chrome */ display: none; /* Safari and Chrome */
} }
.carousel-slide { .carousel-slide {
cursor: pointer;
position: relative; position: relative;
flex: 0 0 100%; flex: 0 0 100%;
scroll-snap-align: start; scroll-snap-align: start;
@@ -984,7 +991,7 @@ abbr[title] {
padding: 0; padding: 0;
} }
.place-photos-carousel-wrapper:hover .carousel-nav-btn:not(.disabled) { .photo-carousel:hover .carousel-nav-btn:not(.disabled) {
opacity: 1; opacity: 1;
} }
@@ -1006,40 +1013,40 @@ abbr[title] {
} }
@media (width <= 768px) { @media (width <= 768px) {
.place-photos-carousel-track { .photo-carousel.inline .photo-carousel-track {
scroll-snap-type: none; scroll-snap-type: none;
gap: 2px; gap: 2px;
background-color: #fff; background-color: #fff;
} }
.carousel-slide { .photo-carousel.inline .carousel-slide {
flex: 0 0 auto; flex: 0 0 auto;
height: 100px; height: 100px;
width: auto; width: auto;
scroll-snap-align: none; scroll-snap-align: none;
} }
.carousel-slide.landscape { .photo-carousel.inline .carousel-slide.landscape {
aspect-ratio: var(--slide-ratio, 16 / 9); aspect-ratio: var(--slide-ratio, 16 / 9);
} }
.carousel-slide.portrait { .photo-carousel.inline .carousel-slide.portrait {
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
} }
.carousel-placeholder { .photo-carousel.inline .carousel-placeholder {
display: block; display: block;
background-color: var(--hover-bg); background-color: var(--hover-bg);
flex: 1 1 0%; flex: 1 1 0%;
min-width: 0; min-width: 0;
} }
.place-header-photo.landscape, .photo-carousel.inline .place-header-photo.landscape,
.place-header-photo.portrait { .photo-carousel.inline .place-header-photo.portrait {
object-fit: cover; object-fit: cover;
} }
.carousel-nav-btn { .photo-carousel.inline .carousel-nav-btn {
display: none; display: none;
} }
} }
@@ -1077,6 +1084,7 @@ abbr[title] {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
text-decoration: none;
} }
.btn:disabled { .btn:disabled {
@@ -1365,10 +1373,10 @@ span.icon {
@media (width <= 768px) { @media (width <= 768px) {
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */ /* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
/* Center Y = (height/2) / 2 = height/4 = 25% */ /* Center Y = (height/2) / 2 = height/4 = 25% + half header height */
.map-container.sidebar-open .map-crosshair { .map-container.sidebar-open .map-crosshair {
left: 50%; /* Reset desktop shift */ left: 50%; /* Reset desktop shift */
top: 25%; top: calc(25% + 30px); /* 30px approx half header height */
} }
} }
@@ -1773,6 +1781,13 @@ button.create-place {
margin-top: 1.5rem; margin-top: 1.5rem;
} }
.mobile-connect-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
.nostr-connect-status { .nostr-connect-status {
margin-top: 1.5rem; margin-top: 1.5rem;
text-align: center; text-align: center;
@@ -1866,3 +1881,209 @@ button.create-place {
.btn-link:hover { .btn-link:hover {
text-decoration: underline; text-decoration: underline;
} }
/* Photo Gallery */
.photo-gallery-overlay {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 90%);
z-index: 9999;
display: flex;
flex-direction: column;
}
.photo-gallery-overlay .photo-gallery-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.photo-gallery-overlay .close-btn {
position: absolute;
top: 0.5rem;
right: 1rem;
width: 48px;
height: 48px;
z-index: 10;
color: white;
background: transparent;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease;
}
@media (width <= 768px) {
.photo-gallery-overlay .close-btn {
right: 0.5rem;
}
}
.photo-gallery-overlay .main-photo-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
overflow: hidden;
}
@media (width <= 768px) {
.photo-gallery-overlay .main-photo-container {
padding: 2rem 0;
}
}
.photo-gallery-overlay .main-photo-container img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* Photo Carousel: Gallery Main Variant */
.photo-carousel.gallery-main {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.photo-carousel.gallery-main .photo-carousel-track {
height: 100%;
background: transparent;
}
.photo-carousel.gallery-main .carousel-slide {
height: 100%;
flex: 0 0 100%;
aspect-ratio: auto;
cursor: default;
}
.photo-carousel.gallery-main .carousel-nav-btn {
width: 48px;
height: 48px;
}
.photo-carousel.gallery-main .place-header-photo-blur,
.photo-carousel.gallery-main .place-header-photo.landscape,
.photo-carousel.gallery-main .place-header-photo.portrait {
object-fit: contain;
}
@media (width <= 768px) {
.photo-carousel.gallery-main .carousel-nav-btn {
display: none;
}
}
/* Photo Carousel: Gallery Thumbnails Variant */
.photo-carousel.gallery-thumbnails {
width: 100%;
height: 100px;
background: rgb(0 0 0 / 50%);
padding-bottom: env(safe-area-inset-bottom, 0); /* Support mobile safe area */
}
.photo-carousel.gallery-thumbnails .photo-carousel-track {
height: 100%;
background: transparent;
gap: 4px;
scroll-snap-type: none;
padding: 0;
}
.photo-carousel.gallery-thumbnails .carousel-slide {
flex: 0 0 auto;
height: 100%;
width: auto;
scroll-snap-align: none;
opacity: 0.6;
transition: opacity 0.2s;
}
.photo-carousel.gallery-thumbnails .carousel-slide.landscape {
aspect-ratio: var(--slide-ratio, 16 / 9);
}
.photo-carousel.gallery-thumbnails .carousel-slide.portrait {
aspect-ratio: 1 / 1;
}
.photo-carousel.gallery-thumbnails .carousel-slide:hover,
.photo-carousel.gallery-thumbnails .carousel-slide.active {
opacity: 1;
}
.photo-carousel.gallery-thumbnails .place-header-photo.landscape,
.photo-carousel.gallery-thumbnails .place-header-photo.portrait {
object-fit: cover;
height: 100%;
}
.photo-carousel.gallery-thumbnails .carousel-nav-btn {
display: none;
}
/* Dropdown Menu Component */
.dropdown-menu-container {
position: relative;
display: inline-block;
}
.dropdown-trigger-btn {
background: transparent;
border: none;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.dropdown-popover {
position: absolute;
top: 100%;
left: 0;
margin-top: 5px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
padding: 0.5rem 0;
z-index: 3001;
min-width: 150px;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.dropdown-item {
background: transparent;
border: none;
padding: 0.5rem 1rem;
text-align: left;
cursor: pointer;
font-size: 0.95rem;
color: #333;
white-space: nowrap;
}
.dropdown-item:hover {
background: #f0f0f0;
}
/* Actions button in photo gallery */
.photo-gallery-overlay .actions-btn-container {
position: absolute;
top: 0.5rem;
left: 0.5rem;
width: 48px;
height: 48px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -18,12 +18,13 @@ export default class ApplicationComponent extends Component {
@tracked isAppMenuOpen = false; @tracked isAppMenuOpen = false;
get isSidebarOpen() { get isSidebarOpen() {
// We consider the sidebar "open" if we are in search or place routes. // We consider the sidebar "open" if we are in search or place routes AND it's visible.
// This helps the map know if it should shift the center or adjust view. // This helps the map know if it should shift the center or adjust view.
return ( return (
this.router.currentRouteName === 'place' || this.mapUi.isSidebarVisible &&
(this.router.currentRouteName === 'place' ||
this.router.currentRouteName === 'place.new' || this.router.currentRouteName === 'place.new' ||
this.router.currentRouteName === 'search' this.router.currentRouteName === 'search')
); );
} }
@@ -48,13 +49,12 @@ export default class ApplicationComponent extends Component {
handleOutsideClick() { handleOutsideClick() {
if (this.isAppMenuOpen) { if (this.isAppMenuOpen) {
this.closeAppMenu(); this.closeAppMenu();
} else if (this.router.currentRouteName === 'search') { } else if (
this.router.transitionTo('index'); this.router.currentRouteName === 'search' ||
} else if (this.router.currentRouteName === 'place') { this.router.currentRouteName === 'place'
// If in place route, decide if we want to go back to search or index ) {
// For now, let's go to index or maybe back to search if search params exist? this.mapUi.clearSelection();
// Simplest behavior: clear selection this.mapUi.hideSidebar();
this.router.transitionTo('index');
} }
} }

View File

@@ -79,6 +79,7 @@ export default class PlaceTemplate extends Component {
if (place === null) { if (place === null) {
// If we have an active search context, return to it (UP navigation) // If we have an active search context, return to it (UP navigation)
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) { if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
this.mapUi.showSidebar();
this.router.transitionTo('search', { this.router.transitionTo('search', {
queryParams: this.mapUi.currentSearch, queryParams: this.mapUi.currentSearch,
}); });
@@ -88,17 +89,19 @@ export default class PlaceTemplate extends Component {
} }
} else { } else {
// If a place is selected (unlikely in this view, but possible if we add related links) // If a place is selected (unlikely in this view, but possible if we add related links)
this.mapUi.showSidebar();
this.router.transitionTo('place', place); this.router.transitionTo('place', place);
} }
} }
@action @action
close() { close() {
// Clear search results so we don't fall back to the list this.mapUi.clearSelection();
this.router.transitionTo('index'); this.mapUi.hideSidebar();
} }
<template> <template>
{{#if this.mapUi.isSidebarVisible}}
<PlacesSidebar <PlacesSidebar
@selectedPlace={{this.place}} @selectedPlace={{this.place}}
@onClose={{this.close}} @onClose={{this.close}}
@@ -106,5 +109,6 @@ export default class PlaceTemplate extends Component {
@onBookmarkChange={{this.refreshMap}} @onBookmarkChange={{this.refreshMap}}
@onUpdate={{this.handleUpdate}} @onUpdate={{this.handleUpdate}}
/> />
{{/if}}
</template> </template>
} }

View File

@@ -56,15 +56,16 @@ export default class PlaceNewTemplate extends Component {
} }
<template> <template>
{{#if this.mapUi.isSidebarVisible}}
<div class="sidebar"> <div class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" /> <h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
New Place</h2> New Place</h2>
<button type="button" class="close-btn" {{on "click" this.close}}><Icon <button
@name="x" type="button"
@size={{20}} class="close-btn"
@color="#333" {{on "click" this.close}}
/></button> ><Icon @name="x" @size={{20}} @color="#333" /></button>
</div> </div>
<div class="sidebar-content"> <div class="sidebar-content">
@@ -79,5 +80,6 @@ export default class PlaceNewTemplate extends Component {
/> />
</div> </div>
</div> </div>
{{/if}}
</template> </template>
} }

View File

@@ -11,6 +11,8 @@ export default class SearchTemplate extends Component {
selectPlace(place) { selectPlace(place) {
if (place) { if (place) {
this.mapUi.returnToSearch = true; this.mapUi.returnToSearch = true;
this.mapUi.showSidebar();
this.mapUi.preventNextZoom = true;
// We don't need to manually set currentSearch here because // We don't need to manually set currentSearch here because
// it was already set in the route's setupController // it was already set in the route's setupController
this.router.transitionTo('place', place); this.router.transitionTo('place', place);
@@ -19,14 +21,16 @@ export default class SearchTemplate extends Component {
@action @action
close() { close() {
this.router.transitionTo('index'); this.mapUi.hideSidebar();
} }
<template> <template>
{{#if this.mapUi.isSidebarVisible}}
<PlacesSidebar <PlacesSidebar
@places={{@model}} @places={{this.mapUi.searchResults}}
@onSelect={{this.selectPlace}} @onSelect={{this.selectPlace}}
@onClose={{this.close}} @onClose={{this.close}}
/> />
{{/if}}
</template> </template>
} }

View File

@@ -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 map from 'feather-icons/dist/icons/map.svg?raw';
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw'; import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
import menu from 'feather-icons/dist/icons/menu.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 navigation from 'feather-icons/dist/icons/navigation.svg?raw';
import phone from 'feather-icons/dist/icons/phone.svg?raw'; import phone from 'feather-icons/dist/icons/phone.svg?raw';
import plus from 'feather-icons/dist/icons/plus.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 industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw'; import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.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 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 openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw'; import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
@@ -116,6 +114,7 @@ import toolbox from '@waysidemapping/pinhead/dist/icons/toolbox.svg?raw';
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw'; import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw'; import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw'; import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
import windingWayWide from '@waysidemapping/pinhead/dist/icons/winding_way_wide.svg?raw';
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw'; import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
import loadingRing from '../icons/270-ring.svg?raw'; import loadingRing from '../icons/270-ring.svg?raw';
@@ -192,11 +191,9 @@ const ICONS = {
mail, mail,
map, map,
'map-pin': mapPin, 'map-pin': mapPin,
'market-stall': marketStall,
'memorial-stone-with-inscription': memorialStoneWithInscription,
menu, menu,
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna, 'more-horizontal': moreHorizontal,
'molar-tooth': molarTooth, 'more-vertical': moreVertical,
navigation, navigation,
'needle-and-spool-of-thread': needleAndSpoolOfThread, 'needle-and-spool-of-thread': needleAndSpoolOfThread,
nostrich, nostrich,
@@ -243,6 +240,7 @@ const ICONS = {
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol, 'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
whatsapp, whatsapp,
wikipedia, wikipedia,
winding_way_wide: windingWayWide,
parking_p: parkingP, parking_p: parkingP,
car, car,
x, x,

View File

@@ -38,6 +38,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 +69,7 @@ export function parsePlacePhotos(events) {
blurhash, blurhash,
isLandscape, isLandscape,
aspectRatio, aspectRatio,
placeIdentifier,
}); });
} }
} }

View File

@@ -109,7 +109,9 @@ export const POI_ICON_RULES = [
{ tags: { amenity: 'arts_center' }, icon: 'comedy-mask-and-tragedy-mask' }, { tags: { amenity: 'arts_center' }, icon: 'comedy-mask-and-tragedy-mask' },
// Historic // Historic
{ tags: { historic: 'canal' }, icon: 'winding_way_wide' },
{ tags: { historic: 'bridge' }, icon: 'bridge' }, { tags: { historic: 'bridge' }, icon: 'bridge' },
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
{ tags: { historic: 'fort' }, icon: 'fort' }, { tags: { historic: 'fort' }, icon: 'fort' },
{ tags: { historic: 'castle' }, icon: 'palace' }, { tags: { historic: 'castle' }, icon: 'palace' },
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' }, { tags: { historic: 'building' }, icon: 'classical-building-with-flag' },

View File

@@ -1,6 +1,6 @@
{ {
"name": "marco", "name": "marco",
"version": "1.20.5", "version": "1.21.2",
"private": true, "private": true,
"description": "Unhosted maps app", "description": "Unhosted maps app",
"repository": { "repository": {
@@ -88,7 +88,7 @@
"qunit": "^2.25.0", "qunit": "^2.25.0",
"qunit-dom": "^3.5.0", "qunit-dom": "^3.5.0",
"remotestorage-widget": "^1.8.1", "remotestorage-widget": "^1.8.1",
"remotestoragejs": "2.0.0-beta.8", "remotestoragejs": "2.0.0-beta.9",
"sinon": "^21.0.1", "sinon": "^21.0.1",
"stylelint": "^16.26.1", "stylelint": "^16.26.1",
"stylelint-config-standard": "^38.0.0", "stylelint-config-standard": "^38.0.0",

37
pnpm-lock.yaml generated
View File

@@ -196,8 +196,8 @@ importers:
specifier: ^1.8.1 specifier: ^1.8.1
version: 1.8.1 version: 1.8.1
remotestoragejs: remotestoragejs:
specifier: 2.0.0-beta.8 specifier: 2.0.0-beta.9
version: 2.0.0-beta.8 version: 2.0.0-beta.9
sinon: sinon:
specifier: ^21.0.1 specifier: ^21.0.1
version: 21.0.1 version: 21.0.1
@@ -1720,9 +1720,6 @@ packages:
'@types/ms@2.1.0': '@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@20.14.0':
resolution: {integrity: sha512-5cHBxFGJx6L4s56Bubp4fglrEpmyJypsqI6RgzMfBHWUJQGWAAi8cWcgetEbZXHYXo9C2Fa4EEds/uSyS4cxmA==}
'@types/node@25.0.7': '@types/node@25.0.7':
resolution: {integrity: sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w==} resolution: {integrity: sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w==}
@@ -1735,9 +1732,6 @@ packages:
'@types/symlink-or-copy@1.2.2': '@types/symlink-or-copy@1.2.2':
resolution: {integrity: sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==} resolution: {integrity: sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==}
'@types/tv4@1.2.33':
resolution: {integrity: sha512-7phCVTXC6Bj50IV1iKOwqGkR4JONJyMbRZnKTSuujv1S/tO9rG5OdCt7BMSjytO+zJmYdn1/I4fd3SH0gtO99g==}
'@types/unist@3.0.3': '@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -4906,8 +4900,8 @@ packages:
remotestorage-widget@1.8.1: remotestorage-widget@1.8.1:
resolution: {integrity: sha512-HxNu2VvIRW3wzkf5fLEzs56ySQ7+YQbRqyp3CKvmw/G+zKhRsmj06HtFoAcm3B14/nJh2SOAv3LyfKuXfUsKPw==} resolution: {integrity: sha512-HxNu2VvIRW3wzkf5fLEzs56ySQ7+YQbRqyp3CKvmw/G+zKhRsmj06HtFoAcm3B14/nJh2SOAv3LyfKuXfUsKPw==}
remotestoragejs@2.0.0-beta.8: remotestoragejs@2.0.0-beta.9:
resolution: {integrity: sha512-rtyHTG2VbtiKTRmbwjponRf5VTPJMcHv/ijNid1zX48C0Z0F8ZCBBfkKD2QCxTQyQvCupkWNy3wuIu4HE+AEng==} resolution: {integrity: sha512-d09ByL7ecbZLMuzl4mQ3SXMFlsCwvvINm6l1CfdR8ylvX9E1nsq44t8gmRxzW6GUS5cwonyYA4FRXYKEhARjTA==}
remove-types@1.0.0: remove-types@1.0.0:
resolution: {integrity: sha512-G7Hk1Q+UJ5DvlNAoJZObxANkBZGiGdp589rVcTW/tYqJWJ5rwfraSnKSQaETN8Epaytw8J40nS/zC7bcHGv36w==} resolution: {integrity: sha512-G7Hk1Q+UJ5DvlNAoJZObxANkBZGiGdp589rVcTW/tYqJWJ5rwfraSnKSQaETN8Epaytw8J40nS/zC7bcHGv36w==}
@@ -5578,9 +5572,6 @@ packages:
underscore@1.13.7: underscore@1.13.7:
resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@7.16.0: undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@@ -5747,8 +5738,8 @@ packages:
web-worker@1.5.0: web-worker@1.5.0:
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
webfinger.js@2.8.2: webfinger.js@3.0.4:
resolution: {integrity: sha512-Zqn9KXkGrD1tVEm029bVUIfmzef2KXs3G7OZrdqehDHtgv9YSxX1oy4RoPoMk2PHWIifwWCA0xwKZOAZqXMpfg==} resolution: {integrity: sha512-5c15N1n4qCm/jGJjUt32mBdPVlSugLbAztIDNBpuDfukGz2E9NhmXPfLikayn2p3kcgEZsI/UOdOwVpxOr8qJA==}
webidl-conversions@7.0.0: webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
@@ -7648,10 +7639,6 @@ snapshots:
'@types/ms@2.1.0': {} '@types/ms@2.1.0': {}
'@types/node@20.14.0':
dependencies:
undici-types: 5.26.5
'@types/node@25.0.7': '@types/node@25.0.7':
dependencies: dependencies:
undici-types: 7.16.0 undici-types: 7.16.0
@@ -7665,8 +7652,6 @@ snapshots:
'@types/symlink-or-copy@1.2.2': {} '@types/symlink-or-copy@1.2.2': {}
'@types/tv4@1.2.33': {}
'@types/unist@3.0.3': {} '@types/unist@3.0.3': {}
'@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.3)': '@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.3)':
@@ -11530,13 +11515,11 @@ snapshots:
remotestorage-widget@1.8.1: {} remotestorage-widget@1.8.1: {}
remotestoragejs@2.0.0-beta.8: remotestoragejs@2.0.0-beta.9:
dependencies: dependencies:
'@types/node': 20.14.0
'@types/tv4': 1.2.33
esm: 3.2.25 esm: 3.2.25
tv4: 1.3.0 tv4: 1.3.0
webfinger.js: 2.8.2 webfinger.js: 3.0.4
xhr2: 0.2.1 xhr2: 0.2.1
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
@@ -12416,8 +12399,6 @@ snapshots:
underscore@1.13.7: {} underscore@1.13.7: {}
undici-types@5.26.5: {}
undici-types@7.16.0: {} undici-types@7.16.0: {}
unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-canonical-property-names-ecmascript@2.0.1: {}
@@ -12569,7 +12550,7 @@ snapshots:
web-worker@1.5.0: {} web-worker@1.5.0: {}
webfinger.js@2.8.2: {} webfinger.js@3.0.4: {}
webidl-conversions@7.0.0: {} webidl-conversions@7.0.0: {}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -39,8 +39,8 @@
<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-Dhq0XoTm.js"></script> <script type="module" crossorigin src="/assets/main-CjxGWim8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CHuW_yI-.css"> <link rel="stylesheet" crossorigin href="/assets/main-M5C-HUrg.css">
</head> </head>
<body> <body>
</body> </body>

View File

@@ -140,12 +140,14 @@ module('Acceptance | map search reset', function (hooks) {
bubbles: true, bubbles: true,
}); });
// Wait for transition to index // Wait for transition or UI update
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
assert.strictEqual(
currentURL(), // Sidebar should be hidden, but we should stay on the search route
'/', assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
'Should have transitioned to index (closed sidebar)' assert.ok(
currentURL().includes('category=coffee'),
'Should have stayed on the search route with markers intact'
); );
// Second Click (Start new search) // Second Click (Start new search)

View File

@@ -95,8 +95,8 @@ module('Acceptance | navigation', function (hooks) {
// Click the Close (X) button // Click the Close (X) button
await click('.close-btn'); await click('.close-btn');
assert.strictEqual(currentURL(), '/', 'Returned to index'); assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
assert.false(mapUi.returnToSearch, 'Flag is reset after closing sidebar'); assert.ok(currentURL().includes('/place/'), 'Remains on place route');
}); });
test('navigating directly to place and back closes sidebar', async function (assert) { test('navigating directly to place and back closes sidebar', async function (assert) {

View File

@@ -1,17 +1,32 @@
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { visit, click, fillIn, currentURL } from '@ember/test-helpers'; import { visit, click, fillIn, currentURL, settled } from '@ember/test-helpers';
import { setupApplicationTest } from 'marco/tests/helpers'; import { setupApplicationTest } from 'marco/tests/helpers';
import Service from '@ember/service'; import Service from '@ember/service';
import { Promise } from 'rsvp'; import { Promise } from 'rsvp';
let photonResolve;
let osmResolve;
class MockPhotonService extends Service { class MockPhotonService extends Service {
cancelAll() {} cancelAll() {}
async search(query) { async search(query) {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 50));
if (query === 'slow') { if (query === 'slow') {
await new Promise((resolve) => setTimeout(resolve, 200)); // Return a promise that we can manually resolve in the test
// to avoid race conditions with native setTimeout
return new Promise((resolve) => {
photonResolve = () => {
resolve([
{
title: 'Test Place',
lat: 1,
lon: 1,
osmId: '123',
osmType: 'node',
},
]);
};
});
} }
return [ return [
{ {
@@ -29,9 +44,12 @@ class MockOsmService extends Service {
cancelAll() {} cancelAll() {}
async getCategoryPois(bounds, category) { async getCategoryPois(bounds, category) {
await new Promise((resolve) => setTimeout(resolve, 50));
if (category === 'slow_category') { if (category === 'slow_category') {
await new Promise((resolve) => setTimeout(resolve, 200)); return new Promise((resolve) => {
osmResolve = () => {
resolve([]);
};
});
} }
return []; return [];
} }
@@ -44,6 +62,8 @@ module('Acceptance | search loading', function (hooks) {
setupApplicationTest(hooks); setupApplicationTest(hooks);
hooks.beforeEach(function () { hooks.beforeEach(function () {
photonResolve = null;
osmResolve = null;
this.owner.register('service:photon', MockPhotonService); this.owner.register('service:photon', MockPhotonService);
this.owner.register('service:osm', MockOsmService); this.owner.register('service:osm', MockOsmService);
}); });
@@ -66,7 +86,12 @@ module('Acceptance | search loading', function (hooks) {
'Loading state is set for text search' 'Loading state is set for text search'
); );
// Resolve the manual promise so the task can finish deterministically
photonResolve();
await searchPromise; await searchPromise;
await settled(); // Wait for ember-concurrency tasks to fully settle
assert.strictEqual( assert.strictEqual(
mapUi.loadingState, mapUi.loadingState,
null, null,
@@ -83,7 +108,12 @@ module('Acceptance | search loading', function (hooks) {
'Loading state is set for category search' 'Loading state is set for category search'
); );
// Resolve the manual promise
osmResolve();
await catPromise; await catPromise;
await settled();
assert.strictEqual( assert.strictEqual(
mapUi.loadingState, mapUi.loadingState,
null, null,
@@ -122,6 +152,7 @@ module('Acceptance | search loading', function (hooks) {
// 4. Click the clear button (should be visible since input has value) // 4. Click the clear button (should be visible since input has value)
await click('.search-clear-btn'); await click('.search-clear-btn');
// Wait for the click and transition to settle
// Verify loading state is cleared immediately // Verify loading state is cleared immediately
assert.strictEqual( assert.strictEqual(
@@ -130,6 +161,11 @@ module('Acceptance | search loading', function (hooks) {
'Loading state is cleared immediately after clicking clear' 'Loading state is cleared immediately after clicking clear'
); );
// Clean up the dangling promise
if (photonResolve) {
photonResolve();
}
// Verify we are back on index (or at least query is gone) // Verify we are back on index (or at least query is gone)
assert.strictEqual(currentURL(), '/', 'Navigated to index'); assert.strictEqual(currentURL(), '/', 'Navigated to index');
}); });

View File

@@ -1,20 +1,20 @@
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers'; import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers'; import { render, click } from '@ember/test-helpers';
import PlacePhotosCarousel from 'marco/components/place-photos-carousel'; import PhotoCarousel from 'marco/components/photo-carousel';
module('Integration | Component | place-photos-carousel', function (hooks) { module('Integration | Component | photo-carousel', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
test('it renders gracefully with no photos', async function (assert) { test('it renders gracefully with no photos', async function (assert) {
this.photos = []; this.photos = [];
await render( await render(
<template><PlacePhotosCarousel @photos={{this.photos}} /></template> <template><PhotoCarousel @photos={{this.photos}} /></template>
); );
assert assert
.dom('.place-photos-carousel-wrapper') .dom('.photo-carousel')
.doesNotExist('it does not render the wrapper when there are no photos'); .doesNotExist('it does not render the wrapper when there are no photos');
}); });
@@ -32,14 +32,12 @@ module('Integration | Component | place-photos-carousel', function (hooks) {
await render( await render(
<template> <template>
<div class="test-container"> <div class="test-container">
<PlacePhotosCarousel @photos={{this.photos}} /> <PhotoCarousel @photos={{this.photos}} />
</div> </div>
</template> </template>
); );
assert assert.dom('.photo-carousel').exists('it renders the wrapper');
.dom('.place-photos-carousel-wrapper')
.exists('it renders the wrapper');
assert assert
.dom('.carousel-slide:not(.carousel-placeholder)') .dom('.carousel-slide:not(.carousel-placeholder)')
.exists({ count: 1 }, 'it renders one real photo slide'); .exists({ count: 1 }, 'it renders one real photo slide');
@@ -84,7 +82,7 @@ module('Integration | Component | place-photos-carousel', function (hooks) {
await render( await render(
<template> <template>
<div class="test-container"> <div class="test-container">
<PlacePhotosCarousel @photos={{this.photos}} /> <PhotoCarousel @photos={{this.photos}} />
</div> </div>
</template> </template>
); );

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

View File

@@ -36,6 +36,8 @@ module('Unit | Route | place', function (hooks) {
selectPlaceCalled = true; selectPlaceCalled = true;
} }
stopSearch() {} stopSearch() {}
showSidebar() {}
hideSidebar() {}
} }
this.owner.register('service:osm', OsmStub); this.owner.register('service:osm', OsmStub);
@@ -76,6 +78,8 @@ module('Unit | Route | place', function (hooks) {
class MapUiStub extends Service { class MapUiStub extends Service {
selectPlace() {} selectPlace() {}
stopSearch() {} stopSearch() {}
showSidebar() {}
hideSidebar() {}
} }
this.owner.register('service:osm', OsmStub); this.owner.register('service:osm', OsmStub);
@@ -110,6 +114,8 @@ module('Unit | Route | place', function (hooks) {
class MapUiStub extends Service { class MapUiStub extends Service {
selectPlace() {} selectPlace() {}
stopSearch() {} stopSearch() {}
showSidebar() {}
hideSidebar() {}
} }
this.owner.register('service:osm', OsmStub); this.owner.register('service:osm', OsmStub);
@@ -155,6 +161,8 @@ module('Unit | Route | place', function (hooks) {
assert.ok(options.preventZoom, 'Prevented zoom on update'); assert.ok(options.preventZoom, 'Prevented zoom on update');
} }
stopSearch() {} stopSearch() {}
showSidebar() {}
hideSidebar() {}
} }
this.owner.register('service:storage', StorageStub); this.owner.register('service:storage', StorageStub);

View File

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