Compare commits

..

16 Commits

Author SHA1 Message Date
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
0f8d7046ac Improve blurhash decode warning, use valid hashes in tests 2026-04-22 09:19:31 +04:00
8ca7481a79 Mock nostr service globally in tests 2026-04-22 09:03:38 +04:00
cd25c55bd7 Smaller gap between photos in carousel on mobile 2026-04-22 08:44:17 +04:00
32c4f7da57 Add integration tests for carousel 2026-04-22 08:34:03 +04:00
71939a30c3 Fix carousel chevron links
Correct color, hide while disabled
2026-04-22 08:30:55 +04:00
23 changed files with 554 additions and 142 deletions

View File

@@ -23,7 +23,7 @@ export default class Blurhash extends Component {
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
} catch (e) {
console.warn('Failed to decode blurhash:', e);
console.warn('Failed to decode blurhash:', e.message || e);
}
});

View File

@@ -1034,7 +1034,7 @@ export default class MapComponent extends Component {
}
handleMapMove = async () => {
if (!this.mapInstance) return;
if (!this.mapInstance || this.isDestroying || this.isDestroyed) return;
const view = this.mapInstance.getView();
const center = toLonLat(view.getCenter());

View File

@@ -26,6 +26,7 @@ export default class PlaceDetails extends Component {
@tracked showLists = false;
@tracked isPhotoUploadModalOpen = false;
@tracked isNostrConnectModalOpen = false;
@tracked newlyUploadedPhotoId = null;
@action
openPhotoUploadModal(e) {
@@ -40,8 +41,19 @@ export default class PlaceDetails extends Component {
}
@action
closePhotoUploadModal() {
closePhotoUploadModal(eventId) {
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
@@ -352,7 +364,12 @@ export default class PlaceDetails extends Component {
@onCancel={{this.cancelEditing}}
/>
{{else}}
<PlacePhotosCarousel @photos={{this.photos}} @name={{this.name}} />
<PlacePhotosCarousel
@photos={{this.photos}}
@name={{this.name}}
@resetKey={{this.place.osmId}}
@scrollToEventId={{this.newlyUploadedPhotoId}}
/>
<h3>{{this.name}}</h3>
<p class="place-type">
{{this.type}}
@@ -554,9 +571,13 @@ export default class PlaceDetails extends Component {
<p class="content-with-icon">
<Icon @name="camera" />
<span>
<a href="#" {{on "click" this.openPhotoUploadModal}}>
<button
type="button"
class="btn-link"
{{on "click" this.openPhotoUploadModal}}
>
Add a photo
</a>
</button>
</span>
</p>
</div>

View File

@@ -7,6 +7,7 @@ import Icon from '#components/icon';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import { isMobile } from '../utils/device';
import Blurhash from './blurhash';
const MAX_IMAGE_DIMENSION = 1920;
const IMAGE_QUALITY = 0.94;
@@ -19,6 +20,7 @@ export default class PlacePhotoUploadItem extends Component {
@service toast;
@tracked thumbnailUrl = '';
@tracked blurhash = '';
@tracked error = '';
constructor() {
@@ -54,6 +56,8 @@ export default class PlacePhotoUploadItem extends Component {
true // computeBlurhash
);
this.blurhash = mainData.blurhash;
// 2. Process thumbnail (no blurhash needed)
const thumbData = await this.imageProcessor.process(
file,
@@ -110,6 +114,9 @@ export default class PlacePhotoUploadItem extends Component {
{{if this.uploadTask.isRunning 'is-uploading'}}
{{if this.error 'has-error'}}"
>
{{#if this.blurhash}}
<Blurhash @hash={{this.blurhash}} class="place-header-photo-blur" />
{{/if}}
<img src={{this.thumbnailUrl}} alt="thumbnail" />
{{#if this.uploadTask.isRunning}}

View File

@@ -17,8 +17,8 @@ export default class PlacePhotoUpload extends Component {
@service blossom;
@service toast;
@tracked files = [];
@tracked uploadedPhotos = [];
@tracked file = null;
@tracked uploadedPhoto = null;
@tracked status = '';
@tracked error = '';
@tracked isPublishing = false;
@@ -34,17 +34,13 @@ export default class PlacePhotoUpload extends Component {
get allUploaded() {
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
handleFileSelect(event) {
this.addFiles(event.target.files);
this.addFile(event.target.files[0]);
event.target.value = ''; // Reset input
}
@@ -64,33 +60,37 @@ export default class PlacePhotoUpload extends Component {
handleDrop(event) {
event.preventDefault();
this.isDragging = false;
this.addFiles(event.dataTransfer.files);
if (event.dataTransfer.files.length > 0) {
this.addFile(event.dataTransfer.files[0]);
}
}
addFiles(fileList) {
if (!fileList) return;
const newFiles = Array.from(fileList).filter((f) =>
f.type.startsWith('image/')
);
this.files = [...this.files, ...newFiles];
addFile(file) {
if (!file || !file.type.startsWith('image/')) {
this.error = 'Please select a valid image file.';
return;
}
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;
}
@action
handleUploadSuccess(photoData) {
this.uploadedPhotos = [...this.uploadedPhotos, photoData];
this.uploadedPhoto = photoData;
}
@action
removeFile(fileToRemove) {
const photoData = this.uploadedPhotos.find((p) => p.file === fileToRemove);
this.files = this.files.filter((f) => f !== fileToRemove);
this.uploadedPhotos = this.uploadedPhotos.filter(
(p) => p.file !== fileToRemove
);
if (photoData && photoData.hash && photoData.url) {
this.deletePhotoTask.perform(photoData);
removeFile() {
if (this.uploadedPhoto) {
this.deletePhotoTask.perform(this.uploadedPhoto);
}
this.file = null;
this.uploadedPhoto = null;
}
deletePhotoTask = task(async (photoData) => {
@@ -142,34 +142,33 @@ export default class PlacePhotoUpload extends Component {
tags.push(['g', Geohash.encode(lat, lon, 9)]);
}
for (const photo of this.uploadedPhotos) {
const imeta = ['imeta', `url ${photo.url}`];
const photo = this.uploadedPhoto;
const imeta = ['imeta', `url ${photo.url}`];
imeta.push(`m ${photo.type}`);
imeta.push(`m ${photo.type}`);
if (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);
if (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);
// NIP-XX draft Place Photo event
const template = {
kind: 360,
@@ -185,15 +184,15 @@ export default class PlacePhotoUpload extends Component {
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, 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
this.files = [];
this.uploadedPhotos = [];
// Clear out the file so user can upload more or be done
this.file = null;
this.uploadedPhoto = null;
if (this.args.onClose) {
this.args.onClose();
this.args.onClose(event.id);
}
} catch (e) {
this.error = 'Failed to publish: ' + e.message;
@@ -205,7 +204,7 @@ export default class PlacePhotoUpload extends Component {
<template>
<div class="place-photo-upload">
<h2>Add Photos for {{this.title}}</h2>
<h2>Add Photo for {{this.title}}</h2>
{{#if this.error}}
<div class="alert alert-error">
@@ -219,36 +218,13 @@ export default class PlacePhotoUpload extends Component {
</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}}
{{#if this.file}}
<div class="photo-grid">
{{#each this.files as |file|}}
<PlacePhotoUploadItem
@file={{file}}
@onSuccess={{this.handleUploadSuccess}}
@onRemove={{this.removeFile}}
/>
{{/each}}
<PlacePhotoUploadItem
@file={{this.file}}
@onSuccess={{this.handleUploadSuccess}}
@onRemove={{this.removeFile}}
/>
</div>
<button
@@ -260,11 +236,29 @@ export default class PlacePhotoUpload extends Component {
{{#if this.isPublishing}}
Publishing...
{{else}}
Publish
{{this.files.length}}
{{this.photoWord}}
Publish Photo
{{/if}}
</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}}
</div>
</template>

View File

@@ -29,6 +29,31 @@ export default class PlacePhotosCarousel extends Component {
return !this.canScrollRight;
}
lastResetKey = null;
resetScrollPosition = modifier((element, [resetKey]) => {
if (resetKey !== undefined && resetKey !== this.lastResetKey) {
this.lastResetKey = resetKey;
element.scrollLeft = 0;
setTimeout(() => this.updateScrollState(), 50);
}
});
scrollToNewPhoto = modifier((element, [eventId]) => {
if (eventId && eventId !== this.lastEventId) {
this.lastEventId = eventId;
// Allow DOM to update first since the photo was *just* added to the store
setTimeout(() => {
const targetSlide = element.querySelector(
`[data-event-id="${eventId}"]`
);
if (targetSlide) {
element.scrollLeft = targetSlide.offsetLeft;
}
}, 100);
}
});
setupCarousel = modifier((element) => {
this.carouselElement = element;
@@ -84,11 +109,17 @@ export default class PlacePhotosCarousel extends Component {
<div
class="place-photos-carousel-track"
{{this.setupCarousel}}
{{this.resetScrollPosition @resetKey}}
{{this.scrollToNewPhoto @scrollToEventId}}
{{on "scroll" this.updateScrollState}}
>
{{#each this.photos as |photo|}}
{{! template-lint-disable no-inline-styles }}
<div class="carousel-slide" style={{photo.style}}>
<div
class="carousel-slide"
style={{photo.style}}
data-event-id={{photo.eventId}}
>
{{#if photo.blurhash}}
<Blurhash
@hash={{photo.blurhash}}
@@ -103,11 +134,11 @@ export default class PlacePhotosCarousel extends Component {
{{#if photo.thumbUrl}}
<source
media="(max-width: 768px)"
srcset={{photo.thumbUrl}}
data-srcset={{photo.thumbUrl}}
/>
{{/if}}
<img
src={{photo.url}}
data-src={{photo.url}}
class="place-header-photo landscape"
alt={{@name}}
{{fadeInImage photo.url}}
@@ -116,7 +147,7 @@ export default class PlacePhotosCarousel extends Component {
{{else}}
{{! Portrait uses thumb everywhere if available }}
<img
src={{if photo.thumbUrl photo.thumbUrl photo.url}}
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
class="place-header-photo portrait"
alt={{@name}}
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
@@ -124,6 +155,8 @@ export default class PlacePhotosCarousel extends Component {
{{/if}}
</div>
{{/each}}
<div class="carousel-placeholder"></div>
</div>
{{#if this.showChevrons}}
@@ -135,7 +168,7 @@ export default class PlacePhotosCarousel extends Component {
disabled={{this.cannotScrollLeft}}
aria-label="Previous photo"
>
<Icon @name="chevron-left" />
<Icon @name="chevron-left" @color="currentColor" />
</button>
<button
@@ -146,7 +179,7 @@ export default class PlacePhotosCarousel extends Component {
disabled={{this.cannotScrollRight}}
aria-label="Next photo"
>
<Icon @name="chevron-right" />
<Icon @name="chevron-right" @color="currentColor" />
</button>
{{/if}}
</div>

View File

@@ -7,24 +7,69 @@ export default modifier((element, [url]) => {
element.classList.remove('loaded');
element.classList.remove('loaded-instant');
// 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');
return;
}
let observer;
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');
}
};
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');
}
// 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 () => {
element.removeEventListener('load', handleLoad);
if (observer) {
observer.disconnect();
}
};
});

View File

@@ -9,25 +9,47 @@ export default class PlaceRoute extends Route {
async model(params) {
const id = params.place_id;
let type, osmId;
let isExplicitOsm = false;
if (
id.startsWith('osm:node:') ||
id.startsWith('osm:way:') ||
id.startsWith('osm:relation:')
) {
const [, type, osmId] = id.split(':');
isExplicitOsm = true;
[, type, osmId] = id.split(':');
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();
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) {
console.debug('Found in bookmarks:', bookmark.title);
return bookmark;
}
if (isExplicitOsm) {
console.debug(
`Not in bookmarks, using explicitly fetched OSM ${type}:`,
osmId
);
return await backgroundFetchPromise;
}
console.warn('Not in bookmarks:', id);
return null;
}
@@ -119,14 +141,14 @@ export default class PlaceRoute extends Route {
}
serialize(model) {
// If the model is a saved bookmark, use its ID
if (model.id) {
return { place_id: model.id };
}
// If it's an OSM POI, use the explicit format
// If it's an OSM POI, use the explicit format first
if (model.osmId && model.osmType) {
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
return { place_id: model.osmId };
}

View File

@@ -8,6 +8,7 @@ export default class OsmService extends Service {
controller = null;
cachedResults = null;
lastQueryKey = null;
cachedPlaces = new Map();
cancelAll() {
if (this.controller) {
@@ -232,6 +233,13 @@ out center;
async fetchOsmObject(osmId, osmType) {
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;
if (osmType === 'node') {
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}`);
}
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) {
console.error('Failed to fetch OSM object:', e);
return null;

View File

@@ -215,12 +215,12 @@ body {
.dropzone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 2rem 1.5rem;
text-align: center;
transition: all 0.2s ease;
margin: 1.5rem 0 1rem;
background-color: rgb(255 255 255 / 2%);
cursor: pointer;
aspect-ratio: 4 / 3;
}
.dropzone.is-dragging {
@@ -232,9 +232,12 @@ body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
cursor: pointer;
color: #898989;
width: 100%;
height: 100%;
}
.dropzone-label p {
@@ -246,25 +249,35 @@ body {
}
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.photo-upload-item {
position: relative;
aspect-ratio: 1 / 1;
aspect-ratio: 4 / 3;
border-radius: 6px;
overflow: hidden;
background: #1e262e;
width: 100%;
}
.photo-upload-item img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
display: block;
z-index: 1;
}
.photo-upload-item .overlay,
.photo-upload-item .btn-remove-photo {
z-index: 2;
}
.photo-upload-item .overlay {
@@ -761,12 +774,19 @@ select.form-control {
border-top: 1px solid #eee;
}
.meta-info a {
.meta-info a,
.meta-info .btn-link {
color: var(--link-color);
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;
}
@@ -889,6 +909,10 @@ abbr[title] {
overflow: hidden;
}
.carousel-placeholder {
display: none;
}
.place-header-photo-blur {
position: absolute;
top: 0;
@@ -950,11 +974,11 @@ abbr[title] {
padding: 0;
}
.place-photos-carousel-wrapper:hover .carousel-nav-btn {
.place-photos-carousel-wrapper:hover .carousel-nav-btn:not(.disabled) {
opacity: 1;
}
.carousel-nav-btn:hover {
.carousel-nav-btn:not(.disabled):hover {
background: rgb(0 0 0 / 80%);
}
@@ -973,22 +997,28 @@ abbr[title] {
@media (width <= 768px) {
.place-photos-carousel-track {
scroll-snap-type: none; /* No snapping on mobile */
gap: 0.25rem;
padding-bottom: 0.5rem; /* Space for the scrollbar if visible, but we hid it */
scroll-snap-type: none;
gap: 2px;
background-color: #fff;
}
.carousel-slide {
flex: 0 0 auto;
height: 100px;
width: calc(100px * var(--slide-ratio, 1.7778));
aspect-ratio: auto;
width: auto;
aspect-ratio: var(--slide-ratio, 16 / 9);
scroll-snap-align: none;
}
.carousel-placeholder {
display: block;
background-color: var(--hover-bg);
flex: 1 1 0%;
min-width: 0;
}
.place-header-photo.landscape,
.place-header-photo.portrait {
/* On mobile, all images use cover inside their precise ratio container */
object-fit: cover;
}

View File

@@ -45,7 +45,7 @@ Used for spatial indexing and discovery. Events MUST include at least one high-p
#### 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` SHOULD 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.
@@ -105,3 +105,7 @@ NIP-68 is designed for general-purpose social feeds (like Instagram). Place phot
### 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.
### 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

@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.19.1",
"version": "1.20.0",
"private": true,
"description": "Unhosted maps app",
"repository": {

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-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-BVEi_-zb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BF2Ls-fG.css">
<script type="module" crossorigin src="/assets/main-AsE4IKjj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BA3LWr76.css">
</head>
<body>
</body>

View File

@@ -3,6 +3,7 @@ import {
setupRenderingTest as upstreamSetupRenderingTest,
setupTest as upstreamSetupTest,
} from 'ember-qunit';
import { setupNostrMocks } from './mock-nostr';
// This file exists to provide wrappers around ember-qunit's
// test setup functions. This way, you can easily extend the setup that is
@@ -10,6 +11,7 @@ import {
function setupApplicationTest(hooks, options) {
upstreamSetupApplicationTest(hooks, options);
setupNostrMocks(hooks);
// Additional setup for application tests can be done here.
//
@@ -29,12 +31,14 @@ function setupApplicationTest(hooks, options) {
function setupRenderingTest(hooks, options) {
upstreamSetupRenderingTest(hooks, options);
setupNostrMocks(hooks);
// Additional setup for rendering tests can be done here.
}
function setupTest(hooks, options) {
upstreamSetupTest(hooks, options);
setupNostrMocks(hooks);
// Additional setup for unit tests can be done here.
}

View File

@@ -0,0 +1,96 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { Promise } from 'rsvp';
export class MockNostrAuthService extends Service {
@tracked pubkey = null;
@tracked signerType = null;
@tracked connectStatus = null;
@tracked connectUri = null;
get isConnected() {
return false;
}
get isMobile() {
return false;
}
get signer() {
return null;
}
async connectWithExtension() {
return Promise.resolve();
}
async connectWithApp() {
return Promise.resolve();
}
disconnect() {}
}
export class MockNostrDataService extends Service {
@tracked profile = null;
@tracked mailboxes = null;
@tracked blossomServers = [];
@tracked placePhotos = [];
store = {
add: () => {},
};
get activeReadRelays() {
return [];
}
get activeWriteRelays() {
return [];
}
get defaultReadRelays() {
return [];
}
get defaultWriteRelays() {
return [];
}
get userDisplayName() {
return 'Mock User';
}
loadPlacesInBounds() {
return Promise.resolve();
}
loadPhotosForPlace() {
return Promise.resolve();
}
loadPlacePhotos() {
return Promise.resolve();
}
}
export class MockNostrRelayService extends Service {
pool = {
publish: () => Promise.resolve([{ ok: true }]),
subscribe: () => {},
unsubscribe: () => {},
close: () => {},
};
async publish() {
return [{ ok: true }];
}
}
export function setupNostrMocks(hooks) {
hooks.beforeEach(function () {
this.owner.register('service:nostrAuth', MockNostrAuthService);
this.owner.register('service:nostrData', MockNostrDataService);
this.owner.register('service:nostrRelay', MockNostrRelayService);
});
}

View File

@@ -0,0 +1,114 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers';
import PlacePhotosCarousel from 'marco/components/place-photos-carousel';
module('Integration | Component | place-photos-carousel', function (hooks) {
setupRenderingTest(hooks);
test('it renders gracefully with no photos', async function (assert) {
this.photos = [];
await render(
<template><PlacePhotosCarousel @photos={{this.photos}} /></template>
);
assert
.dom('.place-photos-carousel-wrapper')
.doesNotExist('it does not render the wrapper when there are no photos');
});
test('it renders a single photo without navigation chevrons', async function (assert) {
this.photos = [
{
url: 'photo1.jpg',
thumbUrl: 'thumb1.jpg',
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
ratio: 1.5,
isLandscape: true,
},
];
await render(
<template>
<div class="test-container">
<PlacePhotosCarousel @photos={{this.photos}} />
</div>
</template>
);
assert
.dom('.place-photos-carousel-wrapper')
.exists('it renders the wrapper');
assert
.dom('.carousel-slide:not(.carousel-placeholder)')
.exists({ count: 1 }, 'it renders one real photo slide');
assert
.dom('.carousel-placeholder')
.exists({ count: 1 }, 'it renders one placeholder');
assert
.dom('img.place-header-photo')
.hasAttribute('data-src', 'photo1.jpg', 'it sets the data-src correctly');
// There should be no chevrons when there's only 1 photo
assert
.dom('.carousel-nav-btn')
.doesNotExist('it does not render chevrons for a single photo');
});
test('it renders multiple photos and shows chevrons', async function (assert) {
this.photos = [
{
url: 'photo1.jpg',
thumbUrl: 'thumb1.jpg',
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
ratio: 1.5,
isLandscape: true,
},
{
url: 'photo2.jpg',
thumbUrl: 'thumb2.jpg',
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
ratio: 1.0,
isLandscape: false,
},
{
url: 'photo3.jpg',
thumbUrl: 'thumb3.jpg',
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
ratio: 0.8,
isLandscape: false,
},
];
await render(
<template>
<div class="test-container">
<PlacePhotosCarousel @photos={{this.photos}} />
</div>
</template>
);
await new Promise((resolve) => setTimeout(resolve, 100));
assert.dom('.carousel-slide').exists({ count: 3 }, 'it renders all slides');
assert
.dom('.carousel-nav-btn')
.exists({ count: 2 }, 'it renders both chevrons');
// Initially, it shouldn't be able to scroll left
assert
.dom('.carousel-nav-btn.prev')
.hasClass('disabled', 'the prev button is disabled initially');
assert
.dom('.carousel-nav-btn.next')
.doesNotHaveClass('disabled', 'the next button is enabled initially');
// We can't perfectly test native scroll behavior easily in JSDOM/QUnit without mocking the DOM elements' scroll properties,
// but we can test that clicking the next button triggers the scrolling method.
// However, since we mock scrollLeft in the component logic implicitly via template action, let's at least ensure clicking doesn't throw.
await click('.carousel-nav-btn.next');
assert.ok(true, 'clicking next button does not throw');
});
});