forked from MapComplete/MapComplete
Refactoring: move all code files into a src directory
This commit is contained in:
parent
de99f56ca8
commit
e75d2789d2
389 changed files with 0 additions and 12 deletions
26
src/UI/Base/AsyncLazy.ts
Normal file
26
src/UI/Base/AsyncLazy.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
21
src/UI/Base/BackButton.svelte
Normal file
21
src/UI/Base/BackButton.svelte
Normal 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
25
src/UI/Base/Button.ts
Normal 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
|
||||
}
|
||||
}
|
||||
32
src/UI/Base/CenterFlexedElement.ts
Normal file
32
src/UI/Base/CenterFlexedElement.ts
Normal 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
38
src/UI/Base/ChartJs.ts
Normal 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
|
||||
}
|
||||
}
|
||||
12
src/UI/Base/Checkbox.svelte
Normal file
12
src/UI/Base/Checkbox.svelte
Normal 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
71
src/UI/Base/Combine.ts
Normal 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
|
||||
}
|
||||
}
|
||||
19
src/UI/Base/DivContainer.ts
Normal file
19
src/UI/Base/DivContainer.ts
Normal 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
|
||||
}
|
||||
}
|
||||
89
src/UI/Base/DragInvitation.svelte
Normal file
89
src/UI/Base/DragInvitation.svelte
Normal 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>
|
||||
14
src/UI/Base/Dropdown.svelte
Normal file
14
src/UI/Base/Dropdown.svelte
Normal 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>
|
||||
50
src/UI/Base/FilteredCombine.ts
Normal file
50
src/UI/Base/FilteredCombine.ts
Normal 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]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
33
src/UI/Base/FixedUiElement.ts
Normal file
33
src/UI/Base/FixedUiElement.ts
Normal 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
|
||||
}
|
||||
}
|
||||
38
src/UI/Base/FloatOver.svelte
Normal file
38
src/UI/Base/FloatOver.svelte
Normal 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>
|
||||
19
src/UI/Base/FromHtml.svelte
Normal file
19
src/UI/Base/FromHtml.svelte
Normal 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
135
src/UI/Base/Hotkeys.ts
Normal 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
24
src/UI/Base/If.svelte
Normal 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}
|
||||
34
src/UI/Base/IfHidden.svelte
Normal file
34
src/UI/Base/IfHidden.svelte
Normal 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
20
src/UI/Base/IfNot.svelte
Normal 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
78
src/UI/Base/Img.ts
Normal 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 ""
|
||||
}
|
||||
|
||||
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
15
src/UI/Base/Lazy.ts
Normal 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
64
src/UI/Base/Link.ts
Normal 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
|
||||
}
|
||||
}
|
||||
78
src/UI/Base/LinkToWeblate.ts
Normal file
78
src/UI/Base/LinkToWeblate.ts
Normal 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
52
src/UI/Base/List.ts
Normal 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
|
||||
}
|
||||
}
|
||||
13
src/UI/Base/Loading.svelte
Normal file
13
src/UI/Base/Loading.svelte
Normal 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
18
src/UI/Base/Loading.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
17
src/UI/Base/LoginButton.svelte
Normal file
17
src/UI/Base/LoginButton.svelte
Normal 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>
|
||||
45
src/UI/Base/LoginToggle.svelte
Normal file
45
src/UI/Base/LoginToggle.svelte
Normal 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}
|
||||
17
src/UI/Base/MapControlButton.svelte
Normal file
17
src/UI/Base/MapControlButton.svelte
Normal 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>
|
||||
26
src/UI/Base/ModalRight.svelte
Normal file
26
src/UI/Base/ModalRight.svelte
Normal 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>
|
||||
25
src/UI/Base/NextButton.svelte
Normal file
25
src/UI/Base/NextButton.svelte
Normal 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
30
src/UI/Base/Paragraph.ts
Normal 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
|
||||
}
|
||||
}
|
||||
30
src/UI/Base/ShareButton.svelte
Normal file
30
src/UI/Base/ShareButton.svelte
Normal 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>
|
||||
31
src/UI/Base/SubtleButton.svelte
Normal file
31
src/UI/Base/SubtleButton.svelte
Normal 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>
|
||||
97
src/UI/Base/SubtleButton.ts
Normal file
97
src/UI/Base/SubtleButton.ts
Normal 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
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
49
src/UI/Base/SubtleLink.svelte
Normal file
49
src/UI/Base/SubtleLink.svelte
Normal 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>
|
||||
44
src/UI/Base/SvelteUIElement.ts
Normal file
44
src/UI/Base/SvelteUIElement.ts
Normal 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
|
||||
}
|
||||
}
|
||||
135
src/UI/Base/TabbedGroup.svelte
Normal file
135
src/UI/Base/TabbedGroup.svelte
Normal 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
135
src/UI/Base/Table.ts
Normal 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
|
||||
}
|
||||
}
|
||||
120
src/UI/Base/TableOfContents.ts
Normal file
120
src/UI/Base/TableOfContents.ts
Normal 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
72
src/UI/Base/Title.ts
Normal 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
|
||||
}
|
||||
}
|
||||
21
src/UI/Base/ToSvelte.svelte
Normal file
21
src/UI/Base/ToSvelte.svelte
Normal 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
38
src/UI/Base/Tr.svelte
Normal 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}
|
||||
62
src/UI/Base/VariableUIElement.ts
Normal file
62
src/UI/Base/VariableUIElement.ts
Normal 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
|
||||
}
|
||||
}
|
||||
33
src/UI/Base/WeblateLink.svelte
Normal file
33
src/UI/Base/WeblateLink.svelte
Normal 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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue