From d9ba73559ed4c9081ef64e78afd9527c39104885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 20 Apr 2026 14:25:15 +0400 Subject: [PATCH] WIP Upload multiple photos --- app/components/place-photo-item.gjs | 164 ++++++++++++++++ app/components/place-photo-upload.gjs | 265 +++++++++++--------------- app/styles/app.css | 112 +++++++++++ app/utils/icons.js | 8 + 4 files changed, 400 insertions(+), 149 deletions(-) create mode 100644 app/components/place-photo-item.gjs diff --git a/app/components/place-photo-item.gjs b/app/components/place-photo-item.gjs new file mode 100644 index 0000000..c9845bc --- /dev/null +++ b/app/components/place-photo-item.gjs @@ -0,0 +1,164 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import { EventFactory } from 'applesauce-core'; +import Icon from '#components/icon'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; + +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(''); +} + +export default class PlacePhotoItem extends Component { + @service nostrAuth; + @service nostrData; + + @tracked thumbnailUrl = ''; + @tracked error = ''; + @tracked isUploaded = false; + + get blossomServer() { + return this.nostrData.blossomServers[0] || DEFAULT_BLOSSOM_SERVER; + } + + 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 { + if (!this.nostrAuth.isConnected) throw new Error('Not connected'); + + 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 buffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); + const payloadHash = bufferToHex(hashBuffer); + + let serverUrl = this.blossomServer; + if (serverUrl.endsWith('/')) { + serverUrl = serverUrl.slice(0, -1); + } + const uploadUrl = `${serverUrl}/upload`; + + 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: '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}`; + + // 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.isUploaded = true; + + if (this.args.onSuccess) { + this.args.onSuccess({ + file, + url: result.url, + type: file.type, + dim, + hash: payloadHash, + }); + } + } catch (e) { + this.error = e.message; + } + }); + + +} diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs index 2a40ee3..94ef85d 100644 --- a/app/components/place-photo-upload.gjs +++ b/app/components/place-photo-upload.gjs @@ -5,26 +5,20 @@ import { inject as service } from '@ember/service'; import { on } from '@ember/modifier'; import { EventFactory } from 'applesauce-core'; 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; - @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,106 +28,56 @@ 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; - - 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 = ''; - } - } + @action + removeFile(fileToRemove) { + this.files = this.files.filter((f) => f !== fileToRemove); + this.uploadedPhotos = this.uploadedPhotos.filter( + (p) => p.file !== fileToRemove + ); } @action @@ -143,8 +87,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 +102,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 +116,21 @@ 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}`, + `m ${photo.type}`, + 'alt A photo of a place', + ]; - if (this.photoDim) { - imeta.splice(3, 0, `dim ${this.photoDim}`); + if (photo.dim) { + imeta.splice(3, 0, `dim ${photo.dim}`); + } + + tags.push(imeta); } - tags.push(imeta); - // NIP-XX draft Place Photo event const template = { kind: 360, @@ -199,16 +146,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; } } } diff --git a/app/styles/app.css b/app/styles/app.css index 4416dad..9389054 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -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; diff --git a/app/utils/icons.js b/app/utils/icons.js index 717b9e8..f2b3029 100644 --- a/app/utils/icons.js +++ b/app/utils/icons.js @@ -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,