Compare commits
4 Commits
ec31d1a59b
...
a89ba904c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
a89ba904c8
|
|||
|
4c540bc713
|
|||
|
bb2411972f
|
|||
|
5cd384cf3a
|
@@ -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"
|
||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
4
app/utils/device.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export function isMobile() {
|
||||||
|
if (typeof navigator === 'undefined') return false;
|
||||||
|
return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user