Compare commits
6 Commits
0f8d7046ac
...
d1d179bb93
| Author | SHA1 | Date | |
|---|---|---|---|
|
d1d179bb93
|
|||
|
b83a16bf13
|
|||
|
c853418fbb
|
|||
|
4fed8c05c5
|
|||
|
670128cbda
|
|||
|
d8fa30c74b
|
@@ -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>
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user