frontend: Add linting for ts code
This commit is contained in:
parent
ddb4bb3546
commit
16fa721048
18 changed files with 2299 additions and 469 deletions
5
Makefile
5
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: clean all deps live
|
||||
.PHONY: clean all deps live tslint
|
||||
|
||||
live:
|
||||
sudo CGO_ENABLED=0 go run ./cmd/wdcli $(ARGS)
|
||||
|
|
@ -9,6 +9,9 @@ wdcli:
|
|||
go generate ./internal/dis/component/control/static/
|
||||
CGO_ENABLED=0 go build -o ./wdcli ./cmd/wdcli
|
||||
|
||||
tslint:
|
||||
cd internal/dis/component/server/assets/ && yarn ts-standard
|
||||
|
||||
deps: internal/dis/component/server/assets/node_modules
|
||||
|
||||
internal/dis/component/server/assets/node_modules:
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ func (am ActionMap) Handle(conn httpx.WebSocketConnection) (name string, err err
|
|||
} else {
|
||||
result.Success = false
|
||||
result.Message = err.Error()
|
||||
if result.Message == "" {
|
||||
result.Message = "unspecified error"
|
||||
}
|
||||
}
|
||||
|
||||
// encode the result message to json!
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Parcel } from "@parcel/core"
|
||||
import { mkdir, rm, writeFile, readFile, unlink, rmdir, } from "fs/promises"
|
||||
import { join } from "path"
|
||||
import { parse as parseHTML } from 'node-html-parser';
|
||||
import { Parcel } from '@parcel/core'
|
||||
import { mkdir, rm, writeFile, readFile, unlink } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { parse as parseHTML } from 'node-html-parser'
|
||||
import { spawnSync } from 'child_process'
|
||||
|
||||
//
|
||||
|
|
@ -15,14 +15,14 @@ const PUBLIC_DIR = '/⛰/' // mountain's don't move, and neither do static files
|
|||
|
||||
const DEST_PACKAGE = process.env.GOPACKAGE ?? 'static'
|
||||
const DEST_DISCLAIMER = (() => {
|
||||
const source = (process.env.GOFILE ?? 'assets.go')
|
||||
const base = source.substring(0, source.length - '.go'.length)
|
||||
return base + '_disclaimer.txt'
|
||||
const source = (process.env.GOFILE ?? 'assets.go')
|
||||
const base = source.substring(0, source.length - '.go'.length)
|
||||
return base + '_disclaimer.txt'
|
||||
})()
|
||||
const DEST_FILE = (() => {
|
||||
const source = (process.env.GOFILE ?? 'assets.go')
|
||||
const base = source.substring(0, source.length - '.go'.length)
|
||||
return base + '_dist.go'
|
||||
const source = (process.env.GOFILE ?? 'assets.go')
|
||||
const base = source.substring(0, source.length - '.go'.length)
|
||||
return base + '_dist.go'
|
||||
})()
|
||||
|
||||
//
|
||||
|
|
@ -31,57 +31,54 @@ const DEST_FILE = (() => {
|
|||
|
||||
process.stdout.write('Preparing directories ...')
|
||||
await Promise.all([
|
||||
mkdir(ENTRY_DIR, { recursive: true }),
|
||||
rm(DIST_DIR, { recursive: true, force: true })
|
||||
mkdir(ENTRY_DIR, { recursive: true }),
|
||||
rm(DIST_DIR, { recursive: true, force: true })
|
||||
])
|
||||
console.log(' Done.')
|
||||
|
||||
|
||||
//
|
||||
// Write the disclaimer
|
||||
//
|
||||
|
||||
process.stdout.write('Generating legal disclaimer ...')
|
||||
|
||||
const disclaimer = await new Promise((r, e) => {
|
||||
var child = spawnSync("yarn", ["licenses", "generate-disclaimer"], { encoding : 'utf8' });
|
||||
if (child.error) {
|
||||
e(child.stderr)
|
||||
return
|
||||
}
|
||||
const disclaimer = await new Promise((resolve, reject) => {
|
||||
const child = spawnSync('yarn', ['licenses', 'generate-disclaimer'], { encoding: 'utf8' })
|
||||
if (child.error) {
|
||||
reject(child.stderr)
|
||||
return
|
||||
}
|
||||
|
||||
r(child.stdout)
|
||||
});
|
||||
resolve(child.stdout)
|
||||
})
|
||||
|
||||
console.log(' Done.')
|
||||
|
||||
|
||||
process.stdout.write(`Writing ${DEST_DISCLAIMER} ...`)
|
||||
await writeFile(DEST_DISCLAIMER, disclaimer)
|
||||
console.log(' Done.')
|
||||
|
||||
|
||||
//
|
||||
// WRITE ENTRY POINTS
|
||||
//
|
||||
|
||||
process.stdout.write('Collecting entry points ')
|
||||
const entries = await Promise.all(ENTRYPOINTS.map(async (name) => {
|
||||
const entry = {
|
||||
'name': name,
|
||||
'bundleName': name + '.html',
|
||||
'src': join(ENTRY_DIR, name + '.html'),
|
||||
}
|
||||
const entry = {
|
||||
name,
|
||||
bundleName: name + '.html',
|
||||
src: join(ENTRY_DIR, name + '.html')
|
||||
}
|
||||
|
||||
const content = `
|
||||
const content = `
|
||||
<script type='module' src='../src/base/index.ts'></script>
|
||||
<script type='module' src='../src/entry/${name}/index.ts'></script>
|
||||
<link rel='stylesheet' href='../src/entry/${name}/index.css'>
|
||||
`;
|
||||
await writeFile(entry.src, content)
|
||||
`
|
||||
await writeFile(entry.src, content)
|
||||
|
||||
process.stdout.write('.')
|
||||
return entry;
|
||||
process.stdout.write('.')
|
||||
return entry
|
||||
}))
|
||||
console.log(' Done.')
|
||||
|
||||
|
|
@ -91,21 +88,21 @@ console.log(' Done.')
|
|||
|
||||
process.stdout.write('Bundleing assets ...')
|
||||
const bundler = new Parcel({
|
||||
entries: entries.map(e => e.src),
|
||||
defaultConfig: '@parcel/config-default',
|
||||
shouldDisableCache: true,
|
||||
shouldContentHash: true,
|
||||
defaultTargetOptions: {
|
||||
shouldOptimize: true,
|
||||
shouldScopeHoist: true,
|
||||
sourceMaps: false,
|
||||
distDir: DIST_DIR,
|
||||
publicUrl: PUBLIC_DIR,
|
||||
engines: {
|
||||
browsers: "defaults",
|
||||
}
|
||||
entries: entries.map(e => e.src),
|
||||
defaultConfig: '@parcel/config-default',
|
||||
shouldDisableCache: true,
|
||||
shouldContentHash: true,
|
||||
defaultTargetOptions: {
|
||||
shouldOptimize: true,
|
||||
shouldScopeHoist: true,
|
||||
sourceMaps: false,
|
||||
distDir: DIST_DIR,
|
||||
publicUrl: PUBLIC_DIR,
|
||||
engines: {
|
||||
browsers: 'defaults'
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
const { bundleGraph } = await bundler.run()
|
||||
console.log(' Done.')
|
||||
|
||||
|
|
@ -116,19 +113,19 @@ console.log(' Done.')
|
|||
process.stdout.write('Find Assets in Output ')
|
||||
const bundles = bundleGraph.getBundles()
|
||||
const assets = await Promise.all(entries.map(async (entry) => {
|
||||
const mainBundle = bundles.find(b => b.name === entry.bundleName)
|
||||
if (mainBundle === undefined) throw new Error('Unable to find bundle for ' + entry.name)
|
||||
const mainBundle = bundles.find(b => b.name === entry.bundleName)
|
||||
if (mainBundle === undefined) throw new Error('Unable to find bundle for ' + entry.name)
|
||||
|
||||
// read, then delete the generated output file
|
||||
const { filePath } = mainBundle
|
||||
const html = parseHTML(await readFile(filePath))
|
||||
await unlink(filePath)
|
||||
// read, then delete the generated output file
|
||||
const { filePath } = mainBundle
|
||||
const html = parseHTML(await readFile(filePath))
|
||||
await unlink(filePath)
|
||||
|
||||
const scripts = html.querySelectorAll('script').map(script => script.outerHTML).join('')
|
||||
const links = html.querySelectorAll('link').map(link => link.outerHTML).join('')
|
||||
const scripts = html.querySelectorAll('script').map(script => script.outerHTML).join('')
|
||||
const links = html.querySelectorAll('link').map(link => link.outerHTML).join('')
|
||||
|
||||
process.stdout.write('.')
|
||||
return { ...entry, scripts, links }
|
||||
process.stdout.write('.')
|
||||
return { ...entry, scripts, links }
|
||||
}))
|
||||
console.log(' Done.')
|
||||
|
||||
|
|
@ -138,7 +135,7 @@ console.log(' Done.')
|
|||
|
||||
process.stdout.write(`Writing ${DEST_FILE} ...`)
|
||||
const goAssets = assets.map(({ name, scripts, links }) => {
|
||||
return `
|
||||
return `
|
||||
// Assets${name} contains assets for the '${name}' entrypoint.
|
||||
var Assets${name} = Assets{
|
||||
\tScripts: \`${scripts}\`,
|
||||
|
|
@ -158,7 +155,7 @@ var Disclaimer string
|
|||
const Public = ${JSON.stringify(PUBLIC_DIR)}
|
||||
|
||||
${goAssets}
|
||||
`;
|
||||
`
|
||||
|
||||
await writeFile(DEST_FILE, goSource)
|
||||
console.log(' Done.')
|
||||
|
|
@ -11,5 +11,17 @@
|
|||
"node-html-parser": "^6.1.1",
|
||||
"parcel": "^2.7.0",
|
||||
"purecss": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ts-standard": "^12.0.2",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"ts-standard": {
|
||||
"ignore": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
".entry-cache",
|
||||
".parcel-cache"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import "purecss/build/pure.css"
|
||||
import "purecss/build/grids-responsive.css"
|
||||
import 'purecss/build/pure.css'
|
||||
import 'purecss/build/grids-responsive.css'
|
||||
|
||||
import "./index.css"
|
||||
import './index.css'
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
// setup highlighting
|
||||
import "~/src/lib/highlight"
|
||||
import '~/src/lib/highlight'
|
||||
|
||||
// setup remote actions
|
||||
import setup from "~/src/lib/remote"
|
||||
setup();
|
||||
import setup from '~/src/lib/remote'
|
||||
|
||||
// include the user styles!
|
||||
import "../User/index.ts"
|
||||
import "../User/index.css"
|
||||
import '../User/index.ts'
|
||||
import '../User/index.css'
|
||||
|
||||
// highlight everything
|
||||
import "highlight.js/styles/default.css"
|
||||
import highlightJs from "highlight.js"
|
||||
highlightJs.highlightAll();
|
||||
import 'highlight.js/styles/default.css'
|
||||
import highlightJs from 'highlight.js'
|
||||
setup()
|
||||
highlightJs.highlightAll()
|
||||
|
|
|
|||
|
|
@ -1,24 +1,23 @@
|
|||
import "../Admin/index.ts"
|
||||
import "../Admin/index.css"
|
||||
import '../Admin/index.ts'
|
||||
import '../Admin/index.css'
|
||||
|
||||
import { Provision } from "~/src/lib/remote/api"
|
||||
import { Provision } from '~/src/lib/remote/api'
|
||||
|
||||
const provision = document.getElementById("provision") as HTMLFormElement;
|
||||
const slug = document.getElementById("slug") as HTMLInputElement;
|
||||
const php = document.getElementById("php") as HTMLSelectElement;
|
||||
const opcacheDevelopment = document.getElementById("opcacheDevelopment") as HTMLInputElement;
|
||||
const provision = document.getElementById('provision') as HTMLFormElement
|
||||
const slug = document.getElementById('slug') as HTMLInputElement
|
||||
const php = document.getElementById('php') as HTMLSelectElement
|
||||
const opcacheDevelopment = document.getElementById('opcacheDevelopment') as HTMLInputElement
|
||||
|
||||
// add an event handler to open the modal form!
|
||||
provision.addEventListener('submit', (evt) => {
|
||||
evt.preventDefault();
|
||||
evt.preventDefault()
|
||||
|
||||
Provision({ Slug: slug.value, System: { PHP: php.value, OpCacheDevelopment: opcacheDevelopment.checked } })
|
||||
.then(slug => {
|
||||
location.href = "/admin/instance/" + slug;
|
||||
})
|
||||
.catch((e) => {console.error(e); location.reload()});
|
||||
Provision({ Slug: slug.value, System: { PHP: php.value, OpCacheDevelopment: opcacheDevelopment.checked } })
|
||||
.then(slug => {
|
||||
location.href = '/admin/instance/' + slug
|
||||
})
|
||||
.catch((e) => { console.error(e); location.reload() })
|
||||
})
|
||||
|
||||
// enable the form!
|
||||
provision.querySelector('fieldset')?.removeAttribute('disabled');
|
||||
|
||||
provision.querySelector('fieldset')?.removeAttribute('disabled')
|
||||
|
|
|
|||
|
|
@ -1,24 +1,23 @@
|
|||
import "../Admin/index.ts"
|
||||
import "../Admin/index.css"
|
||||
import '../Admin/index.ts'
|
||||
import '../Admin/index.css'
|
||||
|
||||
import { Rebuild } from "~/src/lib/remote/api"
|
||||
import { Rebuild } from '~/src/lib/remote/api'
|
||||
|
||||
const slug = document.getElementById("slug") as HTMLInputElement
|
||||
const provision = document.getElementById("provision") as HTMLFormElement;
|
||||
const php = document.getElementById("php") as HTMLSelectElement;
|
||||
const opcacheDevelopment = document.getElementById("opcacheDevelopment") as HTMLInputElement;
|
||||
const slug = document.getElementById('slug') as HTMLInputElement
|
||||
const provision = document.getElementById('provision') as HTMLFormElement
|
||||
const php = document.getElementById('php') as HTMLSelectElement
|
||||
const opcacheDevelopment = document.getElementById('opcacheDevelopment') as HTMLInputElement
|
||||
|
||||
// add an event handler to open the modal form!
|
||||
provision.addEventListener('submit', (evt) => {
|
||||
evt.preventDefault();
|
||||
evt.preventDefault()
|
||||
|
||||
Rebuild(slug.value, { PHP: php.value, OpCacheDevelopment: opcacheDevelopment.checked })
|
||||
.then(slug => {
|
||||
location.href = "/admin/instance/" + slug;
|
||||
})
|
||||
.catch((e) => {console.error(e); location.reload()});
|
||||
Rebuild(slug.value, { PHP: php.value, OpCacheDevelopment: opcacheDevelopment.checked })
|
||||
.then(slug => {
|
||||
location.href = '/admin/instance/' + slug
|
||||
})
|
||||
.catch((e) => { console.error(e); location.reload() })
|
||||
})
|
||||
|
||||
// enable the form!
|
||||
provision.querySelector('fieldset')?.removeAttribute('disabled');
|
||||
|
||||
provision.querySelector('fieldset')?.removeAttribute('disabled')
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
import "~/src/lib/copy"
|
||||
import "~/src/lib/reveal"
|
||||
import '~/src/lib/copy'
|
||||
import '~/src/lib/reveal'
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import "./index.css"
|
||||
import './index.css'
|
||||
|
||||
/** Adapted from http://blog.parkermoore.de/2014/08/01/header-anchor-links-in-vanilla-javascript-for-github-pages-and-jekyll/ */
|
||||
const anchorForId = (id: string) => {
|
||||
const anchor = document.createElement("a")
|
||||
anchor.className = "header-link"
|
||||
anchor.href = "#" + id
|
||||
anchor.innerHTML = "#"
|
||||
return anchor
|
||||
const anchorForId = (id: string): HTMLAnchorElement => {
|
||||
const anchor = document.createElement('a')
|
||||
anchor.className = 'header-link'
|
||||
anchor.href = '#' + id
|
||||
anchor.innerHTML = '#'
|
||||
return anchor
|
||||
}
|
||||
|
||||
const linkifyAnchors = (level: number) => {
|
||||
const headers = document.getElementsByTagName("h" + level);
|
||||
Array.from(headers).forEach((header) => {
|
||||
if (typeof header.id === "undefined" || header.id === "") return
|
||||
header.appendChild(anchorForId(header.id))
|
||||
})
|
||||
const linkifyAnchors = (level: number): void => {
|
||||
const headers = document.getElementsByTagName('h' + level.toString())
|
||||
Array.from(headers).forEach((header) => {
|
||||
if (typeof header.id === 'undefined' || header.id === '') return
|
||||
header.appendChild(anchorForId(header.id))
|
||||
})
|
||||
}
|
||||
|
||||
// linkify all the anchors from 1 ... 6
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import "./index.css"
|
||||
import './index.css'
|
||||
|
||||
import { discard } from '~/src/lib/discard'
|
||||
|
||||
document.querySelectorAll('.copy').forEach((elem: Element) => {
|
||||
elem.addEventListener('click', () => {
|
||||
if (!navigator.clipboard) return;
|
||||
navigator.clipboard.writeText((elem as HTMLElement).innerText);
|
||||
})
|
||||
elem.addEventListener('click', () => {
|
||||
// Check if the clipboard api is supported
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
if (!navigator.clipboard) return
|
||||
|
||||
discard(navigator.clipboard.writeText((elem as HTMLElement).innerText))
|
||||
})
|
||||
})
|
||||
13
internal/dis/component/server/assets/src/lib/discard.ts
Normal file
13
internal/dis/component/server/assets/src/lib/discard.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Mutex, MutexInterface } from 'async-mutex'
|
||||
|
||||
const error = console.error.bind(console)
|
||||
|
||||
/** discard discards the result of a promise, or logs an error if it occurs */
|
||||
export function discard<T> (p: Promise<T>): void {
|
||||
p.then((): void => {}).catch(error)
|
||||
}
|
||||
|
||||
/** runs worker exclusively on m, and discards the resulting promise */
|
||||
export function runMutexExclusive<T> (m: Mutex, worker: MutexInterface.Worker<T>): void {
|
||||
discard(m.runExclusive(worker))
|
||||
}
|
||||
|
|
@ -1,61 +1,61 @@
|
|||
import dayjs from "dayjs"
|
||||
import dayjs from 'dayjs'
|
||||
const types: Record<string, (element: HTMLElement) => HTMLElement | string> = {
|
||||
"date": (element) => {
|
||||
const value = dayjs(element.innerText);
|
||||
const text = value.format('YYYY-MM-DD HH:mm:ss ([UTC]Z)')
|
||||
date: (element) => {
|
||||
const value = dayjs(element.innerText)
|
||||
const text = value.format('YYYY-MM-DD HH:mm:ss ([UTC]Z)')
|
||||
|
||||
// if the date is the zero date, then it is assumed to be invalid
|
||||
if (value.unix() === 0) {
|
||||
const code = document.createElement('code')
|
||||
code.style.color = 'gray'
|
||||
code.append(text)
|
||||
return code
|
||||
}
|
||||
return text
|
||||
},
|
||||
"path": (element) => {
|
||||
const text = element.innerText.split("/");
|
||||
return text[text.length - 1];
|
||||
},
|
||||
"pathbuilder": (element) => {
|
||||
// create a link and get the blob
|
||||
const filename = (element.getAttribute('data-name') ?? 'pathbuilder') + ".xml"
|
||||
const [link, blob] = make_download_link(filename, element.innerText, "application/xml")
|
||||
|
||||
link.className = "pure-button"
|
||||
const title = filename + ' (' + blob.size + ' Bytes)';
|
||||
link.append(title)
|
||||
return link
|
||||
// if the date is the zero date, then it is assumed to be invalid
|
||||
if (value.unix() === 0) {
|
||||
const code = document.createElement('code')
|
||||
code.style.color = 'gray'
|
||||
code.append(text)
|
||||
return code
|
||||
}
|
||||
return text
|
||||
},
|
||||
path: (element) => {
|
||||
const text = element.innerText.split('/')
|
||||
return text[text.length - 1]
|
||||
},
|
||||
pathbuilder: (element) => {
|
||||
// create a link and get the blob
|
||||
const filename = (element.getAttribute('data-name') ?? 'pathbuilder') + '.xml'
|
||||
const [link, blob] = makeDownloadLink(filename, element.innerText, 'application/xml')
|
||||
|
||||
link.className = 'pure-button'
|
||||
const title = filename + ' (' + blob.size.toString() + ' Bytes)'
|
||||
link.append(title)
|
||||
return link
|
||||
}
|
||||
}
|
||||
|
||||
const make_download_link = (filename: string, content: string, type: string): [HTMLAnchorElement, Blob] => {
|
||||
const blob = new Blob(
|
||||
[content],
|
||||
{
|
||||
type: type ?? "text/plain"
|
||||
}
|
||||
);
|
||||
const makeDownloadLink = (filename: string, content: string, type: string): [HTMLAnchorElement, Blob] => {
|
||||
const blob = new Blob(
|
||||
[content],
|
||||
{
|
||||
type: type ?? 'text/plain'
|
||||
}
|
||||
)
|
||||
|
||||
const link = document.createElement("a")
|
||||
link.target = "_blank"
|
||||
link.download = filename
|
||||
link.href = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.target = '_blank'
|
||||
link.download = filename
|
||||
link.href = URL.createObjectURL(blob)
|
||||
|
||||
return [link, blob]
|
||||
return [link, blob]
|
||||
}
|
||||
|
||||
Object.keys(types).forEach(key => {
|
||||
const f = types[key];
|
||||
const elements = document.querySelectorAll("code." + key) as NodeListOf<HTMLElement>
|
||||
elements.forEach(element => {
|
||||
const newElement = f(element)
|
||||
if (typeof newElement === 'string') {
|
||||
element.innerHTML = ""
|
||||
element.appendChild(document.createTextNode(newElement))
|
||||
return
|
||||
}
|
||||
const f = types[key]
|
||||
const elements = document.querySelectorAll<HTMLElement>('code.' + key)
|
||||
elements.forEach(element => {
|
||||
const newElement = f(element)
|
||||
if (typeof newElement === 'string') {
|
||||
element.innerHTML = ''
|
||||
element.appendChild(document.createTextNode(newElement))
|
||||
return
|
||||
}
|
||||
|
||||
element.parentNode!.replaceChild(newElement, element)
|
||||
})
|
||||
element.parentNode?.replaceChild(newElement, element)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,50 +1,50 @@
|
|||
import { createModal } from "~/src/lib/remote"
|
||||
import { createModal } from '~/src/lib/remote'
|
||||
|
||||
/**
|
||||
* Flags to provision a new system.
|
||||
* Should mirror "provision".Flags.
|
||||
*/
|
||||
interface ProvisionFlags {
|
||||
Slug: string
|
||||
System: System
|
||||
Slug: string
|
||||
System: System
|
||||
}
|
||||
|
||||
interface System {
|
||||
PHP: string;
|
||||
OpCacheDevelopment: boolean
|
||||
PHP: string
|
||||
OpCacheDevelopment: boolean
|
||||
}
|
||||
|
||||
/** Rebuild the specified instance */
|
||||
export async function Rebuild(slug: string, system: System): Promise<string> {
|
||||
return new Promise((rs, rj) => {
|
||||
createModal("rebuild", [slug, JSON.stringify(system)], {
|
||||
bufferSize: 0,
|
||||
onClose: (success: boolean, message?: string) => {
|
||||
if (!success) {
|
||||
rj(new Error(message ?? "unspecified error"))
|
||||
return;
|
||||
}
|
||||
export async function Rebuild (slug: string, system: System): Promise<string> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
createModal('rebuild', [slug, JSON.stringify(system)], {
|
||||
bufferSize: 0,
|
||||
onClose: (success: boolean, message?: string) => {
|
||||
if (!success) {
|
||||
reject(new Error(message ?? 'unspecified error'))
|
||||
return
|
||||
}
|
||||
|
||||
rs(slug);
|
||||
},
|
||||
})
|
||||
});
|
||||
resolve(slug)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Provision provisions a new instance */
|
||||
export async function Provision(flags: ProvisionFlags): Promise<string> {
|
||||
// open a modal to provision a new instance
|
||||
return new Promise((rs, rj) => {
|
||||
createModal("provision", [JSON.stringify(flags)], {
|
||||
bufferSize: 0,
|
||||
onClose: (success: boolean, message?: string) => {
|
||||
if (!success) {
|
||||
rj(new Error(message ?? "unspecified error"))
|
||||
return;
|
||||
}
|
||||
export async function Provision (flags: ProvisionFlags): Promise<string> {
|
||||
// open a modal to provision a new instance
|
||||
return await new Promise((resolve, reject) => {
|
||||
createModal('provision', [JSON.stringify(flags)], {
|
||||
bufferSize: 0,
|
||||
onClose: (success: boolean, message?: string) => {
|
||||
if (!success) {
|
||||
reject(new Error(message ?? 'unspecified error'))
|
||||
return
|
||||
}
|
||||
|
||||
rs(flags.Slug);
|
||||
},
|
||||
})
|
||||
});
|
||||
resolve(flags.Slug)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -1,196 +1,215 @@
|
|||
import "./index.css"
|
||||
import './index.css'
|
||||
import callServerAction, { ResultMessage } from './proto'
|
||||
|
||||
type Println = ((line: string, flush?: boolean) => void) & {
|
||||
paintedFrames: number;
|
||||
missedFrames: number;
|
||||
paintedFrames: number
|
||||
missedFrames: number
|
||||
}
|
||||
|
||||
/**
|
||||
* makeTextBuffer returns a println() function that efficiently writes text into target, and keeps at most size elements in the traceback.
|
||||
* scrollContainer is used to scroll on every painted update.
|
||||
*/
|
||||
function makeTextBuffer(target: HTMLElement, scrollContainer: HTMLElement, size: number): Println {
|
||||
let lastAnimationFrame: number | null = null; // last scheduled animation frame
|
||||
function makeTextBuffer (target: HTMLElement, scrollContainer: HTMLElement, size: number): Println {
|
||||
let lastAnimationFrame: number | null = null // last scheduled animation frame
|
||||
|
||||
const buffer: Array<string> = []; // the internal buffer of lines
|
||||
const paint = () => {
|
||||
println.paintedFrames++
|
||||
target.innerText = buffer.join("\n")
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
||||
lastAnimationFrame = null
|
||||
const buffer: string[] = [] // the internal buffer of lines
|
||||
const paint = (): void => {
|
||||
println.paintedFrames++
|
||||
target.innerText = buffer.join('\n')
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
||||
lastAnimationFrame = null
|
||||
}
|
||||
|
||||
const println = (line: string, flush?: boolean): void => {
|
||||
// add the line
|
||||
buffer.push(line)
|
||||
if (size !== 0 && buffer.length > size) {
|
||||
buffer.splice(0, buffer.length - size)
|
||||
}
|
||||
|
||||
const println = (line: string, flush?: boolean) => {
|
||||
// add the line
|
||||
buffer.push(line)
|
||||
if (size !== 0 && buffer.length > size) {
|
||||
buffer.splice(0, buffer.length - size)
|
||||
}
|
||||
|
||||
// and update the browser in the next animation frame
|
||||
if (lastAnimationFrame !== null) {
|
||||
println.missedFrames++
|
||||
window.cancelAnimationFrame(lastAnimationFrame)
|
||||
}
|
||||
|
||||
// force a repaint!
|
||||
if(flush) return paint();
|
||||
|
||||
// schedule an animation frame
|
||||
lastAnimationFrame = window.requestAnimationFrame(paint);
|
||||
// and update the browser in the next animation frame
|
||||
if (lastAnimationFrame !== null) {
|
||||
println.missedFrames++
|
||||
window.cancelAnimationFrame(lastAnimationFrame)
|
||||
}
|
||||
println.paintedFrames = 0;
|
||||
println.missedFrames = 0;
|
||||
|
||||
return println;
|
||||
// force a repaint!
|
||||
if (flush === true) return paint()
|
||||
|
||||
// schedule an animation frame
|
||||
lastAnimationFrame = window.requestAnimationFrame(paint)
|
||||
}
|
||||
println.paintedFrames = 0
|
||||
println.missedFrames = 0
|
||||
|
||||
return println
|
||||
}
|
||||
|
||||
export default function setup() {
|
||||
const remote_action = document.getElementsByClassName('remote-action')
|
||||
Array.from(remote_action).forEach((element) => {
|
||||
const action = element.getAttribute('data-action') as string;
|
||||
const reload = element.getAttribute('data-force-reload');
|
||||
const param = element.getAttribute('data-param') as string | undefined;
|
||||
export default function setup (): void {
|
||||
const remoteAction = document.getElementsByClassName('remote-action')
|
||||
Array.from(remoteAction).forEach((element) => {
|
||||
const action = element.getAttribute('data-action') as string
|
||||
const reload = element.getAttribute('data-force-reload')
|
||||
const param = element.getAttribute('data-param') as string | undefined
|
||||
|
||||
const confirmElementName = element.getAttribute('data-confirm-param');
|
||||
const confirmElement = (confirmElementName ? document.querySelector(confirmElementName) : null) as HTMLInputElement | null;
|
||||
const confirmElementName = element.getAttribute('data-confirm-param')
|
||||
const confirmElement = typeof confirmElementName === 'string' ? document.querySelector(confirmElementName) : null
|
||||
|
||||
const bufferSize = (function () {
|
||||
const number = parseInt(element.getAttribute('data-buffer') ?? "", 10) ?? 0;
|
||||
return (isFinite(number) && number > 0) ? number : 0;
|
||||
})()
|
||||
const getConfirmValue = (): string | null => {
|
||||
if (confirmElement === null) {
|
||||
console.warn('data-confirm-param was not found')
|
||||
return null
|
||||
}
|
||||
if (!('value' in confirmElement)) {
|
||||
return null
|
||||
}
|
||||
const value = confirmElement.value
|
||||
if (value === null || (typeof value !== 'string')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const validate = function() {
|
||||
if (!confirmElement) return true
|
||||
return confirmElement.value === param;
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
if (confirmElement) {
|
||||
const runValidation = () => {
|
||||
if (validate()) {
|
||||
element.removeAttribute('disabled')
|
||||
} else {
|
||||
element.setAttribute('disabled', 'disabled')
|
||||
}
|
||||
}
|
||||
confirmElement.addEventListener('change', runValidation)
|
||||
runValidation()
|
||||
}
|
||||
const bufferSize = (function () {
|
||||
const number = parseInt(element.getAttribute('data-buffer') ?? '', 10) ?? 0
|
||||
return (isFinite(number) && number > 0) ? number : 0
|
||||
})()
|
||||
|
||||
let onClose: (success: boolean) => void | null;
|
||||
if (typeof reload === 'string') {
|
||||
onClose = () => {
|
||||
if (reload === '') location.reload();
|
||||
else location.href = reload;
|
||||
}
|
||||
}
|
||||
const validate = function (): boolean {
|
||||
const confirmValue = getConfirmValue()
|
||||
if (confirmValue === null) return true
|
||||
return confirmValue === param
|
||||
}
|
||||
|
||||
element.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
// do nothing if the validation fails
|
||||
if (!validate()) return;
|
||||
|
||||
// create a modal dialog
|
||||
const params = (typeof param === 'string') ? [param] : [];
|
||||
createModal(action, params, {
|
||||
onClose: onClose,
|
||||
bufferSize: bufferSize,
|
||||
});
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
type ModalOptions = {
|
||||
bufferSize: number;
|
||||
onClose: (success: boolean, message?: string) => void
|
||||
}
|
||||
export function createModal(action: string, params: string[], opts: Partial<ModalOptions>) {
|
||||
// create a modal dialog and append it to the body
|
||||
const modal = document.createElement("div")
|
||||
modal.className = "modal-terminal"
|
||||
document.body.append(modal)
|
||||
|
||||
// create a <pre> to write stuff into
|
||||
const target = document.createElement("pre")
|
||||
const println = makeTextBuffer(target, modal, opts.bufferSize ?? 1000)
|
||||
modal.append(target)
|
||||
|
||||
// create a button to eventually close everything
|
||||
const finishButton = document.createElement("button")
|
||||
finishButton.className = "pure-button pure-button-success"
|
||||
finishButton.append(typeof opts?.onClose === 'function' ? "Close & Finish" : "Close")
|
||||
|
||||
let result: ResultMessage = {success: false};
|
||||
finishButton.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (typeof opts?.onClose === 'function') {
|
||||
finishButton.setAttribute('disabled', 'disabled')
|
||||
target.innerHTML = 'Finishing up ...'
|
||||
opts.onClose(result.success, result.message)
|
||||
return;
|
||||
}
|
||||
|
||||
modal.parentNode?.removeChild(modal);
|
||||
})
|
||||
|
||||
const cancelButton = document.createElement("button")
|
||||
cancelButton.className = "pure-button pure-button-danger"
|
||||
cancelButton.setAttribute("disabled", "disabled")
|
||||
cancelButton.append("Cancel")
|
||||
modal.append(cancelButton)
|
||||
|
||||
const onbeforeunload = window.onbeforeunload;
|
||||
window.onbeforeunload = () => "A remote session is in progress. Are you sure you want to leave?";
|
||||
|
||||
// when closing, add a button to the modal!
|
||||
const close = (message: ResultMessage) => {
|
||||
result = message
|
||||
|
||||
if (result.success) {
|
||||
println('Process completed successfully. ', true);
|
||||
if (confirmElement !== null) {
|
||||
const runValidation = (): void => {
|
||||
if (validate()) {
|
||||
element.removeAttribute('disabled')
|
||||
} else {
|
||||
println('Process reported error: ' + result.message, true);
|
||||
element.setAttribute('disabled', 'disabled')
|
||||
}
|
||||
|
||||
window.onbeforeunload = onbeforeunload;
|
||||
|
||||
modal.removeChild(cancelButton)
|
||||
modal.append(finishButton)
|
||||
|
||||
const quota = (println.paintedFrames / (println.missedFrames + println.paintedFrames)) * 100
|
||||
console.debug(`Terminal: painted=${println.paintedFrames} missed=${println.missedFrames} (${quota}%)`, true)
|
||||
}
|
||||
confirmElement.addEventListener('change', runValidation)
|
||||
runValidation()
|
||||
}
|
||||
|
||||
println("Connecting ...", true)
|
||||
let onClose: ((success: boolean) => void) | undefined
|
||||
if (typeof reload === 'string') {
|
||||
onClose = () => {
|
||||
if (reload === '') location.reload()
|
||||
else location.href = reload
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener('click', function (ev) {
|
||||
ev.preventDefault()
|
||||
|
||||
// connect to the socket and send the action
|
||||
callServerAction(
|
||||
location.href.replace('http', 'ws'),
|
||||
{
|
||||
'name': action,
|
||||
'params': params,
|
||||
},
|
||||
(
|
||||
send: (text: string) => void,
|
||||
cancel: () => void,
|
||||
) => {
|
||||
cancelButton.removeAttribute("disabled")
|
||||
cancelButton.addEventListener("click", (event) => {
|
||||
event.preventDefault()
|
||||
// do nothing if the validation fails
|
||||
if (!validate()) return
|
||||
|
||||
println("Cancelling", true)
|
||||
cancel()
|
||||
})
|
||||
println("Connected", true)
|
||||
},
|
||||
println
|
||||
).then(close)
|
||||
// create a modal dialog
|
||||
const params = (typeof param === 'string') ? [param] : []
|
||||
createModal(action, params, {
|
||||
onClose,
|
||||
bufferSize
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
interface ModalOptions {
|
||||
bufferSize: number
|
||||
onClose: ((success: true) => void) & ((success: false, message: string) => void)
|
||||
}
|
||||
export function createModal (action: string, params: string[], opts: Partial<ModalOptions>): void {
|
||||
// create a modal dialog and append it to the body
|
||||
const modal = document.createElement('div')
|
||||
modal.className = 'modal-terminal'
|
||||
document.body.append(modal)
|
||||
|
||||
// create a <pre> to write stuff into
|
||||
const target = document.createElement('pre')
|
||||
const println = makeTextBuffer(target, modal, opts.bufferSize ?? 1000)
|
||||
modal.append(target)
|
||||
|
||||
// create a button to eventually close everything
|
||||
const finishButton = document.createElement('button')
|
||||
finishButton.className = 'pure-button pure-button-success'
|
||||
finishButton.append(typeof opts?.onClose === 'function' ? 'Close & Finish' : 'Close')
|
||||
|
||||
let result: ResultMessage = { success: false, message: 'Nothing happened' }
|
||||
finishButton.addEventListener('click', (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (typeof opts?.onClose === 'function') {
|
||||
finishButton.setAttribute('disabled', 'disabled')
|
||||
target.innerHTML = 'Finishing up ...'
|
||||
if (result.success) {
|
||||
opts.onClose(result.success)
|
||||
} else {
|
||||
opts.onClose(result.success, result.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
modal.parentNode?.removeChild(modal)
|
||||
})
|
||||
|
||||
const cancelButton = document.createElement('button')
|
||||
cancelButton.className = 'pure-button pure-button-danger'
|
||||
cancelButton.setAttribute('disabled', 'disabled')
|
||||
cancelButton.append('Cancel')
|
||||
modal.append(cancelButton)
|
||||
|
||||
const onbeforeunload = window.onbeforeunload
|
||||
window.onbeforeunload = () => 'A remote session is in progress. Are you sure you want to leave?'
|
||||
|
||||
// when closing, add a button to the modal!
|
||||
const close = (message: ResultMessage): void => {
|
||||
result = message
|
||||
|
||||
if (result.success) {
|
||||
println('Process completed successfully. ', true)
|
||||
} else {
|
||||
println('Process reported error: ' + result.message, true)
|
||||
}
|
||||
|
||||
window.onbeforeunload = onbeforeunload
|
||||
|
||||
modal.removeChild(cancelButton)
|
||||
modal.append(finishButton)
|
||||
|
||||
const quota = (println.paintedFrames / (println.missedFrames + println.paintedFrames)) * 100
|
||||
console.debug(`Terminal: painted=${println.paintedFrames} missed=${println.missedFrames} (${quota}%)`, true)
|
||||
}
|
||||
|
||||
println('Connecting ...', true)
|
||||
|
||||
// connect to the socket and send the action
|
||||
callServerAction(
|
||||
location.href.replace('http', 'ws'),
|
||||
{
|
||||
name: action,
|
||||
params
|
||||
},
|
||||
(
|
||||
send: (text: string) => void,
|
||||
cancel: () => void
|
||||
) => {
|
||||
cancelButton.removeAttribute('disabled')
|
||||
cancelButton.addEventListener('click', (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
println('Cancelling', true)
|
||||
cancel()
|
||||
})
|
||||
println('Connected', true)
|
||||
},
|
||||
println
|
||||
).then(close)
|
||||
.catch(() => {
|
||||
close({ 'success': false, 'message': "connection closed unexpectedly" })
|
||||
close({ success: false, message: 'connection closed unexpectedly' })
|
||||
})
|
||||
}
|
||||
|
|
@ -1,12 +1,17 @@
|
|||
import { Mutex } from "async-mutex";
|
||||
import { Mutex } from 'async-mutex'
|
||||
|
||||
export type CallMessage = { name: string; params?: string[] | null }
|
||||
export type ResultMessage = { success: boolean; message?: string; }
|
||||
export type SignalMessage = { signal: string; }
|
||||
function isResultMessage(value: any): value is ResultMessage {
|
||||
return typeof value === 'object' &&
|
||||
Object.prototype.hasOwnProperty.call(value, 'success') && typeof value['success'] === 'boolean' &&
|
||||
(!Object.prototype.hasOwnProperty.call(value, 'message') || (value['message'] === undefined || typeof value['message'] === 'string'));
|
||||
import { runMutexExclusive } from '~/src/lib/discard'
|
||||
|
||||
export interface CallMessage { name: string, params?: string[] | null }
|
||||
export type ResultMessage = { success: true } | { success: false, message: string }
|
||||
export interface SignalMessage { signal: string }
|
||||
function isResultMessage (value: any): value is ResultMessage {
|
||||
return typeof value === 'object' &&
|
||||
Object.prototype.hasOwnProperty.call(value, 'success') &&
|
||||
(
|
||||
(value.success === true) ||
|
||||
(value.success === false && Object.prototype.hasOwnProperty.call(value, 'message') && (typeof value.message === 'string'))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -17,58 +22,62 @@ function isResultMessage(value: any): value is ResultMessage {
|
|||
* @param onText called when the connection receives some text
|
||||
* @returns a promise that is resolved once the conneciton is closed. Rejected if the connection errors.
|
||||
*/
|
||||
export default async function callServerAction(
|
||||
endpoint: string,
|
||||
call: CallMessage,
|
||||
onOpen: (send: (text: string) => void, cancel: () => void) => void,
|
||||
onText: (text: string) => void,
|
||||
export default async function callServerAction (
|
||||
endpoint: string,
|
||||
call: CallMessage,
|
||||
onOpen: (send: (text: string) => void, cancel: () => void) => void,
|
||||
onText: (text: string) => void
|
||||
): Promise<ResultMessage> {
|
||||
return new Promise((rs, rj) => {
|
||||
const mutex = new Mutex();
|
||||
return await new Promise((resolve, reject) => {
|
||||
const mutex = new Mutex()
|
||||
|
||||
const socket = new WebSocket(endpoint);
|
||||
const socket = new WebSocket(endpoint)
|
||||
|
||||
let result: ResultMessage;
|
||||
socket.onmessage = (msg) => {
|
||||
mutex.runExclusive(async () => {
|
||||
if (typeof msg.data === 'string') {
|
||||
onText(msg.data)
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.data instanceof Blob) {
|
||||
const object = JSON.parse(await msg.data.text())
|
||||
if (isResultMessage(object)) {
|
||||
result = {'success': object['success'], 'message': object['message']}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('Unknown message', msg)
|
||||
})
|
||||
let result: ResultMessage
|
||||
socket.onmessage = (msg) => {
|
||||
runMutexExclusive(mutex, async () => {
|
||||
if (typeof msg.data === 'string') {
|
||||
onText(msg.data)
|
||||
return
|
||||
}
|
||||
socket.onclose = () => {
|
||||
mutex.runExclusive(() => rs(result));
|
||||
};
|
||||
socket.onerror = rj; // if an error occurs, close the socket
|
||||
|
||||
socket.onopen = () => {
|
||||
const blob = new Blob([JSON.stringify(call)]);
|
||||
socket.send(blob)
|
||||
|
||||
onOpen(
|
||||
(text: string) => {
|
||||
if (typeof text !== 'string') {
|
||||
console.warn('Ignoring send() call with unknown type', text)
|
||||
return
|
||||
}
|
||||
socket.send(text)
|
||||
},
|
||||
() => {
|
||||
const blob = new Blob([JSON.stringify({'signal': 'cancel'})]);
|
||||
socket.send(blob)
|
||||
}
|
||||
)
|
||||
if (msg.data instanceof Blob) {
|
||||
const object = JSON.parse(await msg.data.text())
|
||||
if (isResultMessage(object)) {
|
||||
if (object.success) {
|
||||
result = { success: true }
|
||||
} else {
|
||||
result = { success: false, message: object.message }
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.warn('Unknown message', msg)
|
||||
})
|
||||
}
|
||||
socket.onclose = () => {
|
||||
mutex.runExclusive(() => resolve(result)).then(() => {}).catch(console.error.bind(console))
|
||||
}
|
||||
socket.onerror = reject // if an error occurs, close the socket
|
||||
|
||||
socket.onopen = () => {
|
||||
const blob = new Blob([JSON.stringify(call)])
|
||||
socket.send(blob)
|
||||
|
||||
onOpen(
|
||||
(text: string) => {
|
||||
if (typeof text !== 'string') {
|
||||
console.warn('Ignoring send() call with unknown type', text)
|
||||
return
|
||||
}
|
||||
socket.send(text)
|
||||
},
|
||||
() => {
|
||||
const blob = new Blob([JSON.stringify({ signal: 'cancel' })])
|
||||
socket.send(blob)
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,43 +1,45 @@
|
|||
document.querySelectorAll('span').forEach((elem: Element) => {
|
||||
if (!elem.hasAttribute('data-reveal')) return
|
||||
if (!elem.hasAttribute('data-reveal')) return
|
||||
|
||||
addReveal(elem as HTMLSpanElement, 10000);
|
||||
addReveal(elem as HTMLSpanElement, 10000)
|
||||
})
|
||||
|
||||
export function addReveal(span: HTMLSpanElement, hideDelay: number) {
|
||||
const content = span.getAttribute("data-reveal") ?? '(no content)'
|
||||
export function addReveal (span: HTMLSpanElement, hideDelay: number): void {
|
||||
const content = span.getAttribute('data-reveal') ?? '(no content)'
|
||||
|
||||
let isHidden = true
|
||||
let isHidden = true
|
||||
|
||||
// handler to hide the element
|
||||
const hide = () => {
|
||||
isHidden = true
|
||||
span.innerText = "(click to reveal)"
|
||||
}
|
||||
hide()
|
||||
// handler to hide the element
|
||||
const hide = (): void => {
|
||||
isHidden = true
|
||||
span.innerText = '(click to reveal)'
|
||||
}
|
||||
hide()
|
||||
|
||||
const reveal = () => {
|
||||
isHidden = false
|
||||
const code = document.createElement('code')
|
||||
code.append(content)
|
||||
code.addEventListener('click', (evt) => {
|
||||
evt.preventDefault()
|
||||
const reveal = (): void => {
|
||||
isHidden = false
|
||||
const code = document.createElement('code')
|
||||
code.append(content)
|
||||
code.addEventListener('click', (evt) => {
|
||||
evt.preventDefault()
|
||||
|
||||
if (!navigator.clipboard) return
|
||||
navigator.clipboard.writeText(content)
|
||||
})
|
||||
code.style.userSelect = "all";
|
||||
// Check if the clipboard api is supported
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
if (!navigator.clipboard) return
|
||||
|
||||
span.innerHTML = ""
|
||||
span.append(code)
|
||||
}
|
||||
|
||||
span.addEventListener("click", (evt) => {
|
||||
evt.preventDefault()
|
||||
|
||||
|
||||
if (!isHidden) return
|
||||
reveal()
|
||||
setTimeout(hide, hideDelay) // hide again after 1 second
|
||||
navigator.clipboard.writeText(content).then(() => {}).catch(console.error.bind(console))
|
||||
})
|
||||
code.style.userSelect = 'all'
|
||||
|
||||
span.innerHTML = ''
|
||||
span.append(code)
|
||||
}
|
||||
|
||||
span.addEventListener('click', (evt) => {
|
||||
evt.preventDefault()
|
||||
|
||||
if (!isHidden) return
|
||||
reveal()
|
||||
setTimeout(hide, hideDelay) // hide again after 1 second
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue