From d8fa30c74ba4dc7cc173a6cf913221a63e593cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 22 Apr 2026 10:18:47 +0400 Subject: [PATCH] Revert to single photo per upload and event See NIP changes for reasoning. It also keeps the UI a bit cleaner and we don't have to queue processing on mobile for mass uploads. --- app/components/place-photo-upload-item.gjs | 7 + app/components/place-photo-upload.gjs | 166 ++++++++++----------- app/styles/app.css | 18 ++- doc/nostr/nip-place-photos.md | 6 +- 4 files changed, 106 insertions(+), 91 deletions(-) diff --git a/app/components/place-photo-upload-item.gjs b/app/components/place-photo-upload-item.gjs index b47cecc..0194e1b 100644 --- a/app/components/place-photo-upload-item.gjs +++ b/app/components/place-photo-upload-item.gjs @@ -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}} + + {{/if}} thumbnail {{#if this.uploadTask.isRunning}} diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs index fced83a..1e1858b 100644 --- a/app/components/place-photo-upload.gjs +++ b/app/components/place-photo-upload.gjs @@ -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,12 +184,12 @@ 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(); @@ -205,7 +204,7 @@ export default class PlacePhotoUpload extends Component { diff --git a/app/styles/app.css b/app/styles/app.css index 45d8a57..0cc9849 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -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 { diff --git a/doc/nostr/nip-place-photos.md b/doc/nostr/nip-place-photos.md index 2c22c58..61cc14c 100644 --- a/doc/nostr/nip-place-photos.md +++ b/doc/nostr/nip-place-photos.md @@ -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.