Refactoring: move all code files into a src directory

This commit is contained in:
Pieter Vander Vennet 2023-07-09 13:09:05 +02:00
parent de99f56ca8
commit e75d2789d2
389 changed files with 0 additions and 12 deletions

26
src/UI/Base/AsyncLazy.ts Normal file
View file

@ -0,0 +1,26 @@
import BaseUIElement from "../BaseUIElement"
import { VariableUiElement } from "./VariableUIElement"
import { Stores } from "../../Logic/UIEventSource"
import Loading from "./Loading"
export default class AsyncLazy extends BaseUIElement {
private readonly _f: () => Promise<BaseUIElement>
constructor(f: () => Promise<BaseUIElement>) {
super()
this._f = f
}
protected InnerConstructElement(): HTMLElement {
// The caching of the BaseUIElement will guarantee that _f will only be called once
return new VariableUiElement(
Stores.FromPromise(this._f()).map((el) => {
if (el === undefined) {
return new Loading()
}
return el
})
).ConstructElement()
}
}

View file

@ -0,0 +1,21 @@
<script lang="ts">
/**
* Wrapper around 'subtleButton' with an arrow pointing to the right
* See also: NextButton
*/
import SubtleButton from "./SubtleButton.svelte"
import { ChevronLeftIcon } from "@rgossiaux/svelte-heroicons/solid"
import { createEventDispatcher } from "svelte"
import { twMerge } from "tailwind-merge"
const dispatch = createEventDispatcher<{ click }>()
export let clss: string | undefined = undefined
</script>
<SubtleButton
on:click={() => dispatch("click")}
options={{ extraClasses: twMerge("flex items-center", clss) }}
>
<ChevronLeftIcon class="h-12 w-12" slot="image" />
<slot slot="message" />
</SubtleButton>

25
src/UI/Base/Button.ts Normal file
View file

@ -0,0 +1,25 @@
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
export class Button extends BaseUIElement {
private _text: BaseUIElement
constructor(text: string | BaseUIElement, onclick: () => void | Promise<void>) {
super()
this._text = Translations.W(text)
this.onClick(onclick)
}
protected InnerConstructElement(): HTMLElement {
const el = this._text.ConstructElement()
if (el === undefined) {
return undefined
}
const form = document.createElement("form")
const button = document.createElement("button")
button.type = "button"
button.appendChild(el)
form.appendChild(button)
return form
}
}

View file

@ -0,0 +1,32 @@
import BaseUIElement from "../BaseUIElement"
export class CenterFlexedElement extends BaseUIElement {
private _html: string
constructor(html: string) {
super()
this._html = html ?? ""
}
InnerRender(): string {
return this._html
}
AsMarkdown(): string {
return this._html
}
protected InnerConstructElement(): HTMLElement {
const e = document.createElement("div")
e.innerHTML = this._html
e.style.display = "flex"
e.style.height = "100%"
e.style.width = "100%"
e.style.flexDirection = "column"
e.style.flexWrap = "nowrap"
e.style.alignContent = "center"
e.style.justifyContent = "center"
e.style.alignItems = "center"
return e
}
}

38
src/UI/Base/ChartJs.ts Normal file
View file

@ -0,0 +1,38 @@
import BaseUIElement from "../BaseUIElement"
import { Chart, ChartConfiguration, ChartType, DefaultDataPoint, registerables } from "chart.js"
Chart?.register(...(registerables ?? []))
export default class ChartJs<
TType extends ChartType = ChartType,
TData = DefaultDataPoint<TType>,
TLabel = unknown
> extends BaseUIElement {
private readonly _config: ChartConfiguration<TType, TData, TLabel>
constructor(config: ChartConfiguration<TType, TData, TLabel>) {
super()
this._config = config
}
protected InnerConstructElement(): HTMLElement {
const canvas = document.createElement("canvas")
// A bit exceptional: we apply the styles before giving them to 'chartJS'
if (this.style !== undefined) {
canvas.style.cssText = this.style
}
if (this.clss?.size > 0) {
try {
canvas.classList.add(...Array.from(this.clss))
} catch (e) {
console.error(
"Invalid class name detected in:",
Array.from(this.clss).join(" "),
"\nErr msg is ",
e
)
}
}
new Chart(canvas, this._config)
return canvas
}
}

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource.js"
/**
* For some stupid reason, it is very hard to bind inputs
*/
export let selected: UIEventSource<boolean>
let _c: boolean = selected.data ?? true
$: selected.setData(_c)
</script>
<input type="checkbox" bind:checked={_c} />

71
src/UI/Base/Combine.ts Normal file
View file

@ -0,0 +1,71 @@
import { FixedUiElement } from "./FixedUiElement"
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
export default class Combine extends BaseUIElement {
private readonly uiElements: BaseUIElement[]
constructor(uiElements: (string | BaseUIElement)[]) {
super()
this.uiElements = Utils.NoNull(uiElements).map((el) => {
if (typeof el === "string") {
return new FixedUiElement(el)
}
return el
})
}
AsMarkdown(): string {
let sep = " "
if (this.HasClass("flex-col")) {
sep = "\n\n"
}
return this.uiElements.map((el) => el.AsMarkdown()).join(sep)
}
Destroy() {
super.Destroy()
for (const uiElement of this.uiElements) {
uiElement.Destroy()
}
}
public getElements(): BaseUIElement[] {
return this.uiElements
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("span")
try {
if (this.uiElements === undefined) {
console.error(
"PANIC: this.uiElements is undefined. (This might indicate a constructor which did not call 'super'. The constructor name is",
this.constructor /*Disable code quality: used for debugging*/.name + ")"
)
}
for (const subEl of this.uiElements) {
if (subEl === undefined || subEl === null) {
continue
}
try {
const subHtml = subEl.ConstructElement()
if (subHtml !== undefined) {
el.appendChild(subHtml)
}
} catch (e) {
console.error("Could not generate subelement in combine due to ", e)
}
}
} catch (e) {
const domExc = e as DOMException
console.error("DOMException: ", domExc.name)
el.appendChild(
new FixedUiElement("Could not generate this combine!")
.SetClass("alert")
.ConstructElement()
)
}
return el
}
}

View file

@ -0,0 +1,19 @@
import BaseUIElement from "../BaseUIElement"
/**
* Introduces a new element which has an ID
* Mostly a workaround for the import viewer
*/
export default class DivContainer extends BaseUIElement {
private readonly _id: string
constructor(id: string) {
super()
this._id = id
}
protected InnerConstructElement(): HTMLElement {
const e = document.createElement("div")
e.id = this._id
return e
}
}

View file

@ -0,0 +1,89 @@
<script lang="ts">
/**
* This overlay element will regularly show a hand that swipes over the underlying element.
* This element will hide as soon as the Store 'hideSignal' receives a change (which is not undefined)
*/
import { Store } from "../../Logic/UIEventSource"
import { onDestroy } from "svelte"
let mainElem: HTMLElement
export let hideSignal: Store<any>
function hide() {
mainElem.style.visibility = "hidden"
}
let initTime = Date.now()
if (hideSignal) {
onDestroy(
hideSignal.addCallbackD(() => {
if (initTime + 1000 > Date.now()) {
console.log("Ignoring hide signal")
return
}
console.log("Received hide signal")
hide()
return true
})
)
}
$: {
mainElem?.addEventListener("click", (_) => hide())
mainElem?.addEventListener("touchstart", (_) => hide())
}
</script>
<div bind:this={mainElem} class="pointer-events-none absolute bottom-0 right-0 h-full w-full">
<div id="hand-container">
<img src="./assets/svg/hand.svg" />
</div>
</div>
<style>
@keyframes hand-drag-animation {
/* This is the animation on the little extra hand on the location input. If fades in, invites the user to interact/drag the map */
0% {
opacity: 0;
transform: rotate(-30deg);
}
6% {
opacity: 1;
transform: rotate(-30deg);
}
12% {
opacity: 1;
transform: rotate(-45deg);
}
24% {
opacity: 1;
transform: rotate(-00deg);
}
30% {
opacity: 1;
transform: rotate(-30deg);
}
36% {
opacity: 0;
transform: rotate(-30deg);
}
100% {
opacity: 0;
transform: rotate(-30deg);
}
}
#hand-container {
position: absolute;
width: 2rem;
left: calc(50% + 4rem);
top: calc(50%);
opacity: 0.7;
animation: hand-drag-animation 4s ease-in-out infinite;
transform-origin: 50% 125%;
}
</style>

View file

@ -0,0 +1,14 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource.js"
/**
* For some stupid reason, it is very hard to bind inputs
*/
export let value: UIEventSource<number>
let i: number = value.data
$: value.setData(i)
</script>
<select bind:value={i}>
<slot />
</select>

View file

@ -0,0 +1,50 @@
import BaseUIElement from "../BaseUIElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import { VariableUiElement } from "./VariableUIElement"
import Combine from "./Combine"
import Locale from "../i18n/Locale"
import { Utils } from "../../Utils"
export default class FilteredCombine extends VariableUiElement {
/**
* Only shows item matching the search
* If predicate of an item is undefined, it will be filtered out as soon as a non-null or non-empty search term is given
* @param entries
* @param searchedValue
* @param options
*/
constructor(
entries: {
element: BaseUIElement | string
predicate?: (s: string) => boolean
}[],
searchedValue: UIEventSource<string>,
options?: {
onEmpty?: BaseUIElement | string
innerClasses: string
}
) {
entries = Utils.NoNull(entries)
super(
searchedValue.map(
(searchTerm) => {
if (searchTerm === undefined || searchTerm === "") {
return new Combine(entries.map((e) => e.element)).SetClass(
options?.innerClasses ?? ""
)
}
const kept = entries.filter(
(entry) => entry?.predicate !== undefined && entry.predicate(searchTerm)
)
if (kept.length === 0) {
return options?.onEmpty
}
return new Combine(kept.map((entry) => entry.element)).SetClass(
options?.innerClasses ?? ""
)
},
[Locale.language]
)
)
}
}

View file

@ -0,0 +1,33 @@
import BaseUIElement from "../BaseUIElement"
export class FixedUiElement extends BaseUIElement {
public readonly content: string
constructor(html: string) {
super()
this.content = html ?? ""
}
InnerRender(): string {
return this.content
}
AsMarkdown(): string {
if (this.HasClass("code")) {
if (this.content.indexOf("\n") > 0 || this.HasClass("block")) {
return "\n```\n" + this.content + "\n```\n"
}
return "`" + this.content + "`"
}
if (this.HasClass("font-bold")) {
return "*" + this.content + "*"
}
return this.content
}
protected InnerConstructElement(): HTMLElement {
const e = document.createElement("span")
e.innerHTML = this.content
return e
}
}

View file

@ -0,0 +1,38 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import { XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
/**
* The slotted element will be shown on top, with a lower-opacity border
*/
const dispatch = createEventDispatcher<{ close }>()
</script>
<div
class="absolute top-0 right-0 h-screen w-screen p-4 md:p-6"
style="background-color: #00000088"
>
<div class="content normal-background">
<div class="h-full rounded-xl">
<slot />
</div>
<slot name="close-button">
<!-- The close button is placed _after_ the default slot in order to always paint it on top -->
<div
class="absolute right-10 top-10 h-8 w-8 cursor-pointer"
on:click={() => dispatch("close")}
>
<XCircleIcon />
</div>
</slot>
</div>
</div>
<style>
.content {
height: calc(100vh - 2rem);
border-radius: 0.5rem;
overflow-x: auto;
box-shadow: 0 0 1rem #00000088;
}
</style>

View file

@ -0,0 +1,19 @@
<script lang="ts">
/**
* Given an HTML string, properly shows this
*/
export let src: string
let htmlElem: HTMLElement
$: {
if (htmlElem) {
htmlElem.innerHTML = src
}
}
export let clss: string | undefined = undefined
</script>
{#if src !== undefined}
<span bind:this={htmlElem} class={clss} />
{/if}

135
src/UI/Base/Hotkeys.ts Normal file
View file

@ -0,0 +1,135 @@
import { Utils } from "../../Utils"
import Combine from "./Combine"
import BaseUIElement from "../BaseUIElement"
import Title from "./Title"
import Table from "./Table"
import { UIEventSource } from "../../Logic/UIEventSource"
import { VariableUiElement } from "./VariableUIElement"
import { Translation } from "../i18n/Translation"
import { FixedUiElement } from "./FixedUiElement"
import Translations from "../i18n/Translations"
export default class Hotkeys {
private static readonly _docs: UIEventSource<
{
key: { ctrl?: string; shift?: string; alt?: string; nomod?: string; onUp?: boolean }
documentation: string | Translation
}[]
> = new UIEventSource<
{
key: { ctrl?: string; shift?: string; alt?: string; nomod?: string; onUp?: boolean }
documentation: string | Translation
}[]
>([])
private static textElementSelected(): boolean {
return ["input", "textarea"].includes(document?.activeElement?.tagName?.toLowerCase())
}
public static RegisterHotkey(
key: (
| {
ctrl: string
}
| {
shift: string
}
| {
alt: string
}
| {
nomod: string
}
) & {
onUp?: boolean
},
documentation: string | Translation,
action: () => void
) {
const type = key["onUp"] ? "keyup" : "keypress"
let keycode: string = key["ctrl"] ?? key["shift"] ?? key["alt"] ?? key["nomod"]
if (keycode.length == 1) {
keycode = keycode.toLowerCase()
if (key["shift"] !== undefined) {
keycode = keycode.toUpperCase()
}
}
this._docs.data.push({ key, documentation })
this._docs.ping()
if (Utils.runningFromConsole) {
return
}
if (key["ctrl"] !== undefined) {
document.addEventListener("keydown", function (event) {
if (event.ctrlKey && event.key === keycode) {
action()
event.preventDefault()
}
})
} else if (key["shift"] !== undefined) {
document.addEventListener(type, function (event) {
if (Hotkeys.textElementSelected()) {
// A text element is selected, we don't do anything special
return
}
if (event.shiftKey && event.key === keycode) {
action()
event.preventDefault()
}
})
} else if (key["alt"] !== undefined) {
document.addEventListener(type, function (event) {
if (event.altKey && event.key === keycode) {
action()
event.preventDefault()
}
})
} else if (key["nomod"] !== undefined) {
document.addEventListener(type, function (event) {
if (Hotkeys.textElementSelected()) {
// A text element is selected, we don't do anything special
return
}
if (event.key === keycode) {
action()
event.preventDefault()
}
})
}
}
static generateDocumentation(): BaseUIElement {
let byKey: [string, string | Translation][] = Hotkeys._docs.data
.map(({ key, documentation }) => {
const modifiers = Object.keys(key).filter((k) => k !== "nomod" && k !== "onUp")
let keycode: string = key["ctrl"] ?? key["shift"] ?? key["alt"] ?? key["nomod"]
if (keycode.length == 1) {
keycode = keycode.toUpperCase()
}
modifiers.push(keycode)
return <[string, string | Translation]>[modifiers.join("+"), documentation]
})
.sort()
byKey = Utils.NoNull(byKey)
for (let i = byKey.length - 1; i > 0; i--) {
if (byKey[i - 1][0] === byKey[i][0]) {
byKey.splice(i, 1)
}
}
const t = Translations.t.hotkeyDocumentation
return new Combine([
new Title(t.title, 1),
t.intro,
new Table(
[t.key, t.action],
byKey.map(([key, doc]) => {
return [new FixedUiElement(key).SetClass("literal-code"), doc]
})
),
])
}
static generateDocumentationDynamic(): BaseUIElement {
return new VariableUiElement(Hotkeys._docs.map((_) => Hotkeys.generateDocumentation()))
}
}

24
src/UI/Base/If.svelte Normal file
View file

@ -0,0 +1,24 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import { onDestroy } from "svelte"
/**
* For some stupid reason, it is very hard to let {#if} work together with UIEventSources, so we wrap then here
*/
export let condition: UIEventSource<boolean>
let _c = condition.data
onDestroy(
condition.addCallback((c) => {
/* Do _not_ abbreviate this as `.addCallback(c => _c = c)`. This is the same as writing `.addCallback(c => {return _c = c})`,
which will _unregister_ the callback if `c = true`! */
_c = c
return false
})
)
</script>
{#if _c}
<slot />
{:else}
<slot name="else" />
{/if}

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import { onDestroy } from "svelte"
/**
* Functions as 'If', but uses 'display:hidden' instead.
*/
export let condition: UIEventSource<boolean>
let _c = condition.data
let hasBeenShownPositive = false
let hasBeenShownNegative = false
onDestroy(
condition.addCallbackAndRun((c) => {
/* Do _not_ abbreviate this as `.addCallback(c => _c = c)`. This is the same as writing `.addCallback(c => {return _c = c})`,
which will _unregister_ the callback if `c = true`! */
hasBeenShownPositive = hasBeenShownPositive || c
hasBeenShownNegative = hasBeenShownNegative || !c
_c = c
return false
})
)
</script>
{#if hasBeenShownPositive}
<span class={_c ? "" : "hidden"}>
<slot />
</span>
{/if}
{#if hasBeenShownNegative}
<span class={_c ? "hidden" : ""}>
<slot name="else" />
</span>
{/if}

20
src/UI/Base/IfNot.svelte Normal file
View file

@ -0,0 +1,20 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import { onDestroy } from "svelte"
/**
* For some stupid reason, it is very hard to let {#if} work together with UIEventSources, so we wrap then here
*/
export let condition: UIEventSource<boolean>
let _c = !condition.data
onDestroy(
condition.addCallback((c) => {
_c = !c
return false
})
)
</script>
{#if _c}
<slot />
{/if}

78
src/UI/Base/Img.ts Normal file
View file

@ -0,0 +1,78 @@
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
export default class Img extends BaseUIElement {
private readonly _src: string
private readonly _rawSvg: boolean
private readonly _options: { readonly fallbackImage?: string }
constructor(
src: string,
rawSvg = false,
options?: {
fallbackImage?: string
}
) {
super()
if (src === undefined || src === "undefined") {
throw "Undefined src for image"
}
this._src = src
this._rawSvg = rawSvg
this._options = options
}
static AsData(source: string) {
if (Utils.runningFromConsole) {
return source
}
try {
return `data:image/svg+xml;base64,${btoa(source)}`
} catch (e) {
console.error("Cannot create an image for", source.slice(0, 100))
console.trace("Cannot create an image for the given source string due to ", e)
return ""
}
}
static AsImageElement(source: string, css_class: string = "", style = ""): string {
return `<img class="${css_class}" style="${style}" alt="" src="${Img.AsData(source)}">`
}
AsMarkdown(): string {
if (this._rawSvg === true) {
console.warn("Converting raw svgs to markdown is not supported")
return undefined
}
let src = this._src
if (this._src.startsWith("./")) {
src = "https://mapcomplete.osm.be/" + src
}
return "![](" + src + ")"
}
protected InnerConstructElement(): HTMLElement {
const self = this
if (this._rawSvg) {
const e = document.createElement("div")
e.innerHTML = this._src
return e
}
const el = document.createElement("img")
el.src = this._src
el.onload = () => {
el.style.opacity = "1"
}
el.onerror = () => {
if (self._options?.fallbackImage) {
if (el.src === self._options.fallbackImage) {
// Sigh... nothing to be done anymore
return
}
el.src = self._options.fallbackImage
}
}
return el
}
}

15
src/UI/Base/Lazy.ts Normal file
View file

@ -0,0 +1,15 @@
import BaseUIElement from "../BaseUIElement"
export default class Lazy extends BaseUIElement {
private readonly _f: () => BaseUIElement
constructor(f: () => BaseUIElement) {
super()
this._f = f
}
protected InnerConstructElement(): HTMLElement {
// The caching of the BaseUIElement will guarantee that _f will only be called once
return this._f().ConstructElement()
}
}

64
src/UI/Base/Link.ts Normal file
View file

@ -0,0 +1,64 @@
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
import { Store } from "../../Logic/UIEventSource"
export default class Link extends BaseUIElement {
private readonly _href: string | Store<string>
private readonly _embeddedShow: BaseUIElement
private readonly _newTab: boolean
constructor(
embeddedShow: BaseUIElement | string,
href: string | Store<string>,
newTab: boolean = false
) {
super()
this._embeddedShow = Translations.W(embeddedShow)
this._href = href
this._newTab = newTab
if (this._embeddedShow === undefined) {
throw "Error: got a link where embeddedShow is undefined"
}
this.onClick(() => {})
}
public static OsmWiki(key: string, value?: string, hideKey = false) {
if (value !== undefined) {
let k = ""
if (!hideKey) {
k = key + "="
}
return new Link(
k + value,
`https://wiki.openstreetmap.org/wiki/Tag:${key}%3D${value}`,
true
)
}
return new Link(key, "https://wiki.openstreetmap.org/wiki/Key:" + key, true)
}
AsMarkdown(): string {
// @ts-ignore
return `[${this._embeddedShow.AsMarkdown()}](${this._href.data ?? this._href})`
}
protected InnerConstructElement(): HTMLElement {
const embeddedShow = this._embeddedShow?.ConstructElement()
if (embeddedShow === undefined) {
return undefined
}
const el = document.createElement("a")
if (typeof this._href === "string") {
el.href = this._href
} else {
this._href.addCallbackAndRun((href) => {
el.href = href
})
}
if (this._newTab) {
el.target = "_blank"
}
el.appendChild(embeddedShow)
return el
}
}

View file

@ -0,0 +1,78 @@
import { VariableUiElement } from "./VariableUIElement"
import Locale from "../i18n/Locale"
import Link from "./Link"
import Svg from "../../Svg"
/**
* The little 'translate'-icon next to every icon + some static helper functions
*/
export default class LinkToWeblate extends VariableUiElement {
constructor(context: string, availableTranslations: object) {
super(
Locale.language.map(
(ln) => {
if (Locale.showLinkToWeblate.data === false) {
return undefined
}
if (availableTranslations["*"] !== undefined) {
return undefined
}
if (context === undefined || context.indexOf(":") < 0) {
return undefined
}
const icon = Svg.translate_svg().SetClass(
"rounded-full inline-block w-3 h-3 ml-1 weblate-link self-center"
)
if (availableTranslations[ln] === undefined) {
icon.SetClass("bg-red-400")
}
return new Link(icon, LinkToWeblate.hrefToWeblate(ln, context), true)
},
[Locale.showLinkToWeblate]
)
)
this.SetClass("enable-links")
const self = this
Locale.showLinkOnMobile.addCallbackAndRunD((showOnMobile) => {
if (showOnMobile) {
self.RemoveClass("hidden-on-mobile")
} else {
self.SetClass("hidden-on-mobile")
}
})
}
/**
* Creates the url to Hosted weblate
*
* LinkToWeblate.hrefToWeblate("nl", "category:some.context") // => "https://hosted.weblate.org/translate/mapcomplete/category/nl/?offset=1&q=context%3A%3D%22some.context%22"
*/
public static hrefToWeblate(language: string, contextKey: string): string {
if (contextKey === undefined || contextKey.indexOf(":") < 0) {
return undefined
}
const [category, ...rest] = contextKey.split(":")
const key = rest.join(":")
const baseUrl = "https://hosted.weblate.org/translate/mapcomplete/"
return baseUrl + category + "/" + language + "/?offset=1&q=context%3A%3D%22" + key + "%22"
}
public static hrefToWeblateZen(
language: string,
category: "core" | "themes" | "layers" | "shared-questions" | "glossary" | string,
searchKey: string
): string {
const baseUrl = "https://hosted.weblate.org/zen/mapcomplete/"
// ?offset=1&q=+state%3A%3Ctranslated+context%3Acampersite&sort_by=-priority%2Cposition&checksum=
return (
baseUrl +
category +
"/" +
language +
"?offset=1&q=+state%3A%3Ctranslated+context%3A" +
encodeURIComponent(searchKey) +
"&sort_by=-priority%2Cposition&checksum="
)
}
}

52
src/UI/Base/List.ts Normal file
View file

@ -0,0 +1,52 @@
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
import Translations from "../i18n/Translations"
export default class List extends BaseUIElement {
private readonly uiElements: BaseUIElement[]
private readonly _ordered: boolean
constructor(uiElements: (string | BaseUIElement)[], ordered = false) {
super()
this._ordered = ordered
this.uiElements = Utils.NoNull(uiElements).map((s) => Translations.W(s))
}
AsMarkdown(): string {
if (this._ordered) {
return (
"\n\n" +
this.uiElements
.map((el, i) => " " + i + ". " + el.AsMarkdown().replace(/\n/g, " \n"))
.join("\n") +
"\n"
)
} else {
return (
"\n\n" +
this.uiElements
.map((el) => " - " + el.AsMarkdown().replace(/\n/g, " \n"))
.join("\n") +
"\n"
)
}
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement(this._ordered ? "ol" : "ul")
for (const subEl of this.uiElements) {
if (subEl === undefined || subEl === null) {
continue
}
const subHtml = subEl.ConstructElement()
if (subHtml !== undefined) {
const item = document.createElement("li")
item.appendChild(subHtml)
el.appendChild(item)
}
}
return el
}
}

View file

@ -0,0 +1,13 @@
<script>
import ToSvelte from "./ToSvelte.svelte"
import Svg from "../../Svg"
</script>
<div class="flex p-1 pl-2">
<div class="min-w-6 h-6 w-6 animate-spin self-center">
<ToSvelte construct={Svg.loading_svg()} />
</div>
<div class="ml-2">
<slot />
</div>
</div>

18
src/UI/Base/Loading.ts Normal file
View file

@ -0,0 +1,18 @@
import Combine from "./Combine"
import Svg from "../../Svg"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
export default class Loading extends Combine {
constructor(msg?: BaseUIElement | string) {
const t = Translations.W(msg) ?? Translations.t.general.loading
t.SetClass("pl-2")
super([
Svg.loading_svg()
.SetClass("animate-spin self-center")
.SetStyle("width: 1.5rem; height: 1.5rem; min-width: 1.5rem;"),
t,
])
this.SetClass("flex p-1")
}
}

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import Translations from "../i18n/Translations.js"
import Tr from "./Tr.svelte"
import ToSvelte from "./ToSvelte.svelte"
import Svg from "../../Svg"
export let osmConnection: OsmConnection
export let clss: string | undefined = undefined
</script>
<button class={clss} on:click={() => osmConnection.AttemptLogin()}>
<ToSvelte construct={Svg.login_svg().SetClass("w-12 m-1")} />
<slot name="message">
<Tr t={Translations.t.general.loginWithOpenStreetMap} />
</slot>
</button>

View file

@ -0,0 +1,45 @@
<script lang="ts">
import Loading from "./Loading.svelte"
import type { OsmServiceState } from "../../Logic/Osm/OsmConnection"
import { Translation } from "../i18n/Translation"
import Translations from "../i18n/Translations"
import Tr from "./Tr.svelte"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { ImmutableStore, UIEventSource } from "../../Logic/UIEventSource"
export let state: {
osmConnection: OsmConnection
featureSwitches?: { featureSwitchUserbadge?: UIEventSource<boolean> }
}
/**
* If set, 'loading' will act as if we are already logged in.
*/
export let ignoreLoading: boolean = false
let loadingStatus = state.osmConnection.loadingStatus
let badge = state.featureSwitches?.featureSwitchUserbadge ?? new ImmutableStore(true)
const t = Translations.t.general
const offlineModes: Partial<Record<OsmServiceState, Translation>> = {
offline: t.loginFailedOfflineMode,
unreachable: t.loginFailedUnreachableMode,
unknown: t.loginFailedUnreachableMode,
readonly: t.loginFailedReadonlyMode,
}
const apiState = state.osmConnection.apiIsOnline
</script>
{#if $badge}
{#if !ignoreLoading && $loadingStatus === "loading"}
<slot name="loading">
<Loading />
</slot>
{:else if $loadingStatus === "error"}
<div class="alert max-w-64 flex items-center">
<img src="./assets/svg/invalid.svg" class="m-2 h-8 w-8 shrink-0" />
<Tr t={offlineModes[$apiState]} />
</div>
{:else if $loadingStatus === "logged-in"}
<slot />
{:else if $loadingStatus === "not-attempted"}
<slot name="not-logged-in" />
{/if}
{/if}

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import { twJoin } from "tailwind-merge"
/**
* A round button with an icon and possible a small text, which hovers above the map
*/
const dispatch = createEventDispatcher()
export let cls = ""
</script>
<button
on:click={(e) => dispatch("click", e)}
class={twJoin("pointer-events-auto m-0.5 h-fit w-fit rounded-full p-0.5 sm:p-1 md:m-1", cls)}
>
<slot />
</button>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import { XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
/**
* The slotted element will be shown on the right side
*/
const dispatch = createEventDispatcher<{ close }>()
</script>
<div
class="absolute top-0 right-0 h-screen w-full overflow-auto drop-shadow-2xl md:w-6/12 lg:w-5/12 xl:w-4/12"
style="max-width: 100vw; max-height: 100vh"
>
<div class="normal-background m-0 flex flex-col">
<slot name="close-button">
<div
class="absolute right-10 top-10 h-8 w-8 cursor-pointer"
on:click={() => dispatch("close")}
>
<XCircleIcon />
</div>
</slot>
<slot />
</div>
</div>

View file

@ -0,0 +1,25 @@
<script lang="ts">
/**
* Wrapper around 'subtleButton' with an arrow pointing to the right
* See also: BackButton
*/
import SubtleButton from "./SubtleButton.svelte"
import { ChevronRightIcon } from "@rgossiaux/svelte-heroicons/solid"
import { createEventDispatcher } from "svelte"
import { twMerge } from "tailwind-merge"
const dispatch = createEventDispatcher<{ click }>()
export let clss: string | undefined = undefined
</script>
<SubtleButton
on:click={() => dispatch("click")}
options={{ extraClasses: twMerge("flex items-center", clss) }}
>
<slot name="image" slot="image" />
<div class="flex w-full items-center justify-between" slot="message">
<slot />
<ChevronRightIcon class="h-12 w-12" />
</div>
</SubtleButton>

30
src/UI/Base/Paragraph.ts Normal file
View file

@ -0,0 +1,30 @@
import BaseUIElement from "../BaseUIElement"
export class Paragraph extends BaseUIElement {
public readonly content: string | BaseUIElement
constructor(html: string | BaseUIElement) {
super()
this.content = html ?? ""
}
AsMarkdown(): string {
let c: string
if (typeof this.content !== "string") {
c = this.content.AsMarkdown()
} else {
c = this.content
}
return "\n\n" + c + "\n\n"
}
protected InnerConstructElement(): HTMLElement {
const e = document.createElement("p")
if (typeof this.content !== "string") {
e.appendChild(this.content.ConstructElement())
} else {
e.innerHTML = this.content
}
return e
}
}

View file

@ -0,0 +1,30 @@
<script lang="ts">
import ToSvelte from "./ToSvelte.svelte"
import Svg from "../../Svg"
export let generateShareData: () => {
text: string
title: string
url: string
}
function share() {
if (!navigator.share) {
console.log("web share not supported")
return
}
navigator
.share(generateShareData())
.then(() => {
console.log("Thanks for sharing!")
})
.catch((err) => {
console.log(`Couldn't share because of`, err.message)
})
}
</script>
<button on:click={share} class="secondary m-0 h-8 w-8 p-0">
<slot name="content">
<ToSvelte construct={Svg.share_svg().SetClass("w-7 h-7 p-1")} />
</slot>
</button>

View file

@ -0,0 +1,31 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import BaseUIElement from "../BaseUIElement"
import Img from "./Img"
import { twJoin, twMerge } from "tailwind-merge"
export let imageUrl: string | BaseUIElement = undefined
export const message: string | BaseUIElement = undefined
export let options: {
imgSize?: string
extraClasses?: string
} = {}
let imgClasses = twJoin("block justify-center shrink-0 mr-4", options?.imgSize ?? "h-11 w-11")
const dispatch = createEventDispatcher<{ click }>()
</script>
<button
class={twMerge(options.extraClasses, "secondary no-image-background")}
on:click={(e) => dispatch("click", e)}
>
<slot name="image">
{#if imageUrl !== undefined}
{#if typeof imageUrl === "string"}
<Img src={imageUrl} class={imgClasses} />
{/if}
{/if}
</slot>
<slot name="message" />
</button>

View file

@ -0,0 +1,97 @@
import BaseUIElement from "../BaseUIElement"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { UIElement } from "../UIElement"
import { VariableUiElement } from "./VariableUIElement"
import Lazy from "./Lazy"
import Loading from "./Loading"
import SvelteUIElement from "./SvelteUIElement"
import SubtleLink from "./SubtleLink.svelte"
import Translations from "../i18n/Translations"
import Combine from "./Combine"
import Img from "./Img"
/**
* @deprecated
*/
export class SubtleButton extends UIElement {
private readonly imageUrl: string | BaseUIElement
private readonly message: string | BaseUIElement
private readonly options: {
url?: string | Store<string>
newTab?: boolean
imgSize?: string
extraClasses?: string
}
constructor(
imageUrl: string | BaseUIElement,
message: string | BaseUIElement,
options: {
url?: string | Store<string>
newTab?: boolean
imgSize?: "h-11 w-11" | string
extraClasses?: string
} = {}
) {
super()
this.imageUrl = imageUrl
this.message = message
this.options = options
}
protected InnerRender(): string | BaseUIElement {
if (this.options.url !== undefined) {
return new SvelteUIElement(SubtleLink, {
href: this.options.url,
newTab: this.options.newTab,
})
}
const classes = "button"
const message = Translations.W(this.message)?.SetClass(
"block overflow-ellipsis no-images flex-shrink"
)
let img
const imgClasses =
"block justify-center flex-none mr-4 " + (this.options?.imgSize ?? "h-11 w-11")
if ((this.imageUrl ?? "") === "") {
img = undefined
} else if (typeof this.imageUrl === "string") {
img = new Img(this.imageUrl)?.SetClass(imgClasses)
} else {
img = this.imageUrl?.SetClass(imgClasses)
}
const button = new Combine([img, message]).SetClass("flex items-center group w-full")
this.SetClass(classes)
return button
}
public OnClickWithLoading(
loadingText: BaseUIElement | string,
action: () => Promise<void>
): BaseUIElement {
const state = new UIEventSource<"idle" | "running">("idle")
const button = this
button.onClick(async () => {
state.setData("running")
try {
await action()
} catch (e) {
console.error(e)
} finally {
state.setData("idle")
}
})
const loading = new Lazy(() => new Loading(loadingText))
return new VariableUiElement(
state.map((st) => {
if (st === "idle") {
return button
}
return loading
})
)
}
}

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { onMount } from "svelte"
import { twJoin, twMerge } from "tailwind-merge"
import BaseUIElement from "../BaseUIElement"
import Img from "./Img"
export let imageUrl: string | BaseUIElement = undefined
export let href: string
export let newTab = false
export let options: {
imgSize?: string
extraClasses?: string
} = {}
let imgElem: HTMLElement
let imgClasses = twJoin("block justify-center shrink-0 mr-4", options?.imgSize ?? "h-11 w-11")
onMount(() => {
// Image
if (imgElem && imageUrl) {
let img: BaseUIElement
if ((imageUrl ?? "") === "") {
img = undefined
} else if (typeof imageUrl !== "string") {
img = imageUrl?.SetClass(imgClasses)
}
if (img) imgElem.replaceWith(img.ConstructElement())
}
})
</script>
<a
class={twMerge(options.extraClasses, "button text-ellipsis")}
{href}
target={newTab ? "_blank" : undefined}
>
<slot name="image">
{#if imageUrl !== undefined}
{#if typeof imageUrl === "string"}
<Img src={imageUrl} class={imgClasses} />
{:else}
<template bind:this={imgElem} />
{/if}
{/if}
</slot>
<slot />
</a>

View file

@ -0,0 +1,44 @@
import BaseUIElement from "../BaseUIElement"
import { SvelteComponentTyped } from "svelte"
/**
* The SvelteUIComponent serves as a translating class which which wraps a SvelteElement into the BaseUIElement framework.
* Also see ToSvelte.svelte for the opposite conversion
*/
export default class SvelteUIElement<
Props extends Record<string, any> = any,
Events extends Record<string, any> = any,
Slots extends Record<string, any> = any
> extends BaseUIElement {
private readonly _svelteComponent: {
new (args: {
target: HTMLElement
props: Props
events?: Events
slots?: Slots
}): SvelteComponentTyped<Props, Events, Slots>
}
private readonly _props: Props
private readonly _events: Events
private readonly _slots: Slots
constructor(svelteElement, props: Props, events?: Events, slots?: Slots) {
super()
this._svelteComponent = svelteElement
this._props = props
this._events = events
this._slots = slots
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("div")
new this._svelteComponent({
target: el,
props: this._props,
events: this._events,
slots: this._slots,
})
return el
}
}

View file

@ -0,0 +1,135 @@
<script lang="ts">
/**
* Thin wrapper around 'TabGroup' which binds the state
*/
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@rgossiaux/svelte-headlessui"
import { UIEventSource } from "../../Logic/UIEventSource"
import { twJoin } from "tailwind-merge"
export let tab: UIEventSource<number>
let tabElements: HTMLElement[] = []
$: tabElements[$tab]?.click()
$: {
if (tabElements[tab.data]) {
window.setTimeout(() => tabElements[tab.data].click(), 50)
}
}
</script>
<div class="tabbedgroup flex h-full w-full">
<TabGroup
class="flex h-full w-full flex-col"
defaultIndex={1}
on:change={(e) => {
if (e.detail >= 0) {
tab.setData(e.detail)
}
}}
>
<div class="interactive sticky top-0 flex items-center justify-between">
<TabList class="flex flex-wrap">
{#if $$slots.title1}
<Tab class={({ selected }) => twJoin("tab", selected && "primary")}>
<div bind:this={tabElements[0]} class="flex">
<slot name="title0">Tab 0</slot>
</div>
</Tab>
{/if}
{#if $$slots.title1}
<Tab class={({ selected }) => twJoin("tab", selected && "primary")}>
<div bind:this={tabElements[1]} class="flex">
<slot name="title1" />
</div>
</Tab>
{/if}
{#if $$slots.title2}
<Tab class={({ selected }) => twJoin("tab", selected && "primary")}>
<div bind:this={tabElements[2]} class="flex">
<slot name="title2" />
</div>
</Tab>
{/if}
{#if $$slots.title3}
<Tab class={({ selected }) => twJoin("tab", selected && "primary")}>
<div bind:this={tabElements[3]} class="flex">
<slot name="title3" />
</div>
</Tab>
{/if}
{#if $$slots.title4}
<Tab class={({ selected }) => twJoin("tab", selected && "primary")}>
<div bind:this={tabElements[4]} class="flex">
<slot name="title4" />
</div>
</Tab>
{/if}
</TabList>
<slot name="post-tablist" />
</div>
<div class="normal-background h-full overflow-y-auto">
<TabPanels class="tabpanels" defaultIndex={$tab}>
<TabPanel class="tabpanel">
<slot name="content0">
<div>Empty</div>
</slot>
</TabPanel>
<TabPanel class="tabpanel">
<slot name="content1" />
</TabPanel>
<TabPanel class="tabpanel">
<slot name="content2" />
</TabPanel>
<TabPanel class="tabpanel">
<slot name="content3" />
</TabPanel>
<TabPanel class="tabpanel">
<slot name="content4" />
</TabPanel>
</TabPanels>
</div>
</TabGroup>
</div>
<style>
.tabbedgroup {
max-height: 100vh;
height: 100%;
}
:global(.tabpanel) {
height: 100%;
}
:global(.tabpanels) {
height: calc(100% - 2rem);
}
:global(.tab) {
margin: 0.25rem;
padding: 0.25rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
border-radius: 1rem;
}
:global(.tab .flex) {
align-items: center;
gap: 0.25rem;
}
:global(.tab span|div) {
align-items: center;
gap: 0.25rem;
display: flex;
}
:global(.tab-selected svg) {
fill: var(--catch-detail-color-contrast);
}
:global(.tab-unselected) {
background-color: var(--background-color) !important;
color: var(--foreground-color) !important;
}
</style>

135
src/UI/Base/Table.ts Normal file
View file

@ -0,0 +1,135 @@
import BaseUIElement from "../BaseUIElement"
import { Utils } from "../../Utils"
import Translations from "../i18n/Translations"
import { UIEventSource } from "../../Logic/UIEventSource"
export default class Table extends BaseUIElement {
private readonly _header: BaseUIElement[]
private readonly _contents: BaseUIElement[][]
private readonly _contentStyle: string[][]
private readonly _sortable: boolean
constructor(
header: (BaseUIElement | string)[],
contents: (BaseUIElement | string)[][],
options?: {
contentStyle?: string[][]
sortable?: false | boolean
}
) {
super()
this._contentStyle = options?.contentStyle ?? [["min-width: 9rem"]]
this._header = header?.map(Translations.W)
this._contents = contents.map((row) => row.map(Translations.W))
this._sortable = options?.sortable ?? false
}
AsMarkdown(): string {
const headerMarkdownParts = this._header.map((hel) => hel?.AsMarkdown() ?? " ")
const header = Utils.NoNull(headerMarkdownParts).join(" | ")
const headerSep = headerMarkdownParts.map((part) => "-".repeat(part.length + 2)).join(" | ")
const table = this._contents
.map((row) => row.map((el) => el?.AsMarkdown()?.replace("|", "\\|") ?? " ").join(" | "))
.join("\n")
return "\n\n" + [header, headerSep, table, ""].join("\n")
}
protected InnerConstructElement(): HTMLElement {
const table = document.createElement("table")
/**
* Sortmode: i: sort column i ascending;
* if i is negative : sort column (-i - 1) descending
*/
const sortmode = new UIEventSource<number>(undefined)
const self = this
const headerElems = Utils.NoNull(
(this._header ?? []).map((elem, i) => {
if (self._sortable) {
elem.onClick(() => {
const current = sortmode.data
if (current == i) {
sortmode.setData(-1 - i)
} else {
sortmode.setData(i)
}
})
}
return elem.ConstructElement()
})
)
if (headerElems.length > 0) {
const thead = document.createElement("thead")
const tr = document.createElement("tr")
headerElems.forEach((headerElem) => {
const td = document.createElement("th")
td.appendChild(headerElem)
tr.appendChild(td)
})
thead.appendChild(tr)
table.appendChild(thead)
}
for (let i = 0; i < this._contents.length; i++) {
let row = this._contents[i]
const tr = document.createElement("tr")
for (let j = 0; j < row.length; j++) {
try {
let elem = row[j]
if (elem?.ConstructElement === undefined) {
continue
}
const htmlElem = elem?.ConstructElement()
if (htmlElem === undefined) {
continue
}
let style = undefined
if (
this._contentStyle !== undefined &&
this._contentStyle[i] !== undefined &&
this._contentStyle[j] !== undefined
) {
style = this._contentStyle[i][j]
}
const td = document.createElement("td")
td.style.cssText = style
td.appendChild(htmlElem)
tr.appendChild(td)
} catch (e) {
console.error("Could not render an element in a table due to", e, row[j])
}
}
table.appendChild(tr)
}
sortmode.addCallback((sortCol) => {
if (sortCol === undefined) {
return
}
const descending = sortCol < 0
const col = descending ? -sortCol - 1 : sortCol
let rows: HTMLTableRowElement[] = Array.from(table.rows)
rows.splice(0, 1) // remove header row
rows = rows.sort((a, b) => {
const ac = a.cells[col]?.textContent?.toLowerCase()
const bc = b.cells[col]?.textContent?.toLowerCase()
if (ac === bc) {
return 0
}
return ac < bc !== descending ? -1 : 1
})
for (let j = rows.length; j > 1; j--) {
table.deleteRow(j)
}
for (const row of rows) {
table.appendChild(row)
}
})
return table
}
}

View file

@ -0,0 +1,120 @@
import Combine from "./Combine"
import BaseUIElement from "../BaseUIElement"
import { Translation } from "../i18n/Translation"
import { FixedUiElement } from "./FixedUiElement"
import Title from "./Title"
import List from "./List"
import Link from "./Link"
import { Utils } from "../../Utils"
export default class TableOfContents extends Combine {
private readonly titles: Title[]
constructor(
elements: Combine | Title[],
options?: {
noTopLevel: false | boolean
maxDepth?: number
}
) {
let titles: Title[]
if (elements instanceof Combine) {
titles = TableOfContents.getTitles(elements.getElements()) ?? []
} else {
titles = elements ?? []
}
let els: { level: number; content: BaseUIElement }[] = []
for (const title of titles) {
let content: BaseUIElement
if (title.title instanceof Translation) {
content = title.title.Clone()
} else if (title.title instanceof FixedUiElement) {
content = new FixedUiElement(title.title.content)
} else if (Utils.runningFromConsole) {
content = new FixedUiElement(title.AsMarkdown())
} else if (title["title"] !== undefined) {
content = new FixedUiElement(title.title.ConstructElement().textContent)
} else {
console.log("Not generating a title for ", title)
continue
}
const vis = new Link(content, "#" + title.id)
els.push({ level: title.level, content: vis })
}
const minLevel = Math.min(...els.map((e) => e.level))
if (options?.noTopLevel) {
els = els.filter((e) => e.level !== minLevel)
}
if (options?.maxDepth) {
els = els.filter((e) => e.level <= options.maxDepth + minLevel)
}
super(TableOfContents.mergeLevel(els).map((el) => el.SetClass("mt-2")))
this.SetClass("flex flex-col")
this.titles = titles
}
private static getTitles(elements: BaseUIElement[]): Title[] {
const titles = []
for (const uiElement of elements) {
if (uiElement instanceof Combine) {
titles.push(...TableOfContents.getTitles(uiElement.getElements()))
} else if (uiElement instanceof Title) {
titles.push(uiElement)
}
}
return titles
}
private static mergeLevel(
elements: { level: number; content: BaseUIElement }[]
): BaseUIElement[] {
const maxLevel = Math.max(...elements.map((e) => e.level))
const minLevel = Math.min(...elements.map((e) => e.level))
if (maxLevel === minLevel) {
return elements.map((e) => e.content)
}
const result: { level: number; content: BaseUIElement }[] = []
let running: BaseUIElement[] = []
for (const element of elements) {
if (element.level === maxLevel) {
running.push(element.content)
continue
}
if (running.length !== undefined) {
result.push({
content: new List(running),
level: maxLevel - 1,
})
running = []
}
result.push(element)
}
if (running.length !== undefined) {
result.push({
content: new List(running),
level: maxLevel - 1,
})
}
return TableOfContents.mergeLevel(result)
}
AsMarkdown(): string {
const depthIcons = ["1.", " -", " +", " *"]
const lines = ["## Table of contents\n"]
const minLevel = Math.min(...this.titles.map((t) => t.level))
for (const title of this.titles) {
const prefix = depthIcons[title.level - minLevel] ?? " ~"
const text = title.title.AsMarkdown().replace("\n", "")
const link = title.id
lines.push(prefix + " [" + text + "](#" + link + ")")
}
return lines.join("\n") + "\n\n"
}
}

72
src/UI/Base/Title.ts Normal file
View file

@ -0,0 +1,72 @@
import BaseUIElement from "../BaseUIElement"
import { FixedUiElement } from "./FixedUiElement"
import { Utils } from "../../Utils"
export default class Title extends BaseUIElement {
private static readonly defaultClassesPerLevel = [
"",
"text-3xl font-bold",
"text-2xl font-bold",
"text-xl font-bold",
"text-lg font-bold",
]
public readonly title: BaseUIElement
public readonly level: number
public readonly id: string
constructor(embedded: string | BaseUIElement, level: number = 3) {
super()
if (embedded === undefined) {
throw "A title should have some content. Undefined is not allowed"
}
if (typeof embedded === "string") {
this.title = new FixedUiElement(embedded)
} else {
this.title = embedded
}
this.level = level
let text: string = undefined
if (typeof embedded === "string") {
text = embedded
} else if (embedded instanceof FixedUiElement) {
text = embedded.content
} else {
if (!Utils.runningFromConsole) {
text = embedded.ConstructElement()?.textContent
}
}
this.id =
text
?.replace(/ /g, "-")
?.replace(/[?#.;:/]/, "")
?.toLowerCase() ?? ""
this.SetClass(Title.defaultClassesPerLevel[level] ?? "")
}
AsMarkdown(): string {
const embedded = " " + this.title.AsMarkdown() + " "
if (this.level == 1) {
return "\n\n" + embedded + "\n" + "=".repeat(embedded.length) + "\n\n"
}
if (this.level == 2) {
return "\n\n" + embedded + "\n" + "-".repeat(embedded.length) + "\n\n"
}
return "\n\n" + "#".repeat(this.level) + embedded + "\n\n"
}
protected InnerConstructElement(): HTMLElement {
const el = this.title.ConstructElement()
if (el === undefined) {
return undefined
}
const h = document.createElement("h" + this.level)
h.appendChild(el)
el.id = this.id
return h
}
}

View file

@ -0,0 +1,21 @@
<script lang="ts">
import BaseUIElement from "../BaseUIElement.js"
import { onDestroy, onMount } from "svelte"
export let construct: BaseUIElement | (() => BaseUIElement)
let elem: HTMLElement
let html: HTMLElement
onMount(() => {
const uiElem = typeof construct === "function" ? construct() : construct
html = uiElem?.ConstructElement()
if (html !== undefined) {
elem.replaceWith(html)
}
})
onDestroy(() => {
html?.remove()
})
</script>
<span bind:this={elem} />

38
src/UI/Base/Tr.svelte Normal file
View file

@ -0,0 +1,38 @@
<script lang="ts">
/**
* Properly renders a translation
*/
import { Translation } from "../i18n/Translation"
import { onDestroy } from "svelte"
import Locale from "../i18n/Locale"
import { Utils } from "../../Utils"
import FromHtml from "./FromHtml.svelte"
import WeblateLink from "./WeblateLink.svelte"
export let t: Translation
export let cls: string = ""
export let tags: Record<string, string> | undefined = undefined
// Text for the current language
let txt: string | undefined
$: onDestroy(
Locale.language.addCallbackAndRunD((l) => {
const translation = t?.textFor(l)
if (translation === undefined) {
return
}
if (tags) {
txt = Utils.SubstituteKeys(txt, tags)
} else {
txt = translation
}
})
)
</script>
{#if t}
<span class={cls}>
<FromHtml src={txt} />
<WeblateLink context={t.context} />
</span>
{/if}

View file

@ -0,0 +1,62 @@
import { Store } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
import Combine from "./Combine"
export class VariableUiElement extends BaseUIElement {
private readonly _contents?: Store<string | BaseUIElement | BaseUIElement[]>
constructor(contents?: Store<string | BaseUIElement | BaseUIElement[]>) {
super()
this._contents = contents
}
Destroy() {
super.Destroy()
this.isDestroyed = true
}
AsMarkdown(): string {
const d = this._contents?.data
if (typeof d === "string") {
return d
}
if (d instanceof BaseUIElement) {
return d.AsMarkdown()
}
return new Combine(<BaseUIElement[]>d).AsMarkdown()
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("span")
const self = this
this._contents?.addCallbackAndRun((contents) => {
if (self.isDestroyed) {
return true
}
while (el.firstChild) {
el.removeChild(el.lastChild)
}
if (contents === undefined) {
return
}
if (typeof contents === "string") {
el.innerHTML = contents
} else if (contents instanceof Array) {
for (const content of contents) {
const c = content?.ConstructElement()
if (c !== undefined && c !== null) {
el.appendChild(c)
}
}
} else {
const c = contents.ConstructElement()
if (c !== undefined && c !== null) {
el.appendChild(c)
}
}
})
return el
}
}

View file

@ -0,0 +1,33 @@
<script lang="ts">
import Locale from "../i18n/Locale"
import LinkToWeblate from "./LinkToWeblate"
/**
* Shows a small icon which will open up weblate; a contributor can translate the item for 'context' there
*/
export let context: string
let linkToWeblate = Locale.showLinkToWeblate
let linkOnMobile = Locale.showLinkOnMobile
let language = Locale.language
</script>
{#if !!context && context.indexOf(":") > 0}
{#if $linkOnMobile}
<a
href={LinkToWeblate.hrefToWeblate($language, context)}
target="_blank"
class="weblate-link mx-1"
>
<img src="./assets/svg/translate.svg" class="font-gray" />
</a>
{:else if $linkToWeblate}
<a
href={LinkToWeblate.hrefToWeblate($language, context)}
class="weblate-link hidden-on-mobile mx-1"
target="_blank"
>
<img src="./assets/svg/translate.svg" class="font-gray inline-block" />
</a>
{/if}
{/if}