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:
@@ -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,7 +142,7 @@ 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}`);
|
||||||
@@ -168,7 +168,6 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tags.push(imeta);
|
tags.push(imeta);
|
||||||
}
|
|
||||||
|
|
||||||
// NIP-XX draft Place Photo event
|
// NIP-XX draft Place Photo event
|
||||||
const template = {
|
const template = {
|
||||||
@@ -185,12 +184,12 @@ 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();
|
||||||
@@ -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={{file}}
|
@file={{this.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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user