Feature(grb): add popup feature to validate e.g. a user profile

This commit is contained in:
Pieter Vander Vennet 2025-03-17 01:17:02 +01:00
parent c3d905b26a
commit ecd8f5e1da
11 changed files with 247 additions and 64 deletions

View file

@ -19,6 +19,50 @@
"shortDescription": { "shortDescription": {
"nl": "Grb import helper tool" "nl": "Grb import helper tool"
}, },
"popup": [
{
"id": "wikilink-needed",
"condition": "_description!~.*https://wiki.openstreetmap.org/wiki/WikiProject_Belgium/Building_and_address_import.*",
"dismissable": false,
"title": {
"render": {
"en": "Profile mention obligated",
"nl": "Link op profiel verplicht"
}
},
"body": [
{
"render": {
"special": {
"type": "link",
"href": "https://www.openstreetmap.org/profile/edit",
"text": {
"en": "Edit your user profile",
"nl": "Pas je profiel aan"
}
},
"after": {
"en": "to include the link <span class='literal-code'>https://wiki.openstreetmap.org/wiki/WikiProject_Belgium/Building_and_address_import</code>",
"nl": " en voeg deze link toe: <span class='literal-code'>https://wiki.openstreetmap.org/wiki/WikiProject_Belgium/Building_and_address_import</code>"
}
}
},
{
"id": "reload_profile",
"render": {
"special": {
"type": "login_button",
"force": "yes",
"message": {
"en": "Reload your profile",
"nl": "Herlaad je profiel"
}
}
}
}
]
}
],
"icon": "./assets/themes/grb/logo.svg", "icon": "./assets/themes/grb/logo.svg",
"startZoom": 9, "startZoom": 9,
"startLat": 51.0249, "startLat": 51.0249,

View file

@ -623,6 +623,30 @@
} }
} }
} }
},
"popup": {
"0": {
"body": {
"0": {
"render": {
"special": {
"after": "to include the link <span class='literal-code'>https://wiki.openstreetmap.org/wiki/WikiProject_Belgium/Building_and_address_import</code>",
"text": "Edit your user profile"
}
}
},
"1": {
"render": {
"special": {
"msg": "Reload your profile"
}
}
}
},
"title": {
"render": "Profile mention obligated"
}
}
} }
}, },
"guideposts": { "guideposts": {

View file

@ -669,6 +669,30 @@
} }
} }
}, },
"popup": {
"0": {
"body": {
"0": {
"render": {
"special": {
"after": " en voeg deze link toe: <span class='literal-code'>https://wiki.openstreetmap.org/wiki/WikiProject_Belgium/Building_and_address_import</code>",
"text": "Pas je profiel aan"
}
}
},
"1": {
"render": {
"special": {
"msg": "Herlaad je profiel"
}
}
}
},
"title": {
"render": "Link op profiel verplicht"
}
}
},
"shortDescription": "Grb import helper tool", "shortDescription": "Grb import helper tool",
"title": "GRB import helper" "title": "GRB import helper"
}, },

View file

@ -1,16 +1,6 @@
import { import { Concat, Conversion, DesugaringContext, DesugaringStep, Each, Fuse, On, Pass, SetDefault } from "./Conversion"
Concat,
Conversion,
DesugaringContext,
DesugaringStep,
Each,
Fuse,
On,
Pass,
SetDefault,
} from "./Conversion"
import { ThemeConfigJson } from "../Json/ThemeConfigJson" import { ThemeConfigJson } from "../Json/ThemeConfigJson"
import { PrepareLayer } from "./PrepareLayer" import { PrepareLayer, RewriteSpecial } from "./PrepareLayer"
import { LayerConfigJson } from "../Json/LayerConfigJson" import { LayerConfigJson } from "../Json/LayerConfigJson"
import { Utils } from "../../../Utils" import { Utils } from "../../../Utils"
import Constants from "../../Constants" import Constants from "../../Constants"
@ -40,7 +30,7 @@ class SubstituteLayer extends Conversion<string | LayerConfigJson, LayerConfigJs
const knownLayers = Array.from(state.sharedLayers.keys()) const knownLayers = Array.from(state.sharedLayers.keys())
const withDistance: [string, number][] = knownLayers.map((lname) => [ const withDistance: [string, number][] = knownLayers.map((lname) => [
lname, lname,
Utils.levenshteinDistance(name, lname), Utils.levenshteinDistance(name, lname)
]) ])
withDistance.sort((a, b) => a[1] - b[1]) withDistance.sort((a, b) => a[1] - b[1])
const ids = withDistance.map((n) => n[0]) const ids = withDistance.map((n) => n[0])
@ -130,9 +120,9 @@ class SubstituteLayer extends Conversion<string | LayerConfigJson, LayerConfigJs
usedLabels.add(labels[forbiddenLabel]) usedLabels.add(labels[forbiddenLabel])
context.info( context.info(
"Dropping tagRendering " + "Dropping tagRendering " +
tr["id"] + tr["id"] +
" as it has a forbidden label: " + " as it has a forbidden label: " +
labels[forbiddenLabel] labels[forbiddenLabel]
) )
continue continue
} }
@ -150,10 +140,10 @@ class SubstituteLayer extends Conversion<string | LayerConfigJson, LayerConfigJs
usedLabels.add(tr["group"]) usedLabels.add(tr["group"])
context.info( context.info(
"Dropping tagRendering " + "Dropping tagRendering " +
tr["id"] + tr["id"] +
" as its group `" + " as its group `" +
tr["group"] + tr["group"] +
"` is a forbidden label" "` is a forbidden label"
) )
continue continue
} }
@ -164,8 +154,8 @@ class SubstituteLayer extends Conversion<string | LayerConfigJson, LayerConfigJs
if (unused.length > 0) { if (unused.length > 0) {
context.err( context.err(
"This theme specifies that certain tagrenderings have to be removed based on forbidden layers. One or more of these layers did not match any tagRenderings and caused no deletions: " + "This theme specifies that certain tagrenderings have to be removed based on forbidden layers. One or more of these layers did not match any tagRenderings and caused no deletions: " +
unused.join(", ") + unused.join(", ") +
"\n This means that this label can be removed or that the original tagRendering that should be deleted does not have this label anymore" "\n This means that this label can be removed or that the original tagRendering that should be deleted does not have this label anymore"
) )
} }
found.tagRenderings = filtered found.tagRenderings = filtered
@ -205,10 +195,10 @@ export class AddDefaultLayers extends DesugaringStep<ThemeConfigJson> {
if (alreadyLoaded.has(v.id)) { if (alreadyLoaded.has(v.id)) {
context.warn( context.warn(
"Layout " + "Layout " +
context + context +
" already has a layer with name " + " already has a layer with name " +
v.id + v.id +
"; skipping inclusion of this builtin layer" "; skipping inclusion of this builtin layer"
) )
continue continue
} }
@ -352,10 +342,10 @@ class AddDependencyLayersToTheme extends DesugaringStep<ThemeConfigJson> {
.enters("layer dependency") .enters("layer dependency")
.err( .err(
"Layer " + "Layer " +
dependency.neededLayer + dependency.neededLayer +
" is loaded because " + " is loaded because " +
dependency.reason + dependency.reason +
"; so it must specify a `snapName`. This is used in the sentence `move this point to snap it to {snapName}`" "; so it must specify a `snapName`. This is used in the sentence `move this point to snap it to {snapName}`"
) )
} }
} }
@ -380,12 +370,12 @@ class AddDependencyLayersToTheme extends DesugaringStep<ThemeConfigJson> {
if (dep === undefined) { if (dep === undefined) {
const message = [ const message = [
"Loading a dependency failed: layer " + "Loading a dependency failed: layer " +
unmetDependency.neededLayer + unmetDependency.neededLayer +
" is not found, neither as layer of " + " is not found, neither as layer of " +
themeId + themeId +
" nor as builtin layer.", " nor as builtin layer.",
reason, reason,
"Loaded layers are: " + alreadyLoaded.map((l) => l.id).join(","), "Loaded layers are: " + alreadyLoaded.map((l) => l.id).join(",")
] ]
throw message.join("\n\t") throw message.join("\n\t")
} }
@ -395,7 +385,7 @@ class AddDependencyLayersToTheme extends DesugaringStep<ThemeConfigJson> {
dep.description = reason dep.description = reason
dependenciesToAdd.unshift({ dependenciesToAdd.unshift({
config: dep, config: dep,
reason, reason
}) })
loadedLayerIds.add(dep.id) loadedLayerIds.add(dep.id)
unmetDependencies = unmetDependencies.filter( unmetDependencies = unmetDependencies.filter(
@ -440,7 +430,7 @@ class AddDependencyLayersToTheme extends DesugaringStep<ThemeConfigJson> {
return { return {
...theme, ...theme,
layers: layers, layers: layers
} }
} }
} }
@ -510,10 +500,10 @@ class WarnForUnsubstitutedLayersInTheme extends DesugaringStep<ThemeConfigJson>
context.warn( context.warn(
"The theme " + "The theme " +
json.id + json.id +
" has an inline layer: " + " has an inline layer: " +
layer["id"] + layer["id"] +
". This is discouraged." ". This is discouraged."
) )
} }
return json return json
@ -555,12 +545,12 @@ class PostvalidateTheme extends DesugaringStep<ThemeConfigJson> {
if (minZoomAll < layer.minzoom) { if (minZoomAll < layer.minzoom) {
context.err( context.err(
"There are multiple layers based on " + "There are multiple layers based on " +
basedOn + basedOn +
". The layer with id " + ". The layer with id " +
layer.id + layer.id +
" has a minzoom of " + " has a minzoom of " +
layer.minzoom + layer.minzoom +
", and has a name set. Another similar layer has a lower minzoom. As such, the layer selection might show 'zoom in to see features' even though some of the features are already visible. Set `\"name\": null` for this layer and eventually remove the 'name':null for the other layer." ", and has a name set. Another similar layer has a lower minzoom. As such, the layer selection might show 'zoom in to see features' even though some of the features are already visible. Set `\"name\": null` for this layer and eventually remove the 'name':null for the other layer."
) )
} }
} }
@ -586,11 +576,11 @@ class PostvalidateTheme extends DesugaringStep<ThemeConfigJson> {
.enters("layers", config.id, "filter", "sameAs") .enters("layers", config.id, "filter", "sameAs")
.err( .err(
"The layer " + "The layer " +
config.id + config.id +
" follows the filter state of layer " + " follows the filter state of layer " +
sameAs + sameAs +
", but no layer with this name was found.\n\tDid you perhaps mean one of: " + ", but no layer with this name was found.\n\tDid you perhaps mean one of: " +
closeLayers.slice(0, 3).join(", ") closeLayers.slice(0, 3).join(", ")
) )
} }
} }
@ -618,6 +608,13 @@ export class PrepareTheme extends Fuse<ThemeConfigJson> {
new SetDefault("socialImage", "assets/SocialImage.png", true), new SetDefault("socialImage", "assets/SocialImage.png", true),
// We expand all tagrenderings first... // We expand all tagrenderings first...
new On("layers", new Each(new PrepareLayer(state))), new On("layers", new Each(new PrepareLayer(state))),
new On("popup", new Each(
new Fuse("Prepare popups",
new On("body", new Each(new RewriteSpecial())),
new On("title", new RewriteSpecial())
)
)),
// Then we apply the override all. We must first expand everything in case that we override something in an expanded tag // Then we apply the override all. We must first expand everything in case that we override something in an expanded tag
// Note that it'll cheat with tagRenderings+ // Note that it'll cheat with tagRenderings+
new ApplyOverrideAll(), new ApplyOverrideAll(),

View file

@ -3,6 +3,8 @@ import ExtraLinkConfigJson from "./ExtraLinkConfigJson"
import { RasterLayerProperties } from "../../RasterLayerProperties" import { RasterLayerProperties } from "../../RasterLayerProperties"
import { Translatable } from "./Translatable" import { Translatable } from "./Translatable"
import { TagConfigJson } from "./TagConfigJson"
import { TagRenderingConfigJson } from "./TagRenderingConfigJson"
/** /**
* Defines the entire theme. * Defines the entire theme.
@ -468,4 +470,24 @@ export interface ThemeConfigJson {
* group: hidden * group: hidden
*/ */
_usedImages?: string[] _usedImages?: string[]
/**
* If set, an _additional_ popup will be shown under the theme introduction page.
*
* The embedded tagRenderingConfigs will be run against the settings-state of the contributor.
* If multiple popups are set, the first popup of the list will be rendered on top (and thus be seen first).
*/
popup?: {
/**
* ifset: the user can dismiss this message
*/
dismissible?: boolean
condition?: TagConfigJson
title: TagRenderingConfigJson,
body: TagRenderingConfigJson[],
/**
* id of the popup, mostly to keep the translations in check
*/
id: string,
}[]
} }

View file

@ -9,6 +9,10 @@ import LanguageUtils from "../../Utils/LanguageUtils"
import { RasterLayerProperties } from "../RasterLayerProperties" import { RasterLayerProperties } from "../RasterLayerProperties"
import { Translatable } from "./Json/Translatable" import { Translatable } from "./Json/Translatable"
import { TagsFilter } from "../../Logic/Tags/TagsFilter"
import TagRenderingConfig from "./TagRenderingConfig"
import { TagUtils } from "../../Logic/Tags/TagUtils"
import { TagRenderingConfigJson } from "./Json/TagRenderingConfigJson"
/** /**
* Minimal information about a theme * Minimal information about a theme
@ -93,6 +97,14 @@ export default class ThemeConfig implements ThemeInformation {
public readonly source: ThemeConfigJson public readonly source: ThemeConfigJson
public readonly enableCache: boolean public readonly enableCache: boolean
public readonly popups: Readonly<{
id: string,
dismissible?: boolean,
condition: TagsFilter,
title: TagRenderingConfig,
body: TagRenderingConfig[]
}>[]
constructor( constructor(
json: ThemeConfigJson, json: ThemeConfigJson,
official = true, official = true,
@ -193,11 +205,26 @@ export default class ThemeConfig implements ThemeInformation {
icon: "./assets/svg/pop-out.svg", icon: "./assets/svg/pop-out.svg",
href: "https://{basepath}/{theme}.html?lat={lat}&lon={lon}&z={zoom}&language={language}", href: "https://{basepath}/{theme}.html?lat={lat}&lon={lon}&z={zoom}&language={language}",
newTab: true, newTab: true,
requirements: ["iframe", "no-welcome-message"], requirements: ["iframe", "no-welcome-message"]
}, },
context + ".extraLink" context + ".extraLink"
) )
this.popups = (json.popup ?? []).map((p, i) => {
const ctx = context + ".popup." + i
if (!p.id) {
throw (ctx + ": an id is required")
}
const body: TagRenderingConfigJson[] = Array.isArray(p.body) ? p.body : [p.body]
return {
id: p.id,
dismissible: p.dismissible ?? false,
condition: TagUtils.Tag(p.condition),
title: new TagRenderingConfig(p.title, ctx + ".title"),
body: body.map((body, i) => new TagRenderingConfig(body, ctx + ".body." + i))
}
})
this.hideFromOverview = json.hideFromOverview ?? false this.hideFromOverview = json.hideFromOverview ?? false
this.lockLocation = <[[number, number], [number, number]]>json.lockLocation ?? undefined this.lockLocation = <[[number, number], [number, number]]>json.lockLocation ?? undefined
this.enableUserBadge = json.enableUserBadge ?? true this.enableUserBadge = json.enableUserBadge ?? true
@ -351,7 +378,7 @@ export default class ThemeConfig implements ThemeInformation {
// The 'favourite'-layer contains pretty much all images as it bundles all layers, so we exclude it // The 'favourite'-layer contains pretty much all images as it bundles all layers, so we exclude it
const jsonNoFavourites = { const jsonNoFavourites = {
...json, ...json,
layers: json.layers.filter((l) => l["id"] !== "favourite"), layers: json.layers.filter((l) => l["id"] !== "favourite")
} }
const usedImages = jsonNoFavourites._usedImages const usedImages = jsonNoFavourites._usedImages
usedImages.sort() usedImages.sort()

View file

@ -12,7 +12,7 @@ export default class WithContextLoader {
this._context = context this._context = context
} }
/** Given a key, gets the corresponding property from the json (or the default if not found /** Given a key, gets the corresponding property from the json (or the default if not found)
* *
* The found value is interpreted as a tagrendering and fetched/parsed * The found value is interpreted as a tagrendering and fetched/parsed
* */ * */

View file

@ -6,18 +6,26 @@
export let osmConnection: OsmConnection export let osmConnection: OsmConnection
export let clss: string | undefined = undefined export let clss: string | undefined = undefined
/**
* Show the button, even though we are logged in
*/
export let forceShow: boolean = false
export let msg: String = undefined
if (osmConnection === undefined) { if (osmConnection === undefined) {
console.error("No osmConnection passed into loginButton") console.error("No osmConnection passed into loginButton")
} }
let isLoggedIn = osmConnection.isLoggedIn let isLoggedIn = osmConnection.isLoggedIn
</script> </script>
{#if !$isLoggedIn} {#if !$isLoggedIn || forceShow}
<button class={clss} on:click={() => osmConnection.AttemptLogin()} style="margin-left: 0"> <button class={clss} on:click={() => osmConnection.AttemptLogin()} style="margin-left: 0">
<ArrowLeftOnRectangle class="m-1 w-12" /> <ArrowLeftOnRectangle class="m-1 w-12" />
<slot> <slot>
<Tr t={Translations.t.general.loginWithOpenStreetMap} /> {#if msg}
{msg}
{:else}
<Tr t={Translations.t.general.loginWithOpenStreetMap} />
{/if}
</slot> </slot>
</button> </button>
{/if} {/if}

View file

@ -42,8 +42,8 @@
<Modal <Modal
open={_shown} open={_shown}
on:close={() => shown.set(false)} on:close={() =>shown.set(false)}
outsideclose outsideclose={dismissable}
size="xl" size="xl"
{dismissable} {dismissable}
{defaultClass} {defaultClass}

View file

@ -111,12 +111,27 @@ export class SettingsVisualisations {
}, },
{ {
funcName: "login_button", funcName: "login_button",
args: [], args: [{
name: "force",
doc: "Always show this button, even if logged in"
}, {
name: "message",
doc: "Message to display on the button"
}],
docs: "Show a login button", docs: "Show a login button",
needsUrls: [], needsUrls: [],
group: "settings", group: "settings",
constr(state: SpecialVisualizationState): SvelteUIElement { constr(state: SpecialVisualizationState, _, args): SvelteUIElement {
return new SvelteUIElement(LoginButton, { osmConnection: state.osmConnection }) const force = args[0].toLowerCase()
let msg = args[1]
if (msg === "") {
msg = undefined
}
return new SvelteUIElement(LoginButton, {
osmConnection: state.osmConnection,
msg,
forceShow: force === "yes" || force === "true"
})
}, },
}, },

View file

@ -49,6 +49,8 @@
import Loading from "./Base/Loading.svelte" import Loading from "./Base/Loading.svelte"
import { WithSearchState } from "../Models/ThemeViewState/WithSearchState" import { WithSearchState } from "../Models/ThemeViewState/WithSearchState"
import TitleHandler from "../Logic/Actors/TitleHandler" import TitleHandler from "../Logic/Actors/TitleHandler"
import Popup from "./Base/Popup.svelte"
import TagRenderingAnswer from "./Popup/TagRendering/TagRenderingAnswer.svelte"
export let state: WithSearchState export let state: WithSearchState
new TitleHandler(state.selectedElement, state) new TitleHandler(state.selectedElement, state)
@ -76,6 +78,7 @@
let mapproperties: MapProperties = state.mapProperties let mapproperties: MapProperties = state.mapProperties
let searchOpened = state.searchState.showSearchDrawer let searchOpened = state.searchState.showSearchDrawer
let metatags = state.userRelatedState.preferencesAsTags
Orientation.singleton.startMeasurements() Orientation.singleton.startMeasurements()
let slideDuration = 150 // ms let slideDuration = 150 // ms
@ -148,7 +151,7 @@
const bottomRight = mlmap.unproject([rect.right, rect.bottom]) const bottomRight = mlmap.unproject([rect.right, rect.bottom])
const bbox = new BBox([ const bbox = new BBox([
[topLeft.lng, topLeft.lat], [topLeft.lng, topLeft.lat],
[bottomRight.lng, bottomRight.lat], [bottomRight.lng, bottomRight.lat]
]) ])
state.visualFeedbackViewportBounds.setData(bbox) state.visualFeedbackViewportBounds.setData(bbox)
} }
@ -500,5 +503,24 @@
{/if} {/if}
{/if} {/if}
{#each theme.popups as popup}
{#if popup.condition.matchesProperties($metatags)}
<Popup shown={new UIEventSource(true)} dismissable={popup.dismissible}>
<TagRenderingAnswer slot="header" config={popup.title} {state}
tags={metatags}
layer={undefined}
selectedElement={({type: "Feature", properties: $metatags, geometry: {type: "Point", coordinates: [0,0]}})} />
<div class="flex flex-col">
{#each popup.body as body}
<TagRenderingAnswer config={body} {state}
tags={metatags}
layer={undefined}
selectedElement={({type: "Feature", properties: $metatags, geometry: {type: "Point", coordinates: [0,0]}})} />
{/each}
<span class="subtle">{popup.id}</span>
</div>
</Popup>
{/if}
{/each}
<MenuDrawer onlyLink={false} {state} /> <MenuDrawer onlyLink={false} {state} />
</main> </main>