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 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,11 @@ export default class PlaceDetails extends Component {
|
||||
@onCancel={{this.cancelEditing}}
|
||||
/>
|
||||
{{else}}
|
||||
<PlacePhotosCarousel @photos={{this.photos}} @name={{this.name}} />
|
||||
<PlacePhotosCarousel
|
||||
@photos={{this.photos}}
|
||||
@name={{this.name}}
|
||||
@scrollToEventId={{this.newlyUploadedPhotoId}}
|
||||
/>
|
||||
<h3>{{this.name}}</h3>
|
||||
<p class="place-type">
|
||||
{{this.type}}
|
||||
@@ -554,9 +570,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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -29,6 +29,21 @@ export default class PlacePhotosCarousel extends Component {
|
||||
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) => {
|
||||
this.carouselElement = element;
|
||||
|
||||
@@ -84,11 +99,16 @@ export default class PlacePhotosCarousel extends Component {
|
||||
<div
|
||||
class="place-photos-carousel-track"
|
||||
{{this.setupCarousel}}
|
||||
{{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 +123,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 +136,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)}}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -246,25 +246,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 +771,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;
|
||||
}
|
||||
|
||||
@@ -980,8 +997,8 @@ abbr[title] {
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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('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
|
||||
assert
|
||||
|
||||
Reference in New Issue
Block a user