forked from MapComplete/MapComplete
Chore: rework image uploading, should work better now
This commit is contained in:
parent
6f5b0622a5
commit
94ba18785d
17 changed files with 548 additions and 238 deletions
|
@ -344,8 +344,8 @@
|
|||
},
|
||||
"useSearch": "Use the search above to see presets",
|
||||
"useSearchForMore": "Use the search function to search within {total} more values…",
|
||||
"waitingForGeopermission": "Waiting for your permission to use the geolocation...",
|
||||
"waitingForLocation": "Searching your current location...",
|
||||
"waitingForGeopermission": "Waiting for your permission to use the geolocation…",
|
||||
"waitingForLocation": "Searching your current location…",
|
||||
"weekdays": {
|
||||
"abbreviations": {
|
||||
"friday": "Fri",
|
||||
|
@ -416,6 +416,22 @@
|
|||
"pleaseLogin": "Please log in to add a picture",
|
||||
"respectPrivacy": "Do not photograph people nor license plates. Do not upload Google Maps, Google Streetview or other copyrighted sources.",
|
||||
"toBig": "Your image is too large as it is {actual_size}. Please use images of at most {max_size}",
|
||||
"upload": {
|
||||
"failReasons": "You might have lost connection to the internet",
|
||||
"failReasonsAdvanced": "Alternatively, make sure your browser and extensions do not block third-party API's.",
|
||||
"multiple": {
|
||||
"done": "{count} images are successfully uploaded. Thank you!",
|
||||
"partiallyDone": "{count} images are getting uploaded, {done} images are done…",
|
||||
"someFailed": "Sorry, we could not upload {count} images",
|
||||
"uploading": "{count} images are getting uploaded…"
|
||||
},
|
||||
"one": {
|
||||
"done": "Your image was successfully uploaded. Thank you!",
|
||||
"failed": "Sorry, we could not upload your image",
|
||||
"retrying": "Your image is getting uploaded again…",
|
||||
"uploading": "Your image is getting uploaded…"
|
||||
}
|
||||
},
|
||||
"uploadDone": "Your picture has been added. Thanks for helping out!",
|
||||
"uploadFailed": "Could not upload your picture. Are you connected to the Internet, and allow third party API's? The Brave browser or the uMatrix plugin might block them.",
|
||||
"uploadMultipleDone": "{count} pictures have been added. Thanks for helping out!",
|
||||
|
|
|
@ -2682,26 +2682,6 @@ a.link-underline {
|
|||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@-webkit-keyframes spin {
|
||||
to {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.motion-reduce\:animate-spin {
|
||||
-webkit-animation: spin 1s linear infinite;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.max-\[480px\]\:w-full {
|
||||
width: 100%;
|
||||
|
|
|
@ -7,7 +7,7 @@ import { OsmTags } from "../../../Models/OsmFeature"
|
|||
*/
|
||||
export default class FeaturePropertiesStore {
|
||||
private readonly _elements = new Map<string, UIEventSource<Record<string, string>>>()
|
||||
|
||||
public readonly aliases = new Map<string, string>()
|
||||
constructor(...sources: FeatureSource[]) {
|
||||
for (const source of sources) {
|
||||
this.trackFeatureSource(source)
|
||||
|
@ -92,7 +92,6 @@ export default class FeaturePropertiesStore {
|
|||
})
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
public addAlias(oldId: string, newId: string): void {
|
||||
if (newId === undefined) {
|
||||
// We removed the node/way/relation with type 'type' and id 'oldId' on openstreetmap!
|
||||
|
@ -112,6 +111,7 @@ export default class FeaturePropertiesStore {
|
|||
}
|
||||
element.data.id = newId
|
||||
this._elements.set(newId, element)
|
||||
this.aliases.set(newId, oldId)
|
||||
element.ping()
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,150 @@
|
|||
import { ImageUploader } from "./ImageUploader";
|
||||
import LinkImageAction from "../Osm/Actions/LinkImageAction";
|
||||
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore";
|
||||
import { OsmId, OsmTags } from "../../Models/OsmFeature";
|
||||
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
|
||||
import { Store, UIEventSource } from "../UIEventSource";
|
||||
import { OsmConnection } from "../Osm/OsmConnection";
|
||||
import { Changes } from "../Osm/Changes";
|
||||
import Translations from "../../UI/i18n/Translations";
|
||||
|
||||
|
||||
/**
|
||||
* The ImageUploadManager has a
|
||||
*/
|
||||
export class ImageUploadManager {
|
||||
|
||||
private readonly _uploader: ImageUploader;
|
||||
private readonly _featureProperties: FeaturePropertiesStore;
|
||||
private readonly _layout: LayoutConfig;
|
||||
|
||||
private readonly _uploadStarted: Map<string, UIEventSource<number>> = new Map();
|
||||
private readonly _uploadFinished: Map<string, UIEventSource<number>> = new Map();
|
||||
private readonly _uploadFailed: Map<string, UIEventSource<number>> = new Map();
|
||||
private readonly _uploadRetried: Map<string, UIEventSource<number>> = new Map();
|
||||
private readonly _uploadRetriedSuccess: Map<string, UIEventSource<number>> = new Map();
|
||||
private readonly _osmConnection: OsmConnection;
|
||||
private readonly _changes: Changes;
|
||||
|
||||
constructor(layout: LayoutConfig, uploader: ImageUploader, featureProperties: FeaturePropertiesStore, osmConnection: OsmConnection, changes: Changes) {
|
||||
this._uploader = uploader;
|
||||
this._featureProperties = featureProperties;
|
||||
this._layout = layout;
|
||||
this._osmConnection = osmConnection;
|
||||
this._changes = changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets various counters.
|
||||
* Note that counters can only increase
|
||||
* If a retry was a success, both 'retrySuccess' _and_ 'uploadFinished' will be increased
|
||||
* @param featureId: the id of the feature you want information for. '*' has a global counter
|
||||
*/
|
||||
public getCountsFor(featureId: string | "*"): {
|
||||
retried: Store<number>;
|
||||
uploadStarted: Store<number>;
|
||||
retrySuccess: Store<number>;
|
||||
failed: Store<number>;
|
||||
uploadFinished: Store<number>
|
||||
} {
|
||||
return {
|
||||
uploadStarted: this.getCounterFor(this._uploadStarted, featureId),
|
||||
uploadFinished: this.getCounterFor(this._uploadFinished, featureId),
|
||||
retried: this.getCounterFor(this._uploadRetried, featureId),
|
||||
failed: this.getCounterFor(this._uploadFailed, featureId),
|
||||
retrySuccess: this.getCounterFor(this._uploadRetriedSuccess, featureId)
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the given image, applies the correct title and license for the known user
|
||||
*/
|
||||
public async uploadImageAndApply(file: File, tags: OsmTags) {
|
||||
|
||||
const sizeInBytes = file.size
|
||||
const featureId = <OsmId> tags.id
|
||||
console.log(file.name + " has a size of " + sizeInBytes + " Bytes, attaching to", tags.id)
|
||||
const self = this
|
||||
if (sizeInBytes > this._uploader.maxFileSizeInMegabytes * 1000000) {
|
||||
this.increaseCountFor(this._uploadStarted, featureId)
|
||||
this.increaseCountFor(this._uploadFailed, featureId)
|
||||
throw(
|
||||
Translations.t.image.toBig.Subs({
|
||||
actual_size: Math.floor(sizeInBytes / 1000000) + "MB",
|
||||
max_size: self._uploader.maxFileSizeInMegabytes + "MB",
|
||||
}).txt
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const licenseStore = this._osmConnection?.GetPreference("pictures-license", "CC0");
|
||||
const license = licenseStore?.data ?? "CC0";
|
||||
|
||||
const matchingLayer = this._layout?.getMatchingLayer(tags);
|
||||
|
||||
const title =
|
||||
matchingLayer?.title?.GetRenderValue(tags)?.Subs(tags)?.textFor("en") ??
|
||||
tags.name ??
|
||||
"https//osm.org/" + tags.id;
|
||||
const description = [
|
||||
"author:" + this._osmConnection.userDetails.data.name,
|
||||
"license:" + license,
|
||||
"osmid:" + tags.id
|
||||
].join("\n");
|
||||
|
||||
console.log("Upload done, creating ")
|
||||
const action = await this.uploadImageWithLicense(featureId, title, description, file);
|
||||
await this._changes.applyAction(action);
|
||||
}
|
||||
|
||||
private async uploadImageWithLicense(
|
||||
featureId: OsmId,
|
||||
title: string, description: string, blob: File
|
||||
): Promise<LinkImageAction> {
|
||||
this.increaseCountFor(this._uploadStarted, featureId);
|
||||
const properties = this._featureProperties.getStore(featureId);
|
||||
let key: string;
|
||||
let value: string;
|
||||
try {
|
||||
({ key, value } = await this._uploader.uploadImage(title, description, blob));
|
||||
} catch (e) {
|
||||
this.increaseCountFor(this._uploadRetried, featureId);
|
||||
console.error("Could not upload image, trying again:", e);
|
||||
try {
|
||||
|
||||
({ key, value } = await this._uploader.uploadImage(title, description, blob));
|
||||
this.increaseCountFor(this._uploadRetriedSuccess, featureId);
|
||||
} catch (e) {
|
||||
console.error("Could again not upload image due to", e);
|
||||
this.increaseCountFor(this._uploadFailed, featureId);
|
||||
}
|
||||
|
||||
}
|
||||
console.log("Uploading done, creating action for", featureId)
|
||||
const action = new LinkImageAction(featureId, key, value, properties, {
|
||||
theme: this._layout.id,
|
||||
changeType: "add-image"
|
||||
});
|
||||
this.increaseCountFor(this._uploadFinished, featureId);
|
||||
return action;
|
||||
}
|
||||
|
||||
private getCounterFor(collection: Map<string, UIEventSource<number>>, key: string | "*") {
|
||||
if (this._featureProperties.aliases.has(key)) {
|
||||
key = this._featureProperties.aliases.get(key);
|
||||
}
|
||||
if (!collection.has(key)) {
|
||||
collection.set(key, new UIEventSource<number>(0));
|
||||
}
|
||||
return collection.get(key);
|
||||
}
|
||||
|
||||
private increaseCountFor(collection: Map<string, UIEventSource<number>>, key: string | "*") {
|
||||
const counter = this.getCounterFor(collection, key);
|
||||
counter.setData(counter.data + 1);
|
||||
const global = this.getCounterFor(collection, "*");
|
||||
global.setData(counter.data + 1);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
export interface ImageUploader {
|
||||
maxFileSizeInMegabytes?: number;
|
||||
/**
|
||||
* Uploads the 'blob' as image, with some metadata.
|
||||
* Returns the URL to be linked + the appropriate key to add this to OSM
|
||||
* @param title
|
||||
* @param description
|
||||
* @param blob
|
||||
*/
|
||||
uploadImage(
|
||||
title: string,
|
||||
description: string,
|
||||
blob: File
|
||||
): Promise<{ key: string, value: string }>;
|
||||
}
|
|
@ -1,60 +1,30 @@
|
|||
import ImageProvider, { ProvidedImage } from "./ImageProvider"
|
||||
import BaseUIElement from "../../UI/BaseUIElement"
|
||||
import { Utils } from "../../Utils"
|
||||
import Constants from "../../Models/Constants"
|
||||
import { LicenseInfo } from "./LicenseInfo"
|
||||
import ImageProvider, { ProvidedImage } from "./ImageProvider";
|
||||
import BaseUIElement from "../../UI/BaseUIElement";
|
||||
import { Utils } from "../../Utils";
|
||||
import Constants from "../../Models/Constants";
|
||||
import { LicenseInfo } from "./LicenseInfo";
|
||||
import { ImageUploader } from "./ImageUploader";
|
||||
|
||||
export class Imgur extends ImageProvider {
|
||||
export class Imgur extends ImageProvider implements ImageUploader{
|
||||
public static readonly defaultValuePrefix = ["https://i.imgur.com"]
|
||||
public static readonly singleton = new Imgur()
|
||||
public readonly defaultKeyPrefixes: string[] = ["image"]
|
||||
|
||||
public readonly maxFileSizeInMegabytes = 10
|
||||
private constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
static uploadMultiple(
|
||||
/**
|
||||
* Uploads an image, returns the URL where to find the image
|
||||
* @param title
|
||||
* @param description
|
||||
* @param blob
|
||||
*/
|
||||
public async uploadImage(
|
||||
title: string,
|
||||
description: string,
|
||||
blobs: FileList,
|
||||
handleSuccessfullUpload: (imageURL: string) => Promise<void>,
|
||||
allDone: () => void,
|
||||
onFail: (reason: string) => void,
|
||||
offset: number = 0
|
||||
) {
|
||||
if (blobs.length == offset) {
|
||||
allDone()
|
||||
return
|
||||
}
|
||||
const blob = blobs.item(offset)
|
||||
const self = this
|
||||
this.uploadImage(
|
||||
title,
|
||||
description,
|
||||
blob,
|
||||
async (imageUrl) => {
|
||||
await handleSuccessfullUpload(imageUrl)
|
||||
self.uploadMultiple(
|
||||
title,
|
||||
description,
|
||||
blobs,
|
||||
handleSuccessfullUpload,
|
||||
allDone,
|
||||
onFail,
|
||||
offset + 1
|
||||
)
|
||||
},
|
||||
onFail
|
||||
)
|
||||
}
|
||||
|
||||
static uploadImage(
|
||||
title: string,
|
||||
description: string,
|
||||
blob: File,
|
||||
handleSuccessfullUpload: (imageURL: string) => Promise<void>,
|
||||
onFail: (reason: string) => void
|
||||
) {
|
||||
blob: File
|
||||
): Promise<{ key: string, value: string }> {
|
||||
const apiUrl = "https://api.imgur.com/3/image"
|
||||
const apiKey = Constants.ImgurApiKey
|
||||
|
||||
|
@ -63,6 +33,7 @@ export class Imgur extends ImageProvider {
|
|||
formData.append("title", title)
|
||||
formData.append("description", description)
|
||||
|
||||
|
||||
const settings: RequestInit = {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
|
@ -74,17 +45,9 @@ export class Imgur extends ImageProvider {
|
|||
}
|
||||
|
||||
// Response contains stringified JSON
|
||||
// Image URL available at response.data.link
|
||||
fetch(apiUrl, settings)
|
||||
.then(async function (response) {
|
||||
const content = await response.json()
|
||||
await handleSuccessfullUpload(content.data.link)
|
||||
})
|
||||
.catch((reason) => {
|
||||
console.log("Uploading to IMGUR failed", reason)
|
||||
// @ts-ignore
|
||||
onFail(reason)
|
||||
})
|
||||
const response = await fetch(apiUrl, settings)
|
||||
const content = await response.json()
|
||||
return { key: "image", value: content.data.link }
|
||||
}
|
||||
|
||||
SourceIcon(): BaseUIElement {
|
||||
|
|
|
@ -1,11 +1,20 @@
|
|||
import ChangeTagAction from "./ChangeTagAction"
|
||||
import { Tag } from "../../Tags/Tag"
|
||||
import ChangeTagAction from "./ChangeTagAction";
|
||||
import { Tag } from "../../Tags/Tag";
|
||||
import OsmChangeAction from "./OsmChangeAction";
|
||||
import { Changes } from "../Changes";
|
||||
import { ChangeDescription } from "./ChangeDescription";
|
||||
import { Store } from "../../UIEventSource";
|
||||
|
||||
export default class LinkImageAction extends OsmChangeAction {
|
||||
private readonly _proposedKey: "image" | "mapillary" | "wiki_commons" | string;
|
||||
private readonly _url: string;
|
||||
private readonly _currentTags: Store<Record<string, string>>;
|
||||
private readonly _meta: { theme: string; changeType: "add-image" | "link-image" };
|
||||
|
||||
export default class LinkPicture extends ChangeTagAction {
|
||||
/**
|
||||
* Adds a link to an image
|
||||
* Adds an image-link to a feature
|
||||
* @param elementId
|
||||
* @param proposedKey: a key which might be used, typically `image`. If the key is already used with a different URL, `key+":0"` will be used instead (or a higher number if needed)
|
||||
* @param proposedKey a key which might be used, typically `image`. If the key is already used with a different URL, `key+":0"` will be used instead (or a higher number if needed)
|
||||
* @param url
|
||||
* @param currentTags
|
||||
* @param meta
|
||||
|
@ -15,18 +24,31 @@ export default class LinkPicture extends ChangeTagAction {
|
|||
elementId: string,
|
||||
proposedKey: "image" | "mapillary" | "wiki_commons" | string,
|
||||
url: string,
|
||||
currentTags: Record<string, string>,
|
||||
currentTags: Store<Record<string, string>>,
|
||||
meta: {
|
||||
theme: string
|
||||
changeType: "add-image" | "link-image"
|
||||
}
|
||||
) {
|
||||
let key = proposedKey
|
||||
super(elementId, true)
|
||||
this._proposedKey = proposedKey;
|
||||
this._url = url;
|
||||
this._currentTags = currentTags;
|
||||
this._meta = meta;
|
||||
}
|
||||
|
||||
protected CreateChangeDescriptions(): Promise<ChangeDescription[]> {
|
||||
let key = this._proposedKey
|
||||
let i = 0
|
||||
const currentTags = this._currentTags.data
|
||||
const url = this._url
|
||||
while (currentTags[key] !== undefined && currentTags[key] !== url) {
|
||||
key = proposedKey + ":" + i
|
||||
key = this._proposedKey + ":" + i
|
||||
i++
|
||||
}
|
||||
super(elementId, new Tag(key, url), currentTags, meta)
|
||||
const tagChangeAction = new ChangeTagAction ( this.mainObjectId, new Tag(key, url), currentTags, this._meta)
|
||||
return tagChangeAction.CreateChangeDescriptions()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -19,6 +19,9 @@ export default abstract class OsmChangeAction {
|
|||
constructor(mainObjectId: string, trackStatistics: boolean = true) {
|
||||
this.trackStatistics = trackStatistics
|
||||
this.mainObjectId = mainObjectId
|
||||
if(mainObjectId === undefined || mainObjectId === null){
|
||||
throw "OsmObject received '"+mainObjectId+"' as mainObjectId"
|
||||
}
|
||||
}
|
||||
|
||||
public async Perform(changes: Changes) {
|
||||
|
|
|
@ -1,22 +1,22 @@
|
|||
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
|
||||
import { OsmConnection } from "../Osm/OsmConnection"
|
||||
import { MangroveIdentity } from "../Web/MangroveReviews"
|
||||
import { Store, Stores, UIEventSource } from "../UIEventSource"
|
||||
import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource"
|
||||
import { FeatureSource } from "../FeatureSource/FeatureSource"
|
||||
import { Feature } from "geojson"
|
||||
import { Utils } from "../../Utils"
|
||||
import translators from "../../assets/translators.json"
|
||||
import codeContributors from "../../assets/contributors.json"
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
|
||||
import usersettings from "../../../src/assets/generated/layers/usersettings.json"
|
||||
import Locale from "../../UI/i18n/Locale"
|
||||
import LinkToWeblate from "../../UI/Base/LinkToWeblate"
|
||||
import FeatureSwitchState from "./FeatureSwitchState"
|
||||
import Constants from "../../Models/Constants"
|
||||
import { QueryParameters } from "../Web/QueryParameters"
|
||||
import { ThemeMetaTagging } from "./UserSettingsMetaTagging"
|
||||
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
|
||||
import { OsmConnection } from "../Osm/OsmConnection";
|
||||
import { MangroveIdentity } from "../Web/MangroveReviews";
|
||||
import { Store, Stores, UIEventSource } from "../UIEventSource";
|
||||
import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource";
|
||||
import { FeatureSource } from "../FeatureSource/FeatureSource";
|
||||
import { Feature } from "geojson";
|
||||
import { Utils } from "../../Utils";
|
||||
import translators from "../../assets/translators.json";
|
||||
import codeContributors from "../../assets/contributors.json";
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
|
||||
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson";
|
||||
import usersettings from "../../../src/assets/generated/layers/usersettings.json";
|
||||
import Locale from "../../UI/i18n/Locale";
|
||||
import LinkToWeblate from "../../UI/Base/LinkToWeblate";
|
||||
import FeatureSwitchState from "./FeatureSwitchState";
|
||||
import Constants from "../../Models/Constants";
|
||||
import { QueryParameters } from "../Web/QueryParameters";
|
||||
import { ThemeMetaTagging } from "./UserSettingsMetaTagging";
|
||||
import { MapProperties } from "../../Models/MapProperties";
|
||||
|
||||
/**
|
||||
|
@ -43,7 +43,7 @@ export default class UserRelatedState {
|
|||
public readonly homeLocation: FeatureSource
|
||||
public readonly language: UIEventSource<string>
|
||||
public readonly preferredBackgroundLayer: UIEventSource<string | "photo" | "map" | "osmbasedmap" | undefined>
|
||||
public readonly preferredBackgroundLayerForTheme: UIEventSource<string | "photo" | "map" | "osmbasedmap" | undefined>
|
||||
public readonly imageLicense : UIEventSource<string>
|
||||
/**
|
||||
* The number of seconds that the GPS-locations are stored in memory.
|
||||
* Time in seconds
|
||||
|
@ -108,6 +108,9 @@ export default class UserRelatedState {
|
|||
documentation: "The ID of a layer or layer category that MapComplete uses by default"
|
||||
})
|
||||
|
||||
this.imageLicense = this.osmConnection.GetPreference("pictures-license", "CC0", {
|
||||
documentation: "The license under which new images are uploaded"
|
||||
})
|
||||
this.installedUserThemes = this.InitInstalledUserThemes()
|
||||
|
||||
this.homeLocation = this.initHomeLocation()
|
||||
|
|
|
@ -51,6 +51,8 @@ import ThemeViewStateHashActor from "../Logic/Web/ThemeViewStateHashActor";
|
|||
import NoElementsInViewDetector, { FeatureViewState } from "../Logic/Actors/NoElementsInViewDetector";
|
||||
import FilteredLayer from "./FilteredLayer";
|
||||
import { PreferredRasterLayerSelector } from "../Logic/Actors/PreferredRasterLayerSelector";
|
||||
import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager";
|
||||
import { Imgur } from "../Logic/ImageProviders/Imgur";
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -99,6 +101,8 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
readonly userRelatedState: UserRelatedState;
|
||||
readonly geolocation: GeoLocationHandler;
|
||||
|
||||
readonly imageUploadManager: ImageUploadManager
|
||||
|
||||
readonly lastClickObject: WritableFeatureSource;
|
||||
readonly overlayLayerStates: ReadonlyMap<
|
||||
string,
|
||||
|
@ -168,6 +172,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
|
||||
this.availableLayers = AvailableRasterLayers.layersAvailableAt(this.mapProperties.location);
|
||||
|
||||
|
||||
const self = this;
|
||||
this.layerState = new LayerState(this.osmConnection, layout.layers, layout.id);
|
||||
|
||||
|
@ -323,6 +328,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
this.perLayerFiltered = this.showNormalDataOn(this.map);
|
||||
|
||||
this.hasDataInView = new NoElementsInViewDetector(this).hasFeatureInView;
|
||||
this.imageUploadManager = new ImageUploadManager(layout, Imgur.singleton, this.featureProperties, this.osmConnection, this.changes)
|
||||
|
||||
this.initActors();
|
||||
this.addLastClick(lastClick);
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export let accept: string;
|
||||
export let multiple: boolean = true;
|
||||
|
||||
const dispatcher = createEventDispatcher<{ submit: FileList }>();
|
||||
export let cls: string = "";
|
||||
let drawAttention = false;
|
||||
let inputElement: HTMLInputElement;
|
||||
let id = Math.random() * 1000000000 + "";
|
||||
</script>
|
||||
|
||||
<form>
|
||||
<label class={twMerge(cls, drawAttention ? "glowing-shadow" : "")} for={"fileinput"+id}>
|
||||
<slot />
|
||||
|
||||
</label>
|
||||
<input {accept} bind:this={inputElement} class="hidden" id={"fileinput" + id} {multiple} name="file-input"
|
||||
on:change|preventDefault={() => {
|
||||
drawAttention = false;
|
||||
dispatcher("submit", inputElement.files)}}
|
||||
|
||||
on:dragend={ () => {drawAttention = false}}
|
||||
on:dragover|preventDefault|stopPropagation={(e) => {
|
||||
console.log("Dragging over!")
|
||||
drawAttention = true
|
||||
e.dataTransfer.drop = "copy"
|
||||
}}
|
||||
on:dragstart={ () => {drawAttention = false}}
|
||||
on:drop|preventDefault|stopPropagation={(e) => {
|
||||
console.log("Got a 'drop'")
|
||||
drawAttention = false
|
||||
dispatcher("submit", e.dataTransfer.files)
|
||||
}}
|
||||
type="file"
|
||||
>
|
||||
</form>
|
|
@ -1,9 +1,12 @@
|
|||
<script>
|
||||
import ToSvelte from "./ToSvelte.svelte"
|
||||
import Svg from "../../Svg"
|
||||
<script lang="ts">
|
||||
import ToSvelte from "./ToSvelte.svelte";
|
||||
import Svg from "../../Svg";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export let cls : string = undefined
|
||||
</script>
|
||||
|
||||
<div class="flex p-1 pl-2">
|
||||
<div class={twMerge( "flex p-1 pl-2", cls)}>
|
||||
<div class="min-w-6 h-6 w-6 animate-spin self-center">
|
||||
<ToSvelte construct={Svg.loading_svg()} />
|
||||
</div>
|
||||
|
|
|
@ -15,8 +15,9 @@ import Loading from "../Base/Loading"
|
|||
import { LoginToggle } from "../Popup/LoginButton"
|
||||
import Constants from "../../Models/Constants"
|
||||
import { SpecialVisualizationState } from "../SpecialVisualization"
|
||||
import exp from "constants";
|
||||
|
||||
export class ImageUploadFlow extends Toggle {
|
||||
export class ImageUploadFlow extends Combine {
|
||||
private static readonly uploadCountsPerId = new Map<string, UIEventSource<number>>()
|
||||
|
||||
constructor(
|
||||
|
@ -129,7 +130,7 @@ export class ImageUploadFlow extends Toggle {
|
|||
uploader.uploadMany(title, description, filelist)
|
||||
})
|
||||
|
||||
const uploadFlow: BaseUIElement = new Combine([
|
||||
super([
|
||||
new VariableUiElement(
|
||||
uploader.queue
|
||||
.map((q) => q.length)
|
||||
|
@ -183,17 +184,9 @@ export class ImageUploadFlow extends Toggle {
|
|||
})
|
||||
.SetClass("underline"),
|
||||
]).SetStyle("font-size:small;"),
|
||||
]).SetClass("flex flex-col image-upload-flow mt-4 mb-8 text-center leading-none")
|
||||
])
|
||||
this.SetClass("flex flex-col image-upload-flow mt-4 mb-8 text-center leading-none")
|
||||
|
||||
|
||||
super(
|
||||
new LoginToggle(
|
||||
/*We can show the actual upload button!*/
|
||||
uploadFlow,
|
||||
/* User not logged in*/ t.pleaseLogin.Clone(),
|
||||
state
|
||||
),
|
||||
undefined /* Nothing as the user badge is disabled*/,
|
||||
state?.featureSwitchUserbadge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
<script lang="ts">/**
|
||||
* Shows an 'upload'-button which will start the upload for this feature
|
||||
*/
|
||||
|
||||
import type { SpecialVisualizationState } from "../SpecialVisualization";
|
||||
import { Store } from "../../Logic/UIEventSource";
|
||||
import type { OsmTags } from "../../Models/OsmFeature";
|
||||
import LoginToggle from "../Base/LoginToggle.svelte";
|
||||
import Translations from "../i18n/Translations";
|
||||
import Tr from "../Base/Tr.svelte";
|
||||
import UploadingImageCounter from "./UploadingImageCounter.svelte";
|
||||
import FileSelector from "../Base/FileSelector.svelte";
|
||||
import ToSvelte from "../Base/ToSvelte.svelte";
|
||||
import Svg from "../../Svg";
|
||||
|
||||
export let state: SpecialVisualizationState;
|
||||
|
||||
export let tags: Store<OsmTags>;
|
||||
export let image: string = undefined;
|
||||
if (image === "") {
|
||||
image = undefined;
|
||||
}
|
||||
export let labelText: string = undefined;
|
||||
const t = Translations.t.image;
|
||||
|
||||
let licenseStore = state.userRelatedState.imageLicense;
|
||||
|
||||
function handleFiles(files: FileList) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files.item(i);
|
||||
console.log("Got file", file.name)
|
||||
try {
|
||||
state.imageUploadManager.uploadImageAndApply(file, tags.data);
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<LoginToggle {state}>
|
||||
|
||||
<Tr slot="not-logged-in" t={t.pleaseLogin} />
|
||||
<div class="flex flex-col">
|
||||
|
||||
<UploadingImageCounter {state} {tags} />
|
||||
<FileSelector accept="image/*" cls="button border-2 text-2xl" multiple={true}
|
||||
on:submit={e => handleFiles(e.detail)}>
|
||||
<div class="flex items-center">
|
||||
|
||||
{#if image !== undefined}
|
||||
<img src={image} />
|
||||
{:else}
|
||||
<ToSvelte construct={ Svg.camera_plus_svg().SetClass("block w-12 h-12 p-1 text-4xl ")} />
|
||||
{/if}
|
||||
{#if labelText}
|
||||
{labelText}
|
||||
{:else}
|
||||
<Tr t={t.addPicture} />
|
||||
{/if}
|
||||
</div>
|
||||
</FileSelector>
|
||||
<div class="text-sm">
|
||||
<Tr t={t.respectPrivacy} />
|
||||
<a class="cursor-pointer" on:click={() => {state.guistate.openUsersettings("picture-license")}}>
|
||||
<Tr t={t.currentLicense.Subs({license: $licenseStore})} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</LoginToggle>
|
|
@ -1,31 +1,67 @@
|
|||
<script lang="ts">/**
|
||||
* Shows an 'upload'-button which will start the upload for this feature
|
||||
* Shows information about how much images are uploaded for the given feature
|
||||
*/
|
||||
|
||||
import type { SpecialVisualizationState } from "../SpecialVisualization";
|
||||
import type { Feature } from "geojson";
|
||||
import { Store } from "../../Logic/UIEventSource";
|
||||
import type { OsmTags } from "../../Models/OsmFeature";
|
||||
import { ImageUploader } from "../../Logic/ImageProviders/ImageUploader";
|
||||
import LoginToggle from "../Base/LoginToggle.svelte";
|
||||
import Translations from "../i18n/Translations";
|
||||
import Tr from "../Base/Tr.svelte";
|
||||
import { ImageUploadManager } from "../../Logic/ImageProviders/ImageUploadManager";
|
||||
import Loading from "../Base/Loading.svelte";
|
||||
|
||||
export let state: SpecialVisualizationState;
|
||||
export let feature: Feature;
|
||||
|
||||
export let tags: Store<OsmTags>;
|
||||
export let state: SpecialVisualizationState;
|
||||
export let lon: number;
|
||||
export let lat: number;
|
||||
const t = Translations.t.image
|
||||
const featureId = tags.data.id;
|
||||
const {
|
||||
uploadStarted,
|
||||
uploadFinished,
|
||||
retried,
|
||||
failed
|
||||
} = state.imageUploadManager.getCountsFor(featureId);
|
||||
const t = Translations.t.image;
|
||||
|
||||
</script>
|
||||
|
||||
{#if $uploadStarted == 1}
|
||||
{#if $uploadFinished == 1 }
|
||||
<Tr cls="thanks" t={t.upload.one.done} />
|
||||
{:else if $failed == 1}
|
||||
<div class="flex flex-col alert">
|
||||
<Tr cls="self-center" t={t.upload.one.failed} />
|
||||
<Tr t={t.upload.failReasons} />
|
||||
<Tr t={t.upload.failReasonsAdvanced} />
|
||||
</div>
|
||||
{:else if $retried == 1}
|
||||
<Loading cls="alert">
|
||||
<Tr t={t.upload.one.retrying} />
|
||||
</Loading>
|
||||
{:else }
|
||||
<Loading cls="alert">
|
||||
<Tr t={t.upload.one.uploading} />
|
||||
</Loading>
|
||||
{/if}
|
||||
{:else if $uploadStarted > 1}
|
||||
{#if ($uploadFinished + $failed) == $uploadStarted && $uploadFinished > 0}
|
||||
<Tr cls="thanks" t={t.upload.multiple.done.Subs({count: $uploadFinished})} />
|
||||
{:else if $uploadFinished == 0}
|
||||
<Loading cls="alert">
|
||||
<Tr t={t.upload.multiple.uploading.Subs({count: $uploadStarted})} />
|
||||
</Loading>
|
||||
{:else if $uploadFinished > 0}
|
||||
<Loading cls="alert">
|
||||
<Tr t={t.upload.multiple.partiallyDone.Subs({count: $uploadStarted - $uploadFinished, done: $uploadFinished})} />
|
||||
</Loading>
|
||||
{/if}
|
||||
{#if $failed > 0}
|
||||
<div class="flex flex-col alert">
|
||||
{#if failed === 1}
|
||||
<Tr cls="self-center" t={t.upload.one.failed} />
|
||||
{:else}
|
||||
<Tr cls="self-center" t={t.upload.multiple.someFailed.Subs({count: $failed})} />
|
||||
|
||||
<LoginToggle>
|
||||
|
||||
<Tr slot="not-logged-in" t={t.pleaseLogin}/>
|
||||
|
||||
</LoginToggle>
|
||||
{/if}
|
||||
<Tr t={t.upload.failReasons} />
|
||||
<Tr t={t.upload.failReasonsAdvanced} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
@ -1,113 +1,117 @@
|
|||
import { Store, UIEventSource } from "../Logic/UIEventSource"
|
||||
import BaseUIElement from "./BaseUIElement"
|
||||
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
|
||||
import { IndexedFeatureSource, WritableFeatureSource } from "../Logic/FeatureSource/FeatureSource"
|
||||
import { OsmConnection } from "../Logic/Osm/OsmConnection"
|
||||
import { Changes } from "../Logic/Osm/Changes"
|
||||
import { ExportableMap, MapProperties } from "../Models/MapProperties"
|
||||
import LayerState from "../Logic/State/LayerState"
|
||||
import { Feature, Geometry, Point } from "geojson"
|
||||
import FullNodeDatabaseSource from "../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource"
|
||||
import { MangroveIdentity } from "../Logic/Web/MangroveReviews"
|
||||
import { GeoIndexedStoreForLayer } from "../Logic/FeatureSource/Actors/GeoIndexedStore"
|
||||
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
|
||||
import FeatureSwitchState from "../Logic/State/FeatureSwitchState"
|
||||
import { MenuState } from "../Models/MenuState"
|
||||
import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader"
|
||||
import { RasterLayerPolygon } from "../Models/RasterLayers"
|
||||
import { Store, UIEventSource } from "../Logic/UIEventSource";
|
||||
import BaseUIElement from "./BaseUIElement";
|
||||
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig";
|
||||
import { IndexedFeatureSource, WritableFeatureSource } from "../Logic/FeatureSource/FeatureSource";
|
||||
import { OsmConnection } from "../Logic/Osm/OsmConnection";
|
||||
import { Changes } from "../Logic/Osm/Changes";
|
||||
import { ExportableMap, MapProperties } from "../Models/MapProperties";
|
||||
import LayerState from "../Logic/State/LayerState";
|
||||
import { Feature, Geometry, Point } from "geojson";
|
||||
import FullNodeDatabaseSource from "../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource";
|
||||
import { MangroveIdentity } from "../Logic/Web/MangroveReviews";
|
||||
import { GeoIndexedStoreForLayer } from "../Logic/FeatureSource/Actors/GeoIndexedStore";
|
||||
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
|
||||
import FeatureSwitchState from "../Logic/State/FeatureSwitchState";
|
||||
import { MenuState } from "../Models/MenuState";
|
||||
import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader";
|
||||
import { RasterLayerPolygon } from "../Models/RasterLayers";
|
||||
import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager";
|
||||
|
||||
/**
|
||||
* The state needed to render a special Visualisation.
|
||||
*/
|
||||
export interface SpecialVisualizationState {
|
||||
readonly guistate: MenuState
|
||||
readonly layout: LayoutConfig
|
||||
readonly featureSwitches: FeatureSwitchState
|
||||
readonly guistate: MenuState;
|
||||
readonly layout: LayoutConfig;
|
||||
readonly featureSwitches: FeatureSwitchState;
|
||||
|
||||
readonly layerState: LayerState
|
||||
readonly featureProperties: { getStore(id: string): UIEventSource<Record<string, string>> }
|
||||
readonly layerState: LayerState;
|
||||
readonly featureProperties: { getStore(id: string): UIEventSource<Record<string, string>> };
|
||||
|
||||
readonly indexedFeatures: IndexedFeatureSource
|
||||
readonly indexedFeatures: IndexedFeatureSource;
|
||||
|
||||
/**
|
||||
* Some features will create a new element that should be displayed.
|
||||
* These can be injected by appending them to this featuresource (and pinging it)
|
||||
*/
|
||||
readonly newFeatures: WritableFeatureSource
|
||||
/**
|
||||
* Some features will create a new element that should be displayed.
|
||||
* These can be injected by appending them to this featuresource (and pinging it)
|
||||
*/
|
||||
readonly newFeatures: WritableFeatureSource;
|
||||
|
||||
readonly historicalUserLocations: WritableFeatureSource<Feature<Point>>
|
||||
readonly historicalUserLocations: WritableFeatureSource<Feature<Point>>;
|
||||
|
||||
readonly osmConnection: OsmConnection
|
||||
readonly featureSwitchUserbadge: Store<boolean>
|
||||
readonly featureSwitchIsTesting: Store<boolean>
|
||||
readonly changes: Changes
|
||||
readonly osmObjectDownloader: OsmObjectDownloader
|
||||
/**
|
||||
* State of the main map
|
||||
*/
|
||||
readonly mapProperties: MapProperties & ExportableMap
|
||||
readonly osmConnection: OsmConnection;
|
||||
readonly featureSwitchUserbadge: Store<boolean>;
|
||||
readonly featureSwitchIsTesting: Store<boolean>;
|
||||
readonly changes: Changes;
|
||||
readonly osmObjectDownloader: OsmObjectDownloader;
|
||||
/**
|
||||
* State of the main map
|
||||
*/
|
||||
readonly mapProperties: MapProperties & ExportableMap;
|
||||
|
||||
readonly selectedElement: UIEventSource<Feature>
|
||||
/**
|
||||
* Works together with 'selectedElement' to indicate what properties should be displayed
|
||||
*/
|
||||
readonly selectedLayer: UIEventSource<LayerConfig>
|
||||
readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }>
|
||||
readonly selectedElement: UIEventSource<Feature>;
|
||||
/**
|
||||
* Works together with 'selectedElement' to indicate what properties should be displayed
|
||||
*/
|
||||
readonly selectedLayer: UIEventSource<LayerConfig>;
|
||||
readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }>;
|
||||
|
||||
/**
|
||||
* If data is currently being fetched from external sources
|
||||
*/
|
||||
readonly dataIsLoading: Store<boolean>
|
||||
/**
|
||||
* Only needed for 'ReplaceGeometryAction'
|
||||
*/
|
||||
readonly fullNodeDatabase?: FullNodeDatabaseSource
|
||||
/**
|
||||
* If data is currently being fetched from external sources
|
||||
*/
|
||||
readonly dataIsLoading: Store<boolean>;
|
||||
/**
|
||||
* Only needed for 'ReplaceGeometryAction'
|
||||
*/
|
||||
readonly fullNodeDatabase?: FullNodeDatabaseSource;
|
||||
|
||||
readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer>
|
||||
readonly userRelatedState: {
|
||||
readonly showTags: UIEventSource<"no" | undefined | "always" | "yes" | "full">
|
||||
readonly mangroveIdentity: MangroveIdentity
|
||||
readonly showAllQuestionsAtOnce: UIEventSource<boolean>
|
||||
readonly preferencesAsTags: Store<Record<string, string>>
|
||||
readonly language: UIEventSource<string>
|
||||
}
|
||||
readonly lastClickObject: WritableFeatureSource
|
||||
readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer>;
|
||||
readonly userRelatedState: {
|
||||
readonly imageLicense: UIEventSource<string>;
|
||||
readonly showTags: UIEventSource<"no" | undefined | "always" | "yes" | "full">
|
||||
readonly mangroveIdentity: MangroveIdentity
|
||||
readonly showAllQuestionsAtOnce: UIEventSource<boolean>
|
||||
readonly preferencesAsTags: Store<Record<string, string>>
|
||||
readonly language: UIEventSource<string>
|
||||
};
|
||||
readonly lastClickObject: WritableFeatureSource;
|
||||
|
||||
readonly availableLayers: Store<RasterLayerPolygon[]>
|
||||
readonly availableLayers: Store<RasterLayerPolygon[]>;
|
||||
|
||||
readonly imageUploadManager: ImageUploadManager;
|
||||
}
|
||||
|
||||
export interface SpecialVisualization {
|
||||
readonly funcName: string
|
||||
readonly docs: string | BaseUIElement
|
||||
readonly example?: string
|
||||
readonly funcName: string;
|
||||
readonly docs: string | BaseUIElement;
|
||||
readonly example?: string;
|
||||
|
||||
/**
|
||||
* Indicates that this special visualisation will make requests to the 'alLNodesDatabase' and that it thus should be included
|
||||
*/
|
||||
readonly needsNodeDatabase?: boolean
|
||||
readonly args: {
|
||||
name: string
|
||||
defaultValue?: string
|
||||
doc: string
|
||||
required?: false | boolean
|
||||
}[]
|
||||
readonly getLayerDependencies?: (argument: string[]) => string[]
|
||||
/**
|
||||
* Indicates that this special visualisation will make requests to the 'alLNodesDatabase' and that it thus should be included
|
||||
*/
|
||||
readonly needsNodeDatabase?: boolean;
|
||||
readonly args: {
|
||||
name: string
|
||||
defaultValue?: string
|
||||
doc: string
|
||||
required?: false | boolean
|
||||
}[];
|
||||
readonly getLayerDependencies?: (argument: string[]) => string[];
|
||||
|
||||
structuredExamples?(): { feature: Feature<Geometry, Record<string, string>>; args: string[] }[]
|
||||
structuredExamples?(): { feature: Feature<Geometry, Record<string, string>>; args: string[] }[];
|
||||
|
||||
constr(
|
||||
state: SpecialVisualizationState,
|
||||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement
|
||||
constr(
|
||||
state: SpecialVisualizationState,
|
||||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement;
|
||||
}
|
||||
|
||||
export type RenderingSpecification =
|
||||
| string
|
||||
| {
|
||||
func: SpecialVisualization
|
||||
args: string[]
|
||||
style: string
|
||||
}
|
||||
| string
|
||||
| {
|
||||
func: SpecialVisualization
|
||||
args: string[]
|
||||
style: string
|
||||
}
|
||||
|
|
|
@ -35,7 +35,6 @@ import LiveQueryHandler from "../Logic/Web/LiveQueryHandler"
|
|||
import { SubtleButton } from "./Base/SubtleButton"
|
||||
import Svg from "../Svg"
|
||||
import NoteCommentElement from "./Popup/NoteCommentElement"
|
||||
import ImgurUploader from "../Logic/ImageProviders/ImgurUploader"
|
||||
import FileSelectorButton from "./Input/FileSelectorButton"
|
||||
import { LoginToggle } from "./Popup/LoginButton"
|
||||
import Toggle from "./Input/Toggle"
|
||||
|
@ -74,6 +73,7 @@ import FediverseValidator from "./InputElement/Validators/FediverseValidator"
|
|||
import SendEmail from "./Popup/SendEmail.svelte"
|
||||
import NearbyImages from "./Popup/NearbyImages.svelte"
|
||||
import NearbyImagesCollapsed from "./Popup/NearbyImagesCollapsed.svelte"
|
||||
import UploadImage from "./Image/UploadImage.svelte";
|
||||
|
||||
class NearbyImageVis implements SpecialVisualization {
|
||||
// Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests
|
||||
|
@ -616,16 +616,19 @@ export default class SpecialVisualizations {
|
|||
{
|
||||
name: "image-key",
|
||||
doc: "Image tag to add the URL to (or image-tag:0, image-tag:1 when multiple images are added)",
|
||||
defaultValue: "image",
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: "label",
|
||||
doc: "The text to show on the button",
|
||||
defaultValue: "Add image",
|
||||
required: false
|
||||
},
|
||||
],
|
||||
constr: (state, tags, args) => {
|
||||
return new ImageUploadFlow(tags, state, args[0], args[1])
|
||||
return new SvelteUIElement(UploadImage, {
|
||||
state,tags, labelText: args[1], image: args[0]
|
||||
})
|
||||
// return new ImageUploadFlow(tags, state, args[0], args[1])
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
Loading…
Reference in a new issue