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 { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import { isMobile } from '../utils/device';
const MAX_IMAGE_DIMENSION = 1920;
const IMAGE_QUALITY = 0.94;
const MAX_THUMBNAIL_DIMENSION = 350;
const THUMBNAIL_QUALITY = 0.9;
export default class PlacePhotoItem extends Component {
export default class PlacePhotoUploadItem extends Component {
@service blossom;
@service imageProcessor;
@service toast;
@tracked thumbnailUrl = '';
@tracked error = '';
@tracked isUploaded = false;
constructor() {
super(...arguments);
@@ -62,19 +62,29 @@ export default class PlacePhotoItem extends Component {
false
);
// 3. Upload main image (to all servers concurrently)
const mainUploadPromise = this.blossom.upload(mainData.blob);
// 3. Upload main image
// 4. Upload thumbnail
let mainResult, thumbResult;
const isMobileDevice = isMobile();
// 4. Upload thumbnail (to all servers concurrently)
const thumbUploadPromise = this.blossom.upload(thumbData.blob);
if (isMobileDevice) {
// 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
const [mainResult, thumbResult] = await Promise.all([
mainUploadPromise,
thumbUploadPromise,
]);
this.isUploaded = true;
[mainResult, thumbResult] = await Promise.all([
mainUploadPromise,
thumbUploadPromise,
]);
}
if (this.args.onSuccess) {
this.args.onSuccess({
@@ -96,14 +106,14 @@ export default class PlacePhotoItem extends Component {
<template>
<div
class="photo-item
class="photo-upload-item
{{if this.uploadTask.isRunning 'is-uploading'}}
{{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}}
<div class="photo-item-overlay">
<div class="overlay">
<Icon
@name="loading-ring"
@size={{24}}
@@ -116,7 +126,7 @@ export default class PlacePhotoItem extends Component {
{{#if this.error}}
<button
type="button"
class="photo-item-overlay error-overlay"
class="overlay error-overlay"
title={{this.error}}
{{on "click" this.showErrorToast}}
>
@@ -124,12 +134,6 @@ export default class PlacePhotoItem extends Component {
</button>
{{/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"

View File

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

View File

@@ -75,7 +75,7 @@ export default class BlossomService extends Service {
return response.json();
}
async upload(file) {
async upload(file, options = { sequential: false }) {
if (!this.nostrAuth.isConnected) throw new Error('Not connected');
const buffer = await file.arrayBuffer();
@@ -97,28 +97,48 @@ export default class BlossomService extends Service {
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 = [];
let mainResult;
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
);
if (options.sequential) {
// Sequential upload logic
mainResult = await this._uploadToServer(file, payloadHash, mainServer);
for (const serverUrl of fallbackServers) {
try {
const result = await this._uploadToServer(
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';
import { isMobile } from '../utils/device';
export default class NostrAuthService extends Service {
@service nostrRelay;
@service nostrData;
@@ -73,7 +75,7 @@ export default class NostrAuthService extends Service {
}
get isMobile() {
return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
return isMobile();
}
get isConnected() {

View File

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