Compare commits

...

2 Commits

Author SHA1 Message Date
a2a61b0fec Upload to multiple servers, delete from servers when removing in dialog
Introduces a dedicated blossom service to tie everything together
2026-04-20 15:22:17 +04:00
d9ba73559e WIP Upload multiple photos 2026-04-20 14:25:15 +04:00
5 changed files with 521 additions and 147 deletions

View File

@@ -0,0 +1,100 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
import Icon from '#components/icon';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
export default class PlacePhotoItem extends Component {
@service blossom;
@tracked thumbnailUrl = '';
@tracked error = '';
@tracked isUploaded = false;
constructor() {
super(...arguments);
if (this.args.file) {
this.thumbnailUrl = URL.createObjectURL(this.args.file);
this.uploadTask.perform(this.args.file);
}
}
willDestroy() {
super.willDestroy(...arguments);
if (this.thumbnailUrl) {
URL.revokeObjectURL(this.thumbnailUrl);
}
}
uploadTask = task(async (file) => {
this.error = '';
try {
const dim = await new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(`${img.width}x${img.height}`);
img.onerror = () => resolve('');
img.src = this.thumbnailUrl;
});
const result = await this.blossom.upload(file);
this.isUploaded = true;
if (this.args.onSuccess) {
this.args.onSuccess({
file,
url: result.url,
fallbackUrls: result.fallbackUrls,
type: result.type,
dim,
hash: result.hash,
});
}
} catch (e) {
this.error = e.message;
}
});
<template>
<div
class="photo-item
{{if this.uploadTask.isRunning 'is-uploading'}}
{{if this.error 'has-error'}}"
>
<img src={{this.thumbnailUrl}} alt="thumbnail" class="photo-item-img" />
{{#if this.uploadTask.isRunning}}
<div class="photo-item-overlay">
<Icon
@name="loading-ring"
@size={{24}}
@color="white"
class="spin-animation"
/>
</div>
{{/if}}
{{#if this.error}}
<div class="photo-item-overlay error-overlay" title={{this.error}}>
<Icon @name="alert-circle" @size={{24}} @color="white" />
</div>
{{/if}}
{{#if this.isUploaded}}
<div class="photo-item-overlay success-overlay">
<Icon @name="check" @size={{24}} @color="white" />
</div>
{{/if}}
<button
type="button"
class="btn-remove-photo"
title="Remove photo"
{{on "click" (fn @onRemove @file)}}
>
<Icon @name="x" @size={{16}} @color="white" />
</button>
</div>
</template>
}

View File

@@ -4,27 +4,24 @@ import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { on } from '@ember/modifier';
import { EventFactory } from 'applesauce-core';
import { task } from 'ember-concurrency';
import Geohash from 'latlon-geohash';
const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build';
function bufferToHex(buffer) {
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
import PlacePhotoItem from './place-photo-item';
import Icon from '#components/icon';
import { or, not } from 'ember-truth-helpers';
export default class PlacePhotoUpload extends Component {
@service nostrAuth;
@service nostrData;
@service nostrRelay;
@service blossom;
@service toast;
@tracked photoUrl = '';
@tracked photoType = 'image/jpeg';
@tracked photoDim = '';
@tracked files = [];
@tracked uploadedPhotos = [];
@tracked status = '';
@tracked error = '';
@tracked isUploading = false;
@tracked isPublishing = false;
@tracked isDragging = false;
get place() {
return this.args.place || {};
@@ -34,108 +31,72 @@ export default class PlacePhotoUpload extends Component {
return this.place.title || 'this place';
}
get blossomServer() {
return this.nostrData.blossomServers[0] || DEFAULT_BLOSSOM_SERVER;
get allUploaded() {
return (
this.files.length > 0 && this.files.length === this.uploadedPhotos.length
);
}
@action
async handleFileSelected(event) {
const file = event.target.files[0];
if (!file) return;
handleFileSelect(event) {
this.addFiles(event.target.files);
event.target.value = ''; // Reset input
}
this.error = '';
this.status = 'Preparing upload...';
this.isUploading = true;
this.photoType = file.type;
@action
handleDragOver(event) {
event.preventDefault();
this.isDragging = true;
}
try {
if (!this.nostrAuth.isConnected) {
throw new Error('You must connect Nostr first.');
}
@action
handleDragLeave(event) {
event.preventDefault();
this.isDragging = false;
}
// 1. Get image dimensions
const dim = await new Promise((resolve) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(`${img.width}x${img.height}`);
};
img.onerror = () => resolve('');
img.src = url;
});
this.photoDim = dim;
@action
handleDrop(event) {
event.preventDefault();
this.isDragging = false;
this.addFiles(event.dataTransfer.files);
}
// 2. Read file & compute hash
this.status = 'Computing hash...';
const buffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const payloadHash = bufferToHex(hashBuffer);
addFiles(fileList) {
if (!fileList) return;
const newFiles = Array.from(fileList).filter((f) =>
f.type.startsWith('image/')
);
this.files = [...this.files, ...newFiles];
}
// 3. Create BUD-11 Auth Event
this.status = 'Signing auth event...';
let serverUrl = this.blossomServer;
if (serverUrl.endsWith('/')) {
serverUrl = serverUrl.slice(0, -1);
}
const uploadUrl = `${serverUrl}/upload`;
@action
handleUploadSuccess(photoData) {
this.uploadedPhotos = [...this.uploadedPhotos, photoData];
}
const factory = new EventFactory({ signer: this.nostrAuth.signer });
const now = Math.floor(Date.now() / 1000);
const serverHostname = new URL(serverUrl).hostname;
@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
);
const authTemplate = {
kind: 24242,
created_at: now,
content: 'Upload photo for place',
tags: [
['t', 'upload'],
['x', payloadHash],
['expiration', String(now + 3600)],
['server', serverHostname],
],
};
const authEvent = await factory.sign(authTemplate);
const base64 = btoa(JSON.stringify(authEvent));
const base64url = base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const authHeader = `Nostr ${base64url}`;
// 4. Upload to Blossom
this.status = `Uploading to ${serverUrl}...`;
// eslint-disable-next-line warp-drive/no-external-request-patterns
const response = await fetch(uploadUrl, {
method: 'PUT',
headers: {
Authorization: authHeader,
'X-SHA-256': payloadHash,
},
body: file,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Upload failed (${response.status}): ${text}`);
}
const result = await response.json();
this.photoUrl = result.url;
this.status = 'Photo uploaded! Ready to publish.';
} catch (e) {
this.error = e.message;
this.status = '';
} finally {
this.isUploading = false;
if (event && event.target) {
event.target.value = '';
}
if (photoData && photoData.hash && photoData.url) {
this.deletePhotoTask.perform(photoData);
}
}
deletePhotoTask = task(async (photoData) => {
try {
if (!photoData.hash) return;
await this.blossom.delete(photoData.hash);
} catch (e) {
this.toast.show(`Failed to delete photo from server: ${e.message}`, 5000);
}
});
@action
async publish() {
if (!this.nostrAuth.isConnected) {
@@ -143,8 +104,8 @@ export default class PlacePhotoUpload extends Component {
return;
}
if (!this.photoUrl) {
this.error = 'Please upload a photo.';
if (!this.allUploaded) {
this.error = 'Please wait for all photos to finish uploading.';
return;
}
@@ -158,6 +119,7 @@ export default class PlacePhotoUpload extends Component {
this.status = 'Publishing event...';
this.error = '';
this.isPublishing = true;
try {
const factory = new EventFactory({ signer: this.nostrAuth.signer });
@@ -171,19 +133,25 @@ export default class PlacePhotoUpload extends Component {
tags.push(['g', Geohash.encode(lat, lon, 9)]);
}
const imeta = [
'imeta',
`url ${this.photoUrl}`,
`m ${this.photoType}`,
'alt A photo of a place',
];
for (const photo of this.uploadedPhotos) {
const imeta = ['imeta', `url ${photo.url}`];
if (this.photoDim) {
imeta.splice(3, 0, `dim ${this.photoDim}`);
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
for (const fallbackUrl of photo.fallbackUrls) {
imeta.push(`url ${fallbackUrl}`);
}
}
imeta.push(`m ${photo.type}`);
if (photo.dim) {
imeta.push(`dim ${photo.dim}`);
}
imeta.push('alt A photo of a place');
tags.push(imeta);
}
tags.push(imeta);
// NIP-XX draft Place Photo event
const template = {
kind: 360,
@@ -199,16 +167,21 @@ export default class PlacePhotoUpload extends Component {
await this.nostrRelay.publish(event);
this.status = 'Published successfully!';
this.photoUrl = '';
// Clear out the files so user can upload more or be done
this.files = [];
this.uploadedPhotos = [];
} catch (e) {
this.error = 'Failed to publish: ' + e.message;
this.status = '';
} finally {
this.isPublishing = false;
}
}
<template>
<div class="place-photo-upload">
<h2>Add Photo for {{this.title}}</h2>
<h2>Add Photos for {{this.title}}</h2>
{{#if this.error}}
<div class="alert alert-error">
@@ -222,38 +195,53 @@ export default class PlacePhotoUpload extends Component {
</div>
{{/if}}
<div>
{{#if this.photoUrl}}
<div class="preview-group">
<p>Photo Preview:</p>
<img
src={{this.photoUrl}}
alt="Preview"
class="photo-preview-img"
/>
</div>
<button
type="button"
class="btn btn-primary"
{{on "click" this.publish}}
>
Publish Event (kind: 360)
</button>
{{else}}
<label for="photo-upload-input">Select Photo</label>
<input
id="photo-upload-input"
type="file"
accept="image/*"
class="file-input"
disabled={{this.isUploading}}
{{on "change" this.handleFileSelected}}
/>
{{#if this.isUploading}}
<p>Uploading...</p>
{{/if}}
{{/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}} />
<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">
{{#each this.files as |file|}}
<PlacePhotoItem
@file={{file}}
@onSuccess={{this.handleUploadSuccess}}
@onRemove={{this.removeFile}}
/>
{{/each}}
</div>
<button
type="button"
class="btn btn-primary btn-publish"
disabled={{or (not this.allUploaded) this.isPublishing}}
{{on "click" this.publish}}
>
{{#if this.isPublishing}}
Publishing...
{{else}}
Publish
{{this.files.length}}
Photo(s)
{{/if}}
</button>
{{/if}}
</div>
</template>
}

166
app/services/blossom.js Normal file
View File

@@ -0,0 +1,166 @@
import Service, { inject as service } from '@ember/service';
import { EventFactory } from 'applesauce-core';
export const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build';
function bufferToHex(buffer) {
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
function getBlossomUrl(serverUrl, path) {
let url = serverUrl || DEFAULT_BLOSSOM_SERVER;
if (url.endsWith('/')) {
url = url.slice(0, -1);
}
return path.startsWith('/') ? `${url}${path}` : `${url}/${path}`;
}
export default class BlossomService extends Service {
@service nostrAuth;
@service nostrData;
get servers() {
const servers = this.nostrData.blossomServers;
return servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
}
async _getAuthHeader(action, hash, serverUrl) {
const factory = new EventFactory({ signer: this.nostrAuth.signer });
const now = Math.floor(Date.now() / 1000);
const serverHostname = new URL(serverUrl).hostname;
const authTemplate = {
kind: 24242,
created_at: now,
content: action === 'upload' ? 'Upload photo for place' : 'Delete photo',
tags: [
['t', action],
['x', hash],
['expiration', String(now + 3600)],
['server', serverHostname],
],
};
const authEvent = await factory.sign(authTemplate);
const base64 = btoa(JSON.stringify(authEvent));
const base64url = base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return `Nostr ${base64url}`;
}
async _uploadToServer(file, hash, serverUrl) {
const uploadUrl = getBlossomUrl(serverUrl, 'upload');
const authHeader = await this._getAuthHeader('upload', hash, serverUrl);
// eslint-disable-next-line warp-drive/no-external-request-patterns
const response = await fetch(uploadUrl, {
method: 'PUT',
headers: {
Authorization: authHeader,
'X-SHA-256': hash,
},
body: file,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Upload failed (${response.status}): ${text}`);
}
return response.json();
}
async upload(file) {
if (!this.nostrAuth.isConnected) throw new Error('Not connected');
const buffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const payloadHash = bufferToHex(hashBuffer);
const servers = this.servers;
const mainServer = servers[0];
const fallbackServers = servers.slice(1);
// Start all uploads concurrently
const mainPromise = this._uploadToServer(file, payloadHash, mainServer);
const fallbackPromises = fallbackServers.map((serverUrl) =>
this._uploadToServer(file, payloadHash, serverUrl)
);
// Main server MUST succeed
const mainResult = await mainPromise;
// Fallback servers can fail, but we log the warnings
const fallbackResults = await Promise.allSettled(fallbackPromises);
const fallbackUrls = [];
for (let i = 0; i < fallbackResults.length; i++) {
const result = fallbackResults[i];
if (result.status === 'fulfilled') {
fallbackUrls.push(result.value.url);
} else {
console.warn(
`Fallback upload to ${fallbackServers[i]} failed:`,
result.reason
);
}
}
return {
url: mainResult.url,
fallbackUrls,
hash: payloadHash,
type: file.type,
};
}
async _deleteFromServer(hash, serverUrl) {
const deleteUrl = getBlossomUrl(serverUrl, hash);
const authHeader = await this._getAuthHeader('delete', hash, serverUrl);
// eslint-disable-next-line warp-drive/no-external-request-patterns
const response = await fetch(deleteUrl, {
method: 'DELETE',
headers: {
Authorization: authHeader,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || response.statusText);
}
}
async delete(hash) {
if (!this.nostrAuth.isConnected) throw new Error('Not connected');
const servers = this.servers;
const mainServer = servers[0];
const fallbackServers = servers.slice(1);
const mainPromise = this._deleteFromServer(hash, mainServer);
const fallbackPromises = fallbackServers.map((serverUrl) =>
this._deleteFromServer(hash, serverUrl)
);
// Main server MUST succeed
await mainPromise;
// Fallback servers can fail, log warnings
const fallbackResults = await Promise.allSettled(fallbackPromises);
for (let i = 0; i < fallbackResults.length; i++) {
const result = fallbackResults[i];
if (result.status === 'rejected') {
console.warn(
`Fallback delete from ${fallbackServers[i]} failed:`,
result.reason
);
}
}
}
}

View File

@@ -210,6 +210,118 @@ body {
height: auto;
}
.dropzone {
border: 2px dashed #3a4b5c;
border-radius: 8px;
padding: 30px 20px;
text-align: center;
transition: all 0.2s ease;
margin-bottom: 20px;
background-color: rgb(255 255 255 / 2%);
cursor: pointer;
}
.dropzone.is-dragging {
border-color: #61afef;
background-color: rgb(97 175 239 / 5%);
}
.dropzone-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
color: #a0aec0;
}
.dropzone-label p {
margin: 0;
font-size: 1.1rem;
}
.file-input-hidden {
display: none;
}
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.photo-item {
position: relative;
aspect-ratio: 1 / 1;
border-radius: 6px;
overflow: hidden;
background: #1e262e;
}
.photo-item-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photo-item-overlay {
position: absolute;
inset: 0;
background: rgb(0 0 0 / 60%);
display: flex;
align-items: center;
justify-content: center;
}
.error-overlay {
background: rgb(224 108 117 / 80%);
}
.success-overlay {
background: rgb(152 195 121 / 60%);
}
.btn-remove-photo {
position: absolute;
top: 4px;
right: 4px;
background: rgb(0 0 0 / 70%);
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: white;
padding: 0;
}
.btn-remove-photo:hover {
background: rgb(224 108 117 / 90%);
}
.spin-animation {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.btn-publish {
width: 100%;
}
/* User Menu Popover */
.user-menu-container {
position: relative;

View File

@@ -26,8 +26,12 @@ import search from 'feather-icons/dist/icons/search.svg?raw';
import server from 'feather-icons/dist/icons/server.svg?raw';
import settings from 'feather-icons/dist/icons/settings.svg?raw';
import target from 'feather-icons/dist/icons/target.svg?raw';
import trash2 from 'feather-icons/dist/icons/trash-2.svg?raw';
import uploadCloud from 'feather-icons/dist/icons/upload-cloud.svg?raw';
import user from 'feather-icons/dist/icons/user.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw';
import check from 'feather-icons/dist/icons/check.svg?raw';
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
import zap from 'feather-icons/dist/icons/zap.svg?raw';
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
@@ -130,6 +134,8 @@ const ICONS = {
'check-square': checkSquare,
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
climbing_wall: climbingWall,
check,
'alert-circle': alertCircle,
'classical-building': classicalBuilding,
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
'classical-building-with-flag': classicalBuildingWithFlag,
@@ -214,6 +220,8 @@ const ICONS = {
'tattoo-machine': tattooMachine,
toolbox,
target,
'trash-2': trash2,
'upload-cloud': uploadCloud,
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
user,
'village-buildings': villageBuildings,