Chore: rework image uploading, should work better now

This commit is contained in:
Pieter Vander Vennet 2023-09-25 02:13:24 +02:00
parent 6f5b0622a5
commit 94ba18785d
17 changed files with 548 additions and 238 deletions

View file

@ -344,8 +344,8 @@
}, },
"useSearch": "Use the search above to see presets", "useSearch": "Use the search above to see presets",
"useSearchForMore": "Use the search function to search within {total} more values…", "useSearchForMore": "Use the search function to search within {total} more values…",
"waitingForGeopermission": "Waiting for your permission to use the geolocation...", "waitingForGeopermission": "Waiting for your permission to use the geolocation",
"waitingForLocation": "Searching your current location...", "waitingForLocation": "Searching your current location",
"weekdays": { "weekdays": {
"abbreviations": { "abbreviations": {
"friday": "Fri", "friday": "Fri",
@ -416,6 +416,22 @@
"pleaseLogin": "Please log in to add a picture", "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.", "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}", "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!", "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.", "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!", "uploadMultipleDone": "{count} pictures have been added. Thanks for helping out!",

View file

@ -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) { @media (max-width: 480px) {
.max-\[480px\]\:w-full { .max-\[480px\]\:w-full {
width: 100%; width: 100%;

View file

@ -7,7 +7,7 @@ import { OsmTags } from "../../../Models/OsmFeature"
*/ */
export default class FeaturePropertiesStore { export default class FeaturePropertiesStore {
private readonly _elements = new Map<string, UIEventSource<Record<string, string>>>() private readonly _elements = new Map<string, UIEventSource<Record<string, string>>>()
public readonly aliases = new Map<string, string>()
constructor(...sources: FeatureSource[]) { constructor(...sources: FeatureSource[]) {
for (const source of sources) { for (const source of sources) {
this.trackFeatureSource(source) this.trackFeatureSource(source)
@ -92,7 +92,6 @@ export default class FeaturePropertiesStore {
}) })
} }
// noinspection JSUnusedGlobalSymbols
public addAlias(oldId: string, newId: string): void { public addAlias(oldId: string, newId: string): void {
if (newId === undefined) { if (newId === undefined) {
// We removed the node/way/relation with type 'type' and id 'oldId' on openstreetmap! // 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 element.data.id = newId
this._elements.set(newId, element) this._elements.set(newId, element)
this.aliases.set(newId, oldId)
element.ping() element.ping()
} }

View file

@ -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);
}
}

View file

@ -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 }>;
}

View file

@ -1,60 +1,30 @@
import ImageProvider, { ProvidedImage } from "./ImageProvider" import ImageProvider, { ProvidedImage } from "./ImageProvider";
import BaseUIElement from "../../UI/BaseUIElement" import BaseUIElement from "../../UI/BaseUIElement";
import { Utils } from "../../Utils" import { Utils } from "../../Utils";
import Constants from "../../Models/Constants" import Constants from "../../Models/Constants";
import { LicenseInfo } from "./LicenseInfo" 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 defaultValuePrefix = ["https://i.imgur.com"]
public static readonly singleton = new Imgur() public static readonly singleton = new Imgur()
public readonly defaultKeyPrefixes: string[] = ["image"] public readonly defaultKeyPrefixes: string[] = ["image"]
public readonly maxFileSizeInMegabytes = 10
private constructor() { private constructor() {
super() 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, title: string,
description: string, description: string,
blobs: FileList, blob: File
handleSuccessfullUpload: (imageURL: string) => Promise<void>, ): Promise<{ key: string, value: string }> {
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
) {
const apiUrl = "https://api.imgur.com/3/image" const apiUrl = "https://api.imgur.com/3/image"
const apiKey = Constants.ImgurApiKey const apiKey = Constants.ImgurApiKey
@ -63,6 +33,7 @@ export class Imgur extends ImageProvider {
formData.append("title", title) formData.append("title", title)
formData.append("description", description) formData.append("description", description)
const settings: RequestInit = { const settings: RequestInit = {
method: "POST", method: "POST",
body: formData, body: formData,
@ -74,17 +45,9 @@ export class Imgur extends ImageProvider {
} }
// Response contains stringified JSON // Response contains stringified JSON
// Image URL available at response.data.link const response = await fetch(apiUrl, settings)
fetch(apiUrl, settings) const content = await response.json()
.then(async function (response) { return { key: "image", value: content.data.link }
const content = await response.json()
await handleSuccessfullUpload(content.data.link)
})
.catch((reason) => {
console.log("Uploading to IMGUR failed", reason)
// @ts-ignore
onFail(reason)
})
} }
SourceIcon(): BaseUIElement { SourceIcon(): BaseUIElement {

View file

@ -1,11 +1,20 @@
import ChangeTagAction from "./ChangeTagAction" import ChangeTagAction from "./ChangeTagAction";
import { Tag } from "../../Tags/Tag" 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 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 url
* @param currentTags * @param currentTags
* @param meta * @param meta
@ -15,18 +24,31 @@ export default class LinkPicture extends ChangeTagAction {
elementId: string, elementId: string,
proposedKey: "image" | "mapillary" | "wiki_commons" | string, proposedKey: "image" | "mapillary" | "wiki_commons" | string,
url: string, url: string,
currentTags: Record<string, string>, currentTags: Store<Record<string, string>>,
meta: { meta: {
theme: string theme: string
changeType: "add-image" | "link-image" 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 let i = 0
const currentTags = this._currentTags.data
const url = this._url
while (currentTags[key] !== undefined && currentTags[key] !== url) { while (currentTags[key] !== undefined && currentTags[key] !== url) {
key = proposedKey + ":" + i key = this._proposedKey + ":" + i
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()
} }
} }

View file

@ -19,6 +19,9 @@ export default abstract class OsmChangeAction {
constructor(mainObjectId: string, trackStatistics: boolean = true) { constructor(mainObjectId: string, trackStatistics: boolean = true) {
this.trackStatistics = trackStatistics this.trackStatistics = trackStatistics
this.mainObjectId = mainObjectId this.mainObjectId = mainObjectId
if(mainObjectId === undefined || mainObjectId === null){
throw "OsmObject received '"+mainObjectId+"' as mainObjectId"
}
} }
public async Perform(changes: Changes) { public async Perform(changes: Changes) {

View file

@ -1,22 +1,22 @@
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig" import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import { OsmConnection } from "../Osm/OsmConnection" import { OsmConnection } from "../Osm/OsmConnection";
import { MangroveIdentity } from "../Web/MangroveReviews" import { MangroveIdentity } from "../Web/MangroveReviews";
import { Store, Stores, UIEventSource } from "../UIEventSource" import { Store, Stores, UIEventSource } from "../UIEventSource";
import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource" import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource";
import { FeatureSource } from "../FeatureSource/FeatureSource" import { FeatureSource } from "../FeatureSource/FeatureSource";
import { Feature } from "geojson" import { Feature } from "geojson";
import { Utils } from "../../Utils" import { Utils } from "../../Utils";
import translators from "../../assets/translators.json" import translators from "../../assets/translators.json";
import codeContributors from "../../assets/contributors.json" import codeContributors from "../../assets/contributors.json";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig" import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson" import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson";
import usersettings from "../../../src/assets/generated/layers/usersettings.json" import usersettings from "../../../src/assets/generated/layers/usersettings.json";
import Locale from "../../UI/i18n/Locale" import Locale from "../../UI/i18n/Locale";
import LinkToWeblate from "../../UI/Base/LinkToWeblate" import LinkToWeblate from "../../UI/Base/LinkToWeblate";
import FeatureSwitchState from "./FeatureSwitchState" import FeatureSwitchState from "./FeatureSwitchState";
import Constants from "../../Models/Constants" import Constants from "../../Models/Constants";
import { QueryParameters } from "../Web/QueryParameters" import { QueryParameters } from "../Web/QueryParameters";
import { ThemeMetaTagging } from "./UserSettingsMetaTagging" import { ThemeMetaTagging } from "./UserSettingsMetaTagging";
import { MapProperties } from "../../Models/MapProperties"; import { MapProperties } from "../../Models/MapProperties";
/** /**
@ -43,7 +43,7 @@ export default class UserRelatedState {
public readonly homeLocation: FeatureSource public readonly homeLocation: FeatureSource
public readonly language: UIEventSource<string> public readonly language: UIEventSource<string>
public readonly preferredBackgroundLayer: UIEventSource<string | "photo" | "map" | "osmbasedmap" | undefined> 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. * The number of seconds that the GPS-locations are stored in memory.
* Time in seconds * 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" 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.installedUserThemes = this.InitInstalledUserThemes()
this.homeLocation = this.initHomeLocation() this.homeLocation = this.initHomeLocation()

View file

@ -51,6 +51,8 @@ import ThemeViewStateHashActor from "../Logic/Web/ThemeViewStateHashActor";
import NoElementsInViewDetector, { FeatureViewState } from "../Logic/Actors/NoElementsInViewDetector"; import NoElementsInViewDetector, { FeatureViewState } from "../Logic/Actors/NoElementsInViewDetector";
import FilteredLayer from "./FilteredLayer"; import FilteredLayer from "./FilteredLayer";
import { PreferredRasterLayerSelector } from "../Logic/Actors/PreferredRasterLayerSelector"; 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 userRelatedState: UserRelatedState;
readonly geolocation: GeoLocationHandler; readonly geolocation: GeoLocationHandler;
readonly imageUploadManager: ImageUploadManager
readonly lastClickObject: WritableFeatureSource; readonly lastClickObject: WritableFeatureSource;
readonly overlayLayerStates: ReadonlyMap< readonly overlayLayerStates: ReadonlyMap<
string, string,
@ -168,6 +172,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.availableLayers = AvailableRasterLayers.layersAvailableAt(this.mapProperties.location); this.availableLayers = AvailableRasterLayers.layersAvailableAt(this.mapProperties.location);
const self = this; const self = this;
this.layerState = new LayerState(this.osmConnection, layout.layers, layout.id); 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.perLayerFiltered = this.showNormalDataOn(this.map);
this.hasDataInView = new NoElementsInViewDetector(this).hasFeatureInView; this.hasDataInView = new NoElementsInViewDetector(this).hasFeatureInView;
this.imageUploadManager = new ImageUploadManager(layout, Imgur.singleton, this.featureProperties, this.osmConnection, this.changes)
this.initActors(); this.initActors();
this.addLastClick(lastClick); this.addLastClick(lastClick);

View file

@ -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>

View file

@ -1,9 +1,12 @@
<script> <script lang="ts">
import ToSvelte from "./ToSvelte.svelte" import ToSvelte from "./ToSvelte.svelte";
import Svg from "../../Svg" import Svg from "../../Svg";
import { twMerge } from "tailwind-merge";
export let cls : string = undefined
</script> </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"> <div class="min-w-6 h-6 w-6 animate-spin self-center">
<ToSvelte construct={Svg.loading_svg()} /> <ToSvelte construct={Svg.loading_svg()} />
</div> </div>

View file

@ -15,8 +15,9 @@ import Loading from "../Base/Loading"
import { LoginToggle } from "../Popup/LoginButton" import { LoginToggle } from "../Popup/LoginButton"
import Constants from "../../Models/Constants" import Constants from "../../Models/Constants"
import { SpecialVisualizationState } from "../SpecialVisualization" 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>>() private static readonly uploadCountsPerId = new Map<string, UIEventSource<number>>()
constructor( constructor(
@ -129,7 +130,7 @@ export class ImageUploadFlow extends Toggle {
uploader.uploadMany(title, description, filelist) uploader.uploadMany(title, description, filelist)
}) })
const uploadFlow: BaseUIElement = new Combine([ super([
new VariableUiElement( new VariableUiElement(
uploader.queue uploader.queue
.map((q) => q.length) .map((q) => q.length)
@ -183,17 +184,9 @@ export class ImageUploadFlow extends Toggle {
}) })
.SetClass("underline"), .SetClass("underline"),
]).SetStyle("font-size:small;"), ]).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
)
} }
} }

View file

@ -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>

View file

@ -1,31 +1,67 @@
<script lang="ts">/** <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 { SpecialVisualizationState } from "../SpecialVisualization";
import type { Feature } from "geojson";
import { Store } from "../../Logic/UIEventSource"; import { Store } from "../../Logic/UIEventSource";
import type { OsmTags } from "../../Models/OsmFeature"; import type { OsmTags } from "../../Models/OsmFeature";
import { ImageUploader } from "../../Logic/ImageProviders/ImageUploader";
import LoginToggle from "../Base/LoginToggle.svelte";
import Translations from "../i18n/Translations"; import Translations from "../i18n/Translations";
import Tr from "../Base/Tr.svelte"; import Tr from "../Base/Tr.svelte";
import { ImageUploadManager } from "../../Logic/ImageProviders/ImageUploadManager"; import Loading from "../Base/Loading.svelte";
export let state: SpecialVisualizationState; export let state: SpecialVisualizationState;
export let feature: Feature;
export let tags: Store<OsmTags>; export let tags: Store<OsmTags>;
export let state: SpecialVisualizationState; const featureId = tags.data.id;
export let lon: number; const {
export let lat: number; uploadStarted,
const t = Translations.t.image uploadFinished,
retried,
failed
} = state.imageUploadManager.getCountsFor(featureId);
const t = Translations.t.image;
</script> </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> {/if}
<Tr t={t.upload.failReasons} />
<Tr slot="not-logged-in" t={t.pleaseLogin}/> <Tr t={t.upload.failReasonsAdvanced} />
</div>
</LoginToggle> {/if}
{/if}

View file

@ -1,113 +1,117 @@
import { Store, UIEventSource } from "../Logic/UIEventSource" import { Store, UIEventSource } from "../Logic/UIEventSource";
import BaseUIElement from "./BaseUIElement" import BaseUIElement from "./BaseUIElement";
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig" import LayoutConfig from "../Models/ThemeConfig/LayoutConfig";
import { IndexedFeatureSource, WritableFeatureSource } from "../Logic/FeatureSource/FeatureSource" import { IndexedFeatureSource, WritableFeatureSource } from "../Logic/FeatureSource/FeatureSource";
import { OsmConnection } from "../Logic/Osm/OsmConnection" import { OsmConnection } from "../Logic/Osm/OsmConnection";
import { Changes } from "../Logic/Osm/Changes" import { Changes } from "../Logic/Osm/Changes";
import { ExportableMap, MapProperties } from "../Models/MapProperties" import { ExportableMap, MapProperties } from "../Models/MapProperties";
import LayerState from "../Logic/State/LayerState" import LayerState from "../Logic/State/LayerState";
import { Feature, Geometry, Point } from "geojson" import { Feature, Geometry, Point } from "geojson";
import FullNodeDatabaseSource from "../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource" import FullNodeDatabaseSource from "../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource";
import { MangroveIdentity } from "../Logic/Web/MangroveReviews" import { MangroveIdentity } from "../Logic/Web/MangroveReviews";
import { GeoIndexedStoreForLayer } from "../Logic/FeatureSource/Actors/GeoIndexedStore" import { GeoIndexedStoreForLayer } from "../Logic/FeatureSource/Actors/GeoIndexedStore";
import LayerConfig from "../Models/ThemeConfig/LayerConfig" import LayerConfig from "../Models/ThemeConfig/LayerConfig";
import FeatureSwitchState from "../Logic/State/FeatureSwitchState" import FeatureSwitchState from "../Logic/State/FeatureSwitchState";
import { MenuState } from "../Models/MenuState" import { MenuState } from "../Models/MenuState";
import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader" import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader";
import { RasterLayerPolygon } from "../Models/RasterLayers" import { RasterLayerPolygon } from "../Models/RasterLayers";
import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager";
/** /**
* The state needed to render a special Visualisation. * The state needed to render a special Visualisation.
*/ */
export interface SpecialVisualizationState { export interface SpecialVisualizationState {
readonly guistate: MenuState readonly guistate: MenuState;
readonly layout: LayoutConfig readonly layout: LayoutConfig;
readonly featureSwitches: FeatureSwitchState readonly featureSwitches: FeatureSwitchState;
readonly layerState: LayerState readonly layerState: LayerState;
readonly featureProperties: { getStore(id: string): UIEventSource<Record<string, string>> } 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. * Some features will create a new element that should be displayed.
* These can be injected by appending them to this featuresource (and pinging it) * These can be injected by appending them to this featuresource (and pinging it)
*/ */
readonly newFeatures: WritableFeatureSource readonly newFeatures: WritableFeatureSource;
readonly historicalUserLocations: WritableFeatureSource<Feature<Point>> readonly historicalUserLocations: WritableFeatureSource<Feature<Point>>;
readonly osmConnection: OsmConnection readonly osmConnection: OsmConnection;
readonly featureSwitchUserbadge: Store<boolean> readonly featureSwitchUserbadge: Store<boolean>;
readonly featureSwitchIsTesting: Store<boolean> readonly featureSwitchIsTesting: Store<boolean>;
readonly changes: Changes readonly changes: Changes;
readonly osmObjectDownloader: OsmObjectDownloader readonly osmObjectDownloader: OsmObjectDownloader;
/** /**
* State of the main map * State of the main map
*/ */
readonly mapProperties: MapProperties & ExportableMap readonly mapProperties: MapProperties & ExportableMap;
readonly selectedElement: UIEventSource<Feature> readonly selectedElement: UIEventSource<Feature>;
/** /**
* Works together with 'selectedElement' to indicate what properties should be displayed * Works together with 'selectedElement' to indicate what properties should be displayed
*/ */
readonly selectedLayer: UIEventSource<LayerConfig> readonly selectedLayer: UIEventSource<LayerConfig>;
readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }> readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }>;
/** /**
* If data is currently being fetched from external sources * If data is currently being fetched from external sources
*/ */
readonly dataIsLoading: Store<boolean> readonly dataIsLoading: Store<boolean>;
/** /**
* Only needed for 'ReplaceGeometryAction' * Only needed for 'ReplaceGeometryAction'
*/ */
readonly fullNodeDatabase?: FullNodeDatabaseSource readonly fullNodeDatabase?: FullNodeDatabaseSource;
readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer> readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer>;
readonly userRelatedState: { readonly userRelatedState: {
readonly showTags: UIEventSource<"no" | undefined | "always" | "yes" | "full"> readonly imageLicense: UIEventSource<string>;
readonly mangroveIdentity: MangroveIdentity readonly showTags: UIEventSource<"no" | undefined | "always" | "yes" | "full">
readonly showAllQuestionsAtOnce: UIEventSource<boolean> readonly mangroveIdentity: MangroveIdentity
readonly preferencesAsTags: Store<Record<string, string>> readonly showAllQuestionsAtOnce: UIEventSource<boolean>
readonly language: UIEventSource<string> readonly preferencesAsTags: Store<Record<string, string>>
} readonly language: UIEventSource<string>
readonly lastClickObject: WritableFeatureSource };
readonly lastClickObject: WritableFeatureSource;
readonly availableLayers: Store<RasterLayerPolygon[]> readonly availableLayers: Store<RasterLayerPolygon[]>;
readonly imageUploadManager: ImageUploadManager;
} }
export interface SpecialVisualization { export interface SpecialVisualization {
readonly funcName: string readonly funcName: string;
readonly docs: string | BaseUIElement readonly docs: string | BaseUIElement;
readonly example?: string readonly example?: string;
/** /**
* Indicates that this special visualisation will make requests to the 'alLNodesDatabase' and that it thus should be included * Indicates that this special visualisation will make requests to the 'alLNodesDatabase' and that it thus should be included
*/ */
readonly needsNodeDatabase?: boolean readonly needsNodeDatabase?: boolean;
readonly args: { readonly args: {
name: string name: string
defaultValue?: string defaultValue?: string
doc: string doc: string
required?: false | boolean required?: false | boolean
}[] }[];
readonly getLayerDependencies?: (argument: string[]) => string[] readonly getLayerDependencies?: (argument: string[]) => string[];
structuredExamples?(): { feature: Feature<Geometry, Record<string, string>>; args: string[] }[] structuredExamples?(): { feature: Feature<Geometry, Record<string, string>>; args: string[] }[];
constr( constr(
state: SpecialVisualizationState, state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>, tagSource: UIEventSource<Record<string, string>>,
argument: string[], argument: string[],
feature: Feature, feature: Feature,
layer: LayerConfig layer: LayerConfig
): BaseUIElement ): BaseUIElement;
} }
export type RenderingSpecification = export type RenderingSpecification =
| string | string
| { | {
func: SpecialVisualization func: SpecialVisualization
args: string[] args: string[]
style: string style: string
} }

View file

@ -35,7 +35,6 @@ import LiveQueryHandler from "../Logic/Web/LiveQueryHandler"
import { SubtleButton } from "./Base/SubtleButton" import { SubtleButton } from "./Base/SubtleButton"
import Svg from "../Svg" import Svg from "../Svg"
import NoteCommentElement from "./Popup/NoteCommentElement" import NoteCommentElement from "./Popup/NoteCommentElement"
import ImgurUploader from "../Logic/ImageProviders/ImgurUploader"
import FileSelectorButton from "./Input/FileSelectorButton" import FileSelectorButton from "./Input/FileSelectorButton"
import { LoginToggle } from "./Popup/LoginButton" import { LoginToggle } from "./Popup/LoginButton"
import Toggle from "./Input/Toggle" import Toggle from "./Input/Toggle"
@ -74,6 +73,7 @@ import FediverseValidator from "./InputElement/Validators/FediverseValidator"
import SendEmail from "./Popup/SendEmail.svelte" import SendEmail from "./Popup/SendEmail.svelte"
import NearbyImages from "./Popup/NearbyImages.svelte" import NearbyImages from "./Popup/NearbyImages.svelte"
import NearbyImagesCollapsed from "./Popup/NearbyImagesCollapsed.svelte" import NearbyImagesCollapsed from "./Popup/NearbyImagesCollapsed.svelte"
import UploadImage from "./Image/UploadImage.svelte";
class NearbyImageVis implements SpecialVisualization { class NearbyImageVis implements SpecialVisualization {
// Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests // 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", name: "image-key",
doc: "Image tag to add the URL to (or image-tag:0, image-tag:1 when multiple images are added)", 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", name: "label",
doc: "The text to show on the button", doc: "The text to show on the button",
defaultValue: "Add image", required: false
}, },
], ],
constr: (state, tags, args) => { 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])
}, },
}, },
{ {