forked from MapComplete/MapComplete
Feature: add emergency image backup. If uploading images fails, they are saved into local storage and uploaded later on. Part of #2111, but also #2342
This commit is contained in:
parent
7380841205
commit
9f3d198068
9 changed files with 313 additions and 8 deletions
|
@ -63,6 +63,16 @@
|
||||||
"overwrite": "Overwrite in OpenStreetMap",
|
"overwrite": "Overwrite in OpenStreetMap",
|
||||||
"title": "Structured data loaded from the external website"
|
"title": "Structured data loaded from the external website"
|
||||||
},
|
},
|
||||||
|
"failedImages": {
|
||||||
|
"confirmDelete": "Permanently delete this image",
|
||||||
|
"confirmDeleteTitle": "Delete this image?",
|
||||||
|
"delete": "Delete this image",
|
||||||
|
"intro": "The following images failed to upload",
|
||||||
|
"menu": "Failed images ({count})",
|
||||||
|
"noFailedImages": "There are currently no failed images",
|
||||||
|
"retry": "Retry uploading this image",
|
||||||
|
"retryAll": "Retry uploading all images"
|
||||||
|
},
|
||||||
"favourite": {
|
"favourite": {
|
||||||
"reload": "Reload the data"
|
"reload": "Reload the data"
|
||||||
},
|
},
|
||||||
|
|
112
src/Logic/ImageProviders/EmergencyImageBackup.ts
Normal file
112
src/Logic/ImageProviders/EmergencyImageBackup.ts
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import { IdbLocalStorage } from "../Web/IdbLocalStorage"
|
||||||
|
import { Store, UIEventSource } from "../UIEventSource"
|
||||||
|
import ThemeViewState from "../../Models/ThemeViewState"
|
||||||
|
import LinkImageAction from "../Osm/Actions/LinkImageAction"
|
||||||
|
import { WithImageState } from "../../Models/ThemeViewState/WithImageState"
|
||||||
|
|
||||||
|
export interface FailedImageArgs {
|
||||||
|
readonly featureId: string,
|
||||||
|
readonly author: string,
|
||||||
|
readonly blob: File,
|
||||||
|
readonly targetKey: string | undefined,
|
||||||
|
readonly noblur: boolean,
|
||||||
|
readonly ignoreGps: boolean,
|
||||||
|
readonly lastGpsLocation: GeolocationCoordinates,
|
||||||
|
readonly layoutId: string
|
||||||
|
readonly date: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class EmergencyImageBackup {
|
||||||
|
|
||||||
|
public static readonly singleton = new EmergencyImageBackup()
|
||||||
|
private readonly _failedImages: UIEventSource<FailedImageArgs[]>
|
||||||
|
|
||||||
|
public readonly failedImages: Store<FailedImageArgs[]>
|
||||||
|
|
||||||
|
private readonly _isUploading: UIEventSource<boolean> = new UIEventSource(false)
|
||||||
|
public readonly isUploading: Store<boolean> = this._isUploading
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this._failedImages = IdbLocalStorage.Get<FailedImageArgs[]>("failed-images-backup", { defaultValue: [] })
|
||||||
|
this.failedImages = this._failedImages
|
||||||
|
}
|
||||||
|
|
||||||
|
public addFailedImage(args: FailedImageArgs) {
|
||||||
|
this._failedImages.data.push(args)
|
||||||
|
this._failedImages.ping()
|
||||||
|
}
|
||||||
|
|
||||||
|
public delete(img: FailedImageArgs) {
|
||||||
|
const index = this._failedImages.data.indexOf(img)
|
||||||
|
if (index < 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._failedImages.data.splice(index, 1)
|
||||||
|
this._failedImages.ping()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retries uploading the given image
|
||||||
|
* Returns 'true' if the image got correctly uploaded and linked (or upload is no longer necessary, e.g. deleted iem)
|
||||||
|
* @param state
|
||||||
|
* @param i
|
||||||
|
*/
|
||||||
|
public async retryUploading(state: ThemeViewState, i: FailedImageArgs): Promise<boolean> {
|
||||||
|
this._isUploading.set(true)
|
||||||
|
try {
|
||||||
|
|
||||||
|
const feature = await state.osmObjectDownloader.DownloadObjectAsync(i.featureId)
|
||||||
|
if (feature === "deleted") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const asGeojson = feature.asGeoJson()
|
||||||
|
const uploadResult = await state.imageUploadManager.uploadImageWithLicense(
|
||||||
|
i.featureId,
|
||||||
|
i.author,
|
||||||
|
i.blob,
|
||||||
|
i.targetKey,
|
||||||
|
i.noblur,
|
||||||
|
asGeojson,
|
||||||
|
{
|
||||||
|
ignoreGps: i.ignoreGps,
|
||||||
|
noBackup: true,// Don't save this _again_
|
||||||
|
overwriteGps: i.lastGpsLocation
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!uploadResult) {
|
||||||
|
// Upload failed again
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
state.featureProperties.trackFeature(asGeojson)
|
||||||
|
const properties = state.featureProperties.getStore(i.featureId)
|
||||||
|
// Upload successful, time to link this to the image
|
||||||
|
const action = new LinkImageAction(
|
||||||
|
i.featureId,
|
||||||
|
uploadResult.key,
|
||||||
|
uploadResult.value,
|
||||||
|
properties,
|
||||||
|
{
|
||||||
|
theme: i.layoutId,
|
||||||
|
changeType: "add-image"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.changes.applyAction(action)
|
||||||
|
await state.changes.flushChanges("delayed image upload link")
|
||||||
|
this.delete(i)
|
||||||
|
return true
|
||||||
|
} finally {
|
||||||
|
this._isUploading.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async retryAll(state: WithImageState) {
|
||||||
|
for (const img of [...this._failedImages.data]) {
|
||||||
|
await this.retryUploading(state, img)
|
||||||
|
/*this._isUploading.setData(true)
|
||||||
|
await Utils.waitFor(2000)
|
||||||
|
this._isUploading.set(false)*/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,6 +11,7 @@ import { Translation } from "../../UI/i18n/Translation"
|
||||||
import { IndexedFeatureSource } from "../FeatureSource/FeatureSource"
|
import { IndexedFeatureSource } from "../FeatureSource/FeatureSource"
|
||||||
import { GeoOperations } from "../GeoOperations"
|
import { GeoOperations } from "../GeoOperations"
|
||||||
import { Feature } from "geojson"
|
import { Feature } from "geojson"
|
||||||
|
import EmergencyImageBackup from "./EmergencyImageBackup"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ImageUploadManager has a
|
* The ImageUploadManager has a
|
||||||
|
@ -85,7 +86,7 @@ export class ImageUploadManager {
|
||||||
uploadFinished: this.getCounterFor(this._uploadFinished, featureId),
|
uploadFinished: this.getCounterFor(this._uploadFinished, featureId),
|
||||||
retried: this.getCounterFor(this._uploadRetried, featureId),
|
retried: this.getCounterFor(this._uploadRetried, featureId),
|
||||||
failed: this.getCounterFor(this._uploadFailed, featureId),
|
failed: this.getCounterFor(this._uploadFailed, featureId),
|
||||||
retrySuccess: this.getCounterFor(this._uploadRetriedSuccess, featureId),
|
retrySuccess: this.getCounterFor(this._uploadRetriedSuccess, featureId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,10 +95,14 @@ export class ImageUploadManager {
|
||||||
if (sizeInBytes > this._uploader.maxFileSizeInMegabytes * 1000000) {
|
if (sizeInBytes > this._uploader.maxFileSizeInMegabytes * 1000000) {
|
||||||
const error = Translations.t.image.toBig.Subs({
|
const error = Translations.t.image.toBig.Subs({
|
||||||
actual_size: Math.floor(sizeInBytes / 1000000) + "MB",
|
actual_size: Math.floor(sizeInBytes / 1000000) + "MB",
|
||||||
max_size: this._uploader.maxFileSizeInMegabytes + "MB",
|
max_size: this._uploader.maxFileSizeInMegabytes + "MB"
|
||||||
})
|
})
|
||||||
return { error }
|
return { error }
|
||||||
}
|
}
|
||||||
|
const ext = file.name.split(".").at(-1).toLowerCase()
|
||||||
|
if (ext !== "jpg" && ext !== "jpeg") {
|
||||||
|
return { error: new Translation({ en: "Only JPG-files are allowed" }) }
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -148,13 +153,24 @@ export class ImageUploadManager {
|
||||||
properties,
|
properties,
|
||||||
{
|
{
|
||||||
theme: tags?.data?.["_orig_theme"] ?? this._theme.id,
|
theme: tags?.data?.["_orig_theme"] ?? this._theme.id,
|
||||||
changeType: "add-image",
|
changeType: "add-image"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
await this._changes.applyAction(action)
|
await this._changes.applyAction(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads an image; returns undefined if the image upload failed.
|
||||||
|
* Errors are handled internally
|
||||||
|
* @param featureId
|
||||||
|
* @param author
|
||||||
|
* @param blob
|
||||||
|
* @param targetKey
|
||||||
|
* @param noblur
|
||||||
|
* @param feature
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
public async uploadImageWithLicense(
|
public async uploadImageWithLicense(
|
||||||
featureId: string,
|
featureId: string,
|
||||||
author: string,
|
author: string,
|
||||||
|
@ -162,14 +178,21 @@ export class ImageUploadManager {
|
||||||
targetKey: string | undefined,
|
targetKey: string | undefined,
|
||||||
noblur: boolean,
|
noblur: boolean,
|
||||||
feature: Feature,
|
feature: Feature,
|
||||||
ignoreGps: boolean = false
|
options?: {
|
||||||
): Promise<UploadResult> {
|
ignoreGps?: boolean,
|
||||||
|
noBackup?: boolean,
|
||||||
|
overwriteGps?: GeolocationCoordinates
|
||||||
|
|
||||||
|
}
|
||||||
|
): Promise<UploadResult | undefined> {
|
||||||
this.increaseCountFor(this._uploadStarted, featureId)
|
this.increaseCountFor(this._uploadStarted, featureId)
|
||||||
let key: string
|
let key: string
|
||||||
let value: string
|
let value: string
|
||||||
let absoluteUrl: string
|
let absoluteUrl: string
|
||||||
let location: [number, number] = undefined
|
let location: [number, number] = undefined
|
||||||
if (this._gps.data && !ignoreGps) {
|
if (options?.overwriteGps) {
|
||||||
|
location = [options.overwriteGps.longitude, options.overwriteGps.latitude]
|
||||||
|
} else if (this._gps.data && !options?.ignoreGps) {
|
||||||
location = [this._gps.data.longitude, this._gps.data.latitude]
|
location = [this._gps.data.longitude, this._gps.data.latitude]
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
@ -210,13 +233,21 @@ export class ImageUploadManager {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Could again not upload image due to", e)
|
console.error("Could again not upload image due to", e)
|
||||||
this.increaseCountFor(this._uploadFailed, featureId)
|
this.increaseCountFor(this._uploadFailed, featureId)
|
||||||
|
if (!options?.noBackup) {
|
||||||
|
EmergencyImageBackup.singleton.addFailedImage({
|
||||||
|
blob, author, noblur, featureId, targetKey, ignoreGps: options?.ignoreGps,
|
||||||
|
layoutId: this._theme.id,
|
||||||
|
lastGpsLocation: this._gps.data,
|
||||||
|
date: new Date().getTime()
|
||||||
|
})
|
||||||
|
}
|
||||||
await this._reportError(
|
await this._reportError(
|
||||||
e,
|
e,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
ctx: "While uploading an image in the Image Upload Manager",
|
ctx: "While uploading an image in the Image Upload Manager",
|
||||||
featureId,
|
featureId,
|
||||||
author,
|
author,
|
||||||
targetKey,
|
targetKey
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
return undefined
|
return undefined
|
||||||
|
|
|
@ -26,6 +26,7 @@ export class MenuState {
|
||||||
"about_theme",
|
"about_theme",
|
||||||
"download",
|
"download",
|
||||||
"favourites",
|
"favourites",
|
||||||
|
"failedImages",
|
||||||
"usersettings",
|
"usersettings",
|
||||||
"share",
|
"share",
|
||||||
"menu",
|
"menu",
|
||||||
|
|
|
@ -9,6 +9,7 @@ import ThemeViewStateHashActor from "../../Logic/Web/ThemeViewStateHashActor"
|
||||||
import PendingChangesUploader from "../../Logic/Actors/PendingChangesUploader"
|
import PendingChangesUploader from "../../Logic/Actors/PendingChangesUploader"
|
||||||
import { WithGuiState } from "./WithGuiState"
|
import { WithGuiState } from "./WithGuiState"
|
||||||
import { SpecialVisualizationState } from "../../UI/SpecialVisualization"
|
import { SpecialVisualizationState } from "../../UI/SpecialVisualization"
|
||||||
|
import EmergencyImageBackup from "../../Logic/ImageProviders/EmergencyImageBackup"
|
||||||
|
|
||||||
export class WithImageState extends WithGuiState implements SpecialVisualizationState {
|
export class WithImageState extends WithGuiState implements SpecialVisualizationState {
|
||||||
readonly imageUploadManager: ImageUploadManager
|
readonly imageUploadManager: ImageUploadManager
|
||||||
|
@ -42,6 +43,10 @@ export class WithImageState extends WithGuiState implements SpecialVisualization
|
||||||
this.selectCurrentView()
|
this.selectCurrentView()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.osmConnection.userDetails.addCallbackAndRunD(() => {
|
||||||
|
EmergencyImageBackup.singleton.retryAll(this)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -61,6 +61,9 @@
|
||||||
import Hotkeys from "../Base/Hotkeys"
|
import Hotkeys from "../Base/Hotkeys"
|
||||||
import { ArrowTrendingUp } from "@babeard/svelte-heroicons/solid/ArrowTrendingUp"
|
import { ArrowTrendingUp } from "@babeard/svelte-heroicons/solid/ArrowTrendingUp"
|
||||||
import ArrowTopRightOnSquare from "@babeard/svelte-heroicons/mini/ArrowTopRightOnSquare"
|
import ArrowTopRightOnSquare from "@babeard/svelte-heroicons/mini/ArrowTopRightOnSquare"
|
||||||
|
import FailedImagesView from "../Image/FailedImagesView.svelte"
|
||||||
|
import { PhotoIcon } from "@babeard/svelte-heroicons/outline"
|
||||||
|
import EmergencyImageBackup from "../../Logic/ImageProviders/EmergencyImageBackup"
|
||||||
|
|
||||||
export let state: {
|
export let state: {
|
||||||
favourites: FavouritesFeatureSource
|
favourites: FavouritesFeatureSource
|
||||||
|
@ -97,6 +100,8 @@
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
let isAndroid = AndroidPolyfill.inAndroid
|
let isAndroid = AndroidPolyfill.inAndroid
|
||||||
|
let nrOfFailedImages = EmergencyImageBackup.singleton.failedImages
|
||||||
|
let failedImagesOpen = pg.failedImages
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -156,6 +161,16 @@
|
||||||
/>
|
/>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
|
{#if $nrOfFailedImages.length > 0 || $failedImagesOpen}
|
||||||
|
<Page {onlyLink} shown={pg.failedImages} bodyPadding="p-0 pb-4">
|
||||||
|
<svelte:fragment slot="header">
|
||||||
|
<PhotoIcon />
|
||||||
|
<Tr t={Translations.t.failedImages.menu.Subs({count: $nrOfFailedImages.length})} />
|
||||||
|
</svelte:fragment>
|
||||||
|
<FailedImagesView {state} />
|
||||||
|
</Page>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<LoginToggle {state} silentFail>
|
<LoginToggle {state} silentFail>
|
||||||
{#if state.favourites}
|
{#if state.favourites}
|
||||||
<Page {onlyLink} shown={pg.favourites}>
|
<Page {onlyLink} shown={pg.favourites}>
|
||||||
|
|
88
src/UI/Image/FailedImage.svelte
Normal file
88
src/UI/Image/FailedImage.svelte
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { FailedImageArgs } from "../../Logic/ImageProviders/EmergencyImageBackup"
|
||||||
|
import ThemeViewState from "../../Models/ThemeViewState"
|
||||||
|
import EmergencyImageBackup from "../../Logic/ImageProviders/EmergencyImageBackup"
|
||||||
|
import Loading from "../Base/Loading.svelte"
|
||||||
|
import { TrashIcon } from "@babeard/svelte-heroicons/mini"
|
||||||
|
import Popup from "../Base/Popup.svelte"
|
||||||
|
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||||
|
import Page from "../Base/Page.svelte"
|
||||||
|
import BackButton from "../Base/BackButton.svelte"
|
||||||
|
import Tr from "../Base/Tr.svelte"
|
||||||
|
import Translations from "../i18n/Translations"
|
||||||
|
|
||||||
|
let emergencyBackup = EmergencyImageBackup.singleton
|
||||||
|
let isUploading = emergencyBackup.isUploading
|
||||||
|
export let state: ThemeViewState
|
||||||
|
let _state: "idle" | "retrying" | "failed" | "success" = "idle"
|
||||||
|
export let failedImage: FailedImageArgs
|
||||||
|
let confirmDelete = new UIEventSource(false)
|
||||||
|
|
||||||
|
async function retry() {
|
||||||
|
_state = "retrying"
|
||||||
|
const success = await emergencyBackup.retryUploading(state, failedImage)
|
||||||
|
if (success) {
|
||||||
|
_state = "success"
|
||||||
|
} else {
|
||||||
|
_state = "failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function del() {
|
||||||
|
emergencyBackup.delete(failedImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = Translations.t
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="low-interaction rounded border-interactive w-fit p-2 m-1 flex flex-col">
|
||||||
|
|
||||||
|
<img class="max-w-64 w-auto max-h-64 w-auto" src={URL.createObjectURL(failedImage.blob)} />
|
||||||
|
{failedImage.featureId} {failedImage.layoutId}
|
||||||
|
{#if $isUploading || _state === "retrying"}
|
||||||
|
<Loading>
|
||||||
|
<Tr t={t.image.upload.one.uploading} />
|
||||||
|
</Loading>
|
||||||
|
{:else if _state === "idle" || _state === "failed"}
|
||||||
|
<button on:click={() => retry()}>
|
||||||
|
<Tr t={t.failedImages.retry} />
|
||||||
|
</button>
|
||||||
|
{#if _state === "failed"}
|
||||||
|
<span class="alert"><Tr t={t.image.upload.one.failed} /></span>
|
||||||
|
{/if}
|
||||||
|
{:else if _state === "success"}
|
||||||
|
<div class="thanks">
|
||||||
|
<Tr t={t.image.upload.one.done} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button class="as-link self-end" on:click={() => {confirmDelete.set(true)}}>
|
||||||
|
<TrashIcon class="w-4" />
|
||||||
|
<Tr t={t.failedImages.delete} />
|
||||||
|
</button>
|
||||||
|
<Popup shown={confirmDelete} dismissable={true}>
|
||||||
|
<Page shown={confirmDelete}>
|
||||||
|
<svelte:fragment slot="header">
|
||||||
|
<TrashIcon class="w-8 m-1" />
|
||||||
|
<Tr t={t.failedImages.confirmDeleteTitle} />
|
||||||
|
</svelte:fragment>
|
||||||
|
|
||||||
|
<div class="flex flex-col ">
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<img class="max-w-128 w-auto max-h-128 w-auto" src={URL.createObjectURL(failedImage.blob)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full">
|
||||||
|
<BackButton clss="w-full" on:click={() => confirmDelete.set(false)}>
|
||||||
|
<Tr t={t.general.back} />
|
||||||
|
</BackButton>
|
||||||
|
<button on:click={() => del()} class="primary w-full">
|
||||||
|
|
||||||
|
<TrashIcon class="w-8 m-1" />
|
||||||
|
<Tr t={t.failedImages.confirmDelete} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
</Popup>
|
||||||
|
</div>
|
41
src/UI/Image/FailedImagesView.svelte
Normal file
41
src/UI/Image/FailedImagesView.svelte
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import EmergencyImageBackup from "../../Logic/ImageProviders/EmergencyImageBackup"
|
||||||
|
import ThemeViewState from "../../Models/ThemeViewState"
|
||||||
|
import FailedImage from "./FailedImage.svelte"
|
||||||
|
import { ArrowPathIcon } from "@babeard/svelte-heroicons/mini"
|
||||||
|
import Loading from "../Base/Loading.svelte"
|
||||||
|
import { WithImageState } from "../../Models/ThemeViewState/WithImageState"
|
||||||
|
import Tr from "../Base/Tr.svelte"
|
||||||
|
import Translations from "../i18n/Translations"
|
||||||
|
|
||||||
|
let emergencyBackup = EmergencyImageBackup.singleton
|
||||||
|
let failed = emergencyBackup.failedImages
|
||||||
|
export let state: WithImageState
|
||||||
|
let isUploading = emergencyBackup.isUploading
|
||||||
|
|
||||||
|
const t = Translations.t
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="m-4 flex flex-col">
|
||||||
|
{#if $failed.length === 0}
|
||||||
|
<Tr t={t.failedImages.noFailedImages} />
|
||||||
|
{:else}
|
||||||
|
<div>
|
||||||
|
<Tr t={t.failedImages.intro} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $isUploading}
|
||||||
|
<Loading />
|
||||||
|
{:else}
|
||||||
|
<button class="primary" on:click={() => emergencyBackup.retryAll(state)}>
|
||||||
|
<ArrowPathIcon class="w-8 h-8 m-1" />
|
||||||
|
<Tr t={t.failedImages.retryAll} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-wrap">
|
||||||
|
{#each $failed as failedImage (failedImage.date + failedImage.featureId)}
|
||||||
|
<FailedImage {failedImage} {state} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
|
@ -58,7 +58,9 @@
|
||||||
"image",
|
"image",
|
||||||
noBlur,
|
noBlur,
|
||||||
feature,
|
feature,
|
||||||
ignoreGps
|
{
|
||||||
|
ignoreGps
|
||||||
|
}
|
||||||
)
|
)
|
||||||
if (!uploadResult) {
|
if (!uploadResult) {
|
||||||
return
|
return
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue