Compare commits

...

6 Commits

Author SHA1 Message Date
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
10 changed files with 279 additions and 125 deletions

View File

@@ -26,6 +26,7 @@ export default class PlaceDetails extends Component {
@tracked showLists = false; @tracked showLists = false;
@tracked isPhotoUploadModalOpen = false; @tracked isPhotoUploadModalOpen = false;
@tracked isNostrConnectModalOpen = false; @tracked isNostrConnectModalOpen = false;
@tracked newlyUploadedPhotoId = null;
@action @action
openPhotoUploadModal(e) { openPhotoUploadModal(e) {
@@ -40,8 +41,19 @@ export default class PlaceDetails extends Component {
} }
@action @action
closePhotoUploadModal() { closePhotoUploadModal(eventId) {
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
@@ -352,7 +364,11 @@ export default class PlaceDetails extends Component {
@onCancel={{this.cancelEditing}} @onCancel={{this.cancelEditing}}
/> />
{{else}} {{else}}
<PlacePhotosCarousel @photos={{this.photos}} @name={{this.name}} /> <PlacePhotosCarousel
@photos={{this.photos}}
@name={{this.name}}
@scrollToEventId={{this.newlyUploadedPhotoId}}
/>
<h3>{{this.name}}</h3> <h3>{{this.name}}</h3>
<p class="place-type"> <p class="place-type">
{{this.type}} {{this.type}}
@@ -554,9 +570,13 @@ export default class PlaceDetails extends Component {
<p class="content-with-icon"> <p class="content-with-icon">
<Icon @name="camera" /> <Icon @name="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>

View File

@@ -7,6 +7,7 @@ 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.94;
@@ -19,6 +20,7 @@ export default class PlacePhotoUploadItem extends Component {
@service toast; @service toast;
@tracked thumbnailUrl = ''; @tracked thumbnailUrl = '';
@tracked blurhash = '';
@tracked error = ''; @tracked error = '';
constructor() { constructor() {
@@ -54,6 +56,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,
@@ -110,6 +114,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}}

View File

@@ -17,8 +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 status = '';
@tracked error = ''; @tracked error = '';
@tracked isPublishing = false; @tracked isPublishing = false;
@@ -34,17 +34,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,33 +60,37 @@ 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;
} }
@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
);
if (photoData && photoData.hash && photoData.url) {
this.deletePhotoTask.perform(photoData);
} }
this.file = null;
this.uploadedPhoto = null;
} }
deletePhotoTask = task(async (photoData) => { deletePhotoTask = task(async (photoData) => {
@@ -142,34 +142,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,15 +184,15 @@ 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 = ''; 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.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;
@@ -205,7 +204,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">
@@ -219,36 +218,13 @@ export default class PlacePhotoUpload extends Component {
</div> </div>
{{/if}} {{/if}}
<div {{#if this.file}}
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

@@ -29,6 +29,21 @@ export default class PlacePhotosCarousel extends Component {
return !this.canScrollRight; return !this.canScrollRight;
} }
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) => { setupCarousel = modifier((element) => {
this.carouselElement = element; this.carouselElement = element;
@@ -84,11 +99,16 @@ export default class PlacePhotosCarousel extends Component {
<div <div
class="place-photos-carousel-track" class="place-photos-carousel-track"
{{this.setupCarousel}} {{this.setupCarousel}}
{{this.scrollToNewPhoto @scrollToEventId}}
{{on "scroll" this.updateScrollState}} {{on "scroll" this.updateScrollState}}
> >
{{#each this.photos as |photo|}} {{#each this.photos as |photo|}}
{{! template-lint-disable no-inline-styles }} {{! 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}} {{#if photo.blurhash}}
<Blurhash <Blurhash
@hash={{photo.blurhash}} @hash={{photo.blurhash}}
@@ -103,11 +123,11 @@ export default class PlacePhotosCarousel extends Component {
{{#if photo.thumbUrl}} {{#if photo.thumbUrl}}
<source <source
media="(max-width: 768px)" media="(max-width: 768px)"
srcset={{photo.thumbUrl}} data-srcset={{photo.thumbUrl}}
/> />
{{/if}} {{/if}}
<img <img
src={{photo.url}} data-src={{photo.url}}
class="place-header-photo landscape" class="place-header-photo landscape"
alt={{@name}} alt={{@name}}
{{fadeInImage photo.url}} {{fadeInImage photo.url}}
@@ -116,7 +136,7 @@ export default class PlacePhotosCarousel extends Component {
{{else}} {{else}}
{{! Portrait uses thumb everywhere if available }} {{! Portrait uses thumb everywhere if available }}
<img <img
src={{if photo.thumbUrl photo.thumbUrl photo.url}} data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
class="place-header-photo portrait" class="place-header-photo portrait"
alt={{@name}} alt={{@name}}
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}} {{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}

View File

@@ -7,24 +7,69 @@ 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) {
// Already in browser cache, skip the animation
element.classList.add('loaded-instant');
return;
}
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');
}
}; };
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');
}
// 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;
} }
@@ -119,14 +141,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

@@ -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

@@ -246,25 +246,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 {
@@ -761,12 +771,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;
} }
@@ -980,8 +997,8 @@ abbr[title] {
.carousel-slide { .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; aspect-ratio: var(--slide-ratio, 16 / 9);
scroll-snap-align: none; scroll-snap-align: none;
} }

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 #### 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. 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 ### 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

@@ -43,7 +43,7 @@ module('Integration | Component | place-photos-carousel', function (hooks) {
assert.dom('.carousel-slide').exists({ count: 1 }, 'it renders one slide'); assert.dom('.carousel-slide').exists({ count: 1 }, 'it renders one slide');
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