forked from MapComplete/MapComplete
Merge develop
This commit is contained in:
commit
448468c928
97 changed files with 5039 additions and 1139 deletions
|
@ -30,10 +30,15 @@ export default class Combine extends BaseUIElement {
|
|||
if (subEl === undefined || subEl === null) {
|
||||
continue;
|
||||
}
|
||||
try{
|
||||
|
||||
const subHtml = subEl.ConstructElement()
|
||||
if (subHtml !== undefined) {
|
||||
el.appendChild(subHtml)
|
||||
}
|
||||
}catch(e){
|
||||
console.error("Could not generate subelement in combine due to ", e)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const domExc = e as DOMException
|
||||
|
|
|
@ -6,11 +6,16 @@ import {VariableUiElement} from "./VariableUIElement";
|
|||
|
||||
export class TabbedComponent extends Combine {
|
||||
|
||||
constructor(elements: { header: BaseUIElement | string, content: BaseUIElement | string }[], openedTab: (UIEventSource<number> | number) = 0) {
|
||||
constructor(elements: { header: BaseUIElement | string, content: BaseUIElement | string }[],
|
||||
openedTab: (UIEventSource<number> | number) = 0,
|
||||
options?: {
|
||||
leftOfHeader?: BaseUIElement
|
||||
styleHeader?: (header: BaseUIElement) => void
|
||||
}) {
|
||||
|
||||
const openedTabSrc = typeof (openedTab) === "number" ? new UIEventSource(openedTab) : (openedTab ?? new UIEventSource<number>(0))
|
||||
|
||||
const tabs: BaseUIElement[] = []
|
||||
const tabs: BaseUIElement[] = [options?.leftOfHeader ]
|
||||
const contentElements: BaseUIElement[] = [];
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
let element = elements[i];
|
||||
|
@ -25,16 +30,19 @@ export class TabbedComponent extends Combine {
|
|||
}
|
||||
})
|
||||
const content = Translations.W(element.content)
|
||||
content.SetClass("relative p-4 w-full inline-block")
|
||||
content.SetClass("relative w-full inline-block")
|
||||
contentElements.push(content);
|
||||
const tab = header.SetClass("block tab-single-header")
|
||||
tabs.push(tab)
|
||||
}
|
||||
|
||||
const header = new Combine(tabs).SetClass("tabs-header-bar")
|
||||
if(options?.styleHeader){
|
||||
options.styleHeader(header)
|
||||
}
|
||||
const actualContent = new VariableUiElement(
|
||||
openedTabSrc.map(i => contentElements[i])
|
||||
)
|
||||
).SetStyle("max-height: inherit; height: inherit")
|
||||
super([header, actualContent])
|
||||
|
||||
}
|
||||
|
|
|
@ -73,6 +73,9 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
|
|||
}
|
||||
);
|
||||
|
||||
tabs.forEach(c => c.content.SetClass("p-4"))
|
||||
tabsWithAboutMc.forEach(c => c.content.SetClass("p-4"))
|
||||
|
||||
return new Toggle(
|
||||
new TabbedComponent(tabsWithAboutMc, State.state.welcomeMessageOpenedTab),
|
||||
new TabbedComponent(tabs, State.state.welcomeMessageOpenedTab),
|
||||
|
|
|
@ -44,7 +44,10 @@ export default class ImportButton extends Toggle {
|
|||
}
|
||||
originalTags.data["_imported"] = "yes"
|
||||
originalTags.ping() // will set isImported as per its definition
|
||||
const newElementAction = new CreateNewNodeAction(newTags.data, lat, lon)
|
||||
const newElementAction = new CreateNewNodeAction(newTags.data, lat, lon, {
|
||||
theme: State.state.layoutToUse.id,
|
||||
changeType: "import"
|
||||
})
|
||||
await State.state.changes.applyAction(newElementAction)
|
||||
State.state.selectedElement.setData(State.state.allElements.ContainingFeatures.get(
|
||||
newElementAction.newElementId
|
||||
|
|
|
@ -56,7 +56,10 @@ export default class SimpleAddUI extends Toggle {
|
|||
|
||||
|
||||
async function createNewPoint(tags: any[], location: { lat: number, lon: number }, snapOntoWay?: OsmWay) {
|
||||
const newElementAction = new CreateNewNodeAction(tags, location.lat, location.lon, {snapOnto: snapOntoWay})
|
||||
const newElementAction = new CreateNewNodeAction(tags, location.lat, location.lon, {
|
||||
theme: State.state?.layoutToUse?.id ?? "unkown",
|
||||
changeType: "create",
|
||||
snapOnto: snapOntoWay})
|
||||
await State.state.changes.applyAction(newElementAction)
|
||||
selectedPreset.setData(undefined)
|
||||
isShown.setData(false)
|
||||
|
|
|
@ -21,10 +21,11 @@ export default class Attribution extends VariableUiElement {
|
|||
icon?.SetClass("block left").SetStyle("height: 2em; width: 2em; padding-right: 0.5em;"),
|
||||
|
||||
new Combine([
|
||||
Translations.W(license?.artist ?? ".").SetClass("block font-bold"),
|
||||
Translations.W(license?.title).SetClass("block"),
|
||||
Translations.W(license?.artist ?? "").SetClass("block font-bold"),
|
||||
Translations.W((license?.license ?? "") === "" ? "CC0" : (license?.license ?? ""))
|
||||
]).SetClass("flex flex-col")
|
||||
]).SetClass("flex flex-row bg-black text-white text-sm absolute bottom-0 left-0 p-0.5 pl-5 pr-3 rounded-lg")
|
||||
]).SetClass("flex flex-row bg-black text-white text-sm absolute bottom-0 left-0 p-0.5 pl-5 pr-3 rounded-lg no-images")
|
||||
|
||||
}));
|
||||
}
|
||||
|
|
|
@ -16,7 +16,10 @@ export default class DeleteImage extends Toggle {
|
|||
.SetClass("rounded-full p-1")
|
||||
.SetStyle("color:white;background:#ff8c8c")
|
||||
.onClick(async() => {
|
||||
await State.state?.changes?.applyAction(new ChangeTagAction(tags.data.id, new Tag(key, oldValue), tags.data))
|
||||
await State.state?.changes?.applyAction(new ChangeTagAction(tags.data.id, new Tag(key, oldValue), tags.data, {
|
||||
changeType: "answer",
|
||||
theme: "test"
|
||||
}))
|
||||
});
|
||||
|
||||
const deleteButton = Translations.t.image.doDelete.Clone()
|
||||
|
@ -24,7 +27,10 @@ export default class DeleteImage extends Toggle {
|
|||
.SetStyle("color:white;background:#ff8c8c; border-top-left-radius:30rem; border-top-right-radius: 30rem;")
|
||||
.onClick( async() => {
|
||||
await State.state?.changes?.applyAction(
|
||||
new ChangeTagAction(tags.data.id, new Tag(key, ""), tags.data)
|
||||
new ChangeTagAction(tags.data.id, new Tag(key, ""), tags.data,{
|
||||
changeType: "answer",
|
||||
theme: "test"
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
|
|
|
@ -12,10 +12,11 @@ import ImgurUploader from "../../Logic/ImageProviders/ImgurUploader";
|
|||
import UploadFlowStateUI from "../BigComponents/UploadFlowStateUI";
|
||||
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction";
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
|
||||
import {FixedUiElement} from "../Base/FixedUiElement";
|
||||
|
||||
export class ImageUploadFlow extends Toggle {
|
||||
|
||||
constructor(tagsSource: UIEventSource<any>, imagePrefix: string = "image") {
|
||||
constructor(tagsSource: UIEventSource<any>, imagePrefix: string = "image", text: string = undefined) {
|
||||
const uploader = new ImgurUploader(url => {
|
||||
// A file was uploaded - we add it to the tags of the object
|
||||
|
||||
|
@ -31,7 +32,11 @@ export class ImageUploadFlow extends Toggle {
|
|||
console.log("Adding image:" + key, url);
|
||||
Promise.resolve(State.state.changes
|
||||
.applyAction(new ChangeTagAction(
|
||||
tags.id, new Tag(key, url), tagsSource.data
|
||||
tags.id, new Tag(key, url), tagsSource.data,
|
||||
{
|
||||
changeType: "add-image",
|
||||
theme: State.state.layoutToUse.id
|
||||
}
|
||||
)))
|
||||
})
|
||||
|
||||
|
@ -42,10 +47,17 @@ export class ImageUploadFlow extends Toggle {
|
|||
const licensePicker = new LicensePicker()
|
||||
|
||||
const t = Translations.t.image;
|
||||
|
||||
let labelContent : BaseUIElement
|
||||
if(text === undefined) {
|
||||
labelContent = Translations.t.image.addPicture.Clone().SetClass("block align-middle mt-1 ml-3 text-4xl ")
|
||||
}else{
|
||||
labelContent = new FixedUiElement(text).SetClass("block align-middle mt-1 ml-3 text-2xl ")
|
||||
}
|
||||
const label = new Combine([
|
||||
Svg.camera_plus_ui().SetClass("block w-12 h-12 p-1"),
|
||||
Translations.t.image.addPicture.Clone().SetClass("block align-middle mt-1 ml-3")
|
||||
]).SetClass("p-2 border-4 border-black rounded-full text-4xl font-bold h-full align-middle w-full flex justify-center")
|
||||
Svg.camera_plus_ui().SetClass("block w-12 h-12 p-1 text-4xl "),
|
||||
labelContent
|
||||
]).SetClass("p-2 border-4 border-black rounded-full font-bold h-full align-middle w-full flex justify-center")
|
||||
|
||||
const fileSelector = new FileSelectorButton(label)
|
||||
fileSelector.GetValue().addCallback(filelist => {
|
||||
|
|
|
@ -16,6 +16,8 @@ import LengthInput from "./LengthInput";
|
|||
import {GeoOperations} from "../../Logic/GeoOperations";
|
||||
import {Unit} from "../../Models/Unit";
|
||||
import {FixedInputElement} from "./FixedInputElement";
|
||||
import WikidataSearchBox from "../Wikipedia/WikidataSearchBox";
|
||||
import Wikidata from "../../Logic/Web/Wikidata";
|
||||
|
||||
interface TextFieldDef {
|
||||
name: string,
|
||||
|
@ -147,23 +149,58 @@ export default class ValidatedTextField {
|
|||
),
|
||||
ValidatedTextField.tp(
|
||||
"wikidata",
|
||||
"A wikidata identifier, e.g. Q42",
|
||||
"A wikidata identifier, e.g. Q42. Input helper arguments: [ key: the value of this tag will initialize search (default: name), options: { removePrefixes: string[], removePostfixes: string[] } these prefixes and postfixes will be removed from the initial search value]",
|
||||
(str) => {
|
||||
if (str === undefined) {
|
||||
return false;
|
||||
}
|
||||
return (str.length > 1 && (str.startsWith("q") || str.startsWith("Q")) || str.startsWith("https://www.wikidata.org/wiki/Q"))
|
||||
if(str.length <= 2){
|
||||
return false;
|
||||
}
|
||||
return !str.split(";").some(str => Wikidata.ExtractKey(str) === undefined)
|
||||
},
|
||||
(str) => {
|
||||
if (str === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const wd = "https://www.wikidata.org/wiki/";
|
||||
if (str.startsWith(wd)) {
|
||||
str = str.substr(wd.length)
|
||||
let out = str.split(";").map(str => Wikidata.ExtractKey(str)).join("; ")
|
||||
if(str.endsWith(";")){
|
||||
out = out + ";"
|
||||
}
|
||||
return str.toUpperCase();
|
||||
}),
|
||||
return out;
|
||||
},
|
||||
(currentValue, inputHelperOptions) => {
|
||||
const args = inputHelperOptions.args ?? []
|
||||
const searchKey = args[0] ?? "name"
|
||||
|
||||
let searchFor = <string>inputHelperOptions.feature?.properties[searchKey]?.toLowerCase()
|
||||
|
||||
const options = args[1]
|
||||
if (searchFor !== undefined && options !== undefined) {
|
||||
const prefixes = <string[]>options["removePrefixes"]
|
||||
const postfixes = <string[]>options["removePostfixes"]
|
||||
for (const postfix of postfixes ?? []) {
|
||||
if (searchFor.endsWith(postfix)) {
|
||||
searchFor = searchFor.substring(0, searchFor.length - postfix.length)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of prefixes ?? []) {
|
||||
if (searchFor.startsWith(prefix)) {
|
||||
searchFor = searchFor.substring(prefix.length)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return new WikidataSearchBox({
|
||||
value: currentValue,
|
||||
searchText: new UIEventSource<string>(searchFor)
|
||||
})
|
||||
}
|
||||
),
|
||||
|
||||
ValidatedTextField.tp(
|
||||
"int",
|
||||
|
@ -361,13 +398,13 @@ export default class ValidatedTextField {
|
|||
// This implies:
|
||||
// We have to create a dropdown with applicable denominations, and fuse those values
|
||||
const unit = options.unit
|
||||
|
||||
|
||||
|
||||
|
||||
const isSingular = input.GetValue().map(str => str?.trim() === "1")
|
||||
|
||||
const unitDropDown =
|
||||
unit.denominations.length === 1 ?
|
||||
new FixedInputElement( unit.denominations[0].getToggledHuman(isSingular), unit.denominations[0])
|
||||
new FixedInputElement(unit.denominations[0].getToggledHuman(isSingular), unit.denominations[0])
|
||||
: new DropDown("",
|
||||
unit.denominations.map(denom => {
|
||||
return {
|
||||
|
@ -378,17 +415,17 @@ export default class ValidatedTextField {
|
|||
)
|
||||
unitDropDown.GetValue().setData(unit.defaultDenom)
|
||||
unitDropDown.SetClass("w-min")
|
||||
|
||||
const fixedDenom = unit.denominations.length === 1 ? unit.denominations[0] : undefined
|
||||
|
||||
const fixedDenom = unit.denominations.length === 1 ? unit.denominations[0] : undefined
|
||||
input = new CombinedInputElement(
|
||||
input,
|
||||
unitDropDown,
|
||||
// combine the value from the textfield and the dropdown into the resulting value that should go into OSM
|
||||
(text, denom) => {
|
||||
if(denom === undefined){
|
||||
if (denom === undefined) {
|
||||
return text
|
||||
}
|
||||
return denom?.canonicalValue(text, true)
|
||||
return denom?.canonicalValue(text, true)
|
||||
},
|
||||
(valueWithDenom: string) => {
|
||||
// Take the value from OSM and feed it into the textfield and the dropdown
|
||||
|
|
|
@ -4,7 +4,6 @@ import Toggle from "../Input/Toggle";
|
|||
import Translations from "../i18n/Translations";
|
||||
import Svg from "../../Svg";
|
||||
import DeleteAction from "../../Logic/Osm/Actions/DeleteAction";
|
||||
import {Tag} from "../../Logic/Tags/Tag";
|
||||
import {UIEventSource} from "../../Logic/UIEventSource";
|
||||
import {TagsFilter} from "../../Logic/Tags/TagsFilter";
|
||||
import TagRenderingQuestion from "./TagRenderingQuestion";
|
||||
|
@ -13,13 +12,11 @@ import {SubtleButton} from "../Base/SubtleButton";
|
|||
import {FixedUiElement} from "../Base/FixedUiElement";
|
||||
import {Translation} from "../i18n/Translation";
|
||||
import BaseUIElement from "../BaseUIElement";
|
||||
import {Changes} from "../../Logic/Osm/Changes";
|
||||
import {And} from "../../Logic/Tags/And";
|
||||
import Constants from "../../Models/Constants";
|
||||
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction";
|
||||
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig";
|
||||
import {AndOrTagConfigJson} from "../../Models/ThemeConfig/Json/TagConfigJson";
|
||||
import DeleteConfig from "../../Models/ThemeConfig/DeleteConfig";
|
||||
import {OsmObject} from "../../Logic/Osm/OsmObject";
|
||||
|
||||
export default class DeleteWizard extends Toggle {
|
||||
/**
|
||||
|
@ -43,44 +40,32 @@ export default class DeleteWizard extends Toggle {
|
|||
constructor(id: string,
|
||||
options: DeleteConfig) {
|
||||
|
||||
const deleteAction = new DeleteAction(id, options.neededChangesets);
|
||||
const deleteAbility = new DeleteabilityChecker(id, options.neededChangesets)
|
||||
const tagsSource = State.state.allElements.getEventSourceById(id)
|
||||
|
||||
const isDeleted = new UIEventSource(false)
|
||||
const allowSoftDeletion = !!options.softDeletionTags
|
||||
|
||||
const confirm = new UIEventSource<boolean>(false)
|
||||
|
||||
|
||||
async function softDelete(reason: string, tagsToApply: { k: string, v: string }[]) {
|
||||
if (reason !== undefined) {
|
||||
tagsToApply.splice(0, 0, {
|
||||
k: "fixme",
|
||||
v: `A mapcomplete user marked this feature to be deleted (${reason})`
|
||||
})
|
||||
}
|
||||
await (State.state?.changes ?? new Changes())
|
||||
.applyAction(new ChangeTagAction(
|
||||
id, new And(tagsToApply.map(kv => new Tag(kv.k, kv.v))), tagsSource.data
|
||||
))
|
||||
}
|
||||
|
||||
function doDelete(selected: TagsFilter) {
|
||||
// Selected == the reasons, not the tags of the object
|
||||
const tgs = selected.asChange(tagsSource.data)
|
||||
const deleteReasonMatch = tgs.filter(kv => kv.k === "_delete_reason")
|
||||
if (deleteReasonMatch.length > 0) {
|
||||
// We should actually delete!
|
||||
const deleteReason = deleteReasonMatch[0].v
|
||||
deleteAction.DoDelete(deleteReason, () => {
|
||||
// The user doesn't have sufficient permissions to _actually_ delete the feature
|
||||
// We 'soft delete' instead (and add a fixme)
|
||||
softDelete(deleteReason, tgs.filter(kv => kv.k !== "_delete_reason"))
|
||||
|
||||
});
|
||||
return
|
||||
} else {
|
||||
// This is a 'non-delete'-option that was selected
|
||||
softDelete(undefined, tgs)
|
||||
if (deleteReasonMatch.length === 0) {
|
||||
return;
|
||||
}
|
||||
const deleteAction = new DeleteAction(id,
|
||||
options.softDeletionTags,
|
||||
{
|
||||
theme: State.state?.layoutToUse?.id ?? "unkown",
|
||||
specialMotivation: deleteReasonMatch[0]?.v
|
||||
},
|
||||
deleteAbility.canBeDeleted.data.canBeDeleted
|
||||
)
|
||||
State.state.changes.applyAction(deleteAction)
|
||||
isDeleted.setData(true)
|
||||
|
||||
}
|
||||
|
||||
|
@ -98,7 +83,7 @@ export default class DeleteWizard extends Toggle {
|
|||
saveButtonConstr: (v) => DeleteWizard.constructConfirmButton(v).onClick(() => {
|
||||
doDelete(v.data)
|
||||
}),
|
||||
bottomText: (v) => DeleteWizard.constructExplanation(v, deleteAction)
|
||||
bottomText: (v) => DeleteWizard.constructExplanation(v, deleteAbility)
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
@ -110,7 +95,7 @@ export default class DeleteWizard extends Toggle {
|
|||
const deleteButton = new SubtleButton(
|
||||
Svg.delete_icon_ui().SetStyle("width: 2rem; height: 2rem;"), t.delete.Clone()).onClick(
|
||||
() => {
|
||||
deleteAction.CheckDeleteability(true)
|
||||
deleteAbility.CheckDeleteability(true)
|
||||
confirm.setData(true);
|
||||
}
|
||||
).SetClass("w-1/2 float-right");
|
||||
|
@ -132,13 +117,13 @@ export default class DeleteWizard extends Toggle {
|
|||
|
||||
deleteButton,
|
||||
confirm),
|
||||
new VariableUiElement(deleteAction.canBeDeleted.map(cbd => new Combine([cbd.reason.Clone(), t.useSomethingElse.Clone()]))),
|
||||
deleteAction.canBeDeleted.map(cbd => allowSoftDeletion || cbd.canBeDeleted !== false)),
|
||||
new VariableUiElement(deleteAbility.canBeDeleted.map(cbd => new Combine([cbd.reason.Clone(), t.useSomethingElse.Clone()]))),
|
||||
deleteAbility.canBeDeleted.map(cbd => allowSoftDeletion || cbd.canBeDeleted !== false)),
|
||||
|
||||
t.loginToDelete.Clone().onClick(State.state.osmConnection.AttemptLogin),
|
||||
State.state.osmConnection.isLoggedIn
|
||||
),
|
||||
deleteAction.isDeleted),
|
||||
isDeleted),
|
||||
undefined,
|
||||
isShown)
|
||||
|
||||
|
@ -167,7 +152,7 @@ export default class DeleteWizard extends Toggle {
|
|||
}
|
||||
|
||||
|
||||
private static constructExplanation(tags: UIEventSource<TagsFilter>, deleteAction: DeleteAction) {
|
||||
private static constructExplanation(tags: UIEventSource<TagsFilter>, deleteAction: DeleteabilityChecker) {
|
||||
const t = Translations.t.delete;
|
||||
return new VariableUiElement(tags.map(
|
||||
currentTags => {
|
||||
|
@ -263,4 +248,172 @@ export default class DeleteWizard extends Toggle {
|
|||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class DeleteabilityChecker {
|
||||
|
||||
public readonly canBeDeleted: UIEventSource<{ canBeDeleted?: boolean, reason: Translation }>;
|
||||
private readonly _id: string;
|
||||
private readonly _allowDeletionAtChangesetCount: number;
|
||||
|
||||
|
||||
constructor(id: string,
|
||||
allowDeletionAtChangesetCount?: number) {
|
||||
this._id = id;
|
||||
this._allowDeletionAtChangesetCount = allowDeletionAtChangesetCount ?? Number.MAX_VALUE;
|
||||
|
||||
this.canBeDeleted = new UIEventSource<{ canBeDeleted?: boolean; reason: Translation }>({
|
||||
canBeDeleted: undefined,
|
||||
reason: Translations.t.delete.loading
|
||||
})
|
||||
this.CheckDeleteability(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the currently logged in user can delete the current point.
|
||||
* State is written into this._canBeDeleted
|
||||
* @constructor
|
||||
* @private
|
||||
*/
|
||||
public CheckDeleteability(useTheInternet: boolean): void {
|
||||
const t = Translations.t.delete;
|
||||
const id = this._id;
|
||||
const state = this.canBeDeleted
|
||||
if (!id.startsWith("node")) {
|
||||
this.canBeDeleted.setData({
|
||||
canBeDeleted: false,
|
||||
reason: t.isntAPoint
|
||||
})
|
||||
return;
|
||||
}
|
||||
|
||||
// Does the currently logged in user have enough experience to delete this point?
|
||||
|
||||
const deletingPointsOfOtherAllowed = State.state.osmConnection.userDetails.map(ud => {
|
||||
if (ud === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!ud.loggedIn) {
|
||||
return false;
|
||||
}
|
||||
return ud.csCount >= Math.min(Constants.userJourney.deletePointsOfOthersUnlock, this._allowDeletionAtChangesetCount);
|
||||
})
|
||||
|
||||
const previousEditors = new UIEventSource<number[]>(undefined)
|
||||
|
||||
const allByMyself = previousEditors.map(previous => {
|
||||
if (previous === null || previous === undefined) {
|
||||
// Not yet downloaded
|
||||
return null;
|
||||
}
|
||||
const userId = State.state.osmConnection.userDetails.data.uid;
|
||||
return !previous.some(editor => editor !== userId)
|
||||
}, [State.state.osmConnection.userDetails])
|
||||
|
||||
|
||||
// User allowed OR only edited by self?
|
||||
const deletetionAllowed = deletingPointsOfOtherAllowed.map(isAllowed => {
|
||||
if (isAllowed === undefined) {
|
||||
// No logged in user => definitively not allowed to delete!
|
||||
return false;
|
||||
}
|
||||
if (isAllowed === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// At this point, the logged in user is not allowed to delete points created/edited by _others_
|
||||
// however, we query OSM and if it turns out the current point has only be edited by the current user, deletion is allowed after all!
|
||||
|
||||
if (allByMyself.data === null && useTheInternet) {
|
||||
// We kickoff the download here as it hasn't yet been downloaded. Note that this is mapped onto 'all by myself' above
|
||||
OsmObject.DownloadHistory(id).map(versions => versions.map(version => version.tags["_last_edit:contributor:uid"])).syncWith(previousEditors)
|
||||
}
|
||||
if (allByMyself.data === true) {
|
||||
// Yay! We can download!
|
||||
return true;
|
||||
}
|
||||
if (allByMyself.data === false) {
|
||||
// Nope, downloading not allowed...
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// At this point, we don't have enough information yet to decide if the user is allowed to delete the current point...
|
||||
return undefined;
|
||||
}, [allByMyself])
|
||||
|
||||
|
||||
const hasRelations: UIEventSource<boolean> = new UIEventSource<boolean>(null)
|
||||
const hasWays: UIEventSource<boolean> = new UIEventSource<boolean>(null)
|
||||
deletetionAllowed.addCallbackAndRunD(deletetionAllowed => {
|
||||
|
||||
if (deletetionAllowed === false) {
|
||||
// Nope, we are not allowed to delete
|
||||
state.setData({
|
||||
canBeDeleted: false,
|
||||
reason: t.notEnoughExperience
|
||||
})
|
||||
return true; // unregister this caller!
|
||||
}
|
||||
|
||||
if (!useTheInternet) {
|
||||
return;
|
||||
}
|
||||
|
||||
// All right! We have arrived at a point that we should query OSM again to check that the point isn't a part of ways or relations
|
||||
OsmObject.DownloadReferencingRelations(id).then(rels => {
|
||||
hasRelations.setData(rels.length > 0)
|
||||
})
|
||||
|
||||
OsmObject.DownloadReferencingWays(id).then(ways => {
|
||||
hasWays.setData(ways.length > 0)
|
||||
})
|
||||
return true; // unregister to only run once
|
||||
})
|
||||
|
||||
|
||||
const hasWaysOrRelations = hasRelations.map(hasRelationsData => {
|
||||
if (hasRelationsData === true) {
|
||||
return true;
|
||||
}
|
||||
if (hasWays.data === true) {
|
||||
return true;
|
||||
}
|
||||
if (hasWays.data === null || hasRelationsData === null) {
|
||||
return null;
|
||||
}
|
||||
if (hasWays.data === false && hasRelationsData === false) {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}, [hasWays])
|
||||
|
||||
hasWaysOrRelations.addCallbackAndRun(
|
||||
waysOrRelations => {
|
||||
if (waysOrRelations == null) {
|
||||
// Not yet loaded - we still wait a little bit
|
||||
return;
|
||||
}
|
||||
if (waysOrRelations) {
|
||||
// not deleteble by mapcomplete
|
||||
state.setData({
|
||||
canBeDeleted: false,
|
||||
reason: t.partOfOthers
|
||||
})
|
||||
} else {
|
||||
// alright, this point can be safely deleted!
|
||||
state.setData({
|
||||
canBeDeleted: true,
|
||||
reason: allByMyself.data === true ? t.onlyEditedByLoggedInUser : t.safeDelete
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
157
UI/Popup/MultiApply.ts
Normal file
157
UI/Popup/MultiApply.ts
Normal file
|
@ -0,0 +1,157 @@
|
|||
import {UIEventSource} from "../../Logic/UIEventSource";
|
||||
import BaseUIElement from "../BaseUIElement";
|
||||
import Combine from "../Base/Combine";
|
||||
import {SubtleButton} from "../Base/SubtleButton";
|
||||
import {Changes} from "../../Logic/Osm/Changes";
|
||||
import {FixedUiElement} from "../Base/FixedUiElement";
|
||||
import Translations from "../i18n/Translations";
|
||||
import {VariableUiElement} from "../Base/VariableUIElement";
|
||||
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction";
|
||||
import {Tag} from "../../Logic/Tags/Tag";
|
||||
import {ElementStorage} from "../../Logic/ElementStorage";
|
||||
import {And} from "../../Logic/Tags/And";
|
||||
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
|
||||
import Toggle from "../Input/Toggle";
|
||||
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
|
||||
|
||||
|
||||
export interface MultiApplyParams {
|
||||
featureIds: UIEventSource<string[]>,
|
||||
keysToApply: string[],
|
||||
text: string,
|
||||
autoapply: boolean,
|
||||
overwrite: boolean,
|
||||
tagsSource: UIEventSource<any>,
|
||||
state: {
|
||||
changes: Changes,
|
||||
allElements: ElementStorage,
|
||||
layoutToUse: LayoutConfig,
|
||||
osmConnection: OsmConnection
|
||||
}
|
||||
}
|
||||
|
||||
class MultiApplyExecutor {
|
||||
|
||||
private readonly originalValues = new Map<string, string>()
|
||||
private readonly params: MultiApplyParams;
|
||||
|
||||
private constructor(params: MultiApplyParams) {
|
||||
this.params = params;
|
||||
const p = params
|
||||
|
||||
for (const key of p.keysToApply) {
|
||||
this.originalValues.set(key, p.tagsSource.data[key])
|
||||
}
|
||||
|
||||
if (p.autoapply) {
|
||||
|
||||
const self = this;
|
||||
const relevantValues = p.tagsSource.map(tags => {
|
||||
const currentValues = p.keysToApply.map(key => tags[key])
|
||||
// By stringifying, we have a very clear ping when they changec
|
||||
return JSON.stringify(currentValues);
|
||||
})
|
||||
relevantValues.addCallbackD(_ => {
|
||||
self.applyTaggingOnOtherFeatures()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public applyTaggingOnOtherFeatures() {
|
||||
console.log("Multi-applying changes...")
|
||||
const featuresToChange = this.params.featureIds.data
|
||||
const changes = this.params.state.changes
|
||||
const allElements = this.params.state.allElements
|
||||
const keysToChange = this.params.keysToApply
|
||||
const overwrite = this.params.overwrite
|
||||
const selfTags = this.params.tagsSource.data;
|
||||
const theme = this.params.state.layoutToUse.id
|
||||
for (const id of featuresToChange) {
|
||||
const tagsToApply: Tag[] = []
|
||||
const otherFeatureTags = allElements.getEventSourceById(id).data
|
||||
for (const key of keysToChange) {
|
||||
const newValue = selfTags[key]
|
||||
if (newValue === undefined) {
|
||||
continue
|
||||
}
|
||||
const otherValue = otherFeatureTags[key]
|
||||
if (newValue === otherValue) {
|
||||
continue;// No changes to be made
|
||||
}
|
||||
|
||||
if (overwrite) {
|
||||
tagsToApply.push(new Tag(key, newValue))
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (otherValue === undefined || otherValue === "" || otherValue === this.originalValues.get(key)) {
|
||||
tagsToApply.push(new Tag(key, newValue))
|
||||
}
|
||||
}
|
||||
|
||||
if (tagsToApply.length == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
changes.applyAction(
|
||||
new ChangeTagAction(id, new And(tagsToApply), otherFeatureTags, {
|
||||
theme,
|
||||
changeType: "answer"
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
private static executorCache = new Map<string, MultiApplyExecutor>()
|
||||
|
||||
public static GetApplicator(id: string, params: MultiApplyParams): MultiApplyExecutor {
|
||||
if (MultiApplyExecutor.executorCache.has(id)) {
|
||||
return MultiApplyExecutor.executorCache.get(id)
|
||||
}
|
||||
const applicator = new MultiApplyExecutor(params)
|
||||
MultiApplyExecutor.executorCache.set(id, applicator)
|
||||
return applicator
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default class MultiApply extends Toggle {
|
||||
|
||||
constructor(params: MultiApplyParams) {
|
||||
const p = params
|
||||
const t = Translations.t.multi_apply
|
||||
|
||||
|
||||
const featureId = p.tagsSource.data.id
|
||||
|
||||
if (featureId === undefined) {
|
||||
throw "MultiApply needs a feature id"
|
||||
}
|
||||
|
||||
const applicator = MultiApplyExecutor.GetApplicator(featureId, params)
|
||||
|
||||
const elems: (string | BaseUIElement)[] = []
|
||||
if (p.autoapply) {
|
||||
elems.push(new FixedUiElement(p.text).SetClass("block"))
|
||||
elems.push(new VariableUiElement(p.featureIds.map(featureIds =>
|
||||
t.autoApply.Subs({
|
||||
attr_names: p.keysToApply.join(", "),
|
||||
count: "" + featureIds.length
|
||||
}))).SetClass("block subtle text-sm"))
|
||||
} else {
|
||||
elems.push(
|
||||
new SubtleButton(undefined, p.text).onClick(() => applicator.applyTaggingOnOtherFeatures())
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const isShown: UIEventSource<boolean> = p.state.osmConnection.isLoggedIn.map(loggedIn => {
|
||||
return loggedIn && p.featureIds.data.length > 0
|
||||
}, [p.featureIds])
|
||||
super(new Combine(elems), undefined, isShown);
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -136,7 +136,9 @@ export default class SplitRoadWizard extends Toggle {
|
|||
// Save button
|
||||
const saveButton = new Button(t.split.Clone(), () => {
|
||||
hasBeenSplit.setData(true)
|
||||
State.state.changes.applyAction(new SplitAction(id, splitPoints.data.map(ff => ff.feature.geometry.coordinates)))
|
||||
State.state.changes.applyAction(new SplitAction(id, splitPoints.data.map(ff => ff.feature.geometry.coordinates), {
|
||||
theme: State.state?.layoutToUse?.id
|
||||
}))
|
||||
})
|
||||
|
||||
saveButton.SetClass("btn btn-primary mr-3");
|
||||
|
|
|
@ -86,7 +86,10 @@ export default class TagRenderingQuestion extends Combine {
|
|||
if (selection) {
|
||||
(State.state?.changes ?? new Changes())
|
||||
.applyAction(new ChangeTagAction(
|
||||
tags.data.id, selection, tags.data
|
||||
tags.data.id, selection, tags.data, {
|
||||
theme: State.state?.layoutToUse?.id ?? "unkown",
|
||||
changeType: "answer",
|
||||
}
|
||||
)).then(_ => {
|
||||
console.log("Tagchanges applied")
|
||||
})
|
||||
|
@ -133,7 +136,7 @@ export default class TagRenderingQuestion extends Combine {
|
|||
options.cancelButton,
|
||||
saveButton,
|
||||
bottomTags])
|
||||
this.SetClass("question")
|
||||
this.SetClass("question disable-links")
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
|
|||
import FeatureInfoBox from "../Popup/FeatureInfoBox";
|
||||
import State from "../../State";
|
||||
import {ShowDataLayerOptions} from "./ShowDataLayerOptions";
|
||||
import {FixedUiElement} from "../Base/FixedUiElement";
|
||||
|
||||
export default class ShowDataLayer {
|
||||
|
||||
|
@ -28,9 +29,13 @@ export default class ShowDataLayer {
|
|||
*/
|
||||
private readonly leafletLayersPerId = new Map<string, { feature: any, leafletlayer: any }>()
|
||||
|
||||
private readonly showDataLayerid : number;
|
||||
private static dataLayerIds = 0
|
||||
|
||||
constructor(options: ShowDataLayerOptions & { layerToShow: LayerConfig }) {
|
||||
this._leafletMap = options.leafletMap;
|
||||
this.showDataLayerid = ShowDataLayer.dataLayerIds;
|
||||
ShowDataLayer.dataLayerIds++
|
||||
this._enablePopups = options.enablePopups ?? true;
|
||||
if (options.features === undefined) {
|
||||
throw "Invalid ShowDataLayer invocation"
|
||||
|
@ -221,9 +226,8 @@ export default class ShowDataLayer {
|
|||
|
||||
let infobox: FeatureInfoBox = undefined;
|
||||
|
||||
const id = `popup-${feature.properties.id}-${feature.geometry.type}-${this._cleanCount}`
|
||||
popup.setContent(`<div style='height: 65vh' id='${id}'>Popup for ${feature.properties.id} ${feature.geometry.type}</div>`)
|
||||
|
||||
const id = `popup-${feature.properties.id}-${feature.geometry.type}-${this.showDataLayerid}-${this._cleanCount}`
|
||||
popup.setContent(`<div style='height: 65vh' id='${id}'>Popup for ${feature.properties.id} ${feature.geometry.type} ${id} is loading</div>`)
|
||||
leafletLayer.on("popupopen", () => {
|
||||
if (infobox === undefined) {
|
||||
const tags = State.state.allElements.getEventSourceById(feature.properties.id);
|
||||
|
|
|
@ -159,10 +159,8 @@ export class TileHierarchyAggregator implements FeatureSource {
|
|||
const self = this
|
||||
const empty = []
|
||||
return new StaticFeatureSource(
|
||||
locationControl.map(loc => {
|
||||
const targetZoom = loc.zoom
|
||||
|
||||
if(targetZoom > clusteringConfig.maxZoom){
|
||||
locationControl.map(loc => loc.zoom).map(targetZoom => {
|
||||
if(targetZoom-1 > clusteringConfig.maxZoom){
|
||||
return empty
|
||||
}
|
||||
|
||||
|
|
|
@ -26,8 +26,9 @@ import StaticFeatureSource from "../Logic/FeatureSource/Sources/StaticFeatureSou
|
|||
import ShowDataMultiLayer from "./ShowDataLayer/ShowDataMultiLayer";
|
||||
import Minimap from "./Base/Minimap";
|
||||
import AllImageProviders from "../Logic/ImageProviders/AllImageProviders";
|
||||
import WikipediaBox from "./WikipediaBox";
|
||||
import WikipediaBox from "./Wikipedia/WikipediaBox";
|
||||
import SimpleMetaTagger from "../Logic/SimpleMetaTagger";
|
||||
import MultiApply from "./Popup/MultiApply";
|
||||
|
||||
export interface SpecialVisualization {
|
||||
funcName: string,
|
||||
|
@ -81,13 +82,16 @@ export default class SpecialVisualizations {
|
|||
funcName: "image_carousel",
|
||||
docs: "Creates an image carousel for the given sources. An attempt will be made to guess what source is used. Supported: Wikidata identifiers, Wikipedia pages, Wikimedia categories, IMGUR (with attribution, direct links)",
|
||||
args: [{
|
||||
name: "image key/prefix",
|
||||
name: "image key/prefix (multiple values allowed if comma-seperated)",
|
||||
defaultValue: "image",
|
||||
doc: "The keys given to the images, e.g. if <span class='literal-code'>image</span> is given, the first picture URL will be added as <span class='literal-code'>image</span>, the second as <span class='literal-code'>image:0</span>, the third as <span class='literal-code'>image:1</span>, etc... "
|
||||
}],
|
||||
constr: (state: State, tags, args) => {
|
||||
const imagePrefix = args[0];
|
||||
return new ImageCarousel(AllImageProviders.LoadImagesFor(tags, imagePrefix), tags);
|
||||
let imagePrefixes = undefined;
|
||||
if(args.length > 0){
|
||||
imagePrefixes = args;
|
||||
}
|
||||
return new ImageCarousel(AllImageProviders.LoadImagesFor(tags, imagePrefixes), tags);
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -97,9 +101,13 @@ 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"
|
||||
},{
|
||||
name:"label",
|
||||
doc:"The text to show on the button",
|
||||
defaultValue: "Add image"
|
||||
}],
|
||||
constr: (state: State, tags, args) => {
|
||||
return new ImageUploadFlow(tags, args[0])
|
||||
return new ImageUploadFlow(tags, args[0], args[1])
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -114,7 +122,16 @@ export default class SpecialVisualizations {
|
|||
],
|
||||
example: "`{wikipedia()}` is a basic example, `{wikipedia(name:etymology:wikidata)}` to show the wikipedia page of whom the feature was named after. Also remember that these can be styled, e.g. `{wikipedia():max-height: 10rem}` to limit the height",
|
||||
constr: (_, tagsSource, args) =>
|
||||
new WikipediaBox( tagsSource.map(tags => tags[args[0]]))
|
||||
new VariableUiElement(
|
||||
tagsSource.map(tags => tags[args[0]])
|
||||
.map(wikidata => {
|
||||
const wikidatas : string[] =
|
||||
Utils.NoEmpty(wikidata?.split(";")?.map(wd => wd.trim()) ?? [])
|
||||
return new WikipediaBox(wikidatas)
|
||||
})
|
||||
|
||||
)
|
||||
|
||||
},
|
||||
{
|
||||
funcName: "minimap",
|
||||
|
@ -468,8 +485,49 @@ There are also some technicalities in your theme to keep in mind:
|
|||
args[2], args[1], tagSource, rewrittenTags, lat, lon, Number(args[3]), state
|
||||
)
|
||||
}
|
||||
},
|
||||
{funcName: "multi_apply",
|
||||
docs: "A button to apply the tagging of this object onto a list of other features. This is an advanced feature for which you'll need calculatedTags",
|
||||
args:[
|
||||
{name: "feature_ids", doc: "A JSOn-serialized list of IDs of features to apply the tagging on"},
|
||||
{name: "keys", doc: "One key (or multiple keys, seperated by ';') of the attribute that should be copied onto the other features." },
|
||||
{name: "text", doc: "The text to show on the button"},
|
||||
{name:"autoapply",doc:"A boolean indicating wether this tagging should be applied automatically if the relevant tags on this object are changed. A visual element indicating the multi_apply is still shown"},
|
||||
{name:"overwrite",doc:"If set to 'true', the tags on the other objects will always be overwritten. The default behaviour will be to only change the tags on other objects if they are either undefined or had the same value before the change"}
|
||||
],
|
||||
example: "{multi_apply(_features_with_the_same_name_within_100m, name:etymology:wikidata;name:etymology, Apply etymology information on all nearby objects with the same name)}",
|
||||
constr: (state, tagsSource, args) => {
|
||||
const featureIdsKey = args[0]
|
||||
const keysToApply = args[1].split(";")
|
||||
const text = args[2]
|
||||
const autoapply = args[3]?.toLowerCase() === "true"
|
||||
const overwrite = args[4]?.toLowerCase() === "true"
|
||||
const featureIds : UIEventSource<string[]> = tagsSource.map(tags => {
|
||||
const ids = tags[featureIdsKey]
|
||||
try{
|
||||
if(ids === undefined){
|
||||
return []
|
||||
}
|
||||
return JSON.parse(ids);
|
||||
}catch(e){
|
||||
console.warn("Could not parse ", ids, "as JSON to extract IDS which should be shown on the map.")
|
||||
return []
|
||||
}
|
||||
})
|
||||
return new MultiApply(
|
||||
{
|
||||
featureIds,
|
||||
keysToApply,
|
||||
text,
|
||||
autoapply,
|
||||
overwrite,
|
||||
tagsSource,
|
||||
state
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
]
|
||||
|
||||
static HelpMessage: BaseUIElement = SpecialVisualizations.GenHelpMessage();
|
||||
|
|
80
UI/Wikipedia/WikidataPreviewBox.ts
Normal file
80
UI/Wikipedia/WikidataPreviewBox.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import {VariableUiElement} from "../Base/VariableUIElement";
|
||||
import {UIEventSource} from "../../Logic/UIEventSource";
|
||||
import Wikidata, {WikidataResponse} from "../../Logic/Web/Wikidata";
|
||||
import {Translation} from "../i18n/Translation";
|
||||
import {FixedUiElement} from "../Base/FixedUiElement";
|
||||
import Loading from "../Base/Loading";
|
||||
import {Transform} from "stream";
|
||||
import Translations from "../i18n/Translations";
|
||||
import Combine from "../Base/Combine";
|
||||
import Img from "../Base/Img";
|
||||
import {WikimediaImageProvider} from "../../Logic/ImageProviders/WikimediaImageProvider";
|
||||
import Link from "../Base/Link";
|
||||
import Svg from "../../Svg";
|
||||
import BaseUIElement from "../BaseUIElement";
|
||||
|
||||
export default class WikidataPreviewBox extends VariableUiElement {
|
||||
|
||||
constructor(wikidataId : UIEventSource<string>) {
|
||||
let inited = false;
|
||||
const wikidata = wikidataId
|
||||
.stabilized(250)
|
||||
.bind(id => {
|
||||
if (id === undefined || id === "" || id === "Q") {
|
||||
return null;
|
||||
}
|
||||
inited = true;
|
||||
return Wikidata.LoadWikidataEntry(id)
|
||||
})
|
||||
|
||||
super(wikidata.map(maybeWikidata => {
|
||||
if(maybeWikidata === null || !inited){
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if(maybeWikidata === undefined){
|
||||
return new Loading(Translations.t.general.loading)
|
||||
}
|
||||
|
||||
if (maybeWikidata["error"] !== undefined) {
|
||||
return new FixedUiElement(maybeWikidata["error"]).SetClass("alert")
|
||||
}
|
||||
const wikidata = <WikidataResponse> maybeWikidata["success"]
|
||||
return WikidataPreviewBox.WikidataResponsePreview(wikidata)
|
||||
}))
|
||||
|
||||
}
|
||||
|
||||
public static WikidataResponsePreview(wikidata: WikidataResponse): BaseUIElement{
|
||||
let link = new Link(
|
||||
new Combine([
|
||||
wikidata.id,
|
||||
Svg.wikidata_ui().SetStyle("width: 2.5rem").SetClass("block")
|
||||
]).SetClass("flex"),
|
||||
Wikidata.IdToArticle(wikidata.id) ,true).SetClass("must-link")
|
||||
|
||||
let info = new Combine( [
|
||||
new Combine([Translation.fromMap(wikidata.labels).SetClass("font-bold"),
|
||||
link]).SetClass("flex justify-between"),
|
||||
Translation.fromMap(wikidata.descriptions)
|
||||
]).SetClass("flex flex-col link-underline")
|
||||
|
||||
|
||||
let imageUrl = undefined
|
||||
if(wikidata.claims.get("P18")?.size > 0){
|
||||
imageUrl = Array.from(wikidata.claims.get("P18"))[0]
|
||||
}
|
||||
|
||||
|
||||
if(imageUrl){
|
||||
imageUrl = WikimediaImageProvider.singleton.PrepUrl(imageUrl).url
|
||||
info = new Combine([ new Img(imageUrl).SetStyle("max-width: 5rem; width: unset; height: 4rem").SetClass("rounded-xl mr-2"),
|
||||
info.SetClass("w-full")]).SetClass("flex")
|
||||
}
|
||||
|
||||
info.SetClass("p-2 w-full")
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
}
|
117
UI/Wikipedia/WikidataSearchBox.ts
Normal file
117
UI/Wikipedia/WikidataSearchBox.ts
Normal file
|
@ -0,0 +1,117 @@
|
|||
import Combine from "../Base/Combine";
|
||||
import {InputElement} from "../Input/InputElement";
|
||||
import {TextField} from "../Input/TextField";
|
||||
import Translations from "../i18n/Translations";
|
||||
import {UIEventSource} from "../../Logic/UIEventSource";
|
||||
import Wikidata, {WikidataResponse} from "../../Logic/Web/Wikidata";
|
||||
import Locale from "../i18n/Locale";
|
||||
import {VariableUiElement} from "../Base/VariableUIElement";
|
||||
import WikidataPreviewBox from "./WikidataPreviewBox";
|
||||
import Title from "../Base/Title";
|
||||
import WikipediaBox from "./WikipediaBox";
|
||||
import Svg from "../../Svg";
|
||||
|
||||
export default class WikidataSearchBox extends InputElement<string> {
|
||||
|
||||
private readonly wikidataId: UIEventSource<string>
|
||||
private readonly searchText: UIEventSource<string>
|
||||
|
||||
constructor(options?: {
|
||||
searchText?: UIEventSource<string>,
|
||||
value?: UIEventSource<string>
|
||||
}) {
|
||||
super();
|
||||
this.searchText = options?.searchText
|
||||
this.wikidataId = options?.value ?? new UIEventSource<string>(undefined);
|
||||
}
|
||||
|
||||
GetValue(): UIEventSource<string> {
|
||||
return this.wikidataId;
|
||||
}
|
||||
|
||||
protected InnerConstructElement(): HTMLElement {
|
||||
|
||||
const searchField = new TextField({
|
||||
placeholder: Translations.t.general.wikipedia.searchWikidata,
|
||||
value: this.searchText,
|
||||
inputStyle: "width: calc(100% - 0.5rem); border: 1px solid black"
|
||||
|
||||
})
|
||||
const selectedWikidataId = this.wikidataId
|
||||
|
||||
const lastSearchResults = new UIEventSource<WikidataResponse[]>([])
|
||||
const searchFailMessage = new UIEventSource(undefined)
|
||||
searchField.GetValue().addCallbackAndRunD(searchText => {
|
||||
if (searchText.length < 3) {
|
||||
return;
|
||||
}
|
||||
searchFailMessage.setData(undefined)
|
||||
lastSearchResults.WaitForPromise(
|
||||
Wikidata.searchAndFetch(searchText, {
|
||||
lang: Locale.language.data,
|
||||
maxCount: 5
|
||||
}
|
||||
), err => searchFailMessage.setData(err))
|
||||
|
||||
})
|
||||
|
||||
|
||||
const previews = new VariableUiElement(lastSearchResults.map(searchResults => {
|
||||
if (searchFailMessage.data !== undefined) {
|
||||
return new Combine([Translations.t.general.wikipedia.failed.Clone().SetClass("alert"), searchFailMessage.data])
|
||||
}
|
||||
|
||||
if(searchResults.length === 0){
|
||||
return Translations.t.general.wikipedia.noResults.Subs({search: searchField.GetValue().data ?? ""})
|
||||
}
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
return Translations.t.general.wikipedia.doSearch
|
||||
}
|
||||
|
||||
return new Combine(searchResults.map(wikidataresponse => {
|
||||
const el = WikidataPreviewBox.WikidataResponsePreview(wikidataresponse).SetClass("rounded-xl p-1 sm:p-2 md:p-3 m-px border-2 sm:border-4 transition-colors")
|
||||
el.onClick(() => {
|
||||
selectedWikidataId.setData(wikidataresponse.id)
|
||||
})
|
||||
selectedWikidataId.addCallbackAndRunD(selected => {
|
||||
if (selected === wikidataresponse.id) {
|
||||
el.SetClass("subtle-background border-attention")
|
||||
} else {
|
||||
el.RemoveClass("subtle-background")
|
||||
el.RemoveClass("border-attention")
|
||||
}
|
||||
})
|
||||
return el;
|
||||
|
||||
})).SetClass("flex flex-col")
|
||||
|
||||
}, [searchFailMessage]))
|
||||
|
||||
//
|
||||
const full = new Combine([
|
||||
new Title(Translations.t.general.wikipedia.searchWikidata, 3).SetClass("m-2"),
|
||||
new Combine([
|
||||
Svg.search_ui().SetStyle("width: 1.5rem"),
|
||||
searchField.SetClass("m-2 w-full")]).SetClass("flex"),
|
||||
previews
|
||||
]).SetClass("flex flex-col border-2 border-black rounded-xl m-2 p-2")
|
||||
|
||||
return new Combine([
|
||||
new VariableUiElement(selectedWikidataId.map(wid => {
|
||||
if (wid === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return new WikipediaBox(wid.split(";"));
|
||||
})).SetStyle("max-height:12.5rem"),
|
||||
full
|
||||
]).ConstructElement();
|
||||
}
|
||||
|
||||
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
|
||||
|
||||
IsValid(t: string): boolean {
|
||||
return t.startsWith("Q") && !isNaN(Number(t.substring(1)));
|
||||
}
|
||||
|
||||
}
|
205
UI/Wikipedia/WikipediaBox.ts
Normal file
205
UI/Wikipedia/WikipediaBox.ts
Normal file
|
@ -0,0 +1,205 @@
|
|||
import BaseUIElement from "../BaseUIElement";
|
||||
import Locale from "../i18n/Locale";
|
||||
import {VariableUiElement} from "../Base/VariableUIElement";
|
||||
import {Translation} from "../i18n/Translation";
|
||||
import Svg from "../../Svg";
|
||||
import Combine from "../Base/Combine";
|
||||
import Title from "../Base/Title";
|
||||
import Wikipedia from "../../Logic/Web/Wikipedia";
|
||||
import Wikidata, {WikidataResponse} from "../../Logic/Web/Wikidata";
|
||||
import {TabbedComponent} from "../Base/TabbedComponent";
|
||||
import {UIEventSource} from "../../Logic/UIEventSource";
|
||||
import Loading from "../Base/Loading";
|
||||
import {FixedUiElement} from "../Base/FixedUiElement";
|
||||
import Translations from "../i18n/Translations";
|
||||
import Link from "../Base/Link";
|
||||
import WikidataPreviewBox from "./WikidataPreviewBox";
|
||||
|
||||
export default class WikipediaBox extends Combine {
|
||||
|
||||
|
||||
constructor(wikidataIds: string[]) {
|
||||
|
||||
const mainContents = []
|
||||
|
||||
const pages = wikidataIds.map(wdId => WikipediaBox.createLinkedContent(wdId.trim()))
|
||||
if (wikidataIds.length == 1) {
|
||||
const page = pages[0]
|
||||
mainContents.push(
|
||||
new Combine([
|
||||
new Combine([Svg.wikipedia_ui()
|
||||
.SetStyle("width: 1.5rem").SetClass("inline-block mr-3"), page.titleElement])
|
||||
.SetClass("flex"),
|
||||
page.linkElement
|
||||
]).SetClass("flex justify-between align-middle"),
|
||||
)
|
||||
mainContents.push(page.contents)
|
||||
} else if (wikidataIds.length > 1) {
|
||||
|
||||
const tabbed = new TabbedComponent(
|
||||
pages.map(page => {
|
||||
const contents = page.contents.SetClass("block").SetStyle("max-height: inherit; height: inherit; padding-bottom: 3.3rem")
|
||||
return {
|
||||
header: page.titleElement.SetClass("pl-2 pr-2"),
|
||||
content: new Combine([
|
||||
page.linkElement
|
||||
.SetStyle("top: 2rem; right: 2.5rem;")
|
||||
.SetClass("absolute subtle-background rounded-full p-3 opacity-50 hover:opacity-100 transition-opacity"),
|
||||
contents
|
||||
]).SetStyle("max-height: inherit; height: inherit").SetClass("relative")
|
||||
}
|
||||
|
||||
}),
|
||||
0,
|
||||
{
|
||||
leftOfHeader: Svg.wikipedia_ui().SetStyle("width: 1.5rem; align-self: center;").SetClass("mr-4"),
|
||||
styleHeader: header => header.SetClass("subtle-background").SetStyle("height: 3.3rem")
|
||||
}
|
||||
)
|
||||
tabbed.SetStyle("height: inherit; max-height: inherit; overflow: hidden")
|
||||
mainContents.push(tabbed)
|
||||
|
||||
}
|
||||
|
||||
|
||||
super(mainContents)
|
||||
|
||||
|
||||
this.SetClass("block rounded-xl subtle-background m-1 p-2 flex flex-col")
|
||||
.SetStyle("max-height: inherit")
|
||||
}
|
||||
|
||||
private static createLinkedContent(wikidataId: string): {
|
||||
titleElement: BaseUIElement,
|
||||
contents: BaseUIElement,
|
||||
linkElement: BaseUIElement
|
||||
} {
|
||||
|
||||
const wp = Translations.t.general.wikipedia;
|
||||
|
||||
const wikiLink: UIEventSource<[string, string, WikidataResponse] | "loading" | "failed" | ["no page", WikidataResponse]> =
|
||||
Wikidata.LoadWikidataEntry(wikidataId)
|
||||
.map(maybewikidata => {
|
||||
if (maybewikidata === undefined) {
|
||||
return "loading"
|
||||
}
|
||||
if (maybewikidata["error"] !== undefined) {
|
||||
return "failed"
|
||||
|
||||
}
|
||||
const wikidata = <WikidataResponse>maybewikidata["success"]
|
||||
if(wikidata === undefined){
|
||||
return "failed"
|
||||
}
|
||||
if (wikidata.wikisites.size === 0) {
|
||||
return ["no page", wikidata]
|
||||
}
|
||||
|
||||
const preferredLanguage = [Locale.language.data, "en", Array.from(wikidata.wikisites.keys())[0]]
|
||||
let language
|
||||
let pagetitle;
|
||||
let i = 0
|
||||
do {
|
||||
language = preferredLanguage[i]
|
||||
pagetitle = wikidata.wikisites.get(language)
|
||||
i++;
|
||||
} while (pagetitle === undefined)
|
||||
return [pagetitle, language, wikidata]
|
||||
}, [Locale.language])
|
||||
|
||||
|
||||
const contents = new VariableUiElement(
|
||||
wikiLink.map(status => {
|
||||
if (status === "loading") {
|
||||
return new Loading(wp.loading.Clone()).SetClass("pl-6 pt-2")
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
return wp.failed.Clone().SetClass("alert p-4")
|
||||
}
|
||||
if (status[0] == "no page") {
|
||||
const [_, wd] = <[string, WikidataResponse]> status
|
||||
return new Combine([
|
||||
WikidataPreviewBox.WikidataResponsePreview(wd),
|
||||
wp.noWikipediaPage.Clone().SetClass("subtle")]).SetClass("flex flex-col p-4")
|
||||
}
|
||||
|
||||
const [pagetitle, language, wd] = <[string, string, WikidataResponse]> status
|
||||
return WikipediaBox.createContents(pagetitle, language, wd)
|
||||
|
||||
})
|
||||
).SetClass("overflow-auto normal-background rounded-lg")
|
||||
|
||||
|
||||
const titleElement = new VariableUiElement(wikiLink.map(state => {
|
||||
if (typeof state !== "string") {
|
||||
const [pagetitle, _] = state
|
||||
if(pagetitle === "no page"){
|
||||
const wd = <WikidataResponse> state[1]
|
||||
return new Title( Translation.fromMap(wd.labels),3)
|
||||
}
|
||||
return new Title(pagetitle, 3)
|
||||
}
|
||||
//return new Title(Translations.t.general.wikipedia.wikipediaboxTitle.Clone(), 2)
|
||||
return new Title(wikidataId,3)
|
||||
|
||||
}))
|
||||
|
||||
const linkElement = new VariableUiElement(wikiLink.map(state => {
|
||||
if (typeof state !== "string") {
|
||||
const [pagetitle, language] = state
|
||||
if(pagetitle === "no page"){
|
||||
const wd = <WikidataResponse> state[1]
|
||||
return new Link(Svg.pop_out_ui().SetStyle("width: 1.2rem").SetClass("block "),
|
||||
"https://www.wikidata.org/wiki/"+wd.id
|
||||
, true)
|
||||
}
|
||||
|
||||
const url = `https://${language}.wikipedia.org/wiki/${pagetitle}`
|
||||
return new Link(Svg.pop_out_ui().SetStyle("width: 1.2rem").SetClass("block "), url, true)
|
||||
}
|
||||
return undefined
|
||||
}))
|
||||
.SetClass("flex items-center enable-links")
|
||||
|
||||
return {
|
||||
contents: contents,
|
||||
linkElement: linkElement,
|
||||
titleElement: titleElement
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the actual content in a scrollable way
|
||||
* @param pagename
|
||||
* @param language
|
||||
* @private
|
||||
*/
|
||||
private static createContents(pagename: string, language: string, wikidata: WikidataResponse): BaseUIElement {
|
||||
const htmlContent = Wikipedia.GetArticle({
|
||||
pageName: pagename,
|
||||
language: language
|
||||
})
|
||||
const wp = Translations.t.general.wikipedia
|
||||
const contents: UIEventSource<string | BaseUIElement> = htmlContent.map(htmlContent => {
|
||||
if (htmlContent === undefined) {
|
||||
// Still loading
|
||||
return new Loading(wp.loading.Clone())
|
||||
}
|
||||
if (htmlContent["success"] !== undefined) {
|
||||
return new FixedUiElement(htmlContent["success"]).SetClass("wikipedia-article")
|
||||
}
|
||||
if (htmlContent["error"]) {
|
||||
console.warn("Loading wikipage failed due to", htmlContent["error"])
|
||||
return wp.failed.Clone().SetClass("alert p-4")
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
return new Combine([new VariableUiElement(contents)
|
||||
.SetClass("block pl-6 pt-2")])
|
||||
}
|
||||
|
||||
}
|
|
@ -1,109 +0,0 @@
|
|||
import {UIEventSource} from "../Logic/UIEventSource";
|
||||
import {VariableUiElement} from "./Base/VariableUIElement";
|
||||
import Wikipedia from "../Logic/Web/Wikipedia";
|
||||
import Loading from "./Base/Loading";
|
||||
import {FixedUiElement} from "./Base/FixedUiElement";
|
||||
import Combine from "./Base/Combine";
|
||||
import BaseUIElement from "./BaseUIElement";
|
||||
import Title from "./Base/Title";
|
||||
import Translations from "./i18n/Translations";
|
||||
import Svg from "../Svg";
|
||||
import Wikidata, {WikidataResponse} from "../Logic/Web/Wikidata";
|
||||
import Locale from "./i18n/Locale";
|
||||
import Toggle from "./Input/Toggle";
|
||||
|
||||
export default class WikipediaBox extends Toggle {
|
||||
|
||||
|
||||
constructor(wikidataId: string | UIEventSource<string>) {
|
||||
const wp = Translations.t.general.wikipedia;
|
||||
if (typeof wikidataId === "string") {
|
||||
wikidataId = new UIEventSource(wikidataId)
|
||||
}
|
||||
|
||||
|
||||
const wikibox = wikidataId
|
||||
.bind(id => {
|
||||
console.log("Wikidata is", id)
|
||||
if(id === undefined){
|
||||
return undefined
|
||||
}
|
||||
console.log("Initing load WIkidataentry with id", id)
|
||||
return Wikidata.LoadWikidataEntry(id);
|
||||
})
|
||||
.map(maybewikidata => {
|
||||
if (maybewikidata === undefined) {
|
||||
return new Loading(wp.loading.Clone())
|
||||
}
|
||||
if (maybewikidata["error"] !== undefined) {
|
||||
return wp.failed.Clone().SetClass("alert p-4")
|
||||
}
|
||||
const wikidata = <WikidataResponse>maybewikidata["success"]
|
||||
console.log("Got wikidata response", wikidata)
|
||||
if (wikidata.wikisites.size === 0) {
|
||||
return wp.noWikipediaPage.Clone()
|
||||
}
|
||||
|
||||
const preferredLanguage = [Locale.language.data, "en", Array.from(wikidata.wikisites.keys())[0]]
|
||||
let language
|
||||
let pagetitle;
|
||||
let i = 0
|
||||
do {
|
||||
language = preferredLanguage[i]
|
||||
pagetitle = wikidata.wikisites.get(language)
|
||||
i++;
|
||||
} while (pagetitle === undefined)
|
||||
return WikipediaBox.createContents(pagetitle, language)
|
||||
}, [Locale.language])
|
||||
|
||||
|
||||
const contents = new VariableUiElement(
|
||||
wikibox
|
||||
).SetClass("overflow-auto normal-background rounded-lg")
|
||||
|
||||
|
||||
const mainContent = new Combine([
|
||||
new Combine([Svg.wikipedia_ui().SetStyle("width: 1.5rem").SetClass("mr-3"),
|
||||
new Title(Translations.t.general.wikipedia.wikipediaboxTitle.Clone(), 2)]).SetClass("flex"),
|
||||
contents]).SetClass("block rounded-xl subtle-background m-1 p-2 flex flex-col")
|
||||
.SetStyle("max-height: inherit")
|
||||
super(
|
||||
mainContent,
|
||||
undefined,
|
||||
wikidataId.map(id => id !== undefined)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the actual content in a scrollable way
|
||||
* @param pagename
|
||||
* @param language
|
||||
* @private
|
||||
*/
|
||||
private static createContents(pagename: string, language: string): BaseUIElement {
|
||||
const htmlContent = Wikipedia.GetArticle({
|
||||
pageName: pagename,
|
||||
language: language
|
||||
})
|
||||
const wp = Translations.t.general.wikipedia
|
||||
const contents: UIEventSource<string | BaseUIElement> = htmlContent.map(htmlContent => {
|
||||
if (htmlContent === undefined) {
|
||||
// Still loading
|
||||
return new Loading(wp.loading.Clone())
|
||||
}
|
||||
if (htmlContent["success"] !== undefined) {
|
||||
return new FixedUiElement(htmlContent["success"]).SetClass("wikipedia-article")
|
||||
}
|
||||
if (htmlContent["error"]) {
|
||||
console.warn("Loading wikipage failed due to", htmlContent["error"])
|
||||
return wp.failed.Clone().SetClass("alert p-4")
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
return new Combine([new VariableUiElement(contents).SetClass("block pl-6 pt-2")])
|
||||
.SetClass("block")
|
||||
}
|
||||
|
||||
}
|
|
@ -214,4 +214,12 @@ export class Translation extends BaseUIElement {
|
|||
}
|
||||
return allTranslations
|
||||
}
|
||||
|
||||
static fromMap(transl: Map<string, string>) {
|
||||
const translations = {}
|
||||
transl?.forEach((value, key) => {
|
||||
translations[key] = value
|
||||
})
|
||||
return new Translation(translations);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue