forked from MapComplete/MapComplete
A11y: move buttons into fields
This commit is contained in:
parent
30c9034e7b
commit
1b10f1f64d
23 changed files with 529 additions and 414 deletions
111
src/UI/Popup/Notes/AddNoteComment.svelte
Normal file
111
src/UI/Popup/Notes/AddNoteComment.svelte
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<script lang="ts">
|
||||
import type { SpecialVisualizationState } from "../../SpecialVisualization"
|
||||
import { Store, UIEventSource } from "../../../Logic/UIEventSource"
|
||||
import { placeholder } from "../../../Utils/placeholder"
|
||||
import Translations from "../../i18n/Translations"
|
||||
import Speech_bubble from "../../../assets/svg/Speech_bubble.svelte"
|
||||
import Tr from "../../Base/Tr.svelte"
|
||||
import NoteCommentElement from "./NoteCommentElement"
|
||||
import Resolved from "../../../assets/svg/Resolved.svelte"
|
||||
import Note from "../../../assets/svg/Note.svelte"
|
||||
import LoginToggle from "../../Base/LoginToggle.svelte"
|
||||
|
||||
export let state: SpecialVisualizationState
|
||||
export let tags: UIEventSource<Record<string, string>>
|
||||
let id = tags.data.id
|
||||
$: {
|
||||
id = $tags.id
|
||||
}
|
||||
|
||||
let txt = new UIEventSource(undefined)
|
||||
let _txt: string = undefined
|
||||
txt.addCallbackD(t => {
|
||||
_txt = t
|
||||
})
|
||||
$: {
|
||||
txt.setData(_txt)
|
||||
}
|
||||
const t = Translations.t.notes
|
||||
|
||||
let isClosed: Store<boolean> = tags.map((tags) => (tags?.["closed_at"] ?? "") !== "")
|
||||
|
||||
async function addComment() {
|
||||
if ((txt.data ?? "") == "") {
|
||||
return
|
||||
}
|
||||
|
||||
if (isClosed.data) {
|
||||
await state.osmConnection.reopenNote(id, txt.data)
|
||||
await state.osmConnection.closeNote(id)
|
||||
} else {
|
||||
await state.osmConnection.addCommentToNote(id, txt.data)
|
||||
}
|
||||
NoteCommentElement.addCommentTo(txt.data, tags, state)
|
||||
txt.setData("")
|
||||
}
|
||||
|
||||
async function closeNote() {
|
||||
await state.osmConnection.closeNote(id, txt.data)
|
||||
tags.data["closed_at"] = new Date().toISOString()
|
||||
tags.ping()
|
||||
}
|
||||
|
||||
async function reopenNote() {
|
||||
await state.osmConnection.reopenNote(id, txt.data)
|
||||
tags.data["closed_at"] = undefined
|
||||
tags.ping()
|
||||
}
|
||||
</script>
|
||||
|
||||
<LoginToggle ignoreLoading={true} {state}>
|
||||
<Tr slot="not-logged-in" t={t.loginToAddComment} />
|
||||
|
||||
<form class="m-0 px-2 py-1 flex flex-col border-2 border-black rounded-xl interactive border-interactive" on:submit|preventDefault={() => addComment()}>
|
||||
<label class="neutral-label font-bold">
|
||||
<Tr t={t.addAComment} />
|
||||
<textarea bind:value={_txt} class="w-full h-24 rounded-l border border-grey" rows="3"
|
||||
use:placeholder={t.addCommentPlaceholder} />
|
||||
</label>
|
||||
|
||||
<div class="flex flex-col">
|
||||
|
||||
{#if $txt?.length > 0}
|
||||
<button class="primary flex" on:click={() => addComment()}>
|
||||
<!-- Add a comment -->
|
||||
<Speech_bubble class="h-7 w-7 pr-2" />
|
||||
<Tr t={t.addCommentPlaceholder} />
|
||||
</button>
|
||||
{:else}
|
||||
<div class="alert w-full">
|
||||
<Tr t={t.typeText} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
{#if !$isClosed}
|
||||
<button class="flex items-center" on:click={() => closeNote()}>
|
||||
<Resolved class="h-8 w-8 pr-2" />
|
||||
<!-- Close note -->
|
||||
{#if $txt === undefined || $txt === ""}
|
||||
<Tr t={t.closeNote} />
|
||||
{:else}
|
||||
<Tr t={t.addCommentAndClose} />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="flex items-center" on:click={() => reopenNote()}>
|
||||
<!-- Reopen -->
|
||||
<Note class="h-7 w-7 pr-2" />
|
||||
{#if $txt === undefined || $txt === ""}
|
||||
<Tr t={t.reopenNote} />
|
||||
{:else}
|
||||
<Tr t={t.reopenNoteAndComment} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</LoginToggle>
|
||||
26
src/UI/Popup/Notes/AddNoteCommentViz.ts
Normal file
26
src/UI/Popup/Notes/AddNoteCommentViz.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { SpecialVisualization, SpecialVisualizationState } from "../../SpecialVisualization"
|
||||
import { UIEventSource } from "../../../Logic/UIEventSource"
|
||||
import Constants from "../../../Models/Constants"
|
||||
import SvelteUIElement from "../../Base/SvelteUIElement"
|
||||
import AddNoteComment from "./AddNoteComment.svelte"
|
||||
|
||||
export class AddNoteCommentViz implements SpecialVisualization {
|
||||
funcName = "add_note_comment"
|
||||
needsUrls = [Constants.osmAuthConfig.url]
|
||||
docs = "A textfield to add a comment to a node (with the option to close the note)."
|
||||
args = [
|
||||
{
|
||||
name: "Id-key",
|
||||
doc: "The property name where the ID of the note to close can be found",
|
||||
defaultValue: "id",
|
||||
},
|
||||
]
|
||||
|
||||
public constr(
|
||||
state: SpecialVisualizationState,
|
||||
tags: UIEventSource<Record<string, string>>,
|
||||
args: string[]
|
||||
) {
|
||||
return new SvelteUIElement(AddNoteComment, { state, tags })
|
||||
}
|
||||
}
|
||||
102
src/UI/Popup/Notes/CloseNoteButton.ts
Normal file
102
src/UI/Popup/Notes/CloseNoteButton.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import BaseUIElement from "../../BaseUIElement"
|
||||
import Translations from "../../i18n/Translations"
|
||||
import { Utils } from "../../../Utils"
|
||||
import Svg from "../../../Svg"
|
||||
import Img from "../../Base/Img"
|
||||
import { SubtleButton } from "../../Base/SubtleButton"
|
||||
import Toggle from "../../Input/Toggle"
|
||||
import { LoginToggle } from ".././LoginButton"
|
||||
import { SpecialVisualization, SpecialVisualizationState } from "../../SpecialVisualization"
|
||||
import { UIEventSource } from "../../../Logic/UIEventSource"
|
||||
import Constants from "../../../Models/Constants"
|
||||
|
||||
export class CloseNoteButton implements SpecialVisualization {
|
||||
public readonly funcName = "close_note"
|
||||
public readonly needsUrls = [Constants.osmAuthConfig.url]
|
||||
public readonly docs =
|
||||
"Button to close a note. A predifined text can be defined to close the note with. If the note is already closed, will show a small text."
|
||||
public readonly args = [
|
||||
{
|
||||
name: "text",
|
||||
doc: "Text to show on this button",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "icon",
|
||||
doc: "Icon to show",
|
||||
defaultValue: "checkmark.svg",
|
||||
},
|
||||
{
|
||||
name: "idkey",
|
||||
doc: "The property name where the ID of the note to close can be found",
|
||||
defaultValue: "id",
|
||||
},
|
||||
{
|
||||
name: "comment",
|
||||
doc: "Text to add onto the note when closing",
|
||||
},
|
||||
{
|
||||
name: "minZoom",
|
||||
doc: "If set, only show the closenote button if zoomed in enough",
|
||||
},
|
||||
{
|
||||
name: "zoomButton",
|
||||
doc: "Text to show if not zoomed in enough",
|
||||
},
|
||||
]
|
||||
|
||||
public constr(
|
||||
state: SpecialVisualizationState,
|
||||
tags: UIEventSource<Record<string, string>>,
|
||||
args: string[]
|
||||
): BaseUIElement {
|
||||
const t = Translations.t.notes
|
||||
|
||||
const params: {
|
||||
text: string
|
||||
icon: string
|
||||
idkey: string
|
||||
comment: string
|
||||
minZoom: string
|
||||
zoomButton: string
|
||||
} = <any>Utils.ParseVisArgs(this.args, args)
|
||||
|
||||
let icon = Svg.checkmark_svg()
|
||||
if (params.icon !== "checkmark.svg" && (args[2] ?? "") !== "") {
|
||||
icon = new Img(args[1])
|
||||
}
|
||||
let textToShow = t.closeNote
|
||||
if ((params.text ?? "") !== "") {
|
||||
textToShow = Translations.T(args[0])
|
||||
}
|
||||
|
||||
let closeButton: BaseUIElement = new SubtleButton(icon, textToShow)
|
||||
const isClosed = tags.map((tags) => (tags["closed_at"] ?? "") !== "")
|
||||
closeButton.onClick(() => {
|
||||
const id = tags.data[args[2] ?? "id"]
|
||||
state.osmConnection.closeNote(id, args[3])?.then((_) => {
|
||||
tags.data["closed_at"] = new Date().toISOString()
|
||||
tags.ping()
|
||||
})
|
||||
})
|
||||
|
||||
if ((params.minZoom ?? "") !== "" && !isNaN(Number(params.minZoom))) {
|
||||
closeButton = new Toggle(
|
||||
closeButton,
|
||||
params.zoomButton ?? "",
|
||||
state.mapProperties.zoom.map((zoom) => zoom >= Number(params.minZoom))
|
||||
)
|
||||
}
|
||||
|
||||
return new LoginToggle(
|
||||
new Toggle(
|
||||
t.isClosed.SetClass("thanks"),
|
||||
closeButton,
|
||||
|
||||
isClosed
|
||||
),
|
||||
t.loginToClose,
|
||||
state
|
||||
)
|
||||
}
|
||||
}
|
||||
159
src/UI/Popup/Notes/CreateNewNote.svelte
Normal file
159
src/UI/Popup/Notes/CreateNewNote.svelte
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* UIcomponent to create a new note at the given location
|
||||
*/
|
||||
import type { SpecialVisualizationState } from "../../SpecialVisualization"
|
||||
import { UIEventSource } from "../../../Logic/UIEventSource"
|
||||
import { LocalStorageSource } from "../../../Logic/Web/LocalStorageSource"
|
||||
import ValidatedInput from "../../InputElement/ValidatedInput.svelte"
|
||||
import SubtleButton from "../../Base/SubtleButton.svelte"
|
||||
import Tr from "../../Base/Tr.svelte"
|
||||
import Translations from "../../i18n/Translations.js"
|
||||
import type { Feature, Point } from "geojson"
|
||||
import LoginToggle from "../../Base/LoginToggle.svelte"
|
||||
import FilteredLayer from "../../../Models/FilteredLayer"
|
||||
import NewPointLocationInput from "../../BigComponents/NewPointLocationInput.svelte"
|
||||
import ToSvelte from "../../Base/ToSvelte.svelte"
|
||||
import Svg from "../../../Svg"
|
||||
import Layers from "../../../assets/svg/Layers.svelte"
|
||||
import AddSmall from "../../../assets/svg/AddSmall.svelte"
|
||||
import type { OsmTags } from "../../../Models/OsmFeature"
|
||||
import Loading from "../../Base/Loading.svelte"
|
||||
|
||||
export let coordinate: UIEventSource<{ lon: number; lat: number }>
|
||||
export let state: SpecialVisualizationState
|
||||
|
||||
let comment: UIEventSource<string> = LocalStorageSource.Get("note-text")
|
||||
let created = false
|
||||
|
||||
let notelayer: FilteredLayer = state.layerState.filteredLayers.get("note")
|
||||
|
||||
let hasFilter = notelayer?.hasFilter
|
||||
let isDisplayed = notelayer?.isDisplayed
|
||||
|
||||
let submitted = false
|
||||
function enableNoteLayer() {
|
||||
state.guistate.closeAll()
|
||||
isDisplayed.setData(true)
|
||||
}
|
||||
|
||||
async function uploadNote() {
|
||||
submitted = true
|
||||
let txt = comment.data
|
||||
if (txt === undefined || txt === "") {
|
||||
return
|
||||
}
|
||||
const loc = coordinate.data
|
||||
txt += "\n\n #MapComplete #" + state?.layout?.id
|
||||
const id = await state?.osmConnection?.openNote(loc.lat, loc.lon, txt)
|
||||
console.log("Created a note, got id", id)
|
||||
const feature = <Feature<Point, OsmTags>>{
|
||||
type: "Feature",
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [loc.lon, loc.lat],
|
||||
},
|
||||
properties: {
|
||||
id: "" + id.id,
|
||||
date_created: new Date().toISOString(),
|
||||
_first_comment: txt,
|
||||
comments: JSON.stringify([
|
||||
{
|
||||
text: txt,
|
||||
html: txt,
|
||||
user: state.osmConnection?.userDetails?.data?.name,
|
||||
uid: state.osmConnection?.userDetails?.data?.uid,
|
||||
},
|
||||
]),
|
||||
},
|
||||
}
|
||||
// Normally, the 'Changes' will generate the new element. The 'notes' are an exception to this
|
||||
state.newFeatures.features.data.push(feature)
|
||||
state.newFeatures.features.ping()
|
||||
state.selectedElement?.setData(feature)
|
||||
if (state.featureProperties.trackFeature) {
|
||||
state.featureProperties.trackFeature(feature)
|
||||
}
|
||||
comment.setData("")
|
||||
created = true
|
||||
state.selectedElement.setData(feature)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if notelayer === undefined}
|
||||
<div class="alert">
|
||||
This theme does not include the layer 'note'. As a result, no nodes can be created
|
||||
</div>
|
||||
{:else if submitted}
|
||||
<Loading/>
|
||||
{:else if created}
|
||||
<div class="thanks">
|
||||
<Tr t={Translations.t.notes.isCreated} />
|
||||
</div>
|
||||
{:else}
|
||||
<h3>
|
||||
<Tr t={Translations.t.notes.createNoteTitle} />
|
||||
</h3>
|
||||
|
||||
{#if $isDisplayed}
|
||||
<!-- The layer is displayed, so we can add a note without worrying for duplicates -->
|
||||
{#if $hasFilter}
|
||||
<div class="flex flex-col">
|
||||
<!-- ...but a filter is set ...-->
|
||||
<div class="alert">
|
||||
<Tr t={Translations.t.notes.noteLayerHasFilters} />
|
||||
</div>
|
||||
<SubtleButton on:click={() => notelayer.disableAllFilters()}>
|
||||
<Layers class="mr-4 h-8 w-8" />
|
||||
<Tr slot="message" t={Translations.t.notes.disableAllNoteFilters} />
|
||||
</SubtleButton>
|
||||
</div>
|
||||
{:else}
|
||||
<form class="border-grey-500 rounded-sm border" on:submit|preventDefault={uploadNote}>
|
||||
<label class="neutral-label">
|
||||
|
||||
<Tr t={Translations.t.notes.createNoteIntro} />
|
||||
<div class="w-full p-1">
|
||||
<ValidatedInput autofocus={true} type="text" value={comment} />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="h-56 w-full">
|
||||
<NewPointLocationInput value={coordinate} {state}>
|
||||
<div class="h-20 w-full pb-10" slot="image">
|
||||
<ToSvelte construct={Svg.note_svg().SetClass("h-10 w-full")} />
|
||||
</div>
|
||||
</NewPointLocationInput>
|
||||
</div>
|
||||
|
||||
<LoginToggle {state}>
|
||||
<span slot="loading"><!--empty: don't show a loading message--></span>
|
||||
<div slot="not-logged-in" class="alert">
|
||||
<Tr t={Translations.t.notes.warnAnonymous} />
|
||||
</div>
|
||||
</LoginToggle>
|
||||
|
||||
{#if $comment?.length >= 3}
|
||||
<SubtleButton on:click={uploadNote}>
|
||||
<AddSmall slot="image" class="mr-4 h-8 w-8" />
|
||||
<Tr slot="message" t={Translations.t.notes.createNote} />
|
||||
</SubtleButton>
|
||||
{:else}
|
||||
<div class="alert">
|
||||
<Tr t={Translations.t.notes.textNeeded} />
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-col">
|
||||
<div class="alert">
|
||||
<Tr t={Translations.t.notes.noteLayerNotEnabled} />
|
||||
</div>
|
||||
<SubtleButton on:click={enableNoteLayer}>
|
||||
<Layers slot="image" class="mr-4 h-8 w-8" />
|
||||
<Tr slot="message" t={Translations.t.notes.noteLayerDoEnable} />
|
||||
</SubtleButton>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
140
src/UI/Popup/Notes/NoteCommentElement.ts
Normal file
140
src/UI/Popup/Notes/NoteCommentElement.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import Combine from "../../Base/Combine"
|
||||
import BaseUIElement from "../../BaseUIElement"
|
||||
import Svg from "../../../Svg"
|
||||
import Link from "../../Base/Link"
|
||||
import { FixedUiElement } from "../../Base/FixedUiElement"
|
||||
import Translations from "../../i18n/Translations"
|
||||
import { Utils } from "../../../Utils"
|
||||
import Img from "../../Base/Img"
|
||||
import { SlideShow } from "../../Image/SlideShow"
|
||||
import { Stores, UIEventSource } from "../../../Logic/UIEventSource"
|
||||
import { OsmConnection } from "../../../Logic/Osm/OsmConnection"
|
||||
import { VariableUiElement } from "../../Base/VariableUIElement"
|
||||
import { SpecialVisualizationState } from "../../SpecialVisualization"
|
||||
|
||||
export default class NoteCommentElement extends Combine {
|
||||
constructor(
|
||||
comment: {
|
||||
date: string
|
||||
uid: number
|
||||
user: string
|
||||
user_url: string
|
||||
action: "closed" | "opened" | "reopened" | "commented"
|
||||
text: string
|
||||
html: string
|
||||
highlighted: boolean
|
||||
},
|
||||
state?: SpecialVisualizationState,
|
||||
index?: number,
|
||||
totalNumberOfComments?: number
|
||||
) {
|
||||
const t = Translations.t.notes
|
||||
|
||||
let actionIcon: BaseUIElement
|
||||
if (comment.action === "opened" || comment.action === "reopened") {
|
||||
actionIcon = Svg.note_svg()
|
||||
} else if (comment.action === "closed") {
|
||||
actionIcon = Svg.resolved_svg()
|
||||
} else {
|
||||
actionIcon = Svg.speech_bubble_svg()
|
||||
}
|
||||
|
||||
let user: BaseUIElement
|
||||
if (comment.user === undefined) {
|
||||
user = t.anonymous
|
||||
} else {
|
||||
user = new Link(comment.user, comment.user_url ?? "", true)
|
||||
}
|
||||
|
||||
let userinfo = Stores.FromPromise(
|
||||
Utils.downloadJsonCached(
|
||||
"https://api.openstreetmap.org/api/0.6/user/" + comment.uid,
|
||||
24 * 60 * 60 * 1000
|
||||
)
|
||||
)
|
||||
let userImg = new VariableUiElement(
|
||||
userinfo.map((userinfo) => {
|
||||
const href = userinfo?.user?.img?.href
|
||||
if (href !== undefined) {
|
||||
return new Img(href).SetClass("rounded-full w-8 h-8 mr-4")
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
)
|
||||
|
||||
const htmlElement = document.createElement("div")
|
||||
htmlElement.innerHTML = Utils.purify(comment.html)
|
||||
const images = Array.from(htmlElement.getElementsByTagName("a"))
|
||||
.map((link) => link.href)
|
||||
.filter((link) => {
|
||||
link = link.toLowerCase()
|
||||
const lastDotIndex = link.lastIndexOf(".")
|
||||
const extension = link.substring(lastDotIndex + 1, link.length)
|
||||
return Utils.imageExtensions.has(extension)
|
||||
})
|
||||
let imagesEl: BaseUIElement = undefined
|
||||
if (images.length > 0) {
|
||||
const imageEls = images.map((i) =>
|
||||
new Img(i)
|
||||
.SetClass("w-full block cursor-pointer")
|
||||
.onClick(() =>
|
||||
state?.previewedImage?.setData(<any>{
|
||||
url_hd: i,
|
||||
url: i,
|
||||
})
|
||||
)
|
||||
.SetStyle("min-width: 50px; background: grey;")
|
||||
)
|
||||
imagesEl = new SlideShow(new UIEventSource<BaseUIElement[]>(imageEls)).SetClass("mb-1")
|
||||
}
|
||||
|
||||
super([
|
||||
new Combine([
|
||||
actionIcon.SetClass("mr-4 w-6").SetStyle("flex-shrink: 0"),
|
||||
new FixedUiElement(comment.html).SetClass("flex flex-col").SetStyle("margin: 0"),
|
||||
]).SetClass("flex"),
|
||||
imagesEl,
|
||||
new Combine([userImg, user.SetClass("mr-2"), comment.date]).SetClass(
|
||||
"flex justify-end items-center subtle"
|
||||
),
|
||||
])
|
||||
this.SetClass("flex flex-col pb-2 mb-2 border-gray-500 border-b")
|
||||
if (comment.highlighted) {
|
||||
this.SetClass("glowing-shadow")
|
||||
console.log(">>>", index, totalNumberOfComments)
|
||||
if (index + 2 === totalNumberOfComments) {
|
||||
console.log("Scrolling into view")
|
||||
requestAnimationFrame(() => {
|
||||
this.ScrollIntoView()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static addCommentTo(
|
||||
txt: string,
|
||||
tags: UIEventSource<any>,
|
||||
state: { osmConnection: OsmConnection }
|
||||
) {
|
||||
const comments: any[] = JSON.parse(tags.data["comments"])
|
||||
const username = state.osmConnection.userDetails.data.name
|
||||
|
||||
var urlRegex = /(https?:\/\/[^\s]+)/g
|
||||
const html = txt.replace(urlRegex, function (url) {
|
||||
return '<a href="' + url + '">' + url + "</a>"
|
||||
})
|
||||
|
||||
comments.push({
|
||||
date: new Date().toISOString(),
|
||||
uid: state.osmConnection.userDetails.data.uid,
|
||||
user: username,
|
||||
user_url: "https://www.openstreetmap.org/user/" + username,
|
||||
action: "commented",
|
||||
text: txt,
|
||||
html: html,
|
||||
highlighted: true,
|
||||
})
|
||||
tags.data["comments"] = JSON.stringify(comments)
|
||||
tags.ping()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue