forked from MapComplete/MapComplete
More work on A11y
This commit is contained in:
parent
87aee9e2b7
commit
6da72b80ef
28 changed files with 398 additions and 209 deletions
47
src/UI/Base/DirectionIndicator.svelte
Normal file
47
src/UI/Base/DirectionIndicator.svelte
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<script lang="ts">
|
||||
|
||||
/**
|
||||
* An A11Y feature which indicates how far away and in what direction the feature lies.
|
||||
*
|
||||
*/
|
||||
|
||||
import { GeoOperations } from "../../Logic/GeoOperations"
|
||||
import { Store } from "../../Logic/UIEventSource"
|
||||
import type { Feature } from "geojson"
|
||||
import ThemeViewState from "../../Models/ThemeViewState"
|
||||
import Compass_arrow from "../../assets/svg/Compass_arrow.svelte"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { Orientation } from "../../Sensors/Orientation"
|
||||
|
||||
export let state: ThemeViewState
|
||||
export let feature: Feature
|
||||
|
||||
let fcenter = GeoOperations.centerpointCoordinates(feature)
|
||||
// Bearing and distance relative to the map center
|
||||
let bearingAndDist: Store<{ bearing: number; dist: number }> = state.mapProperties.location.map(
|
||||
(l) => {
|
||||
let mapCenter = [l.lon, l.lat]
|
||||
let bearing = Math.round(GeoOperations.bearing(fcenter, mapCenter))
|
||||
let dist = Math.round(GeoOperations.distanceBetween(fcenter, mapCenter))
|
||||
return { bearing, dist }
|
||||
},
|
||||
)
|
||||
let bearingFromGps = state.geolocation.geolocationState.currentGPSLocation.mapD(coordinate => {
|
||||
return GeoOperations.bearing([coordinate.longitude, coordinate.latitude], fcenter)
|
||||
})
|
||||
let compass = Orientation.singleton.alpha.map(compass => compass ?? 0)
|
||||
export let size = "w-8 h-8"
|
||||
</script>
|
||||
|
||||
<div class={twMerge("relative", size)}>
|
||||
<div class={twMerge("absolute top-0 left-0 flex items-center justify-center text-sm",size)}>
|
||||
{GeoOperations.distanceToHuman($bearingAndDist.dist)}
|
||||
</div>
|
||||
{#if $bearingFromGps !== undefined}
|
||||
<div class={twMerge("absolute top-0 left-0 rounded-full border border-gray-500", size)}>
|
||||
<Compass_arrow class={size}
|
||||
style={`transform: rotate( calc( 45deg + ${$bearingFromGps - $compass}deg) );`} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span>{$bearingAndDist.bearing}° {GeoOperations.bearingToHuman($bearingAndDist.bearing)} {GeoOperations.bearingToHumanRelative($bearingAndDist.bearing - $compass)}</span>
|
||||
|
|
@ -47,7 +47,8 @@ export default class Hotkeys {
|
|||
onUp?: boolean
|
||||
},
|
||||
documentation: string | Translation,
|
||||
action: () => void | false
|
||||
action: () => void | false,
|
||||
alsoTriggeredOn?: Translation[]
|
||||
) {
|
||||
const type = key["onUp"] ? "keyup" : "keypress"
|
||||
let keycode: string = key["ctrl"] ?? key["shift"] ?? key["alt"] ?? key["nomod"]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
<a
|
||||
{href}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
target={newTab ? "_blank" : undefined}
|
||||
{download}
|
||||
class={classnames}
|
||||
|
|
|
|||
36
src/UI/BigComponents/MapCenterDetails.svelte
Normal file
36
src/UI/BigComponents/MapCenterDetails.svelte
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts">
|
||||
import { Store } from "../../Logic/UIEventSource"
|
||||
import { GeoOperations } from "../../Logic/GeoOperations"
|
||||
import ThemeViewState from "../../Models/ThemeViewState"
|
||||
import Tr from "../Base/Tr.svelte"
|
||||
import Translations from "../i18n/Translations"
|
||||
|
||||
/**
|
||||
* Indicates how far away the viewport center is from the current user location
|
||||
*/
|
||||
export let state: ThemeViewState
|
||||
const t = Translations.t.general.visualFeedback
|
||||
let map = state.mapProperties
|
||||
|
||||
let currentLocation = state.geolocation.geolocationState.currentGPSLocation
|
||||
let distanceToCurrentLocation: Store<{ distance: string, distanceInMeters: number, bearing: number }> = map.location.mapD(({ lon, lat }) => {
|
||||
const current = currentLocation.data
|
||||
if (!current) {
|
||||
return undefined
|
||||
}
|
||||
const gps: [number, number] = [current.longitude, current.latitude]
|
||||
const mapCenter: [number, number] = [lon, lat]
|
||||
const distanceInMeters = Math.round(GeoOperations.distanceBetween(gps, mapCenter))
|
||||
const distance = GeoOperations.distanceToHuman(distanceInMeters)
|
||||
const bearing = Math.round(GeoOperations.bearing(gps, mapCenter))
|
||||
return { distance, bearing, distanceInMeters }
|
||||
}, [currentLocation])
|
||||
</script>
|
||||
|
||||
{#if $currentLocation !== undefined}
|
||||
{#if $distanceToCurrentLocation.distanceInMeters < 20}
|
||||
<Tr t={t.viewportCenterCloseToGps} />
|
||||
{:else}
|
||||
<Tr t={t.viewportCenterDetails.Subs($distanceToCurrentLocation)} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
@ -8,8 +8,11 @@
|
|||
import Hotkeys from "../Base/Hotkeys"
|
||||
import Translations from "../i18n/Translations"
|
||||
import Locale from "../i18n/Locale"
|
||||
import MapCenterDetails from "./MapCenterDetails.svelte"
|
||||
import ThemeViewState from "../../Models/ThemeViewState"
|
||||
|
||||
export let mapProperties: MapProperties
|
||||
export let state: ThemeViewState
|
||||
let mapProperties = state.mapProperties
|
||||
let lastDisplayed: Date = undefined
|
||||
let currentLocation: string = undefined
|
||||
|
||||
|
|
@ -51,8 +54,9 @@
|
|||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
class="normal-background border-interactive rounded-full px-2"
|
||||
class="normal-background border-interactive rounded-full px-2 flex flex-col items-center"
|
||||
>
|
||||
{currentLocation}
|
||||
<MapCenterDetails {state}/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,11 @@
|
|||
<div class="flex flex-col">
|
||||
<!-- Title element-->
|
||||
<h3>
|
||||
<a href={`#${$tags.id}`}>
|
||||
<TagRenderingAnswer config={layer.title} {selectedElement} {state} {tags} {layer} />
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<div
|
||||
class="no-weblate title-icons links-as-button mr-2 flex flex-row flex-wrap items-center gap-x-0.5 p-1 pt-0.5 sm:pt-1"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
import type { SpecialVisualizationState } from "../SpecialVisualization"
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||
import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte"
|
||||
import { GeoOperations } from "../../Logic/GeoOperations"
|
||||
import { Store } from "../../Logic/UIEventSource"
|
||||
import DirectionIndicator from "../Base/DirectionIndicator.svelte"
|
||||
|
||||
export let state: SpecialVisualizationState
|
||||
export let feature: Feature
|
||||
|
|
@ -13,30 +12,13 @@
|
|||
let tags = state.featureProperties.getStore(id)
|
||||
let layer: LayerConfig = state.layout.getMatchingLayer(tags.data)
|
||||
|
||||
function select() {
|
||||
state.selectedElement.setData(undefined)
|
||||
state.selectedLayer.setData(layer)
|
||||
state.selectedElement.setData(feature)
|
||||
}
|
||||
|
||||
let bearingAndDist: Store<{ bearing: number; dist: number }> = state.mapProperties.location.map(
|
||||
(l) => {
|
||||
let fcenter = GeoOperations.centerpointCoordinates(feature)
|
||||
let mapCenter = [l.lon, l.lat]
|
||||
|
||||
let bearing = Math.round(GeoOperations.bearing(fcenter, mapCenter))
|
||||
let dist = Math.round(GeoOperations.distanceBetween(fcenter, mapCenter))
|
||||
return { bearing, dist }
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="small flex cursor-pointer" on:click={() => select()}>
|
||||
<span class="flex">
|
||||
{#if i !== undefined}
|
||||
<span class="font-bold">{i + 1}.</span>
|
||||
{/if}
|
||||
<TagRenderingAnswer config={layer.title} {layer} selectedElement={feature} {state} {tags} />
|
||||
{$bearingAndDist.dist}m {$bearingAndDist.bearing}°
|
||||
</span>
|
||||
</div>
|
||||
<a class="small flex space-x-1 cursor-pointer w-fit" href={`#${feature.properties.id}`}>
|
||||
{#if i !== undefined}
|
||||
<span class="font-bold">{i + 1} </span>
|
||||
{/if}
|
||||
<TagRenderingAnswer config={layer.title} extraClasses="inline-flex w-fit" {layer} selectedElement={feature} {state}
|
||||
{tags} />
|
||||
<DirectionIndicator {feature} {state} />
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -7,18 +7,24 @@
|
|||
import ThemeViewState from "../../Models/ThemeViewState"
|
||||
import Summary from "./Summary.svelte"
|
||||
import Tr from "../Base/Tr.svelte"
|
||||
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import type { KeyNavigationEvent } from "../../Models/MapProperties"
|
||||
import type { Feature } from "geojson"
|
||||
import MapCenterDetails from "./MapCenterDetails.svelte"
|
||||
|
||||
export let state: ThemeViewState
|
||||
export let featuresInViewPort: Store<Feature[]>
|
||||
console.log("Visual feedback panel:", featuresInViewPort)
|
||||
const t = Translations.t.general.visualFeedback
|
||||
let map = state.mapProperties
|
||||
let centerFeatures = state.closestFeatures.features
|
||||
let translationWithLength = centerFeatures.mapD(cf => cf.length).mapD(n => {
|
||||
if (n === 1) {
|
||||
return t.oneFeatureInView
|
||||
}
|
||||
return t.closestFeaturesAre.Subs({ n })
|
||||
})
|
||||
|
||||
|
||||
let lastAction: UIEventSource<KeyNavigationEvent> = new UIEventSource<KeyNavigationEvent>(
|
||||
undefined
|
||||
undefined,
|
||||
)
|
||||
state.mapProperties.onKeyNavigationEvent((event) => {
|
||||
lastAction.setData(event)
|
||||
|
|
@ -26,17 +32,23 @@
|
|||
lastAction.stabilized(750).addCallbackAndRunD((_) => lastAction.setData(undefined))
|
||||
</script>
|
||||
|
||||
<div aria-live="assertive" class="p-1" role="alert">
|
||||
{#if $lastAction !== undefined}
|
||||
<div aria-live="assertive" class="p-1 interactive" role="alert">
|
||||
{#if $lastAction?.key === "out"}
|
||||
<Tr t={t.out.Subs({z: map.zoom.data - 1})} />
|
||||
{:else if $lastAction?.key === "in"}
|
||||
<Tr t={t.out.Subs({z: map.zoom.data + 1})} />
|
||||
{:else if $lastAction !== undefined}
|
||||
<Tr t={t[$lastAction.key]} />
|
||||
{:else if $centerFeatures.length === 0}
|
||||
{:else if $centerFeatures?.length === 0}
|
||||
<Tr t={t.noCloseFeatures} />
|
||||
{:else}
|
||||
<MapCenterDetails {state} />
|
||||
{:else if $centerFeatures !== undefined}
|
||||
<div class="pointer-events-auto">
|
||||
<Tr t={t.closestFeaturesAre.Subs({ n: $featuresInViewPort?.length })} />
|
||||
<ol class="list-none">
|
||||
{#each $centerFeatures as feat, i (feat.properties.id)}
|
||||
<li class="flex">
|
||||
<Tr t={$translationWithLength} />
|
||||
<MapCenterDetails {state} />
|
||||
<ol>
|
||||
{#each $centerFeatures.slice(0, 9) as feat, i (feat.properties.id)}
|
||||
<li>
|
||||
<Summary {state} feature={feat} {i} />
|
||||
</li>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -33,15 +33,7 @@
|
|||
const coord = GeoOperations.centerpointCoordinates(feature)
|
||||
const distance = state.mapProperties.location.stabilized(500).mapD(({ lon, lat }) => {
|
||||
let meters = Math.round(GeoOperations.distanceBetween(coord, [lon, lat]))
|
||||
|
||||
if (meters < 1000) {
|
||||
return meters + "m"
|
||||
}
|
||||
|
||||
meters = Math.round(meters / 100)
|
||||
const kmStr = "" + meters
|
||||
|
||||
return kmStr.substring(0, kmStr.length - 1) + "." + kmStr.substring(kmStr.length - 1) + "km"
|
||||
return GeoOperations.distanceToHuman(meters)
|
||||
})
|
||||
const titleIconBlacklist = ["osmlink", "sharelink", "favourite_title_icon"]
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -952,17 +952,21 @@ export class ToTextualDescription {
|
|||
}
|
||||
|
||||
if (OH.weekdaysIdentical(ranges, 0, 4)) {
|
||||
result.push(
|
||||
t.on_weekdays.Subs({ ranges: ToTextualDescription.createRangesFor(ranges[0]) })
|
||||
)
|
||||
if (ranges[0].length > 0) {
|
||||
result.push(
|
||||
t.on_weekdays.Subs({ ranges: ToTextualDescription.createRangesFor(ranges[0]) })
|
||||
)
|
||||
}
|
||||
} else {
|
||||
addRange(0, 4)
|
||||
}
|
||||
|
||||
if (OH.weekdaysIdentical(ranges, 5, 6)) {
|
||||
result.push(
|
||||
t.on_weekdays.Subs({ ranges: ToTextualDescription.createRangesFor(ranges[5]) })
|
||||
)
|
||||
if (ranges[5].length > 0) {
|
||||
result.push(
|
||||
t.on_weekdays.Subs({ ranges: ToTextualDescription.createRangesFor(ranges[5]) })
|
||||
)
|
||||
}
|
||||
} else {
|
||||
addRange(5, 6)
|
||||
}
|
||||
|
|
@ -983,7 +987,6 @@ export class ToTextualDescription {
|
|||
}
|
||||
|
||||
private static createRangeFor(range: OpeningRange): Translation {
|
||||
console.log(">>>", range)
|
||||
return Translations.t.general.opening_hours.ranges.Subs({
|
||||
starttime: ToTextualDescription.timeString(range.startDate),
|
||||
endtime: ToTextualDescription.timeString(range.endDate),
|
||||
|
|
@ -991,6 +994,9 @@ export class ToTextualDescription {
|
|||
}
|
||||
|
||||
private static createRangesFor(ranges: OpeningRange[]): Translation {
|
||||
if (ranges.length === 0) {
|
||||
// return undefined
|
||||
}
|
||||
let tr = ToTextualDescription.createRangeFor(ranges[0])
|
||||
for (let i = 1; i < ranges.length; i++) {
|
||||
tr = Translations.t.general.opening_hours.rangescombined.Subs({
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@
|
|||
export let score: number
|
||||
export let cutoff: number
|
||||
export let starSize = "w-h h-4"
|
||||
export let i: number
|
||||
|
||||
let dispatch = createEventDispatcher<{ hover: { score: number } }>()
|
||||
let dispatch = createEventDispatcher<{ hover: { score: number }, click: { score: number } }>()
|
||||
let container: HTMLElement
|
||||
|
||||
function getScore(e: MouseEvent): number {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
* Number between 0 and 100. Every 10 points, another half star is added
|
||||
*/
|
||||
export let score: number
|
||||
let dispatch = createEventDispatcher<{ hover: number; click: number }>()
|
||||
|
||||
let cutoffs = [20, 40, 60, 80, 100]
|
||||
export let starSize = "w-h h-4"
|
||||
|
|
@ -14,8 +13,8 @@
|
|||
|
||||
{#if score !== undefined}
|
||||
<div class="flex" on:mouseout>
|
||||
{#each cutoffs as cutoff}
|
||||
<StarElement {score} {cutoff} {starSize} on:hover on:click />
|
||||
{#each cutoffs as cutoff, i}
|
||||
<StarElement {score} {i} {cutoff} {starSize} on:hover on:click />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,7 @@
|
|||
import type { MapProperties } from "../Models/MapProperties"
|
||||
import Geosearch from "./BigComponents/Geosearch.svelte"
|
||||
import Translations from "./i18n/Translations"
|
||||
import {
|
||||
CogIcon,
|
||||
EyeIcon,
|
||||
HeartIcon,
|
||||
MenuIcon,
|
||||
XCircleIcon,
|
||||
} from "@rgossiaux/svelte-heroicons/solid"
|
||||
import { CogIcon, EyeIcon, HeartIcon, MenuIcon, XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
|
||||
import Tr from "./Base/Tr.svelte"
|
||||
import CommunityIndexView from "./BigComponents/CommunityIndexView.svelte"
|
||||
import FloatOver from "./Base/FloatOver.svelte"
|
||||
|
|
@ -72,9 +66,6 @@
|
|||
import FilterPanel from "./BigComponents/FilterPanel.svelte"
|
||||
import PrivacyPolicy from "./BigComponents/PrivacyPolicy.svelte"
|
||||
import { BBox } from "../Logic/BBox"
|
||||
import { GeoOperations } from "../Logic/GeoOperations"
|
||||
import ShowDataLayer from "./Map/ShowDataLayer"
|
||||
import StaticFeatureSource from "../Logic/FeatureSource/Sources/StaticFeatureSource"
|
||||
|
||||
export let state: ThemeViewState
|
||||
let layout = state.layout
|
||||
|
|
@ -102,37 +93,38 @@
|
|||
})
|
||||
|
||||
let selectedLayer: Store<LayerConfig> = state.selectedElement.mapD((element) =>
|
||||
state.layout.getMatchingLayer(element.properties)
|
||||
state.layout.getMatchingLayer(element.properties),
|
||||
)
|
||||
let currentZoom = state.mapProperties.zoom
|
||||
let showCrosshair = state.userRelatedState.showCrosshair
|
||||
let visualFeedback = state.visualFeedback
|
||||
let viewport: UIEventSource<HTMLDivElement> = new UIEventSource<HTMLDivElement>(undefined)
|
||||
let featuresInViewPort: UIEventSource<Feature[]> = new UIEventSource<Feature[]>(undefined)
|
||||
viewport.addCallbackAndRunD((viewport) => {
|
||||
state.featuresInView.features.addCallbackAndRunD((features: Feature[]) => {
|
||||
const rect = viewport.getBoundingClientRect()
|
||||
const mlmap = state.map.data
|
||||
if (!mlmap) {
|
||||
return undefined
|
||||
}
|
||||
const topLeft = mlmap.unproject([rect.left, rect.top])
|
||||
const bottomRight = mlmap.unproject([rect.right, rect.bottom])
|
||||
const bbox = new BBox([
|
||||
[topLeft.lng, topLeft.lat],
|
||||
[bottomRight.lng, bottomRight.lat],
|
||||
])
|
||||
const bboxGeo = bbox.asGeoJson({})
|
||||
console.log("BBOX:", bboxGeo)
|
||||
|
||||
const filtered = features.filter((f: Feature) => {
|
||||
console.log(f, bboxGeo)
|
||||
return GeoOperations.calculateOverlap(bboxGeo, [f]).length > 0
|
||||
})
|
||||
featuresInViewPort.setData(filtered)
|
||||
})
|
||||
})
|
||||
let mapproperties: MapProperties = state.mapProperties
|
||||
|
||||
function updateViewport() {
|
||||
const rect = viewport.data?.getBoundingClientRect()
|
||||
if (!rect) {
|
||||
return
|
||||
}
|
||||
const mlmap = state.map.data
|
||||
if (!mlmap) {
|
||||
return undefined
|
||||
}
|
||||
const topLeft = mlmap.unproject([rect.left, rect.top])
|
||||
const bottomRight = mlmap.unproject([rect.right, rect.bottom])
|
||||
const bbox = new BBox([
|
||||
[topLeft.lng, topLeft.lat],
|
||||
[bottomRight.lng, bottomRight.lat],
|
||||
])
|
||||
state.visualFeedbackViewportBounds.setData(bbox)
|
||||
}
|
||||
|
||||
viewport.addCallbackAndRunD(_ => {
|
||||
updateViewport()
|
||||
})
|
||||
mapproperties.bounds.addCallbackAndRunD(_ => {
|
||||
updateViewport()
|
||||
})
|
||||
let featureSwitches: FeatureSwitchState = state.featureSwitches
|
||||
let availableLayers = state.availableLayers
|
||||
let currentViewLayer = layout.layers.find((l) => l.id === "current_view")
|
||||
|
|
@ -142,7 +134,7 @@
|
|||
onDestroy(
|
||||
rasterLayer.addCallbackAndRunD((l) => {
|
||||
rasterLayerName = l.properties.name
|
||||
})
|
||||
}),
|
||||
)
|
||||
let previewedImage = state.previewedImage
|
||||
|
||||
|
|
@ -173,8 +165,14 @@
|
|||
|
||||
<div class="pointer-events-none absolute top-0 left-0 w-full">
|
||||
<!-- Top components -->
|
||||
<If condition={state.featureSwitches.featureSwitchSearch}>
|
||||
<div class="pointer-events-auto float-right mt-1 px-1 max-[480px]:w-full sm:m-2">
|
||||
|
||||
<div class="pointer-events-auto float-right mt-1 px-1 max-[480px]:w-full sm:m-2 flex flex-col">
|
||||
<If condition={state.visualFeedback}>
|
||||
<div class="w-fit">
|
||||
<VisualFeedbackPanel {state} />
|
||||
</div>
|
||||
</If>
|
||||
<If condition={state.featureSwitches.featureSwitchSearch}>
|
||||
<Geosearch
|
||||
bounds={state.mapProperties.bounds}
|
||||
on:searchCompleted={() => {
|
||||
|
|
@ -183,8 +181,8 @@
|
|||
perLayer={state.perLayer}
|
||||
selectedElement={state.selectedElement}
|
||||
/>
|
||||
</div>
|
||||
</If>
|
||||
</If>
|
||||
</div>
|
||||
<div class="float-left m-1 flex flex-col sm:mt-2">
|
||||
<MapControlButton
|
||||
on:click={() => state.guistate.themeIsOpened.setData(true)}
|
||||
|
|
@ -229,7 +227,7 @@
|
|||
<!-- Flex and w-full are needed for the positioning -->
|
||||
<!-- Centermessage -->
|
||||
<StateIndicator {state} />
|
||||
<ReverseGeocoding mapProperties={mapproperties} />
|
||||
<ReverseGeocoding {state} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -280,9 +278,6 @@
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<If condition={state.visualFeedback}>
|
||||
<VisualFeedbackPanel {state} {featuresInViewPort} />
|
||||
</If>
|
||||
|
||||
<div class="flex flex-col items-end">
|
||||
<!-- bottom right elements -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue