Compare commits
16 Commits
0f8d7046ac
...
v1.20.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
98dcb4f25b
|
|||
|
7709634a9a
|
|||
|
3ddc85669f
|
|||
|
95961e680f
|
|||
|
9468a6a0cc
|
|||
|
c9465c8fa8
|
|||
|
6c5c1fea27
|
|||
|
fe41369754
|
|||
|
1498c5a713
|
|||
|
b6e2964f8e
|
|||
|
d1d179bb93
|
|||
|
b83a16bf13
|
|||
|
c853418fbb
|
|||
|
4fed8c05c5
|
|||
|
670128cbda
|
|||
|
d8fa30c74b
|
@@ -12,6 +12,7 @@ const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
|||||||
export default class AppMenuSettingsNostr extends Component {
|
export default class AppMenuSettingsNostr extends Component {
|
||||||
@service settings;
|
@service settings;
|
||||||
@service nostrData;
|
@service nostrData;
|
||||||
|
@service toast;
|
||||||
|
|
||||||
@tracked newReadRelay = '';
|
@tracked newReadRelay = '';
|
||||||
@tracked newWriteRelay = '';
|
@tracked newWriteRelay = '';
|
||||||
@@ -90,6 +91,16 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
this.settings.update('nostrWriteRelays', null);
|
this.settings.update('nostrWriteRelays', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async clearCache() {
|
||||||
|
try {
|
||||||
|
await this.nostrData.clearCache();
|
||||||
|
this.toast.show('Nostr cache cleared');
|
||||||
|
} catch (e) {
|
||||||
|
this.toast.show(`Failed to clear Nostr cache: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
{{! template-lint-disable no-nested-interactive }}
|
{{! template-lint-disable no-nested-interactive }}
|
||||||
<details>
|
<details>
|
||||||
@@ -213,6 +224,18 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
</button>
|
</button>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Cached data</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-full"
|
||||||
|
{{on "click" this.clearCache}}
|
||||||
|
>
|
||||||
|
<Icon @name="database" @size={{18}} @color="var(--danger-color)" />
|
||||||
|
Clear profiles, photos, and reviews
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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,12 @@ 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}}
|
||||||
|
@resetKey={{this.place.osmId}}
|
||||||
|
@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 +571,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,31 @@ export default class PlacePhotosCarousel extends Component {
|
|||||||
return !this.canScrollRight;
|
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) => {
|
setupCarousel = modifier((element) => {
|
||||||
this.carouselElement = element;
|
this.carouselElement = element;
|
||||||
|
|
||||||
@@ -84,11 +109,17 @@ export default class PlacePhotosCarousel extends Component {
|
|||||||
<div
|
<div
|
||||||
class="place-photos-carousel-track"
|
class="place-photos-carousel-track"
|
||||||
{{this.setupCarousel}}
|
{{this.setupCarousel}}
|
||||||
|
{{this.resetScrollPosition @resetKey}}
|
||||||
|
{{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 +134,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 +147,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)}}
|
||||||
@@ -124,6 +155,8 @@ export default class PlacePhotosCarousel extends Component {
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|
||||||
|
<div class="carousel-placeholder"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if this.showChevrons}}
|
{{#if this.showChevrons}}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -356,6 +356,13 @@ export default class NostrDataService extends Service {
|
|||||||
return 'Not connected';
|
return 'Not connected';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clearCache() {
|
||||||
|
await this._cachePromise;
|
||||||
|
if (this.cache) {
|
||||||
|
await this.cache.deleteAllEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_cleanupSubscriptions() {
|
_cleanupSubscriptions() {
|
||||||
if (this._requestSub) {
|
if (this._requestSub) {
|
||||||
this._requestSub.unsubscribe();
|
this._requestSub.unsubscribe();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -215,12 +215,12 @@ body {
|
|||||||
.dropzone {
|
.dropzone {
|
||||||
border: 2px dashed #ccc;
|
border: 2px dashed #ccc;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
margin: 1.5rem 0 1rem;
|
margin: 1.5rem 0 1rem;
|
||||||
background-color: rgb(255 255 255 / 2%);
|
background-color: rgb(255 255 255 / 2%);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropzone.is-dragging {
|
.dropzone.is-dragging {
|
||||||
@@ -232,9 +232,12 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #898989;
|
color: #898989;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropzone-label p {
|
.dropzone-label p {
|
||||||
@@ -246,25 +249,35 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.photo-grid {
|
.photo-grid {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-upload-item {
|
.photo-upload-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 4 / 3;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #1e262e;
|
background: #1e262e;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-upload-item img {
|
.photo-upload-item img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-upload-item .overlay,
|
||||||
|
.photo-upload-item .btn-remove-photo {
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-upload-item .overlay {
|
.photo-upload-item .overlay {
|
||||||
@@ -761,12 +774,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -889,6 +909,10 @@ abbr[title] {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.carousel-placeholder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.place-header-photo-blur {
|
.place-header-photo-blur {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -975,16 +999,24 @@ abbr[title] {
|
|||||||
.place-photos-carousel-track {
|
.place-photos-carousel-track {
|
||||||
scroll-snap-type: none;
|
scroll-snap-type: none;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
background-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.carousel-placeholder {
|
||||||
|
display: block;
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
flex: 1 1 0%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.place-header-photo.landscape,
|
.place-header-photo.landscape,
|
||||||
.place-header-photo.portrait {
|
.place-header-photo.portrait {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
|||||||
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
|
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
|
||||||
import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw';
|
import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw';
|
||||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||||
|
import database from 'feather-icons/dist/icons/database.svg?raw';
|
||||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||||
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
|
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
|
||||||
import gift from 'feather-icons/dist/icons/gift.svg?raw';
|
import gift from 'feather-icons/dist/icons/gift.svg?raw';
|
||||||
@@ -153,6 +154,7 @@ const ICONS = {
|
|||||||
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
|
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
|
||||||
croissant,
|
croissant,
|
||||||
'cup-and-saucer': cupAndSaucer,
|
'cup-and-saucer': cupAndSaucer,
|
||||||
|
database,
|
||||||
donut,
|
donut,
|
||||||
edit,
|
edit,
|
||||||
eyeglasses,
|
eyeglasses,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ While NIP-68 (Picture-first feeds) caters to general visual feeds, this NIP spec
|
|||||||
|
|
||||||
## Content
|
## Content
|
||||||
|
|
||||||
The `.content` of the event SHOULD generally be empty. If a user wishes to provide a detailed description, summary, or caption for a place, clients SHOULD encourage them to create a Place Review event (`kind: 30360`) instead.
|
The `.content` of the event SHOULD generally be empty. If a user wishes to provide a detailed description for a place, clients SHOULD encourage them to create a Place Review event (`kind: 30360`) instead.
|
||||||
|
|
||||||
## Tags
|
## Tags
|
||||||
|
|
||||||
@@ -45,17 +45,19 @@ Used for spatial indexing and discovery. Events MUST include at least one high-p
|
|||||||
|
|
||||||
#### 3. `imeta` — Inline Media Metadata
|
#### 3. `imeta` — Inline Media Metadata
|
||||||
|
|
||||||
Media files MUST be attached using the `imeta` tag as defined in NIP-92. Each `imeta` tag represents one media item. The primary `url` SHOULD also be appended to the event's `.content` for backwards compatibility with clients that do not parse `imeta` tags.
|
An event MUST contain exactly one `imeta` tag representing a single media item. The primary `url` MAY also be appended to the event's `.content` for backwards compatibility with clients that do not parse `imeta` tags.
|
||||||
|
|
||||||
Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `m` (MIME type), and `blurhash` where possible.
|
Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `m` (MIME type), `thumb` (URL to a smaller thumbnail image), and `blurhash` where possible. Clients MAY also include `fallback` URLs if the media is hosted on multiple servers.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
"imeta",
|
"imeta",
|
||||||
"url https://example.com/photo.jpg",
|
"url https://blossom.example.com/8e2e28a503fa37482de5b0959ee38b2bb4de4e0a752db24c568981c2ab410260.jpg",
|
||||||
"m image/jpeg",
|
"m image/jpeg",
|
||||||
"dim 3024x4032",
|
"dim 1440x1920",
|
||||||
"alt A steaming bowl of ramen on a wooden table at the restaurant.",
|
"alt A steaming bowl of ramen on a wooden table at the restaurant.",
|
||||||
|
"fallback https://mirror.example.com/8e2e28a503fa37482de5b0959ee38b2bb4de4e0a752db24c568981c2ab410260.jpg",
|
||||||
|
"thumb https://example.com/7a1f592f6ea8e932b1de9568285b01851e4cf708466b0a03010b91e92c6c8135.jpg",
|
||||||
"blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$"
|
"blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$"
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -83,10 +85,12 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
|
|||||||
|
|
||||||
[
|
[
|
||||||
"imeta",
|
"imeta",
|
||||||
"url https://example.com/ramen.jpg",
|
"url https://blossom.example.com/a9c84e183789a74288b8e05d04cc61230e74f386925a953e6b29f957e8cc3a61.jpg",
|
||||||
"m image/jpeg",
|
"m image/jpeg",
|
||||||
"dim 1080x1080",
|
"dim 1920x1920",
|
||||||
"alt A close-up of spicy miso ramen with chashu pork, soft boiled egg, and scallions.",
|
"alt A close-up of spicy miso ramen with chashu pork, soft boiled egg, and scallions.",
|
||||||
|
"fallback https://mirror.example.com/a9c84e183789a74288b8e05d04cc61230e74f386925a953e6b29f957e8cc3a61.jpg",
|
||||||
|
"thumb https://example.com/c5a528e20235e16cc1c18090b8f04179de76288ea4e410b0fcb8d1487e416a2d.jpg",
|
||||||
"blurhash UHI=0o~q4T-o~q%MozM{x]t7RjRPt7oKkCWB"
|
"blurhash UHI=0o~q4T-o~q%MozM{x]t7RjRPt7oKkCWB"
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -98,6 +102,10 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
|
|||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
|
### Kind 360
|
||||||
|
|
||||||
|
Easy to remember as a 360-degree view of places.
|
||||||
|
|
||||||
### Why not use NIP-68 (Picture-first feeds)?
|
### Why not use NIP-68 (Picture-first feeds)?
|
||||||
|
|
||||||
NIP-68 is designed for general-purpose social feeds (like Instagram). Place photos require strict guarantees about what entity is being depicted to be useful for map clients, directories, and review aggregators. By mandating the `i` tag for POI linking and the `g` tag for spatial querying, this kind ensures interoperability for geo-spatial applications without cluttering general picture feeds with mundane POI images (like photos of storefronts or menus).
|
NIP-68 is designed for general-purpose social feeds (like Instagram). Place photos require strict guarantees about what entity is being depicted to be useful for map clients, directories, and review aggregators. By mandating the `i` tag for POI linking and the `g` tag for spatial querying, this kind ensures interoperability for geo-spatial applications without cluttering general picture feeds with mundane POI images (like photos of storefronts or menus).
|
||||||
@@ -105,3 +113,7 @@ NIP-68 is designed for general-purpose social feeds (like Instagram). Place phot
|
|||||||
### Separation from Place Reviews
|
### Separation from Place Reviews
|
||||||
|
|
||||||
Reviews (kind 30360) and media have different lifecycles and data models. A user might upload 10 photos of a park without writing a review, or write a detailed review without attaching photos. Keeping them as separate events allows clients to query `imeta` attachments for a specific `i` tag to quickly build a photo gallery for a place, regardless of whether a review was attached.
|
Reviews (kind 30360) and media have different lifecycles and data models. A user might upload 10 photos of a park without writing a review, or write a detailed review without attaching photos. Keeping them as separate events allows clients to query `imeta` attachments for a specific `i` tag to quickly build a photo gallery for a place, regardless of whether a review was attached.
|
||||||
|
|
||||||
|
### Single Photo per Event
|
||||||
|
|
||||||
|
Restricting events to a single `imeta` attachment (one photo per event) is an intentional design choice. Batching photos into a single event forces all engagement (likes, zaps) to apply to the entire batch, rendering granular tagging and sorting impossible. Single-photo events enable per-photo engagement, fine-grained categorization (e.g., tagging one photo as "food" and another as "menu"), and richer sorting algorithms based on individual photo popularity.
|
||||||
|
|||||||
@@ -276,6 +276,10 @@ Content payloads SHOULD NOT include place identifiers.
|
|||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
|
### Kind 30360
|
||||||
|
|
||||||
|
Pairs with kind 360 (Place Photos). Easy to remember as a 360-degree review of all aspects of a place.
|
||||||
|
|
||||||
### No Place Field in Content
|
### No Place Field in Content
|
||||||
|
|
||||||
Avoids duplication and inconsistency with tags.
|
Avoids duplication and inconsistency with tags.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.19.1",
|
"version": "1.20.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
1
release/assets/image-processor-Dj3-kZwI.js
Normal file
1
release/assets/image-processor-Dj3-kZwI.js
Normal 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})}}}();
|
||||||
1
release/assets/main-BA3LWr76.css
Normal file
1
release/assets/main-BA3LWr76.css
Normal file
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
16
release/assets/main-CGySSjv6.js
Normal file
16
release/assets/main-CGySSjv6.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
release/assets/negentropy-CkK5_v5U.js
Normal file
2
release/assets/negentropy-CkK5_v5U.js
Normal file
File diff suppressed because one or more lines are too long
@@ -39,8 +39,8 @@
|
|||||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-BVEi_-zb.js"></script>
|
<script type="module" crossorigin src="/assets/main-CGySSjv6.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-BF2Ls-fG.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-BA3LWr76.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -40,10 +40,15 @@ module('Integration | Component | place-photos-carousel', function (hooks) {
|
|||||||
assert
|
assert
|
||||||
.dom('.place-photos-carousel-wrapper')
|
.dom('.place-photos-carousel-wrapper')
|
||||||
.exists('it renders the wrapper');
|
.exists('it renders the wrapper');
|
||||||
assert.dom('.carousel-slide').exists({ count: 1 }, 'it renders one slide');
|
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
|
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