Compare commits

...

4 Commits

Author SHA1 Message Date
a89ba904c8 Pluralize button text based on number of files 2026-04-21 09:38:25 +04:00
4c540bc713 Rename component, clean up CSS 2026-04-21 09:28:34 +04:00
bb2411972f Improve upload item UI 2026-04-21 09:17:44 +04:00
5cd384cf3a Do sequential image processing/uploads on mobile
Uploading multiple large files at once can fail easily
2026-04-20 19:37:24 +04:00
6 changed files with 89 additions and 65 deletions

View File

@@ -6,20 +6,20 @@ import { task } from 'ember-concurrency';
import Icon from '#components/icon'; 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';
const MAX_IMAGE_DIMENSION = 1920; const MAX_IMAGE_DIMENSION = 1920;
const IMAGE_QUALITY = 0.94; const IMAGE_QUALITY = 0.94;
const MAX_THUMBNAIL_DIMENSION = 350; const MAX_THUMBNAIL_DIMENSION = 350;
const THUMBNAIL_QUALITY = 0.9; const THUMBNAIL_QUALITY = 0.9;
export default class PlacePhotoItem extends Component { export default class PlacePhotoUploadItem extends Component {
@service blossom; @service blossom;
@service imageProcessor; @service imageProcessor;
@service toast; @service toast;
@tracked thumbnailUrl = ''; @tracked thumbnailUrl = '';
@tracked error = ''; @tracked error = '';
@tracked isUploaded = false;
constructor() { constructor() {
super(...arguments); super(...arguments);
@@ -62,19 +62,29 @@ export default class PlacePhotoItem extends Component {
false false
); );
// 3. Upload main image (to all servers concurrently) // 3. Upload main image
const mainUploadPromise = this.blossom.upload(mainData.blob); // 4. Upload thumbnail
let mainResult, thumbResult;
const isMobileDevice = isMobile();
// 4. Upload thumbnail (to all servers concurrently) if (isMobileDevice) {
const thumbUploadPromise = this.blossom.upload(thumbData.blob); // Mobile: sequential uploads to preserve bandwidth and memory
mainResult = await this.blossom.upload(mainData.blob, {
sequential: true,
});
thumbResult = await this.blossom.upload(thumbData.blob, {
sequential: true,
});
} else {
// Desktop: concurrent uploads
const mainUploadPromise = this.blossom.upload(mainData.blob);
const thumbUploadPromise = this.blossom.upload(thumbData.blob);
// Await both uploads [mainResult, thumbResult] = await Promise.all([
const [mainResult, thumbResult] = await Promise.all([ mainUploadPromise,
mainUploadPromise, thumbUploadPromise,
thumbUploadPromise, ]);
]); }
this.isUploaded = true;
if (this.args.onSuccess) { if (this.args.onSuccess) {
this.args.onSuccess({ this.args.onSuccess({
@@ -96,14 +106,14 @@ export default class PlacePhotoItem extends Component {
<template> <template>
<div <div
class="photo-item class="photo-upload-item
{{if this.uploadTask.isRunning 'is-uploading'}} {{if this.uploadTask.isRunning 'is-uploading'}}
{{if this.error 'has-error'}}" {{if this.error 'has-error'}}"
> >
<img src={{this.thumbnailUrl}} alt="thumbnail" class="photo-item-img" /> <img src={{this.thumbnailUrl}} alt="thumbnail" />
{{#if this.uploadTask.isRunning}} {{#if this.uploadTask.isRunning}}
<div class="photo-item-overlay"> <div class="overlay">
<Icon <Icon
@name="loading-ring" @name="loading-ring"
@size={{24}} @size={{24}}
@@ -116,7 +126,7 @@ export default class PlacePhotoItem extends Component {
{{#if this.error}} {{#if this.error}}
<button <button
type="button" type="button"
class="photo-item-overlay error-overlay" class="overlay error-overlay"
title={{this.error}} title={{this.error}}
{{on "click" this.showErrorToast}} {{on "click" this.showErrorToast}}
> >
@@ -124,12 +134,6 @@ export default class PlacePhotoItem extends Component {
</button> </button>
{{/if}} {{/if}}
{{#if this.isUploaded}}
<div class="photo-item-overlay success-overlay">
<Icon @name="check" @size={{24}} @color="white" />
</div>
{{/if}}
<button <button
type="button" type="button"
class="btn-remove-photo" class="btn-remove-photo"

View File

@@ -6,7 +6,7 @@ import { on } from '@ember/modifier';
import { EventFactory } from 'applesauce-core'; import { EventFactory } from 'applesauce-core';
import { task } from 'ember-concurrency'; import { task } from 'ember-concurrency';
import Geohash from 'latlon-geohash'; import Geohash from 'latlon-geohash';
import PlacePhotoItem from './place-photo-item'; import PlacePhotoUploadItem from './place-photo-upload-item';
import Icon from '#components/icon'; import Icon from '#components/icon';
import { or, not } from 'ember-truth-helpers'; import { or, not } from 'ember-truth-helpers';
@@ -37,6 +37,10 @@ export default class PlacePhotoUpload extends Component {
); );
} }
get photoWord() {
return this.files.length === 1 ? 'Photo' : 'Photos';
}
@action @action
handleFileSelect(event) { handleFileSelect(event) {
this.addFiles(event.target.files); this.addFiles(event.target.files);
@@ -232,7 +236,7 @@ export default class PlacePhotoUpload extends Component {
{{#if this.files.length}} {{#if this.files.length}}
<div class="photo-grid"> <div class="photo-grid">
{{#each this.files as |file|}} {{#each this.files as |file|}}
<PlacePhotoItem <PlacePhotoUploadItem
@file={{file}} @file={{file}}
@onSuccess={{this.handleUploadSuccess}} @onSuccess={{this.handleUploadSuccess}}
@onRemove={{this.removeFile}} @onRemove={{this.removeFile}}
@@ -251,7 +255,7 @@ export default class PlacePhotoUpload extends Component {
{{else}} {{else}}
Publish Publish
{{this.files.length}} {{this.files.length}}
Photo(s) {{this.photoWord}}
{{/if}} {{/if}}
</button> </button>
{{/if}} {{/if}}

View File

@@ -75,7 +75,7 @@ export default class BlossomService extends Service {
return response.json(); return response.json();
} }
async upload(file) { async upload(file, options = { sequential: false }) {
if (!this.nostrAuth.isConnected) throw new Error('Not connected'); if (!this.nostrAuth.isConnected) throw new Error('Not connected');
const buffer = await file.arrayBuffer(); const buffer = await file.arrayBuffer();
@@ -97,28 +97,48 @@ export default class BlossomService extends Service {
const mainServer = servers[0]; const mainServer = servers[0];
const fallbackServers = servers.slice(1); 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 = []; const fallbackUrls = [];
let mainResult;
for (let i = 0; i < fallbackResults.length; i++) { if (options.sequential) {
const result = fallbackResults[i]; // Sequential upload logic
if (result.status === 'fulfilled') { mainResult = await this._uploadToServer(file, payloadHash, mainServer);
fallbackUrls.push(result.value.url);
} else { for (const serverUrl of fallbackServers) {
console.warn( try {
`Fallback upload to ${fallbackServers[i]} failed:`, const result = await this._uploadToServer(
result.reason file,
); payloadHash,
serverUrl
);
fallbackUrls.push(result.url);
} catch (error) {
console.warn(`Fallback upload to ${serverUrl} failed:`, error);
}
}
} else {
// Concurrent upload logic
const mainPromise = this._uploadToServer(file, payloadHash, mainServer);
const fallbackPromises = fallbackServers.map((serverUrl) =>
this._uploadToServer(file, payloadHash, serverUrl)
);
// Main server MUST succeed
mainResult = await mainPromise;
// Fallback servers can fail, but we log the warnings
const fallbackResults = await Promise.allSettled(fallbackPromises);
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
);
}
} }
} }

View File

@@ -14,6 +14,8 @@ const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay';
const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app'; const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
import { isMobile } from '../utils/device';
export default class NostrAuthService extends Service { export default class NostrAuthService extends Service {
@service nostrRelay; @service nostrRelay;
@service nostrData; @service nostrData;
@@ -73,7 +75,7 @@ export default class NostrAuthService extends Service {
} }
get isMobile() { get isMobile() {
return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent); return isMobile();
} }
get isConnected() { get isConnected() {

View File

@@ -250,7 +250,7 @@ body {
margin-bottom: 20px; margin-bottom: 20px;
} }
.photo-item { .photo-upload-item {
position: relative; position: relative;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
border-radius: 6px; border-radius: 6px;
@@ -258,14 +258,14 @@ body {
background: #1e262e; background: #1e262e;
} }
.photo-item-img { .photo-upload-item img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; display: block;
} }
.photo-item-overlay { .photo-upload-item .overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: rgb(0 0 0 / 60%); background: rgb(0 0 0 / 60%);
@@ -274,7 +274,7 @@ body {
justify-content: center; justify-content: center;
} }
.error-overlay { .photo-upload-item .error-overlay {
background: rgb(224 108 117 / 80%); background: rgb(224 108 117 / 80%);
cursor: pointer; cursor: pointer;
border: none; border: none;
@@ -283,11 +283,7 @@ body {
width: 100%; width: 100%;
} }
.success-overlay { .photo-upload-item .btn-remove-photo {
background: rgb(152 195 121 / 60%);
}
.btn-remove-photo {
position: absolute; position: absolute;
top: 4px; top: 4px;
right: 4px; right: 4px;
@@ -304,8 +300,8 @@ body {
padding: 0; padding: 0;
} }
.btn-remove-photo:hover { .photo-upload-item .btn-remove-photo:hover {
background: rgb(224 108 117 / 90%); background: var(--marker-color-primary);
} }
.spin-animation { .spin-animation {
@@ -1588,12 +1584,6 @@ button.create-place {
color: #00c; color: #00c;
} }
.connected-status {
margin-bottom: 1rem;
color: #080;
word-break: break-all;
}
.preview-group { .preview-group {
margin-bottom: 1rem; margin-bottom: 1rem;
} }

4
app/utils/device.js Normal file
View File

@@ -0,0 +1,4 @@
export function isMobile() {
if (typeof navigator === 'undefined') return false;
return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
}