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.
This commit is contained in:
2026-04-22 10:18:47 +04:00
parent 0f8d7046ac
commit d8fa30c74b
4 changed files with 106 additions and 91 deletions

View File

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

View File

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

View File

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

View File

@@ -45,7 +45,7 @@ Used for spatial indexing and discovery. Events MUST include at least one high-p
#### 3. `imeta` — Inline Media Metadata
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.