Studio: add slideshow, add useability tweaks

This commit is contained in:
Pieter Vander Vennet 2023-10-24 22:01:10 +02:00
parent 2df9aa8564
commit 8bc555fbe0
26 changed files with 440 additions and 316 deletions

View file

@ -28,6 +28,18 @@ async function prepareFile(url: string): Promise<string> {
if (fs.existsSync(filePath)) {
return fs.readFileSync(filePath, "utf8")
}
while (url.startsWith("/")) {
url = url.slice(1)
}
const sliced = url.split("/").slice(1)
if (!sliced) {
return
}
const backupFile = path.join(STATIC_PATH, ...sliced)
console.log("Using bakcup path", backupFile)
if (fs.existsSync(backupFile)) {
return fs.readFileSync(backupFile, "utf8")
}
return null
}
@ -51,7 +63,9 @@ http.createServer(async (req, res) => {
for (let i = 1; i < paths.length; i++) {
const p = paths.slice(0, i)
const dir = STATIC_PATH + p.join("/")
console.log("Checking if", dir, "exists...")
if (!fs.existsSync(dir)) {
console.log("Creating new directory", dir)
fs.mkdirSync(dir)
}
}
@ -61,22 +75,28 @@ http.createServer(async (req, res) => {
res.end()
return
}
if (req.url.endsWith("/overview")) {
const url = new URL(`http://127.0.0.1/` + req.url)
if (url.pathname.endsWith("overview")) {
console.log("Giving overview")
let userId = url.searchParams.get("userId")
const allFiles = ScriptUtils.readDirRecSync(STATIC_PATH)
.filter((p) => p.endsWith(".json") && !p.endsWith("license_info.json"))
.filter(
(p) =>
p.endsWith(".json") &&
!p.endsWith("license_info.json") &&
(p.startsWith("layers") ||
p.startsWith("themes") ||
userId !== undefined ||
p.startsWith(userId))
)
.map((p) => p.substring(STATIC_PATH.length + 1))
res.writeHead(200, { "Content-Type": MIME_TYPES.json })
res.write(JSON.stringify({ allFiles }))
res.end()
return
}
if (!fs.existsSync(STATIC_PATH + req.url)) {
res.writeHead(404, { "Content-Type": MIME_TYPES.html })
res.write("<html><body><p>Not found...</p></body></html>")
res.end()
return
}
const file = await prepareFile(req.url)
if (file === null) {
res.writeHead(404, { "Content-Type": MIME_TYPES.html })

View file

@ -435,7 +435,6 @@ class MappedStore<TIn, T> extends Store<T> {
* const mapped = src.map(i => i * 2)
* src.setData(3)
* mapped.data // => 6
*
*/
get data(): T {
if (!this._callbacksAreRegistered) {

View file

@ -666,25 +666,29 @@ class MiscTagRenderingChecks extends DesugaringStep<TagRenderingConfigJson> {
}
if (json.freeform) {
const c = context.enters("freeform", "render")
if (json.render === undefined) {
c.err(
"This tagRendering allows to set a freeform, but does not define a way to `render` this value"
)
context
.enter("render")
.err(
"This tagRendering allows to set a value to key " +
json.freeform.key +
", but does not define a `render`. Please, add a value here which contains `{" +
json.freeform.key +
"}`"
)
} else {
const render = new Translation(<any>json.render)
for (const ln in render.translations) {
if (ln.startsWith("_")) {
continue
}
const txt: string = render.translations[ln]
if (txt === "") {
c.err(" Rendering for language " + ln + " is empty")
context.enter("render").err(" Rendering for language " + ln + " is empty")
}
if (
txt.indexOf("{" + json.freeform.key + "}") >= 0 ||
txt.indexOf("&LBRACE" + json.freeform.key + "&RBRACE")
txt.indexOf("&LBRACE" + json.freeform.key + "&RBRACE") >= 0
) {
continue
}
@ -721,9 +725,11 @@ class MiscTagRenderingChecks extends DesugaringStep<TagRenderingConfigJson> {
) {
continue
}
c.err(
`The rendering for language ${ln} does not contain the freeform key {${json.freeform.key}}. This is a bug, as this rendering should show exactly this freeform key!\nThe rendering is ${txt} `
)
context
.enter("render")
.err(
`The rendering for language ${ln} does not contain \`{${json.freeform.key}}\`. This is a bug, as this rendering should show exactly this freeform key!`
)
}
}
}
@ -783,7 +789,7 @@ export class ValidateLayer extends Conversion<
private readonly _path?: string
private readonly _isBuiltin: boolean
private readonly _doesImageExist: DoesImageExist
private _studioValidations: boolean
private readonly _studioValidations: boolean
constructor(
path: string,
@ -816,7 +822,7 @@ export class ValidateLayer extends Conversion<
}
if (json.id === undefined) {
context.err(`Not a valid layer: id is undefined: ${JSON.stringify(json)}`)
context.enter("id").err(`Not a valid layer: id is undefined`)
}
if (json.source === undefined) {
@ -922,6 +928,21 @@ export class ValidateLayer extends Conversion<
"Title is `null`. This results in an element that cannot be clicked - even though tagRenderings is set."
)
}
{
// Check for multiple, identical builtin questions - usability for studio users
const duplicates = Utils.Duplicates(
<string[]>json.tagRenderings.filter((tr) => typeof tr === "string")
)
for (let i = 0; i < json.tagRenderings.length; i++) {
const tagRendering = json.tagRenderings[i]
if (typeof tagRendering === "string" && duplicates.indexOf(tagRendering) > 0) {
context
.enters("tagRenderings", i)
.err(`This builtin question is used multiple times (${tagRendering})`)
}
}
}
}
if (json["builtin"] !== undefined) {

View file

@ -15,11 +15,18 @@ import { Translatable } from "./Translatable"
*/
export interface LayerConfigJson {
/**
* The id of this layer.
* This should be a simple, lowercase, human readable string that is used to identify the layer.
*
* group: Basic
* question: What is the identifier of this layer?
*
* This should be a simple, lowercase, human readable string that is used to identify the layer.
* A good ID is:
* - a noun
* - written in singular
* - describes the object
* - in english
* - only has lowercase letters, numbers or underscores. Do not use a space or a dash
*
* type: id
* group: Basic
*/
id: string

View file

@ -26,8 +26,7 @@
}
const apiState = state.osmConnection.apiIsOnline
</script>
<slot />
<!--
{#if $badge}
{#if !ignoreLoading && $loadingStatus === "loading"}
<slot name="loading">
@ -43,4 +42,4 @@
{:else if $loadingStatus === "not-attempted"}
<slot name="not-logged-in" />
{/if}
{/if} -->
{/if}

View file

@ -22,7 +22,7 @@ $: documentation = TagUtils.modeDocumentation[mode];
</script>
<BasicTagInput bind:mode={mode} {dropdownFocussed} {overpassSupportNeeded} {silent} tag={value} {uploadableOnly} />
<BasicTagInput bind:mode={mode} {dropdownFocussed} {overpassSupportNeeded} {silent} tag={value} {uploadableOnly} on:submit />
{#if $dropdownFocussed}
<div class="border border-dashed border-black p-2 m-2">
<b>{documentation.name}</b>

View file

@ -19,4 +19,4 @@ let tag: UIEventSource<string | TagConfigJson> = value
</script>
<FullTagInput {overpassSupportNeeded} {silent} {tag} {uploadableOnly} />
<FullTagInput {overpassSupportNeeded} {silent} {tag} {uploadableOnly} on:submit/>

View file

@ -5,20 +5,14 @@
import { createEventDispatcher, onDestroy } from "svelte";
import ValidatedInput from "../ValidatedInput.svelte";
export let value: UIEventSource<string> = new UIEventSource<string>("");
export let value: UIEventSource<Record<string, string>> = new UIEventSource<Record<string, string>>({});
export let args: string[] = []
let prefix = args[0]
let postfix = args[1]
let prefix = args[0] ?? ""
let postfix = args[1] ?? ""
let translations: UIEventSource<Record<string, string>> = value.sync((s) => {
try {
return JSON.parse(s);
} catch (e) {
return {};
}
}, [], v => JSON.stringify(v));
let translations: UIEventSource<Record<string, string>> = value
const allLanguages: string[] = LanguageUtils.usedLanguagesSorted;
let currentLang = new UIEventSource("en");
@ -28,6 +22,9 @@
function update() {
const v = currentVal.data;
const l = currentLang.data;
if(translations.data === "" || translations.data === undefined){
translations.data = {}
}
if (translations.data[l] === v) {
return;
}
@ -37,6 +34,9 @@
onDestroy(currentLang.addCallbackAndRunD(currentLang => {
console.log("Applying current lang:", currentLang);
if(!translations.data){
translations.data = {}
}
translations.data[currentLang] = translations.data[currentLang] ?? "";
currentVal.setData(translations.data[currentLang]);
}));

View file

@ -27,14 +27,13 @@
let properties = { feature, args: args ?? [] };
let dispatch = createEventDispatcher<{
selected,
submit
selected
}>();
</script>
{#if type === "translation" }
<TranslationInput {value} on:submit={() => dispatch("submit")} {args} />
<TranslationInput {value} on:submit {args} />
{:else if type === "direction"}
<DirectionInput {value} mapProperties={InputHelpers.constructMapProperties(properties)} />
{:else if type === "date"}
@ -44,9 +43,9 @@
{:else if type === "image"}
<ImageHelper { value } />
{:else if type === "tag"}
<TagInput { value } />
<TagInput { value } on:submit />
{:else if type === "simple_tag"}
<SimpleTagInput { value } {args} />
<SimpleTagInput { value } {args} on:submit />
{:else if type === "opening_hours"}
<OpeningHoursInput { value } />
{:else if type === "wikidata"}

View file

@ -109,7 +109,7 @@
* Dispatches the submit, but only if the value is valid
*/
function sendSubmit(){
if(feedback.data){
if(feedback?.data){
console.log("Not sending a submit as there is feedback")
}
dispatch("submit")

View file

@ -259,7 +259,6 @@
value={freeformInput}
on:selected={() => (selectedMapping = config.mappings?.length)}
on:submit={onSave}
submit={onSave}
/>
</label>
{/if}

View file

@ -1336,6 +1336,7 @@ export default class SpecialVisualizations {
const tr = typeof v === "string" ? JSON.parse(v) : v
return new Translation(tr).SetClass("font-bold")
} catch (e) {
console.error("Cannot create a translation for", v, "due to", e)
return JSON.stringify(v)
}
})

View file

@ -0,0 +1,28 @@
<script lang="ts">
import Marker from "../Map/Marker.svelte";
import NextButton from "../Base/NextButton.svelte";
import { createEventDispatcher } from "svelte";
import { AllSharedLayers } from "../../Customizations/AllSharedLayers";
export let layerIds : { id: string }[]
const dispatch = createEventDispatcher<{layerSelected: string}>()
function fetchIconDescription(layerId): any {
return AllSharedLayers.getSharedLayersConfigs().get(layerId)?._layerIcon;
}
</script>
{#if layerIds.length > 0}
<slot name="title"/>
<div class="flex flex-wrap">
{#each Array.from(layerIds) as layer}
<NextButton clss="small" on:click={() => dispatch("layerSelected", layer.id)}>
<div class="w-4 h-4 mr-1">
<Marker icons={fetchIconDescription(layer.id)} />
</div>
{layer.id}
</NextButton>
{/each}
</div>
{/if}

View file

@ -11,6 +11,7 @@
import type { ConversionMessage } from "../../Models/ThemeConfig/Conversion/Conversion";
import ErrorIndicatorForRegion from "./ErrorIndicatorForRegion.svelte";
import { ChevronRightIcon } from "@rgossiaux/svelte-heroicons/solid";
import SchemaBasedInput from "./SchemaBasedInput.svelte";
const layerSchema: ConfigMeta[] = <any>layerSchemaRaw;
@ -25,7 +26,6 @@
* Blacklist of regions for the general area tab
* These are regions which are handled by a different tab
*/
const regionBlacklist = ["hidden", undefined, "infobox", "tagrenderings", "maprendering", "editing", "title", "linerendering", "pointrendering"];
const allNames = Utils.Dedup(layerSchema.map(meta => meta.hints.group));
const perRegion: Record<string, ConfigMeta[]> = {};
@ -33,12 +33,7 @@
perRegion[region] = layerSchema.filter(meta => meta.hints.group === region);
}
const baselayerRegions: string[] = ["Basic", "presets", "filters"];
for (const baselayerRegion of baselayerRegions) {
if (perRegion[baselayerRegion] === undefined) {
console.error("BaseLayerRegions in editLayer: no items have group '" + baselayerRegion + "\"");
}
}
const title: Store<string> = state.getStoreFor(["id"]);
const wl = window.location;
const baseUrl = wl.protocol + "//" + wl.host + "/theme.html?userlayout=";
@ -46,80 +41,122 @@
function firstPathsFor(...regionNames: string[]): Set<string> {
const pathNames = new Set<string>();
for (const regionName of regionNames) {
const region: ConfigMeta[] = perRegion[regionName]
const region: ConfigMeta[] = perRegion[regionName];
for (const configMeta of region) {
pathNames.add(configMeta.path[0])
pathNames.add(configMeta.path[0]);
}
}
return pathNames;
}
function configForRequiredField(id: string): ConfigMeta{
let config = layerSchema.find(config => config.path.length === 1 && config.path[0] === id)
config = Utils.Clone(config)
config.required = true
console.log(">>>", config)
config.hints.ifunset = undefined
return config
}
let requiredFields = ["id", "name", "description"];
let currentlyMissing = state.configuration.map(config => {
const missing = [];
for (const requiredField of requiredFields) {
if (!config[requiredField]) {
missing.push(requiredField);
}
}
return missing;
});
</script>
<div class="w-full flex justify-between">
<slot />
<h3>Editing layer {$title}</h3>
{#if $hasErrors > 0}
<div class="alert">{$hasErrors} errors detected</div>
{:else}
<a class="primary button" href={baseUrl+state.server.layerUrl(title.data)} target="_blank" rel="noopener">
Try it out
<ChevronRightIcon class= "h-6 w-6 shrink-0"/>
</a>
{/if}
</div>
<div class="m4">
<TabbedGroup>
<div slot="title0" class="flex">General properties
<ErrorIndicatorForRegion firstPaths={firstPathsFor(...baselayerRegions)} {state} />
</div>
<div class="flex flex-col" slot="content0">
{#each baselayerRegions as region}
<Region {state} configs={perRegion[region]} title={region} />
{/each}
</div>
<div slot="title1" class="flex">Information panel (questions and answers)
<ErrorIndicatorForRegion firstPaths={firstPathsFor("title","tagrenderings","editing")} {state} /></div>
<div slot="content1">
<Region configs={perRegion["title"]} {state} title="Popup title" />
<Region configs={perRegion["tagrenderings"]} {state} title="Popup contents" />
<Region configs={perRegion["editing"]} {state} title="Other editing elements" />
</div>
{#if $currentlyMissing.length > 0}
<div slot="title2" class="flex">Rendering on the map
<ErrorIndicatorForRegion firstPaths={firstPathsFor("linerendering","pointrendering")} {state} /></div>
<div slot="content2">
<Region configs={perRegion["linerendering"]} {state} />
<Region configs={perRegion["pointrendering"]} {state} />
</div>
{#each requiredFields as required}
<SchemaBasedInput {state}
schema={configForRequiredField(required)}
path={[required]} />
{/each}
{:else}
<div class="w-full flex justify-between my-2">
<slot />
<h3>Editing layer {$title}</h3>
{#if $hasErrors > 0}
<div class="alert">{$hasErrors} errors detected</div>
{:else}
<a class="primary button" href={baseUrl+state.server.layerUrl(title.data)} target="_blank" rel="noopener">
Try it out
<ChevronRightIcon class="h-6 w-6 shrink-0" />
</a>
{/if}
</div>
<div class="m4">
<TabbedGroup>
<div slot="title0" class="flex">General properties
<ErrorIndicatorForRegion firstPaths={firstPathsFor("Basic")} {state} />
</div>
<div class="flex flex-col" slot="content0">
<Region {state} configs={perRegion["Basic"]} />
<div slot="title3" class="flex">Advanced functionality
<ErrorIndicatorForRegion firstPaths={firstPathsFor("advanced","expert")} {state} /></div>
<div slot="content3">
<Region configs={perRegion["advanced"]} {state} />
<Region configs={perRegion["expert"]} {state} />
</div>
<div slot="title4">Configuration file</div>
<div slot="content4">
<div>
Below, you'll find the raw configuration file in `.json`-format.
This is mostly for debugging purposes
</div>
<div class="literal-code">
{JSON.stringify($configuration, null, " ")}
<div slot="title1" class="flex">Information panel (questions and answers)
<ErrorIndicatorForRegion firstPaths={firstPathsFor("title","tagrenderings","editing")} {state} />
</div>
{#each $messages as message}
<li>
{message.level}
<span class="literal-code">{message.context.path.join(".")}</span>
{message.message}
<span class="literal-code">
<div slot="content1">
<Region configs={perRegion["title"]} {state} title="Popup title" />
<Region configs={perRegion["tagrenderings"]} {state} title="Popup contents" />
<Region configs={perRegion["editing"]} {state} title="Other editing elements" />
</div>
<div slot="title2">
<ErrorIndicatorForRegion firstPaths={firstPathsFor("presets")} {state} />
Creating a new point
</div>
<div slot="content2">
<Region {state} configs={perRegion["presets"]} />
</div>
<div slot="title3" class="flex">Rendering on the map
<ErrorIndicatorForRegion firstPaths={firstPathsFor("linerendering","pointrendering")} {state} />
</div>
<div slot="content3">
<Region configs={perRegion["linerendering"]} {state} />
<Region configs={perRegion["pointrendering"]} {state} />
</div>
<div slot="title4" class="flex">Advanced functionality
<ErrorIndicatorForRegion firstPaths={firstPathsFor("advanced","expert")} {state} />
</div>
<div slot="content4">
<Region configs={perRegion["advanced"]} {state} />
<Region configs={perRegion["expert"]} {state} />
</div>
<div slot="title5">Configuration file</div>
<div slot="content5">
<div>
Below, you'll find the raw configuration file in `.json`-format.
This is mosSendertly for debugging purposes
</div>
<div class="literal-code">
{JSON.stringify($configuration, null, " ")}
</div>
{#each $messages as message}
<li>
{message.level}
<span class="literal-code">{message.context.path.join(".")}</span>
{message.message}
<span class="literal-code">
{message.context.operation.join(".")}
</span>
</li>
{/each}
</div>
</TabbedGroup>
</li>
{/each}
</div>
</TabbedGroup>
</div>
</div>
{/if}

View file

@ -1,8 +1,6 @@
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { ConfigMeta } from "./configMeta"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
import { QueryParameters } from "../../Logic/Web/QueryParameters"
import {
ConversionContext,
ConversionMessage,
@ -16,25 +14,29 @@ import { QuestionableTagRenderingConfigJson } from "../../Models/ThemeConfig/Jso
import { TagUtils } from "../../Logic/Tags/TagUtils"
import StudioServer from "./StudioServer"
import { Utils } from "../../Utils"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
/**
* Sends changes back to the server
*/
export class LayerStateSender {
constructor(layerState: EditLayerState) {
layerState.configuration.addCallback(async (config) => {
const id = config.id
if (id === undefined) {
console.warn("No id found in layer, not updating")
return
}
await layerState.server.updateLayer(<LayerConfigJson>config)
})
const layerId = layerState.configuration.map((config) => config.id)
layerState.configuration
.mapD((config) => JSON.stringify(config, null, " "))
.stabilized(100)
.addCallbackD(async (config) => {
const id = layerId.data
if (id === undefined) {
console.warn("No id found in layer, not updating")
return
}
await layerState.server.updateLayer(id, config)
})
}
}
export default class EditLayerState {
public readonly osmConnection: OsmConnection
public readonly schema: ConfigMeta[]
public readonly featureSwitches: { featureSwitchIsDebugging: UIEventSource<boolean> }
@ -44,17 +46,14 @@ export default class EditLayerState {
>({})
public readonly messages: Store<ConversionMessage[]>
public readonly server: StudioServer
// Needed for the special visualisations
public readonly osmConnection: OsmConnection
private readonly _stores = new Map<string, UIEventSource<any>>()
constructor(schema: ConfigMeta[], server: StudioServer) {
constructor(schema: ConfigMeta[], server: StudioServer, osmConnection: OsmConnection) {
this.schema = schema
this.server = server
this.osmConnection = new OsmConnection({
oauth_token: QueryParameters.GetQueryParameter(
"oauth_token",
undefined,
"Used to complete the login"
),
})
this.osmConnection = osmConnection
this.featureSwitches = {
featureSwitchIsDebugging: new UIEventSource<boolean>(true),
}
@ -118,7 +117,6 @@ export default class EditLayerState {
return entry
}
private readonly _stores = new Map<string, UIEventSource<any>>()
public getStoreFor<T>(path: ReadonlyArray<string | number>): UIEventSource<T | undefined> {
const key = path.join(".")
@ -139,7 +137,9 @@ export default class EditLayerState {
value: Store<any>,
noInitialSync: boolean = false
): () => void {
const unsync = value.addCallback((v) => this.setValueAt(path, v))
const unsync = value.addCallback((v) => {
this.setValueAt(path, v)
})
if (!noInitialSync) {
this.setValueAt(path, value.data)
}
@ -180,6 +180,7 @@ export default class EditLayerState {
public setValueAt(path: ReadonlyArray<string | number>, v: any) {
let entry = this.configuration.data
console.log("Setting value at", path, v)
const isUndefined =
v === undefined ||
v === null ||
@ -197,15 +198,35 @@ export default class EditLayerState {
}
entry = entry[breadcrumb]
}
const lastBreadcrumb = path.at(-1)
if (isUndefined) {
if (entry && entry[lastBreadcrumb]) {
console.log("Deleting", lastBreadcrumb, "of", path.join("."))
delete entry[lastBreadcrumb]
this.configuration.ping()
}
} else {
} else if (entry[lastBreadcrumb] !== v) {
console.log("Assigning and pinging at", path)
entry[lastBreadcrumb] = v
this.configuration.ping()
}
this.configuration.ping()
}
public messagesFor(path: ReadonlyArray<string | number>): Store<ConversionMessage[]> {
return this.messages.map((msgs) => {
if (!msgs) {
return []
}
return msgs.filter((msg) => {
const pth = msg.context.path
for (let i = 0; i < Math.min(pth.length, path.length); i++) {
if (pth[i] !== path[i]) {
return false
}
}
return true
})
})
}
}

View file

@ -110,7 +110,7 @@
{/if}
<div class="border border-black">
{#if isTagRenderingBlock}
<TagRenderingInput path={path.concat(value)} {state} {schema} >
<TagRenderingInput path={[...path, (value)]} {state} {schema} >
<button slot="upper-right" class="border-black border rounded-full p-1 w-fit h-fit"
on:click={() => {del(value)}}>
<TrashIcon class="w-4 h-4" />

View file

@ -21,12 +21,12 @@
const isTranslation = schema.hints.typehint === "translation" || schema.hints.typehint === "rendered" || ConfigMetaUtils.isTranslation(schema);
let type = schema.hints.typehint ?? "string";
let rendervalue = ((schema.hints.inline ?? schema.path.join(".")) + " <b>{translated(value)}</b>");
let rendervalue = (schema.hints.inline ?? schema.path.join(".")) + (isTranslation ? " <b>{translated(value)}</b>": " <b>{value}</b>");
if(schema.type === "boolean"){
rendervalue = undefined
}
if(schema.hints.typehint === "tag") {
if(schema.hints.typehint === "tag" || schema.hints.typehint === "simple_tag") {
rendervalue = "{tags()}"
}
@ -61,12 +61,12 @@
if (schema.hints.default) {
configJson.mappings = [{
if: "value=", // We leave this blank
then: schema.path.at(-1) + " is not set. The default value <b>" + schema.hints.default + "</b> will be used. " + (schema.hints.ifunset ?? "")
then: path.at(-1) + " is not set. The default value <b>" + schema.hints.default + "</b> will be used. " + (schema.hints.ifunset ?? "")
}];
} else if (!schema.required) {
configJson.mappings = [{
if: "value=",
then: schema.path.at(-1) + " is not set. " + (schema.hints.ifunset ?? "")
then: path.at(-1) + " is not set. " + (schema.hints.ifunset ?? "")
}];
}
@ -109,15 +109,7 @@
}
let config: TagRenderingConfig;
let err: string = undefined;
let messages = state.messages.mapD(msgs => msgs.filter(msg => {
const pth = msg.context.path;
for (let i = 0; i < Math.min(pth.length, path.length); i++) {
if (pth[i] !== path[i]) {
return false;
}
}
return true;
}));
let messages = state.messagesFor(path)
try {
config = new TagRenderingConfig(configJson, "config based on " + schema.path.join("."));
} catch (e) {
@ -130,7 +122,7 @@
onDestroy(state.register(path, tags.map(tgs => {
const v = tgs["value"];
if (typeof v !== "string") {
return v;
return { ... v };
}
if (schema.type === "boolan") {
return v === "true" || v === "yes" || v === "1";
@ -140,7 +132,6 @@
return true;
}
if (v === "false" || v === "no" || v === "0") {
console.log("Setting false...");
return false;
}
}
@ -173,7 +164,7 @@
{/each}
{/if}
{#if window.location.hostname === "127.0.0.1"}
<span class="subtle">{schema.path.join(".")} {schema.hints.typehint}</span>
<span class="subtle">{path.join(".")} {schema.hints.typehint}</span>
{/if}
</div>
{/if}

View file

@ -14,7 +14,7 @@
{#if schema.hints.typehint === "tagrendering[]"}
<!-- We cheat a bit here by matching this 'magical' type... -->
<SchemaBasedArray {path} {state} {schema} />
{:else if schema.type === "array" && schema.hints.multianswer === "true"}
{:else if schema.type === "array" && schema.hints.multianswer === "true"}
<ArrayMultiAnswer {path} {state} {schema}/>
{:else if schema.type === "array"}
<SchemaBasedArray {path} {state} {schema} />

View file

@ -13,7 +13,6 @@
import type { JsonSchemaType } from "./jsonSchema";
// @ts-ignore
import nmd from "nano-markdown";
import { writable } from "svelte/store";
/**
* If 'types' is defined: allow the user to pick one of the types to input.
@ -42,7 +41,7 @@
}
const configJson: QuestionableTagRenderingConfigJson = {
id: "TYPE_OF:" + path.join("_"),
question: "Which subcategory is needed for "+schema.path.at(-1)+"?",
question: "Which subcategory is needed for " + schema.path.at(-1) + "?",
questionHint: nmd(schema.description),
mappings: types.map(opt => opt.trim()).filter(opt => opt.length > 0).map((opt, i) => ({
if: "chosen_type_index=" + i,
@ -127,14 +126,14 @@
possibleTypes.sort((a, b) => b.optionalMatches - a.optionalMatches);
possibleTypes.sort((a, b) => b.matchingPropertiesCount - a.matchingPropertiesCount);
if (possibleTypes.length > 0) {
chosenOption = possibleTypes[0].index
tags.setData({ chosen_type_index: "" + chosenOption});
chosenOption = possibleTypes[0].index;
tags.setData({ chosen_type_index: "" + chosenOption });
}
} else if (defaultOption !== undefined) {
tags.setData({ chosen_type_index: "" + defaultOption });
}else{
chosenOption = defaultOption
} else {
chosenOption = defaultOption;
}
if (hasBooleanOption >= 0 || lastIsString) {
@ -154,7 +153,7 @@
let subSchemas: ConfigMeta[] = [];
let subpath = path;
const store = state.getStoreFor(path)
const store = state.getStoreFor(path);
onDestroy(tags.addCallbackAndRun(tags => {
if (tags["value"] !== undefined && tags["value"] !== "") {
chosenOption = undefined;
@ -170,7 +169,7 @@
for (const key of type?.required ?? []) {
o[key] ??= {};
}
store.setData(o)
store.setData(o);
}
if (!type) {
return;
@ -191,6 +190,7 @@
subSchemas.push(...(state.getSchema([...cleanPath, crumble])));
}
}));
let messages = state.messagesFor(path);
</script>
@ -209,5 +209,9 @@
<SchemaBasedInput {state} schema={subschema}
path={[...subpath, (subschema?.path?.at(-1) ?? "???")]}></SchemaBasedInput>
{/each}
{:else if $messages.length > 0}
{#each $messages as msg}
<div class="alert">{msg.message}</div>
{/each}
{/if}
</div>

View file

@ -8,7 +8,7 @@
export let path: (string | number)[] = [];
export let schema: ConfigMeta;
let value = new UIEventSource<string>("{}");
let value = new UIEventSource<string>({});
console.log("Registering translation to path", path)
state.register(path, value.mapD(v => JSON.parse(value.data )));
</script>

View file

@ -1,23 +1,51 @@
import { Utils } from "../../Utils"
import Constants from "../../Models/Constants"
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
import { Store } from "../../Logic/UIEventSource"
export default class StudioServer {
private readonly url: string
private readonly _userId: Store<number>
constructor(url: string) {
constructor(url: string, userId: Store<number>) {
this.url = url
this._userId = userId
}
public async fetchLayerOverview(): Promise<Set<string>> {
public async fetchLayerOverview(): Promise<
{
id: string
owner: number
}[]
> {
const uid = this._userId.data
let uidQueryParam = ""
if (this._userId.data !== undefined) {
uidQueryParam = "?userId=" + uid
}
const { allFiles } = <{ allFiles: string[] }>(
await Utils.downloadJson(this.url + "/overview")
await Utils.downloadJson(this.url + "/overview" + uidQueryParam)
)
const layers = allFiles
.filter((f) => f.startsWith("layers/"))
.map((l) => l.substring(l.lastIndexOf("/") + 1, l.length - ".json".length))
.filter((layerId) => Constants.priviliged_layers.indexOf(<any>layerId) < 0)
return new Set<string>(layers)
const layerOverview: {
id: string
owner: number | undefined
}[] = []
for (let file of allFiles) {
let owner = undefined
if (file.startsWith("" + uid)) {
owner = uid
file = file.substring(file.indexOf("/") + 1)
}
if (!file.startsWith("layers/")) {
continue
}
const id = file.substring(file.lastIndexOf("/") + 1, file.length - ".json".length)
if (Constants.priviliged_layers.indexOf(<any>id) > 0) {
continue
}
layerOverview.push({ id, owner })
}
return layerOverview
}
async fetchLayer(layerId: string): Promise<LayerConfigJson> {
@ -28,8 +56,7 @@ export default class StudioServer {
}
}
async updateLayer(config: LayerConfigJson) {
const id = config.id
async updateLayer(id: string, config: string) {
if (id === undefined || id === "") {
return
}
@ -38,11 +65,13 @@ export default class StudioServer {
headers: {
"Content-Type": "application/json;charset=utf-8",
},
body: JSON.stringify(config, null, " "),
body: config,
})
}
public layerUrl(id: string) {
return `${this.url}/layers/${id}/${id}.json`
const uid = this._userId.data
const uidStr = uid !== undefined ? "/" + uid : ""
return `${this.url}${uidStr}/layers/${id}/${id}.json`
}
}

View file

@ -139,7 +139,7 @@ if (!initialTag) {
<div class="border-l-4 border-black flex flex-col ml-1 pl-1">
{#each $basicTags as basicTag (basicTag)}
<div class="flex">
<BasicTagInput {silent} {overpassSupportNeeded} {uploadableOnly} tag={basicTag} />
<BasicTagInput {silent} {overpassSupportNeeded} {uploadableOnly} tag={basicTag} on:submit />
{#if $basicTags.length + $expressions.length > 1}
<button class="border border-black rounded-full w-fit h-fit p-0"
on:click={() => removeTag(basicTag)}>

View file

@ -111,7 +111,7 @@
<div class="flex h-fit ">
<ValidatedInput feedback={feedbackKey} placeholder="The key of the tag" type="key"
value={keyValue}></ValidatedInput>
value={keyValue} on:submit></ValidatedInput>
<select bind:value={mode} on:focusin={() => dropdownFocussed.setData(true)} on:focusout={() => dropdownFocussed.setData(false)}>
{#each modes as option}
<option value={option}>
@ -120,7 +120,7 @@
{/each}
</select>
<ValidatedInput feedback={feedbackValue} placeholder="The value of the tag" type="string"
value={valueValue}></ValidatedInput>
value={valueValue} on:submit></ValidatedInput>
</div>
{#if $feedbackKey}

View file

@ -13,7 +13,7 @@ export let silent: boolean
</script>
<div class="m-2">
<TagExpression {silent} {overpassSupportNeeded} {tag} {uploadableOnly}>
<TagExpression {silent} {overpassSupportNeeded} {tag} {uploadableOnly} on:submit>
<slot name="delete" slot="delete"/>
</TagExpression>
</div>

View file

@ -58,7 +58,6 @@ tags.addCallbackAndRunD(tgs => {
let mappings: UIEventSource<MappingConfigJson[]> = state.getStoreFor([...path, "mappings"]);
$: console.log("Allow questions:", $allowQuestions)
const topLevelItems: Record<string, ConfigMeta> = {};
for (const item of questionableTagRenderingSchemaRaw) {
if (item.path.length === 1) {
@ -81,7 +80,6 @@ const missing: string[] = questionableTagRenderingSchemaRaw.filter(schema => sch
</script>
{#if typeof value === "string"}
<div class="flex low-interaction">
<TagRenderingEditable config={configBuiltin} selectedElement={undefined} showQuestionIfUnknown={true} {state}
{tags} />
@ -92,12 +90,9 @@ const missing: string[] = questionableTagRenderingSchemaRaw.filter(schema => sch
<div class="flex justify-end">
<slot name="upper-right" />
</div>
{#if $allowQuestions}
<SchemaBasedField {state} path={[...path,"question"]} schema={topLevelItems["question"]} />
<SchemaBasedField {state} path={[...path,"questionHint"]} schema={topLevelItems["questionHint"]} />
{:else}
{/if}
{#each ($mappings ?? []) as mapping, i (mapping)}
<div class="flex interactive w-full">

View file

@ -2,13 +2,10 @@
import NextButton from "./Base/NextButton.svelte";
import { UIEventSource } from "../Logic/UIEventSource";
import ValidatedInput from "./InputElement/ValidatedInput.svelte";
import { Store, UIEventSource } from "../Logic/UIEventSource";
import EditLayerState from "./Studio/EditLayerState";
import EditLayer from "./Studio/EditLayer.svelte";
import Loading from "../assets/svg/Loading.svelte";
import Marker from "./Map/Marker.svelte";
import { AllSharedLayers } from "../Customizations/AllSharedLayers";
import StudioServer from "./Studio/StudioServer";
import LoginToggle from "./Base/LoginToggle.svelte";
import { OsmConnection } from "../Logic/Osm/OsmConnection";
@ -17,73 +14,15 @@
import layerSchemaRaw from "../../src/assets/schemas/layerconfigmeta.json";
import If from "./Base/If.svelte";
import BackButton from "./Base/BackButton.svelte";
import ChooseLayerToEdit from "./Studio/ChooseLayerToEdit.svelte";
import { LocalStorageSource } from "../Logic/Web/LocalStorageSource";
import FloatOver from "./Base/FloatOver.svelte";
import Walkthrough from "./Walkthrough/Walkthrough.svelte";
import * as intro from "../assets/studio_introduction.json";
import { QuestionMarkCircleIcon } from "@babeard/svelte-heroicons/mini";
import type { ConfigMeta } from "./Studio/configMeta";
export let studioUrl = window.location.hostname === "127.0.0.1" ? "http://127.0.0.1:1235" : "https://studio.mapcomplete.org";
const studio = new StudioServer(studioUrl);
let layersWithErr = UIEventSource.FromPromiseWithErr(studio.fetchLayerOverview());
let layers = layersWithErr.mapD(l => l.success);
let state: undefined | "edit_layer" | "new_layer" | "edit_theme" | "new_theme" | "editing_layer" | "loading" = undefined;
let initialLayerConfig: { id: string };
let newLayerId = new UIEventSource<string>("");
/**
* Also used in the input field as 'feedback', hence not a mappedStore as it must be writable
*/
let layerIdFeedback = new UIEventSource<string>(undefined);
newLayerId.addCallbackD(layerId => {
if (layerId === "") {
return;
}
if (layers.data?.has(layerId)) {
layerIdFeedback.setData("This id is already used");
}
}, [layers]);
const layerSchema: ConfigMeta[] = <any>layerSchemaRaw;
let editLayerState = new EditLayerState(layerSchema, studio);
let layerId = editLayerState.configuration.map(layerConfig => layerConfig.id);
function fetchIconDescription(layerId): any {
return AllSharedLayers.getSharedLayersConfigs().get(layerId)?._layerIcon;
}
async function createNewLayer() {
if (layerIdFeedback.data !== undefined) {
console.warn("There is still some feedback - not starting to create a new layer");
return;
}
state = "loading";
const id = newLayerId.data;
const createdBy = osmConnection.userDetails.data.name;
try {
const loaded = await studio.fetchLayer(id);
initialLayerConfig = loaded ?? {
id, credits: createdBy,
minzoom: 15,
pointRendering: [
{
location: ["point", "centroid"],
marker: [{
icon: "circle",
color: "white"
}]
}
],
lineRendering: [{
width: 1,
color: "blue"
}]
};
} catch (e) {
initialLayerConfig = { id, credits: createdBy };
}
state = "editing_layer";
}
let osmConnection = new OsmConnection(new OsmConnection({
oauth_token: QueryParameters.GetQueryParameter(
@ -92,6 +31,55 @@
"Used to complete the login"
)
}));
const createdBy = osmConnection.userDetails.data.name;
const uid = osmConnection.userDetails.map(ud => ud?.uid);
const studio = new StudioServer(studioUrl, uid);
let layersWithErr = uid.bind(uid => UIEventSource.FromPromiseWithErr(studio.fetchLayerOverview()));
let layers: Store<{ owner: number }[]> = layersWithErr.mapD(l => l.success);
let selfLayers = layers.mapD(ls => ls.filter(l => l.owner === uid.data), [uid]);
let otherLayers = layers.mapD(ls => ls.filter(l => l.owner !== uid.data), [uid]);
let state: undefined | "edit_layer" | "edit_theme" | "new_theme" | "editing_layer" | "loading" = undefined;
let initialLayerConfig: { id: string };
const layerSchema: ConfigMeta[] = <any>layerSchemaRaw;
let editLayerState = new EditLayerState(layerSchema, studio, osmConnection);
let layerId = editLayerState.configuration.map(layerConfig => layerConfig.id);
let showIntro = UIEventSource.asBoolean(LocalStorageSource.Get("studio-show-intro", "true"));
async function editLayer(event: Event) {
const layerId = event.detail;
state = "loading";
initialLayerConfig = await studio.fetchLayer(layerId);
state = "editing_layer";
}
async function createNewLayer() {
state = "loading";
initialLayerConfig = {
credits: createdBy,
minzoom: 15,
pointRendering: [
{
location: ["point", "centroid"],
marker: [{
icon: "circle",
color: "white"
}]
}
],
lineRendering: [{
width: 1,
color: "blue"
}]
};
state = "editing_layer";
}
</script>
@ -125,75 +113,61 @@
</NextButton>
</div>
{#if state === undefined}
<h1>MapComplete Studio</h1>
<div class="w-full flex flex-col">
<div class="m-4">
<h1>MapComplete Studio</h1>
<div class="w-full flex flex-col">
<NextButton on:click={() => state = "edit_layer"}>
Edit an existing layer
</NextButton>
<NextButton on:click={() => state = "new_layer"}>
Create a new layer
</NextButton>
<!--
<NextButton on:click={() => state = "edit_theme"}>
Edit a theme
</NextButton>
<NextButton on:click={() => state = "new_theme"}>
Create a new theme
</NextButton>
-->
<NextButton on:click={() => state = "edit_layer"}>
Edit an existing layer
</NextButton>
<NextButton on:click={() => createNewLayer()}>
Create a new layer
</NextButton>
<!--
<NextButton on:click={() => state = "edit_theme"}>
Edit a theme
</NextButton>
<NextButton on:click={() => state = "new_theme"}>
Create a new theme
</NextButton>
-->
<NextButton clss="small" on:click={() => {showIntro.setData(true)} }>
<QuestionMarkCircleIcon class="w-6 h-6" />
Show the introduction again
</NextButton>
</div>
</div>
{:else if state === "edit_layer"}
<BackButton clss="small p-1" imageClass="w-8 h-8" on:click={() => {state =undefined}}>MapComplete Studio</BackButton>
<h3>Choose a layer to edit</h3>
<div class="flex flex-wrap">
{#each Array.from($layers) as layerId}
<NextButton clss="small" on:click={async () => {
state = "loading"
initialLayerConfig = await studio.fetchLayer(layerId)
state = "editing_layer"
}}>
<div class="w-4 h-4 mr-1">
<Marker icons={fetchIconDescription(layerId)} />
</div>
{layerId}
</NextButton>
{/each}
</div>
{:else if state === "new_layer"}
<div class="interactive flex m-2 rounded-2xl flex-col p-2">
<h3>Enter the ID for the new layer</h3>
A good ID is:
<ul>
<li>a noun</li>
<li>singular</li>
<li>describes the object</li>
<li>in English</li>
</ul>
<div class="m-2 p-2 w-full">
<ValidatedInput type="id" value={newLayerId} feedback={layerIdFeedback} on:submit={() => createNewLayer()} />
</div>
{#if $layerIdFeedback !== undefined}
<div class="alert">
{$layerIdFeedback}
</div>
{:else }
<NextButton clss="primary" on:click={() => createNewLayer()}>
Create layer {$newLayerId}
</NextButton>
{/if}
<div class="flex flex-col m-4">
<BackButton clss="small p-1" imageClass="w-8 h-8" on:click={() => {state =undefined}}>MapComplete Studio
</BackButton>
<h2>Choose a layer to edit</h2>
<ChooseLayerToEdit layerIds={$selfLayers} on:layerSelected={editLayer}>
<h3 slot="title">Your layers</h3>
</ChooseLayerToEdit>
<h3>Official layers</h3>
<ChooseLayerToEdit layerIds={$otherLayers} on:layerSelected={editLayer} />
</div>
{:else if state === "loading"}
<div class="w-8 h-8">
<Loading />
</div>
{:else if state === "editing_layer"}
<EditLayer {initialLayerConfig} state={editLayerState} >
<BackButton clss="small p-1" imageClass="w-8 h-8" on:click={() => {state =undefined}}>MapComplete Studio</BackButton>
<EditLayer {initialLayerConfig} state={editLayerState}>
<BackButton clss="small p-1" imageClass="w-8 h-8" on:click={() => {state =undefined}}>MapComplete Studio
</BackButton>
</EditLayer>
{/if}
</LoginToggle>
</If>
{#if $showIntro}
<FloatOver>
<div class="flex p-4 h-full">
<Walkthrough pages={intro.sections} on:done={() => {showIntro.setData(false)}} />
</div>
</FloatOver>
{/if}