diff --git a/assets/layers/hackerspace/hackerspace.json b/assets/layers/hackerspace/hackerspace.json index 8ebf9bf87..2beb640e2 100644 --- a/assets/layers/hackerspace/hackerspace.json +++ b/assets/layers/hackerspace/hackerspace.json @@ -112,6 +112,7 @@ "website", "email", "phone", + "mastodon", { "builtin": "opening_hours_24_7", "override": { diff --git a/assets/layers/questions/questions.json b/assets/layers/questions/questions.json index 678b0b402..c3412b52d 100644 --- a/assets/layers/questions/questions.json +++ b/assets/layers/questions/questions.json @@ -173,11 +173,13 @@ "render": { "*": "{phone}" }, + "icon": "./assets/layers/questions/phone.svg", "mappings": [ { "if": "contact:phone~*", "then": "{contact:phone}", - "hideInAnswer": true + "hideInAnswer": true, + "icon": "./assets/layers/questions/phone.svg" } ], "freeform": { @@ -188,6 +190,21 @@ ] } }, + { + "id": "mastodon", + "description": "Shows and asks for the mastodon handle", + "question": { + "en": "What is the Mastodon-handle of {title()}?" + }, + "freeform": { + "key": "contact:mastodon", + "type": "fediverse" + }, + "render": { + "*": "{fediverse_link(contact:mastodon)}" + }, + "icon": "./assets/svg/mastodon.svg" + }, { "id": "osmlink", "render": { @@ -205,6 +222,7 @@ "render": { "*": "{email}" }, + "icon": "./assets/svg/envelope.svg", "labels": [ "contact" ], @@ -236,6 +254,7 @@ "mappings": [ { "if": "contact:email~*", + "icon": "./assets/svg/envelope.svg", "then": "{contact:email}", "hideInAnswer": true } @@ -253,6 +272,7 @@ "labels": [ "contact" ], + "icon": "./assets/layers/icons/website.svg", "question": { "en": "What is the website of {title()}?", "nl": "Wat is de website van {title()}?", @@ -292,7 +312,8 @@ { "if": "contact:website~*", "then": "{contact:website}", - "hideInAnswer": true + "hideInAnswer": true, + "icon": "./assets/layers/icons/website.svg" } ] }, @@ -2203,4 +2224,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/langs/en.json b/langs/en.json index 62a481843..24f6953cf 100644 --- a/langs/en.json +++ b/langs/en.json @@ -610,6 +610,11 @@ "feedback": "This is not a valid email address", "noAt": "An e-mail address must contain an @" }, + "fediverse": { + "description": "A fediverse handle, often @username@server.tld", + "feedback": "A fediverse handle consists of @username@server.tld or is a link to a profile", + "invalidHost": "{host} is not a valid hostname" + }, "float": { "description": "a number", "feedback": "This is not a number" diff --git a/src/Models/ThemeConfig/Json/TagRenderingConfigJson.ts b/src/Models/ThemeConfig/Json/TagRenderingConfigJson.ts index 583294f3d..56bfd7876 100644 --- a/src/Models/ThemeConfig/Json/TagRenderingConfigJson.ts +++ b/src/Models/ThemeConfig/Json/TagRenderingConfigJson.ts @@ -41,6 +41,26 @@ export interface TagRenderingConfigJson { | Record | { special: Record> & { type: string } } + /** + * An icon shown next to the rendering; typically shown pretty small + * This is only shown next to the "render" value + * Type: icon + */ + icon?: + | string + | { + /** + * The path to the icon + * Type: icon + */ + path: string + /** + * A hint to mapcomplete on how to render this icon within the mapping. + * This is translated to 'mapping-icon-', so defining your own in combination with a custom CSS is possible (but discouraged) + */ + class?: "small" | "medium" | "large" | string + } + /** * Only show this tagrendering (or ask the question) if the selected object also matches the tags specified as `condition`. * diff --git a/src/Models/ThemeConfig/TagRenderingConfig.ts b/src/Models/ThemeConfig/TagRenderingConfig.ts index 6cd5dc4dc..940056a99 100644 --- a/src/Models/ThemeConfig/TagRenderingConfig.ts +++ b/src/Models/ThemeConfig/TagRenderingConfig.ts @@ -1,23 +1,24 @@ -import { Translation, TypedTranslation } from "../../UI/i18n/Translation" -import { TagsFilter } from "../../Logic/Tags/TagsFilter" +import {Translation, TypedTranslation} from "../../UI/i18n/Translation" +import {TagsFilter} from "../../Logic/Tags/TagsFilter" import Translations from "../../UI/i18n/Translations" -import { TagUtils, UploadableTag } from "../../Logic/Tags/TagUtils" -import { And } from "../../Logic/Tags/And" -import { Utils } from "../../Utils" -import { Tag } from "../../Logic/Tags/Tag" +import {TagUtils, UploadableTag} from "../../Logic/Tags/TagUtils" +import {And} from "../../Logic/Tags/And" +import {Utils} from "../../Utils" +import {Tag} from "../../Logic/Tags/Tag" import BaseUIElement from "../../UI/BaseUIElement" import Combine from "../../UI/Base/Combine" import Title from "../../UI/Base/Title" import Link from "../../UI/Base/Link" import List from "../../UI/Base/List" -import { - MappingConfigJson, - QuestionableTagRenderingConfigJson, -} from "./Json/QuestionableTagRenderingConfigJson" -import { FixedUiElement } from "../../UI/Base/FixedUiElement" -import { Paragraph } from "../../UI/Base/Paragraph" +import {MappingConfigJson, QuestionableTagRenderingConfigJson,} from "./Json/QuestionableTagRenderingConfigJson" +import {FixedUiElement} from "../../UI/Base/FixedUiElement" +import {Paragraph} from "../../UI/Base/Paragraph" import Svg from "../../Svg" -import Validators, { ValidatorType } from "../../UI/InputElement/Validators" +import Validators, {ValidatorType} from "../../UI/InputElement/Validators" + +export interface Icon { + +} export interface Mapping { readonly if: UploadableTag @@ -45,6 +46,8 @@ export interface Mapping { export default class TagRenderingConfig { public readonly id: string public readonly render?: TypedTranslation + public readonly renderIcon?: string + public readonly renderIconClass?: string public readonly question?: TypedTranslation public readonly questionhint?: TypedTranslation public readonly condition?: TagsFilter @@ -121,9 +124,16 @@ export default class TagRenderingConfig { this.question = Translations.T(json.question, translationKey + ".question") this.questionhint = Translations.T(json.questionHint, translationKey + ".questionHint") this.description = Translations.T(json.description, translationKey + ".description") - this.condition = TagUtils.Tag(json.condition ?? { and: [] }, `${context}.condition`) + this.condition = TagUtils.Tag(json.condition ?? {and: []}, `${context}.condition`) + if (typeof json.icon === "string") { + this.renderIcon = json.icon + this.renderIconClass = "small" + }else if (typeof json.icon === "object"){ + this.renderIcon = json.icon.path + this.renderIconClass = json.icon.class + } this.metacondition = TagUtils.Tag( - json.metacondition ?? { and: [] }, + json.metacondition ?? {and: []}, `${context}.metacondition` ) if (json.freeform) { @@ -238,15 +248,17 @@ export default class TagRenderingConfig { if (txt.indexOf("{" + this.freeform.key + ":") >= 0) { continue } - if (txt.indexOf("{canonical(" + this.freeform.key + ")") >= 0) { - continue - } + if ( this.freeform.type === "opening_hours" && txt.indexOf("{opening_hours_table(") >= 0 ) { continue } + const keyFirstArg = ["canonical", "fediverse_link"] + if (keyFirstArg.some(funcName => txt.indexOf(`{${funcName}(${this.freeform.key}`) >= 0)) { + continue + } if ( this.freeform.type === "wikidata" && txt.indexOf("{wikipedia(" + this.freeform.key) >= 0 @@ -532,7 +544,7 @@ export default class TagRenderingConfig { */ public GetRenderValueWithImage( tags: Record - ): { then: TypedTranslation; icon?: string } | undefined { + ): { then: TypedTranslation; icon?: string, iconClass?: string } | undefined { if (this.condition !== undefined) { if (!this.condition.matchesProperties(tags)) { return undefined @@ -551,7 +563,7 @@ export default class TagRenderingConfig { } if (this.freeform?.key === undefined || tags[this.freeform.key] !== undefined) { - return { then: this.render } + return {then: this.render, icon: this.renderIcon, iconClass: this.renderIconClass} } return undefined @@ -773,7 +785,7 @@ export default class TagRenderingConfig { if (m.ifnot !== undefined) { msgs.push( "Unselecting this answer will add " + - m.ifnot.asHumanString(true, false, {}) + m.ifnot.asHumanString(true, false, {}) ) } return msgs @@ -803,12 +815,12 @@ export default class TagRenderingConfig { this.description, this.question !== undefined ? new Combine([ - "The question is ", - new FixedUiElement(this.question.txt).SetClass("font-bold bold"), - ]) + "The question is ", + new FixedUiElement(this.question.txt).SetClass("font-bold bold"), + ]) : new FixedUiElement( - "This tagrendering has no question and is thus read-only" - ).SetClass("italic"), + "This tagrendering has no question and is thus read-only" + ).SetClass("italic"), new Combine(withRender), mappings, condition, diff --git a/src/UI/InputElement/Validators.ts b/src/UI/InputElement/Validators.ts index c340fc355..1e10e8ec3 100644 --- a/src/UI/InputElement/Validators.ts +++ b/src/UI/InputElement/Validators.ts @@ -18,6 +18,7 @@ import ColorValidator from "./Validators/ColorValidator" import BaseUIElement from "../BaseUIElement" import Combine from "../Base/Combine" import Title from "../Base/Title" +import FediverseValidator from "./Validators/FediverseValidator"; export type ValidatorType = (typeof Validators.availableTypes)[number] @@ -39,6 +40,7 @@ export default class Validators { "phone", "opening_hours", "color", + "fediverse" ] as const public static readonly AllValidators: ReadonlyArray = [ @@ -58,6 +60,7 @@ export default class Validators { new PhoneValidator(), new OpeningHoursValidator(), new ColorValidator(), + new FediverseValidator() ] private static _byType = Validators._byTypeConstructor() diff --git a/src/UI/InputElement/Validators/FediverseValidator.ts b/src/UI/InputElement/Validators/FediverseValidator.ts new file mode 100644 index 000000000..f1d5f6682 --- /dev/null +++ b/src/UI/InputElement/Validators/FediverseValidator.ts @@ -0,0 +1,63 @@ +import {Validator} from "../Validator" +import {Translation} from "../../i18n/Translation"; +import Translations from "../../i18n/Translations"; + +export default class FediverseValidator extends Validator { + + public static readonly usernameAtServer: RegExp = /^@?(\w+)@((\w|\.)+)$/ + + constructor() { + super("fediverse", "Validates fediverse addresses and normalizes them into `@username@server`-format"); + } + + /** + * Returns an `@username@host` + * @param s + */ + reformat(s: string): string { + if(!s.startsWith("@")){ + s = "@"+s + } + if (s.match(FediverseValidator.usernameAtServer)) { + return s + } + try { + const url = new URL(s) + const path = url.pathname + if (path.match(/^\/\w+$/)) { + return `@${path.substring(1)}@${url.hostname}`; + } + } catch (e) { + // Nothing to do here + } + return undefined + } +getFeedback(s: string): Translation | undefined { + const match = s.match(FediverseValidator.usernameAtServer) + console.log("Match:", match) + if (match) { + const host = match[2] + try { + const url = new URL("https://" + host) + return undefined + } catch (e) { + return Translations.t.validation.fediverse.invalidHost.Subs({host}) + } + } + try { + const url = new URL(s) + const path = url.pathname + if (path.match(/^\/\w+$/)) { + return undefined + } + } catch (e) { + // Nothing to do here + } + return Translations.t.validation.fediverse.feedback +} + + isValid(s): boolean { + return this.getFeedback(s) === undefined + + } +} diff --git a/src/UI/SpecialVisualizations.ts b/src/UI/SpecialVisualizations.ts index 7395c4f14..2c0260c47 100644 --- a/src/UI/SpecialVisualizations.ts +++ b/src/UI/SpecialVisualizations.ts @@ -1,56 +1,52 @@ import Combine from "./Base/Combine" -import { FixedUiElement } from "./Base/FixedUiElement" +import {FixedUiElement} from "./Base/FixedUiElement" import BaseUIElement from "./BaseUIElement" import Title from "./Base/Title" import Table from "./Base/Table" -import { - RenderingSpecification, - SpecialVisualization, - SpecialVisualizationState, -} from "./SpecialVisualization" -import { HistogramViz } from "./Popup/HistogramViz" -import { MinimapViz } from "./Popup/MinimapViz" -import { ShareLinkViz } from "./Popup/ShareLinkViz" -import { UploadToOsmViz } from "./Popup/UploadToOsmViz" -import { MultiApplyViz } from "./Popup/MultiApplyViz" -import { AddNoteCommentViz } from "./Popup/AddNoteCommentViz" -import { PlantNetDetectionViz } from "./Popup/PlantNetDetectionViz" +import {RenderingSpecification, SpecialVisualization, SpecialVisualizationState,} from "./SpecialVisualization" +import {HistogramViz} from "./Popup/HistogramViz" +import {MinimapViz} from "./Popup/MinimapViz" +import {ShareLinkViz} from "./Popup/ShareLinkViz" +import {UploadToOsmViz} from "./Popup/UploadToOsmViz" +import {MultiApplyViz} from "./Popup/MultiApplyViz" +import {AddNoteCommentViz} from "./Popup/AddNoteCommentViz" +import {PlantNetDetectionViz} from "./Popup/PlantNetDetectionViz" import TagApplyButton from "./Popup/TagApplyButton" -import { CloseNoteButton } from "./Popup/CloseNoteButton" -import { MapillaryLinkVis } from "./Popup/MapillaryLinkVis" -import { Store, Stores, UIEventSource } from "../Logic/UIEventSource" +import {CloseNoteButton} from "./Popup/CloseNoteButton" +import {MapillaryLinkVis} from "./Popup/MapillaryLinkVis" +import {Store, Stores, UIEventSource} from "../Logic/UIEventSource" import AllTagsPanel from "./Popup/AllTagsPanel.svelte" import AllImageProviders from "../Logic/ImageProviders/AllImageProviders" -import { ImageCarousel } from "./Image/ImageCarousel" -import { ImageUploadFlow } from "./Image/ImageUploadFlow" -import { VariableUiElement } from "./Base/VariableUIElement" -import { Utils } from "../Utils" -import Wikidata, { WikidataResponse } from "../Logic/Web/Wikidata" -import { Translation } from "./i18n/Translation" +import {ImageCarousel} from "./Image/ImageCarousel" +import {ImageUploadFlow} from "./Image/ImageUploadFlow" +import {VariableUiElement} from "./Base/VariableUIElement" +import {Utils} from "../Utils" +import Wikidata, {WikidataResponse} from "../Logic/Web/Wikidata" +import {Translation} from "./i18n/Translation" import Translations from "./i18n/Translations" import ReviewForm from "./Reviews/ReviewForm" import ReviewElement from "./Reviews/ReviewElement" import OpeningHoursVisualization from "./OpeningHours/OpeningHoursVisualization" import LiveQueryHandler from "../Logic/Web/LiveQueryHandler" -import { SubtleButton } from "./Base/SubtleButton" +import {SubtleButton} from "./Base/SubtleButton" import Svg from "../Svg" import NoteCommentElement from "./Popup/NoteCommentElement" import ImgurUploader from "../Logic/ImageProviders/ImgurUploader" import FileSelectorButton from "./Input/FileSelectorButton" -import { LoginToggle } from "./Popup/LoginButton" +import {LoginToggle} from "./Popup/LoginButton" import Toggle from "./Input/Toggle" -import { SubstitutedTranslation } from "./SubstitutedTranslation" +import {SubstitutedTranslation} from "./SubstitutedTranslation" import List from "./Base/List" import StatisticsPanel from "./BigComponents/StatisticsPanel" import AutoApplyButton from "./Popup/AutoApplyButton" -import { LanguageElement } from "./Popup/LanguageElement" +import {LanguageElement} from "./Popup/LanguageElement" import FeatureReviews from "../Logic/Web/MangroveReviews" import Maproulette from "../Logic/Maproulette" import SvelteUIElement from "./Base/SvelteUIElement" -import { BBoxFeatureSourceForLayer } from "../Logic/FeatureSource/Sources/TouchesBboxFeatureSource" +import {BBoxFeatureSourceForLayer} from "../Logic/FeatureSource/Sources/TouchesBboxFeatureSource" import QuestionViz from "./Popup/QuestionViz" -import { Feature, Point } from "geojson" -import { GeoOperations } from "../Logic/GeoOperations" +import {Feature, Point} from "geojson" +import {GeoOperations} from "../Logic/GeoOperations" import CreateNewNote from "./Popup/CreateNewNote.svelte" import AddNewPoint from "./Popup/AddNewPoint/AddNewPoint.svelte" import UserProfile from "./BigComponents/UserProfile.svelte" @@ -58,30 +54,27 @@ import LanguagePicker from "./LanguagePicker" import Link from "./Base/Link" import LayerConfig from "../Models/ThemeConfig/LayerConfig" import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig" -import NearbyImages, { - NearbyImageOptions, - P4CPicture, - SelectOneNearbyImage, -} from "./Popup/NearbyImages" -import { Tag } from "../Logic/Tags/Tag" +import NearbyImages, {NearbyImageOptions, P4CPicture, SelectOneNearbyImage,} from "./Popup/NearbyImages" +import {Tag} from "../Logic/Tags/Tag" import ChangeTagAction from "../Logic/Osm/Actions/ChangeTagAction" -import { And } from "../Logic/Tags/And" -import { SaveButton } from "./Popup/SaveButton" +import {And} from "../Logic/Tags/And" +import {SaveButton} from "./Popup/SaveButton" import Lazy from "./Base/Lazy" -import { CheckBox } from "./Input/Checkboxes" +import {CheckBox} from "./Input/Checkboxes" import Slider from "./Input/Slider" -import { OsmTags, WayId } from "../Models/OsmFeature" +import {OsmTags, WayId} from "../Models/OsmFeature" import MoveWizard from "./Popup/MoveWizard" import SplitRoadWizard from "./Popup/SplitRoadWizard" -import { ExportAsGpxViz } from "./Popup/ExportAsGpxViz" +import {ExportAsGpxViz} from "./Popup/ExportAsGpxViz" import WikipediaPanel from "./Wikipedia/WikipediaPanel.svelte" import TagRenderingEditable from "./Popup/TagRendering/TagRenderingEditable.svelte" -import { PointImportButtonViz } from "./Popup/ImportButtons/PointImportButtonViz" +import {PointImportButtonViz} from "./Popup/ImportButtons/PointImportButtonViz" import WayImportButtonViz from "./Popup/ImportButtons/WayImportButtonViz" import ConflateImportButtonViz from "./Popup/ImportButtons/ConflateImportButtonViz" import DeleteWizard from "./Popup/DeleteFlow/DeleteWizard.svelte" -import { OpenJosm } from "./BigComponents/OpenJosm" +import {OpenJosm} from "./BigComponents/OpenJosm" import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte" +import FediverseValidator from "./InputElement/Validators/FediverseValidator"; class NearbyImageVis implements SpecialVisualization { // Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests @@ -180,7 +173,7 @@ class NearbyImageVis implements SpecialVisualization { towardsCenter, new Combine([ new VariableUiElement( - radius.GetValue().map((radius) => t.withinRadius.Subs({ radius })) + radius.GetValue().map((radius) => t.withinRadius.Subs({radius})) ), radius, ]).SetClass("flex justify-between"), @@ -393,24 +386,24 @@ export default class SpecialVisualizations { viz.docs, viz.args.length > 0 ? new Table( - ["name", "default", "description"], - viz.args.map((arg) => { - let defaultArg = arg.defaultValue ?? "_undefined_" - if (defaultArg == "") { - defaultArg = "_empty string_" - } - return [arg.name, defaultArg, arg.doc] - }) - ) + ["name", "default", "description"], + viz.args.map((arg) => { + let defaultArg = arg.defaultValue ?? "_undefined_" + if (defaultArg == "") { + defaultArg = "_empty string_" + } + return [arg.name, defaultArg, arg.doc] + }) + ) : undefined, new Title("Example usage of " + viz.funcName, 4), new FixedUiElement( viz.example ?? - "`{" + - viz.funcName + - "(" + - viz.args.map((arg) => arg.defaultValue).join(",") + - ")}`" + "`{" + + viz.funcName + + "(" + + viz.args.map((arg) => arg.defaultValue).join(",") + + ")}`" ).SetClass("literal-code"), ]) } @@ -469,14 +462,14 @@ export default class SpecialVisualizations { s.structuredExamples === undefined ? [] : s.structuredExamples().map((e) => { - return s.constr( - state, - new UIEventSource>(e.feature.properties), - e.args, - e.feature, - undefined - ) - }) + return s.constr( + state, + new UIEventSource>(e.feature.properties), + e.args, + e.feature, + undefined + ) + }) return new Combine([new Title(s.funcName), s.docs, ...examples]) } @@ -491,7 +484,7 @@ export default class SpecialVisualizations { let [lon, lat] = GeoOperations.centerpointCoordinates(feature) return new SvelteUIElement(AddNewPoint, { state, - coordinate: { lon, lat }, + coordinate: {lon, lat}, }) }, }, @@ -610,7 +603,7 @@ export default class SpecialVisualizations { feature: Feature ): BaseUIElement { const [lon, lat] = GeoOperations.centerpointCoordinates(feature) - return new SvelteUIElement(CreateNewNote, { state, coordinate: { lon, lat } }) + return new SvelteUIElement(CreateNewNote, {state, coordinate: {lon, lat}}) }, }, new CloseNoteButton(), @@ -687,7 +680,7 @@ export default class SpecialVisualizations { docs: "Prints all key-value pairs of the object - used for debugging", args: [], constr: (state, tags: UIEventSource) => - new SvelteUIElement(AllTagsPanel, { tags, state }), + new SvelteUIElement(AllTagsPanel, {tags, state}), }, { funcName: "image_carousel", @@ -1326,7 +1319,7 @@ export default class SpecialVisualizations { ], constr(state, featureTags, args) { const [key, tr] = args - const translation = new Translation({ "*": tr }) + const translation = new Translation({"*": tr}) return new VariableUiElement( featureTags.map((tags) => { const properties: object[] = JSON.parse(tags[key]) @@ -1344,12 +1337,32 @@ export default class SpecialVisualizations { ) }, }, + { + funcName: "fediverse_link", + docs: "Converts a fediverse username or link into a clickable link", + args: [{ + name: "key", + doc: "The attribute-name containing the link", + required: true + }], + constr(state: SpecialVisualizationState, tagSource: UIEventSource>, argument: string[], feature: Feature, layer: LayerConfig): BaseUIElement { + const key = argument[0] + const validator = new FediverseValidator() + return new VariableUiElement(tagSource.map(tags => tags[key]).map(fediAccount => { + fediAccount = validator.reformat(fediAccount) + const [_, username, host] = fediAccount.match(FediverseValidator.usernameAtServer) + + return new Link(fediAccount, "https://" + host + "/@" + username, true) + } + )) + } + } ] specialVisualizations.push(new AutoApplyButton(specialVisualizations)) const invalid = specialVisualizations - .map((sp, i) => ({ sp, i })) + .map((sp, i) => ({sp, i})) .filter((sp) => sp.sp.funcName === undefined) if (invalid.length > 0) { throw (