Compare commits

...

54 Commits

Author SHA1 Message Date
632efeeab5 1.21.3
All checks were successful
CI / Lint (push) Successful in 34s
CI / Test (push) Successful in 57s
2026-05-08 11:43:57 +02:00
deeea9961f Merge pull request 'Add photo actions, fix portrait photos using thumb URLs' (#54) from feature/photo_actions into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 56s
Reviewed-on: #54
2026-05-05 09:49:25 +00:00
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
1d022b21bd 1.20.5
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 56s
2026-04-25 11:10:44 +01:00
3e831a7686 Enable relay pings
This should re-establish relay connections when PWAs are being woken up
after background sleep
2026-04-25 11:08:55 +01:00
2943125dbd 1.20.4
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 57s
2026-04-25 10:55:20 +01:00
a32ad7572b Add more marker icons
Some checks failed
CI / Lint (push) Successful in 32s
CI / Test (push) Has been cancelled
2026-04-25 10:53:53 +01:00
a1b3957c83 Fix photo icon in map markers 2026-04-25 10:42:05 +01:00
9f2f233c22 Adjust JPEG quality for large photos 2026-04-25 10:41:39 +01:00
1ba4afdf08 1.20.3
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 57s
2026-04-24 13:55:07 +01:00
d764134513 Remove superfluous publishing status alert 2026-04-24 13:53:40 +01:00
e38f540c79 1.20.2
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 58s
2026-04-24 12:28:08 +01:00
73ad5b4eb1 Disable closing modal during photo upload 2026-04-24 12:24:19 +01:00
b4a70233cf Show detailed photo upload status
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 56s
2026-04-24 11:56:37 +01:00
cb4b9c6b40 Render portrait thumbnails as squares on mobile
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 56s
A bit too small otherwise
2026-04-24 11:01:58 +01:00
98dcb4f25b 1.20.1
All checks were successful
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 57s
2026-04-23 09:23:41 +01:00
7709634a9a Merge pull request 'Clear Nostr event cache from Settings' (#47) from feature/clear_nostr_cache into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 57s
Reviewed-on: #47
2026-04-23 08:22:02 +00:00
3ddc85669f Clear nostr event cache from Settings
All checks were successful
CI / Lint (pull_request) Successful in 32s
CI / Test (pull_request) Successful in 57s
Release Drafter / Update release notes draft (pull_request) Successful in 7s
2026-04-23 09:19:25 +01:00
95961e680f Add rationale for kind numbers
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 58s
2026-04-22 15:37:45 +04:00
9468a6a0cc Revise photos NIP draft
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 58s
2026-04-22 15:31:24 +04:00
c9465c8fa8 1.20.0
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 57s
2026-04-22 13:33:55 +04:00
6c5c1fea27 Merge pull request 'Connect Nostr, integrate place photos' (#45) from feature/nostr_place_reviews into master
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 1m0s
Reviewed-on: #45
2026-04-22 09:31:59 +00:00
fe41369754 Reset scroll position when switching between places
All checks were successful
CI / Lint (pull_request) Successful in 36s
CI / Test (pull_request) Successful in 1m3s
Release Drafter / Update release notes draft (pull_request) Successful in 7s
2026-04-22 13:10:11 +04:00
1498c5a713 Improve dropzone size 2026-04-22 13:02:12 +04:00
b6e2964f8e Show placeholder on mobile when photos not filling space 2026-04-22 12:40:45 +04:00
d1d179bb93 Lazy-load place photos
Only preload photos in view as well as the next one(s), not all of them
2026-04-22 12:02:44 +04:00
b83a16bf13 Use button element for add-photo link 2026-04-22 11:32:57 +04:00
c853418fbb Fix auto-scroll to new photo on mobile 2026-04-22 11:32:37 +04:00
4fed8c05c5 Change routing to always use OSM IDs except for custom places
Also implements a short term cache for OSM place data, so we can load it
multiple times without multiplying network requests where needed
2026-04-22 11:01:32 +04:00
670128cbda Immediately render newly uploaded photo and scroll to it 2026-04-22 10:38:06 +04:00
d8fa30c74b Revert to single photo per upload and event
See NIP changes for reasoning. It also keeps the UI a bit cleaner and
we don't have to queue processing on mobile for mass uploads.
2026-04-22 10:18:47 +04:00
51 changed files with 1679 additions and 630 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

@@ -12,6 +12,7 @@ const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
export default class AppMenuSettingsNostr extends Component { export default class AppMenuSettingsNostr extends Component {
@service settings; @service settings;
@service nostrData; @service nostrData;
@service toast;
@tracked newReadRelay = ''; @tracked newReadRelay = '';
@tracked newWriteRelay = ''; @tracked newWriteRelay = '';
@@ -90,6 +91,16 @@ export default class AppMenuSettingsNostr extends Component {
this.settings.update('nostrWriteRelays', null); this.settings.update('nostrWriteRelays', null);
} }
@action
async clearCache() {
try {
await this.nostrData.clearCache();
this.toast.show('Nostr cache cleared');
} catch (e) {
this.toast.show(`Failed to clear Nostr cache: ${e.message}`);
}
}
<template> <template>
{{! template-lint-disable no-nested-interactive }} {{! template-lint-disable no-nested-interactive }}
<details> <details>
@@ -97,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>
@@ -213,6 +224,18 @@ export default class AppMenuSettingsNostr extends Component {
</button> </button>
{{/if}} {{/if}}
</div> </div>
<div class="form-group">
<label>Cached data</label>
<button
type="button"
class="btn btn-outline btn-full"
{{on "click" this.clearCache}}
>
<Icon @name="database" @size={{18}} @color="var(--danger-color)" />
Clear profiles, photos, and reviews
</button>
</div>
</div> </div>
</details> </details>
</template> </template>

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

@@ -11,6 +11,7 @@ export default class Modal extends Component {
@action @action
close() { close() {
if (this.args.disableClose) return;
if (this.args.onClose) { if (this.args.onClose) {
this.args.onClose(); this.args.onClose();
} }
@@ -31,10 +32,11 @@ export default class Modal extends Component {
> >
<button <button
type="button" type="button"
class="close-modal-btn btn-text" class="close-modal-btn btn-text {{if @disableClose 'disabled'}}"
disabled={{@disableClose}}
{{on "click" this.close}} {{on "click" this.close}}
> >
<Icon @name="x" @size={{24}} /> <Icon @name="x" @size={{24}} @color="currentColor" />
</button> </button>
{{yield}} {{yield}}
</div> </div>

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,124 @@
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 { fn } from '@ember/helper';
import Icon from '#components/icon';
import PhotoCarousel from './photo-carousel';
import DropdownMenu from '#components/dropdown-menu';
export default class PhotoGallery extends Component {
@service toast;
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
@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
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();
}
<template>
<div
class="photo-gallery-overlay"
role="dialog"
tabindex="-1"
{{on "click" this.handleBackgroundClick}}
>
{{! 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>
<button
class="dropdown-item"
type="button"
{{on "click" closeMenu}}
>Report Photo</button>
</DropdownMenu>
</div>
<button
type="button"
class="close-btn btn-text"
{{on "click" this.handleClose}}
aria-label="Close gallery"
title="Close"
>
<Icon @name="x" @size={{24}} @color="white" />
</button>
<div class="main-photo-container">
<PhotoCarousel
@variant="gallery-main"
@photos={{@photos}}
@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,8 +25,18 @@ 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;
@action
handleUploadStateChange(isActive) {
this.isPhotoUploadActive = isActive;
}
@action @action
openPhotoUploadModal(e) { openPhotoUploadModal(e) {
@@ -40,8 +51,20 @@ export default class PlaceDetails extends Component {
} }
@action @action
closePhotoUploadModal() { closePhotoUploadModal(eventId) {
if (this.isPhotoUploadActive) return;
this.isPhotoUploadModalOpen = false; this.isPhotoUploadModalOpen = false;
if (typeof eventId === 'string') {
this.newlyUploadedPhotoId = eventId;
// Allow DOM to update first, then scroll to the top to show the new photo in the carousel
setTimeout(() => {
const sidebar = document.querySelector('.sidebar-content');
if (sidebar) {
sidebar.scrollTop = 0;
}
}, 50);
}
} }
@action @action
@@ -343,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}}
@@ -352,7 +387,14 @@ export default class PlaceDetails extends Component {
@onCancel={{this.cancelEditing}} @onCancel={{this.cancelEditing}}
/> />
{{else}} {{else}}
<PlacePhotosCarousel @photos={{this.photos}} @name={{this.name}} /> <PhotoCarousel
@variant="inline"
@photos={{this.photos}}
@name={{this.name}}
@resetKey={{this.place.osmId}}
@scrollToEventId={{this.newlyUploadedPhotoId}}
@onPhotoClick={{this.openGallery}}
/>
<h3>{{this.name}}</h3> <h3>{{this.name}}</h3>
<p class="place-type"> <p class="place-type">
{{this.type}} {{this.type}}
@@ -552,11 +594,15 @@ export default class PlaceDetails extends Component {
{{#if this.osmUrl}} {{#if this.osmUrl}}
<div class="meta-info"> <div class="meta-info">
<p class="content-with-icon"> <p class="content-with-icon">
<Icon @name="camera" /> <Icon @name="feather-camera" />
<span> <span>
<a href="#" {{on "click" this.openPhotoUploadModal}}> <button
type="button"
class="btn-link"
{{on "click" this.openPhotoUploadModal}}
>
Add a photo Add a photo
</a> </button>
</span> </span>
</p> </p>
</div> </div>
@@ -564,10 +610,14 @@ export default class PlaceDetails extends Component {
</div> </div>
{{#if this.isPhotoUploadModalOpen}} {{#if this.isPhotoUploadModalOpen}}
<Modal @onClose={{this.closePhotoUploadModal}}> <Modal
@onClose={{this.closePhotoUploadModal}}
@disableClose={{this.isPhotoUploadActive}}
>
<PlacePhotoUpload <PlacePhotoUpload
@place={{this.saveablePlace}} @place={{this.saveablePlace}}
@onClose={{this.closePhotoUploadModal}} @onClose={{this.closePhotoUploadModal}}
@onUploadStateChange={{this.handleUploadStateChange}}
/> />
</Modal> </Modal>
{{/if}} {{/if}}
@@ -577,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

@@ -7,9 +7,10 @@ import Icon from '#components/icon';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
import { fn } from '@ember/helper'; import { fn } from '@ember/helper';
import { isMobile } from '../utils/device'; import { isMobile } from '../utils/device';
import Blurhash from './blurhash';
const MAX_IMAGE_DIMENSION = 1920; const MAX_IMAGE_DIMENSION = 1920;
const IMAGE_QUALITY = 0.94; const IMAGE_QUALITY = 0.9;
const MAX_THUMBNAIL_DIMENSION = 350; const MAX_THUMBNAIL_DIMENSION = 350;
const THUMBNAIL_QUALITY = 0.9; const THUMBNAIL_QUALITY = 0.9;
@@ -19,7 +20,9 @@ export default class PlacePhotoUploadItem extends Component {
@service toast; @service toast;
@tracked thumbnailUrl = ''; @tracked thumbnailUrl = '';
@tracked blurhash = '';
@tracked error = ''; @tracked error = '';
@tracked statusText = '';
constructor() { constructor() {
super(...arguments); super(...arguments);
@@ -45,6 +48,7 @@ export default class PlacePhotoUploadItem extends Component {
uploadTask = task(async (file) => { uploadTask = task(async (file) => {
this.error = ''; this.error = '';
this.statusText = 'Processing';
try { try {
// 1. Process main image and generate blurhash in worker // 1. Process main image and generate blurhash in worker
const mainData = await this.imageProcessor.process( const mainData = await this.imageProcessor.process(
@@ -54,6 +58,8 @@ export default class PlacePhotoUploadItem extends Component {
true // computeBlurhash true // computeBlurhash
); );
this.blurhash = mainData.blurhash;
// 2. Process thumbnail (no blurhash needed) // 2. Process thumbnail (no blurhash needed)
const thumbData = await this.imageProcessor.process( const thumbData = await this.imageProcessor.process(
file, file,
@@ -67,18 +73,34 @@ export default class PlacePhotoUploadItem extends Component {
let mainResult, thumbResult; let mainResult, thumbResult;
const isMobileDevice = isMobile(); const isMobileDevice = isMobile();
const mainProgress = (status) => {
if (status === 'signing') this.statusText = 'Signing photo upload';
if (status === 'uploading') this.statusText = 'Uploading photo';
};
const thumbProgress = (status) => {
if (status === 'signing') this.statusText = 'Signing thumbnail upload';
if (status === 'uploading') this.statusText = 'Uploading thumbnail';
};
if (isMobileDevice) { if (isMobileDevice) {
// Mobile: sequential uploads to preserve bandwidth and memory // Mobile: sequential uploads to preserve bandwidth and memory
mainResult = await this.blossom.upload(mainData.blob, { mainResult = await this.blossom.upload(mainData.blob, {
sequential: true, sequential: true,
onProgress: mainProgress,
}); });
thumbResult = await this.blossom.upload(thumbData.blob, { thumbResult = await this.blossom.upload(thumbData.blob, {
sequential: true, sequential: true,
onProgress: thumbProgress,
}); });
} else { } else {
// Desktop: concurrent uploads // Desktop: concurrent uploads
const mainUploadPromise = this.blossom.upload(mainData.blob); const mainUploadPromise = this.blossom.upload(mainData.blob, {
const thumbUploadPromise = this.blossom.upload(thumbData.blob); onProgress: mainProgress,
});
const thumbUploadPromise = this.blossom.upload(thumbData.blob, {
onProgress: thumbProgress,
});
[mainResult, thumbResult] = await Promise.all([ [mainResult, thumbResult] = await Promise.all([
mainUploadPromise, mainUploadPromise,
@@ -110,6 +132,9 @@ export default class PlacePhotoUploadItem extends Component {
{{if this.uploadTask.isRunning 'is-uploading'}} {{if this.uploadTask.isRunning 'is-uploading'}}
{{if this.error 'has-error'}}" {{if this.error 'has-error'}}"
> >
{{#if this.blurhash}}
<Blurhash @hash={{this.blurhash}} class="place-header-photo-blur" />
{{/if}}
<img src={{this.thumbnailUrl}} alt="thumbnail" /> <img src={{this.thumbnailUrl}} alt="thumbnail" />
{{#if this.uploadTask.isRunning}} {{#if this.uploadTask.isRunning}}
@@ -120,6 +145,9 @@ export default class PlacePhotoUploadItem extends Component {
@color="white" @color="white"
class="spin-animation" class="spin-animation"
/> />
{{#if this.statusText}}
<span class="upload-status-text">{{this.statusText}}</span>
{{/if}}
</div> </div>
{{/if}} {{/if}}

View File

@@ -17,9 +17,8 @@ export default class PlacePhotoUpload extends Component {
@service blossom; @service blossom;
@service toast; @service toast;
@tracked files = []; @tracked file = null;
@tracked uploadedPhotos = []; @tracked uploadedPhoto = null;
@tracked status = '';
@tracked error = ''; @tracked error = '';
@tracked isPublishing = false; @tracked isPublishing = false;
@tracked isDragging = false; @tracked isDragging = false;
@@ -34,17 +33,13 @@ export default class PlacePhotoUpload extends Component {
get allUploaded() { get allUploaded() {
return ( return (
this.files.length > 0 && this.files.length === this.uploadedPhotos.length this.file && this.uploadedPhoto && this.file === this.uploadedPhoto.file
); );
} }
get photoWord() {
return this.files.length === 1 ? 'Photo' : 'Photos';
}
@action @action
handleFileSelect(event) { handleFileSelect(event) {
this.addFiles(event.target.files); this.addFile(event.target.files[0]);
event.target.value = ''; // Reset input event.target.value = ''; // Reset input
} }
@@ -64,32 +59,42 @@ export default class PlacePhotoUpload extends Component {
handleDrop(event) { handleDrop(event) {
event.preventDefault(); event.preventDefault();
this.isDragging = false; this.isDragging = false;
this.addFiles(event.dataTransfer.files); if (event.dataTransfer.files.length > 0) {
this.addFile(event.dataTransfer.files[0]);
}
} }
addFiles(fileList) { addFile(file) {
if (!fileList) return; if (!file || !file.type.startsWith('image/')) {
const newFiles = Array.from(fileList).filter((f) => this.error = 'Please select a valid image file.';
f.type.startsWith('image/') return;
); }
this.files = [...this.files, ...newFiles]; this.error = '';
// If a photo was already uploaded but not published, delete it from the server
if (this.uploadedPhoto) {
this.deletePhotoTask.perform(this.uploadedPhoto);
}
this.file = file;
this.uploadedPhoto = null;
if (this.args.onUploadStateChange) {
this.args.onUploadStateChange(true);
}
} }
@action @action
handleUploadSuccess(photoData) { handleUploadSuccess(photoData) {
this.uploadedPhotos = [...this.uploadedPhotos, photoData]; this.uploadedPhoto = photoData;
} }
@action @action
removeFile(fileToRemove) { removeFile() {
const photoData = this.uploadedPhotos.find((p) => p.file === fileToRemove); if (this.uploadedPhoto) {
this.files = this.files.filter((f) => f !== fileToRemove); this.deletePhotoTask.perform(this.uploadedPhoto);
this.uploadedPhotos = this.uploadedPhotos.filter( }
(p) => p.file !== fileToRemove this.file = null;
); this.uploadedPhoto = null;
if (this.args.onUploadStateChange) {
if (photoData && photoData.hash && photoData.url) { this.args.onUploadStateChange(false);
this.deletePhotoTask.perform(photoData);
} }
} }
@@ -126,7 +131,6 @@ export default class PlacePhotoUpload extends Component {
return; return;
} }
this.status = 'Publishing event...';
this.error = ''; this.error = '';
this.isPublishing = true; this.isPublishing = true;
@@ -142,34 +146,33 @@ export default class PlacePhotoUpload extends Component {
tags.push(['g', Geohash.encode(lat, lon, 9)]); tags.push(['g', Geohash.encode(lat, lon, 9)]);
} }
for (const photo of this.uploadedPhotos) { const photo = this.uploadedPhoto;
const imeta = ['imeta', `url ${photo.url}`]; const imeta = ['imeta', `url ${photo.url}`];
imeta.push(`m ${photo.type}`); imeta.push(`m ${photo.type}`);
if (photo.dim) { if (photo.dim) {
imeta.push(`dim ${photo.dim}`); imeta.push(`dim ${photo.dim}`);
}
imeta.push('alt A photo of a place');
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
for (const fallbackUrl of photo.fallbackUrls) {
imeta.push(`fallback ${fallbackUrl}`);
}
}
if (photo.thumbUrl) {
imeta.push(`thumb ${photo.thumbUrl}`);
}
if (photo.blurhash) {
imeta.push(`blurhash ${photo.blurhash}`);
}
tags.push(imeta);
} }
imeta.push('alt A photo of a place');
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
for (const fallbackUrl of photo.fallbackUrls) {
imeta.push(`fallback ${fallbackUrl}`);
}
}
if (photo.thumbUrl) {
imeta.push(`thumb ${photo.thumbUrl}`);
}
if (photo.blurhash) {
imeta.push(`blurhash ${photo.blurhash}`);
}
tags.push(imeta);
// NIP-XX draft Place Photo event // NIP-XX draft Place Photo event
const template = { const template = {
kind: 360, kind: 360,
@@ -185,19 +188,21 @@ export default class PlacePhotoUpload extends Component {
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event); await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
this.nostrData.store.add(event); this.nostrData.store.add(event);
this.toast.show('Photos published successfully'); this.toast.show('Photo published successfully');
this.status = '';
// Clear out the files so user can upload more or be done // Clear out the file so user can upload more or be done
this.files = []; this.file = null;
this.uploadedPhotos = []; this.uploadedPhoto = null;
if (this.args.onUploadStateChange) {
this.args.onUploadStateChange(false);
}
if (this.args.onClose) { if (this.args.onClose) {
this.args.onClose(); this.args.onClose(event.id);
} }
} catch (e) { } catch (e) {
this.error = 'Failed to publish: ' + e.message; this.error = 'Failed to publish: ' + e.message;
this.status = '';
} finally { } finally {
this.isPublishing = false; this.isPublishing = false;
} }
@@ -205,7 +210,7 @@ export default class PlacePhotoUpload extends Component {
<template> <template>
<div class="place-photo-upload"> <div class="place-photo-upload">
<h2>Add Photos for {{this.title}}</h2> <h2>Add Photo for {{this.title}}</h2>
{{#if this.error}} {{#if this.error}}
<div class="alert alert-error"> <div class="alert alert-error">
@@ -213,42 +218,13 @@ export default class PlacePhotoUpload extends Component {
</div> </div>
{{/if}} {{/if}}
{{#if this.status}} {{#if this.file}}
<div class="alert alert-info">
{{this.status}}
</div>
{{/if}}
<div
class="dropzone {{if this.isDragging 'is-dragging'}}"
{{on "dragover" this.handleDragOver}}
{{on "dragleave" this.handleDragLeave}}
{{on "drop" this.handleDrop}}
>
<label for="photo-upload-input" class="dropzone-label">
<Icon @name="upload-cloud" @size={{48}} @color="#ccc" />
<p>Drag and drop photos here, or click to browse</p>
</label>
<input
id="photo-upload-input"
type="file"
accept="image/*"
multiple
class="file-input-hidden"
disabled={{this.isPublishing}}
{{on "change" this.handleFileSelect}}
/>
</div>
{{#if this.files.length}}
<div class="photo-grid"> <div class="photo-grid">
{{#each this.files as |file|}} <PlacePhotoUploadItem
<PlacePhotoUploadItem @file={{this.file}}
@file={{file}} @onSuccess={{this.handleUploadSuccess}}
@onSuccess={{this.handleUploadSuccess}} @onRemove={{this.removeFile}}
@onRemove={{this.removeFile}} />
/>
{{/each}}
</div> </div>
<button <button
@@ -260,11 +236,29 @@ export default class PlacePhotoUpload extends Component {
{{#if this.isPublishing}} {{#if this.isPublishing}}
Publishing... Publishing...
{{else}} {{else}}
Publish Publish Photo
{{this.files.length}}
{{this.photoWord}}
{{/if}} {{/if}}
</button> </button>
{{else}}
<div
class="dropzone {{if this.isDragging 'is-dragging'}}"
{{on "dragover" this.handleDragOver}}
{{on "dragleave" this.handleDragLeave}}
{{on "drop" this.handleDrop}}
>
<label for="photo-upload-input" class="dropzone-label">
<Icon @name="upload-cloud" @size={{48}} @color="#ccc" />
<p>Drag and drop a photo here, or click to browse</p>
</label>
<input
id="photo-upload-input"
type="file"
accept="image/*"
class="file-input-hidden"
disabled={{this.isPublishing}}
{{on "change" this.handleFileSelect}}
/>
</div>
{{/if}} {{/if}}
</div> </div>
</template> </template>

View File

@@ -1,155 +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;
}
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}}
{{on "scroll" this.updateScrollState}}
>
{{#each this.photos as |photo|}}
{{! template-lint-disable no-inline-styles }}
<div class="carousel-slide" style={{photo.style}}>
{{#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)"
srcset={{photo.thumbUrl}}
/>
{{/if}}
<img
src={{photo.url}}
class="place-header-photo landscape"
alt={{@name}}
{{fadeInImage photo.url}}
/>
</picture>
{{else}}
{{! Portrait uses thumb everywhere if available }}
<img
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>
{{#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

@@ -7,24 +7,87 @@ export default modifier((element, [url]) => {
element.classList.remove('loaded'); element.classList.remove('loaded');
element.classList.remove('loaded-instant'); element.classList.remove('loaded-instant');
// Create an off-DOM image to reliably check cache status let observer;
// without waiting for the actual DOM element to load it
const img = new Image();
img.src = url;
if (img.complete) { const hideBlurhash = () => {
// Already in browser cache, skip the animation const parent = element.parentElement;
element.classList.add('loaded-instant'); const slide =
return; 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 = () => {
element.classList.add('loaded'); // Only apply the fade-in animation if it wasn't already loaded instantly
if (!element.classList.contains('loaded-instant')) {
element.classList.add('loaded');
}
hideBlurhash();
}; };
element.addEventListener('load', handleLoad); element.addEventListener('load', handleLoad);
const loadWhenVisible = (entries, obs) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// Stop observing once we start loading
obs.unobserve(element);
// Check if the image is already in the browser cache
// Create an off-DOM image to reliably check cache status
// without waiting for the actual DOM element to load it
const img = new Image();
img.src = url;
if (img.complete) {
// Already in browser cache, skip the animation
element.classList.add('loaded-instant');
hideBlurhash();
}
// If this image is inside a <picture> tag, we also need to swap <source> tags
const parent = element.parentElement;
if (parent && parent.tagName === 'PICTURE') {
const sources = parent.querySelectorAll('source');
sources.forEach((source) => {
if (source.dataset.srcset) {
source.srcset = source.dataset.srcset;
}
});
}
// Swap data-src to src to trigger the actual network fetch (or render from cache)
if (element.dataset.src) {
element.src = element.dataset.src;
} else {
// Fallback if data-src wasn't used but the modifier was called
element.src = url;
}
}
});
};
// Setup Intersection Observer to only load when the image enters the viewport
observer = new IntersectionObserver(loadWhenVisible, {
root: null, // Use the viewport as the root
rootMargin: '100px 100%', // Load one full viewport width ahead/behind
threshold: 0, // Trigger immediately when any part enters the expanded margin
});
observer.observe(element);
return () => { return () => {
element.removeEventListener('load', handleLoad); element.removeEventListener('load', handleLoad);
if (observer) {
observer.disconnect();
}
}; };
}); });

View File

@@ -9,25 +9,47 @@ export default class PlaceRoute extends Route {
async model(params) { async model(params) {
const id = params.place_id; const id = params.place_id;
let type, osmId;
let isExplicitOsm = false;
if ( if (
id.startsWith('osm:node:') || id.startsWith('osm:node:') ||
id.startsWith('osm:way:') || id.startsWith('osm:way:') ||
id.startsWith('osm:relation:') id.startsWith('osm:relation:')
) { ) {
const [, type, osmId] = id.split(':'); isExplicitOsm = true;
[, type, osmId] = id.split(':');
console.debug(`Fetching explicit OSM ${type}:`, osmId); console.debug(`Fetching explicit OSM ${type}:`, osmId);
return this.loadOsmPlace(osmId, type); }
let backgroundFetchPromise = null;
if (isExplicitOsm) {
backgroundFetchPromise = this.loadOsmPlace(osmId, type);
} }
await this.waitForSync(); await this.waitForSync();
let bookmark = this.storage.findPlaceById(id); let lookupId = isExplicitOsm ? osmId : id;
let bookmark = this.storage.findPlaceById(lookupId);
// Ensure type matches if we are looking up by osmId
if (bookmark && isExplicitOsm && bookmark.osmType !== type) {
bookmark = null; // Type mismatch, not the same OSM object
}
if (bookmark) { if (bookmark) {
console.debug('Found in bookmarks:', bookmark.title); console.debug('Found in bookmarks:', bookmark.title);
return bookmark; return bookmark;
} }
if (isExplicitOsm) {
console.debug(
`Not in bookmarks, using explicitly fetched OSM ${type}:`,
osmId
);
return await backgroundFetchPromise;
}
console.warn('Not in bookmarks:', id); console.warn('Not in bookmarks:', id);
return null; return null;
} }
@@ -74,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)
@@ -119,14 +142,14 @@ export default class PlaceRoute extends Route {
} }
serialize(model) { serialize(model) {
// If the model is a saved bookmark, use its ID // If it's an OSM POI, use the explicit format first
if (model.id) {
return { place_id: model.id };
}
// If it's an OSM POI, use the explicit format
if (model.osmId && model.osmType) { if (model.osmId && model.osmType) {
return { place_id: `osm:${model.osmType}:${model.osmId}` }; return { place_id: `osm:${model.osmType}:${model.osmId}` };
} }
// If the model is a saved bookmark (and not OSM, e.g. custom place), use its ID
if (model.id) {
return { place_id: model.id };
}
// Fallback // Fallback
return { place_id: model.osmId }; return { place_id: model.osmId };
} }

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

@@ -60,10 +60,13 @@ export default class BlossomService extends Service {
return `Nostr ${base64url}`; return `Nostr ${base64url}`;
} }
async _uploadToServer(file, hash, serverUrl) { async _uploadToServer(file, hash, serverUrl, onProgress) {
const uploadUrl = getBlossomUrl(serverUrl, 'upload'); const uploadUrl = getBlossomUrl(serverUrl, 'upload');
if (onProgress) onProgress('signing');
const authHeader = await this._getAuthHeader('upload', hash, serverUrl); const authHeader = await this._getAuthHeader('upload', hash, serverUrl);
if (onProgress) onProgress('uploading');
// eslint-disable-next-line warp-drive/no-external-request-patterns // eslint-disable-next-line warp-drive/no-external-request-patterns
const response = await fetch(uploadUrl, { const response = await fetch(uploadUrl, {
method: 'PUT', method: 'PUT',
@@ -109,14 +112,20 @@ export default class BlossomService extends Service {
if (options.sequential) { if (options.sequential) {
// Sequential upload logic // Sequential upload logic
mainResult = await this._uploadToServer(file, payloadHash, mainServer); mainResult = await this._uploadToServer(
file,
payloadHash,
mainServer,
options.onProgress
);
for (const serverUrl of fallbackServers) { for (const serverUrl of fallbackServers) {
try { try {
const result = await this._uploadToServer( const result = await this._uploadToServer(
file, file,
payloadHash, payloadHash,
serverUrl serverUrl,
options.onProgress
); );
fallbackUrls.push(result.url); fallbackUrls.push(result.url);
} catch (error) { } catch (error) {
@@ -125,9 +134,14 @@ export default class BlossomService extends Service {
} }
} else { } else {
// Concurrent upload logic // Concurrent upload logic
const mainPromise = this._uploadToServer(file, payloadHash, mainServer); const mainPromise = this._uploadToServer(
file,
payloadHash,
mainServer,
options.onProgress
);
const fallbackPromises = fallbackServers.map((serverUrl) => const fallbackPromises = fallbackServers.map((serverUrl) =>
this._uploadToServer(file, payloadHash, serverUrl) this._uploadToServer(file, payloadHash, serverUrl, options.onProgress)
); );
// Main server MUST succeed // Main server MUST succeed

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

@@ -356,6 +356,13 @@ export default class NostrDataService extends Service {
return 'Not connected'; return 'Not connected';
} }
async clearCache() {
await this._cachePromise;
if (this.cache) {
await this.cache.deleteAllEvents();
}
}
_cleanupSubscriptions() { _cleanupSubscriptions() {
if (this._requestSub) { if (this._requestSub) {
this._requestSub.unsubscribe(); this._requestSub.unsubscribe();

View File

@@ -2,7 +2,7 @@ import Service from '@ember/service';
import { RelayPool } from 'applesauce-relay'; import { RelayPool } from 'applesauce-relay';
export default class NostrRelayService extends Service { export default class NostrRelayService extends Service {
pool = new RelayPool(); pool = new RelayPool({ enablePing: true });
async publish(relays, event) { async publish(relays, event) {
if (!relays || relays.length === 0) { if (!relays || relays.length === 0) {

View File

@@ -8,6 +8,7 @@ export default class OsmService extends Service {
controller = null; controller = null;
cachedResults = null; cachedResults = null;
lastQueryKey = null; lastQueryKey = null;
cachedPlaces = new Map();
cancelAll() { cancelAll() {
if (this.controller) { if (this.controller) {
@@ -232,6 +233,13 @@ out center;
async fetchOsmObject(osmId, osmType) { async fetchOsmObject(osmId, osmType) {
if (!osmId || !osmType) return null; if (!osmId || !osmType) return null;
const cacheKey = `${osmType}:${osmId}`;
const cached = this.cachedPlaces.get(cacheKey);
if (cached && Date.now() - cached.timestamp < 10000) {
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
return cached.data;
}
let url; let url;
if (osmType === 'node') { if (osmType === 'node') {
url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`; url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`;
@@ -253,8 +261,25 @@ out center;
} }
throw new Error(`OSM API request failed: ${res.status}`); throw new Error(`OSM API request failed: ${res.status}`);
} }
const data = await res.json(); const data = await res.json();
return this.normalizeOsmApiData(data.elements, osmId, osmType); const normalizedData = this.normalizeOsmApiData(
data.elements,
osmId,
osmType
);
this.cachedPlaces.set(cacheKey, {
data: normalizedData,
timestamp: Date.now(),
});
// Cleanup cache entry automatically after 10 seconds
setTimeout(() => {
this.cachedPlaces.delete(cacheKey);
}, 10000);
return normalizedData;
} catch (e) { } catch (e) {
console.error('Failed to fetch OSM object:', e); console.error('Failed to fetch OSM object:', e);
return null; return null;

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

@@ -215,12 +215,12 @@ body {
.dropzone { .dropzone {
border: 2px dashed #ccc; border: 2px dashed #ccc;
border-radius: 8px; border-radius: 8px;
padding: 2rem 1.5rem;
text-align: center; text-align: center;
transition: all 0.2s ease; transition: all 0.2s ease;
margin: 1.5rem 0 1rem; margin: 1.5rem 0 1rem;
background-color: rgb(255 255 255 / 2%); background-color: rgb(255 255 255 / 2%);
cursor: pointer; cursor: pointer;
aspect-ratio: 4 / 3;
} }
.dropzone.is-dragging { .dropzone.is-dragging {
@@ -232,9 +232,12 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
gap: 1rem; gap: 1rem;
cursor: pointer; cursor: pointer;
color: #898989; color: #898989;
width: 100%;
height: 100%;
} }
.dropzone-label p { .dropzone-label p {
@@ -246,25 +249,35 @@ body {
} }
.photo-grid { .photo-grid {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); flex-direction: column;
gap: 12px; gap: 12px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.photo-upload-item { .photo-upload-item {
position: relative; position: relative;
aspect-ratio: 1 / 1; aspect-ratio: 4 / 3;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
background: #1e262e; background: #1e262e;
width: 100%;
} }
.photo-upload-item img { .photo-upload-item img {
position: absolute;
top: 0;
left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: contain;
display: block; display: block;
z-index: 1;
}
.photo-upload-item .overlay,
.photo-upload-item .btn-remove-photo {
z-index: 2;
} }
.photo-upload-item .overlay { .photo-upload-item .overlay {
@@ -272,10 +285,20 @@ body {
inset: 0; inset: 0;
background: rgb(0 0 0 / 60%); background: rgb(0 0 0 / 60%);
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.upload-status-text {
color: white;
margin-top: 1rem;
font-size: 0.9rem;
text-shadow: 0 1px 3px rgb(0 0 0 / 80%);
text-align: center;
padding: 0 1rem;
}
.photo-upload-item .error-overlay { .photo-upload-item .error-overlay {
background: rgb(224 108 117 / 80%); background: rgb(224 108 117 / 80%);
cursor: pointer; cursor: pointer;
@@ -571,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;
@@ -761,12 +787,19 @@ select.form-control {
border-top: 1px solid #eee; border-top: 1px solid #eee;
} }
.meta-info a { .meta-info a,
.meta-info .btn-link {
color: var(--link-color); color: var(--link-color);
text-decoration: none; text-decoration: none;
background: none;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
} }
.meta-info a:hover { .meta-info a:hover,
.meta-info .btn-link:hover {
text-decoration: underline; text-decoration: underline;
} }
@@ -860,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;
@@ -874,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;
@@ -889,6 +926,10 @@ abbr[title] {
overflow: hidden; overflow: hidden;
} }
.carousel-placeholder {
display: none;
}
.place-header-photo-blur { .place-header-photo-blur {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -950,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;
} }
@@ -972,25 +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;
} }
.carousel-slide { .photo-carousel.inline .carousel-slide {
flex: 0 0 auto; flex: 0 0 auto;
height: 100px; height: 100px;
width: calc(100px * var(--slide-ratio, 1.7778)); width: auto;
aspect-ratio: auto;
scroll-snap-align: none; scroll-snap-align: none;
} }
.place-header-photo.landscape, .photo-carousel.inline .carousel-slide.landscape {
.place-header-photo.portrait { aspect-ratio: var(--slide-ratio, 16 / 9);
}
.photo-carousel.inline .carousel-slide.portrait {
aspect-ratio: 1 / 1;
}
.photo-carousel.inline .carousel-placeholder {
display: block;
background-color: var(--hover-bg);
flex: 1 1 0%;
min-width: 0;
}
.photo-carousel.inline .place-header-photo.landscape,
.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;
} }
} }
@@ -1028,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 {
@@ -1316,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 */
} }
} }
@@ -1724,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;
@@ -1766,6 +1830,12 @@ button.create-place {
top: 1rem; top: 1rem;
right: 1rem; right: 1rem;
cursor: pointer; cursor: pointer;
color: #898989;
}
.close-modal-btn.disabled {
color: #ccc;
cursor: not-allowed;
} }
.place-photo-upload h2 { .place-photo-upload h2 {
@@ -1784,11 +1854,6 @@ button.create-place {
color: #c00; color: #c00;
} }
.alert-info {
background: #eef;
color: #00c;
}
.preview-group { .preview-group {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -1816,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.new' || (this.router.currentRouteName === 'place' ||
this.router.currentRouteName === 'search' this.router.currentRouteName === 'place.new' ||
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,23 +89,26 @@ 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>
<PlacesSidebar {{#if this.mapUi.isSidebarVisible}}
@selectedPlace={{this.place}} <PlacesSidebar
@onClose={{this.close}} @selectedPlace={{this.place}}
@onSelect={{this.navigateBack}} @onClose={{this.close}}
@onBookmarkChange={{this.refreshMap}} @onSelect={{this.navigateBack}}
@onUpdate={{this.handleUpdate}} @onBookmarkChange={{this.refreshMap}}
/> @onUpdate={{this.handleUpdate}}
/>
{{/if}}
</template> </template>
} }

View File

@@ -56,28 +56,30 @@ export default class PlaceNewTemplate extends Component {
} }
<template> <template>
<div class="sidebar"> {{#if this.mapUi.isSidebarVisible}}
<div class="sidebar-header"> <div class="sidebar">
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" /> <div class="sidebar-header">
New Place</h2> <h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
<button type="button" class="close-btn" {{on "click" this.close}}><Icon New Place</h2>
@name="x" <button
@size={{20}} type="button"
@color="#333" class="close-btn"
/></button> {{on "click" this.close}}
</div> ><Icon @name="x" @size={{20}} @color="#333" /></button>
</div>
<div class="sidebar-content"> <div class="sidebar-content">
<p class="helper-text"> <p class="helper-text">
Drag the map to position the crosshair. Drag the map to position the crosshair.
</p> </p>
<PlaceEditForm <PlaceEditForm
@place={{this.initialPlace}} @place={{this.initialPlace}}
@onSave={{this.savePlace}} @onSave={{this.savePlace}}
@onCancel={{this.close}} @onCancel={{this.close}}
/> />
</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>
<PlacesSidebar {{#if this.mapUi.isSidebarVisible}}
@places={{@model}} <PlacesSidebar
@onSelect={{this.selectPlace}} @places={{this.mapUi.searchResults}}
@onClose={{this.close}} @onSelect={{this.selectPlace}}
/> @onClose={{this.close}}
/>
{{/if}}
</template> </template>
} }

View File

@@ -2,11 +2,12 @@
import activity from 'feather-icons/dist/icons/activity.svg?raw'; import activity from 'feather-icons/dist/icons/activity.svg?raw';
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw'; import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw'; import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
import camera from 'feather-icons/dist/icons/camera.svg?raw'; import featherCamera from 'feather-icons/dist/icons/camera.svg?raw';
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw'; import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw'; import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw'; import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw';
import clock from 'feather-icons/dist/icons/clock.svg?raw'; import clock from 'feather-icons/dist/icons/clock.svg?raw';
import database from 'feather-icons/dist/icons/database.svg?raw';
import edit from 'feather-icons/dist/icons/edit.svg?raw'; import edit from 'feather-icons/dist/icons/edit.svg?raw';
import facebook from 'feather-icons/dist/icons/facebook.svg?raw'; import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
import gift from 'feather-icons/dist/icons/gift.svg?raw'; import gift from 'feather-icons/dist/icons/gift.svg?raw';
@@ -21,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';
@@ -44,7 +47,9 @@ import badgeShieldWithFire from '@waysidemapping/pinhead/dist/icons/badge_shield
import beachUmbrellaInGround from '@waysidemapping/pinhead/dist/icons/beach_umbrella_in_ground.svg?raw'; import beachUmbrellaInGround from '@waysidemapping/pinhead/dist/icons/beach_umbrella_in_ground.svg?raw';
import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_foam.svg?raw'; import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_foam.svg?raw';
import burgerAndDrinkCupWithStraw from '@waysidemapping/pinhead/dist/icons/burger_and_drink_cup_with_straw.svg?raw'; import burgerAndDrinkCupWithStraw from '@waysidemapping/pinhead/dist/icons/burger_and_drink_cup_with_straw.svg?raw';
import bridge from '@waysidemapping/pinhead/dist/icons/bridge.svg?raw';
import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw'; import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw'; import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw'; import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw'; import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
@@ -75,12 +80,9 @@ import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw'; import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw';
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw'; import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw'; import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.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';
@@ -102,6 +104,7 @@ import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_str
import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw'; import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw';
import scissorsOpen from '@waysidemapping/pinhead/dist/icons/scissors_open.svg?raw'; import scissorsOpen from '@waysidemapping/pinhead/dist/icons/scissors_open.svg?raw';
import shipwreckInWater from '@waysidemapping/pinhead/dist/icons/shipwreck_in_water.svg?raw'; import shipwreckInWater from '@waysidemapping/pinhead/dist/icons/shipwreck_in_water.svg?raw';
import steamTrainOnRailwayTrack from '@waysidemapping/pinhead/dist/icons/steam_train_on_railway_track.svg?raw';
import shoppingBag from '@waysidemapping/pinhead/dist/icons/shopping_bag.svg?raw'; import shoppingBag from '@waysidemapping/pinhead/dist/icons/shopping_bag.svg?raw';
import shoppingBasket from '@waysidemapping/pinhead/dist/icons/shopping_basket.svg?raw'; import shoppingBasket from '@waysidemapping/pinhead/dist/icons/shopping_basket.svg?raw';
import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?raw'; import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?raw';
@@ -111,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';
@@ -131,8 +135,10 @@ const ICONS = {
bookmark, bookmark,
'boxing-glove-up': boxingGloveUp, 'boxing-glove-up': boxingGloveUp,
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw, 'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
bridge,
bus, bus,
camera, camera,
'feather-camera': featherCamera,
'check-square': checkSquare, 'check-square': checkSquare,
'chevron-left': chevronLeft, 'chevron-left': chevronLeft,
'chevron-right': chevronRight, 'chevron-right': chevronRight,
@@ -153,6 +159,7 @@ const ICONS = {
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask, 'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
croissant, croissant,
'cup-and-saucer': cupAndSaucer, 'cup-and-saucer': cupAndSaucer,
database,
donut, donut,
edit, edit,
eyeglasses, eyeglasses,
@@ -174,6 +181,7 @@ const ICONS = {
heart, heart,
home, home,
'ice-cream-on-cone': iceCreamOnCone, 'ice-cream-on-cone': iceCreamOnCone,
'industrial-building': industrialBuilding,
info, info,
instagram, instagram,
jewel, jewel,
@@ -183,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,
@@ -214,6 +220,7 @@ const ICONS = {
'sailing-ship-in-water': sailingShipInWater, 'sailing-ship-in-water': sailingShipInWater,
'scissors-open': scissorsOpen, 'scissors-open': scissorsOpen,
'shipwreck-in-water': shipwreckInWater, 'shipwreck-in-water': shipwreckInWater,
'steam-train-on-railway-track': steamTrainOnRailwayTrack,
'shopping-bag': shoppingBag, 'shopping-bag': shoppingBag,
search, search,
server, server,
@@ -233,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

@@ -109,6 +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_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' },
@@ -119,6 +122,12 @@ export const POI_ICON_RULES = [
tags: { historic: 'monument' }, tags: { historic: 'monument' },
icon: 'classical-building-with-dome-and-flag', icon: 'classical-building-with-dome-and-flag',
}, },
{ tags: { historic: 'folly' }, icon: 'classical-building' },
{ tags: { historic: 'industrial' }, icon: 'industrial-building' },
{
tags: { historic: 'railway_station' },
icon: 'steam-train-on-railway-track',
},
{ tags: { historic: 'ship' }, icon: 'sailing-ship-in-water' }, { tags: { historic: 'ship' }, icon: 'sailing-ship-in-water' },
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' }, { tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
{ tags: { historic: 'ruins' }, icon: 'camera' }, { tags: { historic: 'ruins' }, icon: 'camera' },

View File

@@ -41,7 +41,7 @@ export const POI_CATEGORIES = [
{ {
id: 'things-to-do', id: 'things-to-do',
label: 'Things to do', label: 'Things to do',
icon: 'camera', icon: 'feather-camera',
filter: [ filter: [
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]', '["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]', '["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',

View File

@@ -14,7 +14,7 @@ While NIP-68 (Picture-first feeds) caters to general visual feeds, this NIP spec
## Content ## Content
The `.content` of the event SHOULD generally be empty. If a user wishes to provide a detailed description, summary, or caption for a place, clients SHOULD encourage them to create a Place Review event (`kind: 30360`) instead. The `.content` of the event SHOULD generally be empty. If a user wishes to provide a detailed description for a place, clients SHOULD encourage them to create a Place Review event (`kind: 30360`) instead.
## Tags ## Tags
@@ -45,17 +45,19 @@ Used for spatial indexing and discovery. Events MUST include at least one high-p
#### 3. `imeta` — Inline Media Metadata #### 3. `imeta` — Inline Media Metadata
Media files MUST be attached using the `imeta` tag as defined in NIP-92. Each `imeta` tag represents one media item. The primary `url` SHOULD also be appended to the event's `.content` for backwards compatibility with clients that do not parse `imeta` tags. An event MUST contain exactly one `imeta` tag representing a single media item. The primary `url` MAY also be appended to the event's `.content` for backwards compatibility with clients that do not parse `imeta` tags.
Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `m` (MIME type), and `blurhash` where possible. Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `m` (MIME type), `thumb` (URL to a smaller thumbnail image), and `blurhash` where possible. Clients MAY also include `fallback` URLs if the media is hosted on multiple servers.
```json ```json
[ [
"imeta", "imeta",
"url https://example.com/photo.jpg", "url https://blossom.example.com/8e2e28a503fa37482de5b0959ee38b2bb4de4e0a752db24c568981c2ab410260.jpg",
"m image/jpeg", "m image/jpeg",
"dim 3024x4032", "dim 1440x1920",
"alt A steaming bowl of ramen on a wooden table at the restaurant.", "alt A steaming bowl of ramen on a wooden table at the restaurant.",
"fallback https://mirror.example.com/8e2e28a503fa37482de5b0959ee38b2bb4de4e0a752db24c568981c2ab410260.jpg",
"thumb https://example.com/7a1f592f6ea8e932b1de9568285b01851e4cf708466b0a03010b91e92c6c8135.jpg",
"blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$" "blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$"
] ]
``` ```
@@ -83,10 +85,12 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
[ [
"imeta", "imeta",
"url https://example.com/ramen.jpg", "url https://blossom.example.com/a9c84e183789a74288b8e05d04cc61230e74f386925a953e6b29f957e8cc3a61.jpg",
"m image/jpeg", "m image/jpeg",
"dim 1080x1080", "dim 1920x1920",
"alt A close-up of spicy miso ramen with chashu pork, soft boiled egg, and scallions.", "alt A close-up of spicy miso ramen with chashu pork, soft boiled egg, and scallions.",
"fallback https://mirror.example.com/a9c84e183789a74288b8e05d04cc61230e74f386925a953e6b29f957e8cc3a61.jpg",
"thumb https://example.com/c5a528e20235e16cc1c18090b8f04179de76288ea4e410b0fcb8d1487e416a2d.jpg",
"blurhash UHI=0o~q4T-o~q%MozM{x]t7RjRPt7oKkCWB" "blurhash UHI=0o~q4T-o~q%MozM{x]t7RjRPt7oKkCWB"
], ],
@@ -98,6 +102,10 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
## Rationale ## Rationale
### Kind 360
Easy to remember as a 360-degree view of places.
### Why not use NIP-68 (Picture-first feeds)? ### Why not use NIP-68 (Picture-first feeds)?
NIP-68 is designed for general-purpose social feeds (like Instagram). Place photos require strict guarantees about what entity is being depicted to be useful for map clients, directories, and review aggregators. By mandating the `i` tag for POI linking and the `g` tag for spatial querying, this kind ensures interoperability for geo-spatial applications without cluttering general picture feeds with mundane POI images (like photos of storefronts or menus). NIP-68 is designed for general-purpose social feeds (like Instagram). Place photos require strict guarantees about what entity is being depicted to be useful for map clients, directories, and review aggregators. By mandating the `i` tag for POI linking and the `g` tag for spatial querying, this kind ensures interoperability for geo-spatial applications without cluttering general picture feeds with mundane POI images (like photos of storefronts or menus).
@@ -105,3 +113,7 @@ NIP-68 is designed for general-purpose social feeds (like Instagram). Place phot
### Separation from Place Reviews ### Separation from Place Reviews
Reviews (kind 30360) and media have different lifecycles and data models. A user might upload 10 photos of a park without writing a review, or write a detailed review without attaching photos. Keeping them as separate events allows clients to query `imeta` attachments for a specific `i` tag to quickly build a photo gallery for a place, regardless of whether a review was attached. Reviews (kind 30360) and media have different lifecycles and data models. A user might upload 10 photos of a park without writing a review, or write a detailed review without attaching photos. Keeping them as separate events allows clients to query `imeta` attachments for a specific `i` tag to quickly build a photo gallery for a place, regardless of whether a review was attached.
### Single Photo per Event
Restricting events to a single `imeta` attachment (one photo per event) is an intentional design choice. Batching photos into a single event forces all engagement (likes, zaps) to apply to the entire batch, rendering granular tagging and sorting impossible. Single-photo events enable per-photo engagement, fine-grained categorization (e.g., tagging one photo as "food" and another as "menu"), and richer sorting algorithms based on individual photo popularity.

View File

@@ -276,6 +276,10 @@ Content payloads SHOULD NOT include place identifiers.
## Rationale ## Rationale
### Kind 30360
Pairs with kind 360 (Place Photos). Easy to remember as a 360-degree review of all aspects of a place.
### No Place Field in Content ### No Place Field in Content
Avoids duplication and inconsistency with tags. Avoids duplication and inconsistency with tags.

View File

@@ -1,6 +1,6 @@
{ {
"name": "marco", "name": "marco",
"version": "1.19.1", "version": "1.21.3",
"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: {}

View File

@@ -0,0 +1 @@
!function(){"use strict";var t=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","#","$","%","*","+",",","-",".",":",";","=","?","@","[","]","^","_","{","|","}","~"],e=(e,a)=>{var o="";for(let r=1;r<=a;r++){let h=Math.floor(e)/Math.pow(83,a-r)%83;o+=t[Math.floor(h)]}return o},a=t=>{let e=t/255;return e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4)},o=t=>{let e=Math.max(0,Math.min(1,t));return e<=.0031308?Math.trunc(12.92*e*255+.5):Math.trunc(255*(1.055*Math.pow(e,.4166666666666667)-.055)+.5)},r=(t,e)=>(t=>t<0?-1:1)(t)*Math.pow(Math.abs(t),e),h=class extends Error{constructor(t){super(t),this.name="ValidationError",this.message=t}},i=(t,e,o,r)=>{let h=0,i=0,n=0,s=4*e;for(let g=0;g<e;g++){let e=4*g;for(let l=0;l<o;l++){let o=e+l*s,c=r(g,l);h+=c*a(t[o]),i+=c*a(t[o+1]),n+=c*a(t[o+2])}}let l=1/(e*o);return[h*l,i*l,n*l]};self.onmessage=async t=>{if("PROCESS_IMAGE"!==t.data?.type)return;const{id:a,file:n,targetWidth:s,targetHeight:l,quality:g,computeBlurhash:c}=t.data;try{let t,M;try{const e=await createImageBitmap(n,{resizeWidth:s,resizeHeight:l,resizeQuality:"high"});if(t=new OffscreenCanvas(s,l),M=t.getContext("2d"),!M)throw new Error("Failed to get 2d context from OffscreenCanvas");M.drawImage(e,0,0,s,l),e.close()}catch(f){console.warn("Hardware resize failed, falling back to stepped software scaling:",f);const e=await n.arrayBuffer(),a=new Blob([e],{type:n.type}),o=await createImageBitmap(a);let r=o.width,h=o.height,i=new OffscreenCanvas(r,h),g=i.getContext("2d");for(g.imageSmoothingEnabled=!0,g.imageSmoothingQuality="high",g.drawImage(o,0,0);.5*i.width>s&&.5*i.height>l;){const t=new OffscreenCanvas(Math.floor(.5*i.width),Math.floor(.5*i.height)),e=t.getContext("2d");e.imageSmoothingEnabled=!0,e.imageSmoothingQuality="high",e.drawImage(i,0,0,t.width,t.height),i=t}t=new OffscreenCanvas(s,l),M=t.getContext("2d"),M.imageSmoothingEnabled=!0,M.imageSmoothingQuality="high",M.drawImage(i,0,0,s,l),o.close()}let d=null;if(c)try{d=((t,a,n)=>{if(a*n*4!==t.length)throw new h("Width and height must match the pixels array");let s=[];for(let e=0;e<3;e++)for(let o=0;o<4;o++){let r=0==o&&0==e?1:2,h=i(t,a,n,(t,h)=>r*Math.cos(Math.PI*o*t/a)*Math.cos(Math.PI*e*h/n));s.push(h)}let l,g=s[0],c=s.slice(1),f="";if(f+=e(21,1),c.length>0){let t=Math.max(...c.map(t=>Math.max(...t))),a=Math.floor(Math.max(0,Math.min(82,Math.floor(166*t-.5))));l=(a+1)/166,f+=e(a,1)}else l=1,f+=e(0,1);return f+=e((t=>(o(t[0])<<16)+(o(t[1])<<8)+o(t[2]))(g),4),c.forEach(t=>{f+=e(((t,e)=>19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[0]/e,.5)+9.5))))*19+19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[1]/e,.5)+9.5))))+Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[2]/e,.5)+9.5)))))(t,l),2)}),f})(M.getImageData(0,0,s,l).data,s,l)}catch(m){console.warn("Could not generate blurhash (possible canvas fingerprinting protection):",m)}const u=await t.convertToBlob({type:"image/jpeg",quality:g}),w=`${s}x${l}`;self.postMessage({id:a,success:!0,blob:u,dim:w,blurhash:d})}catch(M){self.postMessage({id:a,success:!1,error:M.message})}}}();

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-BVEi_-zb.js"></script> <script type="module" crossorigin src="/assets/main-C_1D7C3-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BF2Ls-fG.css"> <link rel="stylesheet" crossorigin href="/assets/main-BmLeTC2Y.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,18 +32,21 @@ 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.dom('.photo-carousel').exists('it renders the wrapper');
assert assert
.dom('.place-photos-carousel-wrapper') .dom('.carousel-slide:not(.carousel-placeholder)')
.exists('it renders the wrapper'); .exists({ count: 1 }, 'it renders one real photo slide');
assert.dom('.carousel-slide').exists({ count: 1 }, 'it renders one slide'); assert
.dom('.carousel-placeholder')
.exists({ count: 1 }, 'it renders one placeholder');
assert assert
.dom('img.place-header-photo') .dom('img.place-header-photo')
.hasAttribute('src', 'photo1.jpg', 'it renders the photo'); .hasAttribute('data-src', 'photo1.jpg', 'it sets the data-src correctly');
// There should be no chevrons when there's only 1 photo // There should be no chevrons when there's only 1 photo
assert assert
@@ -79,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

@@ -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(),