mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-18 16:47:42 +01:00
uses regex mostly, resulting in a significantly faster execution (old: ~2s, new: ~31ms). Only matches classList.add/remove and class="..." (won't touch string literal variables or colours), but these weren't really used anyway. Supports multi-line classList.add/remove, which was the reason I wrote this anyway (formatting the codebase introduced some of these).
512 lines
19 KiB
TypeScript
512 lines
19 KiB
TypeScript
declare var window: GlobalWindow;
|
|
import dateParser from "any-date-parser";
|
|
import { Temporal } from "temporal-polyfill";
|
|
|
|
export function toDateString(date: Date): string {
|
|
const locale = window.language || (window as any).navigator.userLanguage || window.navigator.language;
|
|
const t12 = document.getElementById("lang-12h") as HTMLInputElement;
|
|
const t24 = document.getElementById("lang-24h") as HTMLInputElement;
|
|
let args1 = {};
|
|
let args2: Intl.DateTimeFormatOptions = {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
};
|
|
if (t12 && t24) {
|
|
if (t12.checked) {
|
|
args1["hour12"] = true;
|
|
args2["hour12"] = true;
|
|
} else if (t24.checked) {
|
|
args1["hour12"] = false;
|
|
args2["hour12"] = false;
|
|
}
|
|
}
|
|
return date.toLocaleDateString(locale, args1) + " " + date.toLocaleString(locale, args2);
|
|
}
|
|
|
|
export const parseDateString = (value: string): ParsedDate => {
|
|
let out: ParsedDate = {
|
|
text: value,
|
|
// Used just to tell use what fields the user passed.
|
|
attempt: dateParser.attempt(value),
|
|
// note Date.fromString is also provided by dateParser.
|
|
date: (Date as any).fromString(value) as Date,
|
|
};
|
|
if ("invalid" in (out.date as any)) {
|
|
out.invalid = true;
|
|
} else {
|
|
// getTimezoneOffset returns UTC - Timezone, so invert it to get distance from UTC -to- timezone.
|
|
out.attempt.offsetMinutesFromUTC = -1 * out.date.getTimezoneOffset();
|
|
}
|
|
// Month in Date objects is 0-based, so make our parsed date that way too
|
|
if ("month" in out.attempt) out.attempt.month -= 1;
|
|
return out;
|
|
};
|
|
|
|
// DateCountdown sets the given el's textContent to the time till the given date (unixSeconds), updating
|
|
// every minute. It returns the timeout, so it can be later removed with clearTimeout if desired.
|
|
export function DateCountdown(el: HTMLElement, unixSeconds: number): ReturnType<typeof setTimeout> {
|
|
let then = Temporal.Instant.fromEpochMilliseconds(unixSeconds * 1000);
|
|
const toString = (): string => {
|
|
let out = "";
|
|
let now = Temporal.Now.instant();
|
|
let nowPlain = Temporal.Now.plainDateTimeISO();
|
|
let diff = now.until(then).round({
|
|
largestUnit: "years",
|
|
smallestUnit: "minutes",
|
|
relativeTo: nowPlain,
|
|
});
|
|
// FIXME: I'd really like this to be localized, but don't know of any nice solutions.
|
|
const fields = [diff.years, diff.months, diff.days, diff.hours, diff.minutes];
|
|
const abbrevs = ["y", "mo", "d", "h", "m"];
|
|
for (let i = 0; i < fields.length; i++) {
|
|
if (fields[i]) {
|
|
out += "" + fields[i] + abbrevs[i] + " ";
|
|
}
|
|
}
|
|
return out.slice(0, -1);
|
|
};
|
|
const update = () => {
|
|
el.textContent = toString();
|
|
};
|
|
update();
|
|
return setTimeout(update, 60000);
|
|
}
|
|
|
|
export const _get = (
|
|
url: string,
|
|
data: Object,
|
|
onreadystatechange: (req: XMLHttpRequest) => void,
|
|
noConnectionError: boolean = false,
|
|
): void => {
|
|
let req = new XMLHttpRequest();
|
|
if (window.pages) {
|
|
url = window.pages.Base + url;
|
|
}
|
|
req.open("GET", url, true);
|
|
req.responseType = "json";
|
|
req.setRequestHeader("Authorization", "Bearer " + window.token);
|
|
req.setRequestHeader("Content-Type", "application/json; charset=UTF-8");
|
|
req.onreadystatechange = () => {
|
|
if (req.status == 0) {
|
|
if (!noConnectionError) window.notifications.connectionError();
|
|
return;
|
|
} else if (req.status == 401) {
|
|
window.notifications.customError("401Error", window.lang.notif("error401Unauthorized"));
|
|
}
|
|
onreadystatechange(req);
|
|
};
|
|
req.send(JSON.stringify(data));
|
|
};
|
|
|
|
export const _download = (url: string, fname: string): void => {
|
|
let req = new XMLHttpRequest();
|
|
if (window.pages) {
|
|
url = window.pages.Base + url;
|
|
}
|
|
req.open("GET", url, true);
|
|
req.responseType = "blob";
|
|
req.setRequestHeader("Authorization", "Bearer " + window.token);
|
|
req.setRequestHeader("Content-Type", "application/json; charset=UTF-8");
|
|
req.onload = (e: Event) => {
|
|
let link = document.createElement("a") as HTMLAnchorElement;
|
|
link.href = URL.createObjectURL(req.response);
|
|
link.download = fname;
|
|
link.dispatchEvent(new MouseEvent("click"));
|
|
};
|
|
req.send();
|
|
};
|
|
|
|
export const _upload = (url: string, formData: FormData): void => {
|
|
let req = new XMLHttpRequest();
|
|
if (window.pages) {
|
|
url = window.pages.Base + url;
|
|
}
|
|
req.open("POST", url, true);
|
|
req.setRequestHeader("Authorization", "Bearer " + window.token);
|
|
// req.setRequestHeader('Content-Type', 'multipart/form-data');
|
|
req.send(formData);
|
|
};
|
|
|
|
export const _req = (
|
|
method: string,
|
|
url: string,
|
|
data: Object,
|
|
onreadystatechange: (req: XMLHttpRequest) => void,
|
|
response?: boolean,
|
|
statusHandler?: (req: XMLHttpRequest) => void,
|
|
noConnectionError: boolean = false,
|
|
): void => {
|
|
let req = new XMLHttpRequest();
|
|
if (window.pages) {
|
|
url = window.pages.Base + url;
|
|
}
|
|
req.open(method, url, true);
|
|
if (response) {
|
|
req.responseType = "json";
|
|
}
|
|
req.setRequestHeader("Authorization", "Bearer " + window.token);
|
|
req.setRequestHeader("Content-Type", "application/json; charset=UTF-8");
|
|
req.onreadystatechange = () => {
|
|
if (statusHandler) {
|
|
statusHandler(req);
|
|
} else if (req.status == 0) {
|
|
if (!noConnectionError) window.notifications.connectionError();
|
|
return;
|
|
} else if (req.status == 401) {
|
|
window.notifications.customError("401Error", window.lang.notif("error401Unauthorized"));
|
|
}
|
|
onreadystatechange(req);
|
|
};
|
|
req.send(JSON.stringify(data));
|
|
};
|
|
|
|
export const _post = (
|
|
url: string,
|
|
data: Object,
|
|
onreadystatechange: (req: XMLHttpRequest) => void,
|
|
response?: boolean,
|
|
statusHandler?: (req: XMLHttpRequest) => void,
|
|
noConnectionError: boolean = false,
|
|
): void => _req("POST", url, data, onreadystatechange, response, statusHandler, noConnectionError);
|
|
|
|
export const _put = (
|
|
url: string,
|
|
data: Object,
|
|
onreadystatechange: (req: XMLHttpRequest) => void,
|
|
response?: boolean,
|
|
statusHandler?: (req: XMLHttpRequest) => void,
|
|
noConnectionError: boolean = false,
|
|
): void => _req("PUT", url, data, onreadystatechange, response, statusHandler, noConnectionError);
|
|
|
|
export const _patch = (
|
|
url: string,
|
|
data: Object,
|
|
onreadystatechange: (req: XMLHttpRequest) => void,
|
|
response?: boolean,
|
|
statusHandler?: (req: XMLHttpRequest) => void,
|
|
noConnectionError: boolean = false,
|
|
): void => _req("PATCH", url, data, onreadystatechange, response, statusHandler, noConnectionError);
|
|
|
|
export function _delete(
|
|
url: string,
|
|
data: Object,
|
|
onreadystatechange: (req: XMLHttpRequest) => void,
|
|
noConnectionError: boolean = false,
|
|
): void {
|
|
let req = new XMLHttpRequest();
|
|
if (window.pages) {
|
|
url = window.pages.Base + url;
|
|
}
|
|
req.open("DELETE", url, true);
|
|
req.setRequestHeader("Authorization", "Bearer " + window.token);
|
|
req.setRequestHeader("Content-Type", "application/json; charset=UTF-8");
|
|
req.onreadystatechange = () => {
|
|
if (req.status == 0) {
|
|
if (!noConnectionError) window.notifications.connectionError();
|
|
return;
|
|
} else if (req.status == 401) {
|
|
window.notifications.customError("401Error", window.lang.notif("error401Unauthorized"));
|
|
}
|
|
onreadystatechange(req);
|
|
};
|
|
req.send(JSON.stringify(data));
|
|
}
|
|
|
|
export function toClipboard(str: string) {
|
|
const el = document.createElement("textarea") as HTMLTextAreaElement;
|
|
el.value = str;
|
|
el.readOnly = true;
|
|
el.style.position = "absolute";
|
|
el.style.left = "-9999px";
|
|
document.body.appendChild(el);
|
|
const selected = document.getSelection().rangeCount > 0 ? document.getSelection().getRangeAt(0) : false;
|
|
el.select();
|
|
document.execCommand("copy");
|
|
document.body.removeChild(el);
|
|
if (selected) {
|
|
document.getSelection().removeAllRanges();
|
|
document.getSelection().addRange(selected);
|
|
}
|
|
}
|
|
|
|
export class notificationBox implements NotificationBox {
|
|
private _box: HTMLDivElement;
|
|
private _errorTypes: { [type: string]: boolean } = {};
|
|
private _positiveTypes: { [type: string]: boolean } = {};
|
|
timeout: number;
|
|
constructor(box: HTMLDivElement, timeout?: number) {
|
|
this._box = box;
|
|
this._box.classList.add("flex", "flex-col", "gap-2");
|
|
this.timeout = timeout || 5;
|
|
}
|
|
|
|
static baseClasses = ["aside", "flex", "flex-row", "justify-between", "gap-4"];
|
|
|
|
private _error = (message: string): HTMLElement => {
|
|
const noti = document.createElement("aside");
|
|
noti.classList.add(...notificationBox.baseClasses, "~critical", "@low", "notification-error");
|
|
let error = "";
|
|
if (window.lang) {
|
|
error = window.lang.strings("error") + ":";
|
|
}
|
|
noti.innerHTML = `<div><strong>${error}</strong> ${message}</div>`;
|
|
const closeButton = document.createElement("span") as HTMLSpanElement;
|
|
closeButton.classList.add("button", "~critical", "@low");
|
|
closeButton.innerHTML = `<i class="icon ri-close-line"></i>`;
|
|
closeButton.onclick = () => this._close(noti);
|
|
noti.classList.add("animate-slide-in");
|
|
noti.appendChild(closeButton);
|
|
return noti;
|
|
};
|
|
|
|
private _positive = (bold: string, message: string): HTMLElement => {
|
|
const noti = document.createElement("aside");
|
|
noti.classList.add(...notificationBox.baseClasses, "~positive", "@low", "notification-positive");
|
|
noti.innerHTML = `<div><strong>${bold}</strong> ${message}</div>`;
|
|
const closeButton = document.createElement("span") as HTMLSpanElement;
|
|
closeButton.classList.add("button", "~positive", "@low");
|
|
closeButton.innerHTML = `<i class="icon ri-close-line"></i>`;
|
|
closeButton.onclick = () => this._close(noti);
|
|
noti.classList.add("animate-slide-in");
|
|
noti.appendChild(closeButton);
|
|
return noti;
|
|
};
|
|
|
|
private _close = (noti: HTMLElement) => {
|
|
noti.classList.remove("animate-slide-in");
|
|
noti.classList.add("animate-slide-out");
|
|
noti.addEventListener(
|
|
window.animationEvent,
|
|
() => {
|
|
this._box.removeChild(noti);
|
|
},
|
|
false,
|
|
);
|
|
};
|
|
|
|
connectionError = () => {
|
|
this.customError("connectionError", window.lang.notif("errorConnection"));
|
|
};
|
|
|
|
customError = (type: string, message: string) => {
|
|
this._errorTypes[type] = this._errorTypes[type] || false;
|
|
const noti = this._error(message);
|
|
noti.classList.add("error-" + type);
|
|
const previousNoti: HTMLElement | undefined = this._box.querySelector("aside.error-" + type);
|
|
if (this._errorTypes[type] && previousNoti !== undefined && previousNoti != null) {
|
|
this._box.removeChild(previousNoti);
|
|
noti.classList.add("animate-pulse");
|
|
noti.classList.remove("animate-slide-in");
|
|
}
|
|
this._box.appendChild(noti);
|
|
this._errorTypes[type] = true;
|
|
setTimeout(() => {
|
|
if (this._box.contains(noti)) {
|
|
this._close(noti);
|
|
this._errorTypes[type] = false;
|
|
}
|
|
}, this.timeout * 1000);
|
|
};
|
|
|
|
customPositive = (type: string, bold: string, message: string) => {
|
|
this._positiveTypes[type] = this._positiveTypes[type] || false;
|
|
const noti = this._positive(bold, message);
|
|
noti.classList.add("positive-" + type);
|
|
const previousNoti: HTMLElement | undefined = this._box.querySelector("aside.positive-" + type);
|
|
if (this._positiveTypes[type] && previousNoti !== undefined && previousNoti != null) {
|
|
this._box.removeChild(previousNoti);
|
|
noti.classList.add("animate-pulse");
|
|
noti.classList.remove("animate-slide-in");
|
|
}
|
|
this._box.appendChild(noti);
|
|
this._positiveTypes[type] = true;
|
|
setTimeout(() => {
|
|
if (this._box.contains(noti)) {
|
|
this._close(noti);
|
|
this._positiveTypes[type] = false;
|
|
}
|
|
}, this.timeout * 1000);
|
|
};
|
|
|
|
customSuccess = (type: string, message: string) =>
|
|
this.customPositive(type, window.lang.strings("success") + ":", message);
|
|
}
|
|
|
|
export const whichAnimationEvent = () => {
|
|
const el = document.createElement("fakeElement");
|
|
if (el.style["animation"] !== void 0) {
|
|
return "animationend";
|
|
}
|
|
return "webkitAnimationEnd";
|
|
};
|
|
|
|
export function toggleLoader(el: HTMLElement, small: boolean = true) {
|
|
if (el.classList.contains("loader")) {
|
|
el.classList.remove("loader");
|
|
el.classList.remove("loader-sm");
|
|
const dot = el.querySelector("span.dot");
|
|
if (dot) {
|
|
dot.remove();
|
|
}
|
|
} else {
|
|
el.classList.add("loader");
|
|
if (small) {
|
|
el.classList.add("loader-sm");
|
|
}
|
|
const dot = document.createElement("span") as HTMLSpanElement;
|
|
dot.classList.add("dot");
|
|
el.appendChild(dot);
|
|
}
|
|
}
|
|
|
|
export function addLoader(el: HTMLElement, small: boolean = true, relative: boolean = false) {
|
|
if (el.classList.contains("loader")) return;
|
|
el.classList.add("loader");
|
|
if (relative) el.classList.add("rel");
|
|
if (small) {
|
|
el.classList.add("loader-sm");
|
|
}
|
|
const dot = document.createElement("span") as HTMLSpanElement;
|
|
dot.classList.add("dot");
|
|
el.appendChild(dot);
|
|
}
|
|
|
|
export function removeLoader(el: HTMLElement, small: boolean = true) {
|
|
if (el.classList.contains("loader")) {
|
|
el.classList.remove("loader");
|
|
el.classList.remove("loader-sm");
|
|
el.classList.remove("rel");
|
|
const dot = el.querySelector("span.dot");
|
|
if (dot) {
|
|
dot.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
export function insertText(textarea: HTMLTextAreaElement, text: string) {
|
|
// https://kubyshkin.name/posts/insert-text-into-textarea-at-cursor-position <3
|
|
const isSuccess = document.execCommand("insertText", false, text);
|
|
|
|
// Firefox (non-standard method)
|
|
if (!isSuccess && typeof textarea.setRangeText === "function") {
|
|
const start = textarea.selectionStart;
|
|
textarea.setRangeText(text);
|
|
// update cursor to be at the end of insertion
|
|
textarea.selectionStart = textarea.selectionEnd = start + text.length;
|
|
|
|
// Notify any possible listeners of the change
|
|
const e = document.createEvent("UIEvent");
|
|
e.initEvent("input", true, false);
|
|
textarea.dispatchEvent(e);
|
|
textarea.focus();
|
|
}
|
|
}
|
|
|
|
export function bindManualDropdowns() {
|
|
const buttons = Array.from(
|
|
document.getElementsByClassName("dropdown-manual-toggle") as HTMLCollectionOf<HTMLSpanElement>,
|
|
);
|
|
for (let button of buttons) {
|
|
const parent = button.closest(".dropdown.manual");
|
|
const display = parent.querySelector(".dropdown-display");
|
|
const mousein = () => parent.classList.add("selected");
|
|
const mouseout = () => parent.classList.remove("selected");
|
|
button.addEventListener("mouseover", mousein);
|
|
button.addEventListener("mouseout", mouseout);
|
|
display.addEventListener("mouseover", mousein);
|
|
display.addEventListener("mouseout", mouseout);
|
|
button.onclick = () => {
|
|
parent.classList.add("selected");
|
|
document.addEventListener("click", outerClickListener);
|
|
button.removeEventListener("mouseout", mouseout);
|
|
display.removeEventListener("mouseout", mouseout);
|
|
};
|
|
const outerClickListener = (event: Event) => {
|
|
if (
|
|
!(
|
|
event.target instanceof HTMLElement &&
|
|
(display.contains(event.target) || button.contains(event.target))
|
|
)
|
|
) {
|
|
parent.classList.remove("selected");
|
|
document.removeEventListener("click", outerClickListener);
|
|
button.addEventListener("mouseout", mouseout);
|
|
display.addEventListener("mouseout", mouseout);
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
export function unicodeB64Decode(s: string): string {
|
|
const decoded = atob(s);
|
|
const byteArray = Uint8Array.from(decoded, (m) => m.codePointAt(0));
|
|
const toUnicode = new TextDecoder().decode(byteArray);
|
|
return toUnicode;
|
|
}
|
|
|
|
export function unicodeB64Encode(s: string): string {
|
|
const encoded = new TextEncoder().encode(s);
|
|
const bin = String.fromCodePoint(...encoded);
|
|
return btoa(bin);
|
|
}
|
|
|
|
// Only allow running a function every n milliseconds.
|
|
// Source: Clément Prévost at https://stackoverflow.com/questions/27078285/simple-throttle-in-javascript
|
|
// function foo<T>(bar: T): T {
|
|
export function throttle(callback: () => void, limitMilliseconds: number): () => void {
|
|
var waiting = false; // Initially, we're not waiting
|
|
return function () {
|
|
// We return a throttled function
|
|
if (!waiting) {
|
|
// If we're not waiting
|
|
callback.apply(this, arguments); // Execute users function
|
|
waiting = true; // Prevent future invocations
|
|
setTimeout(function () {
|
|
// After a period of time
|
|
waiting = false; // And allow future invocations
|
|
}, limitMilliseconds);
|
|
}
|
|
};
|
|
}
|
|
|
|
export function SetupCopyButton(
|
|
button: HTMLButtonElement,
|
|
text: string | (() => string),
|
|
baseClasses?: string[],
|
|
notif?: string,
|
|
) {
|
|
if (!notif) notif = window.lang.strings("copied");
|
|
if (!baseClasses || baseClasses.length == 0) baseClasses = ["~info", "dark:~d_info"];
|
|
// script will probably turn this into multiple
|
|
button.type = "button";
|
|
button.classList.add("button", ...baseClasses, "@low", "p-1");
|
|
button.title = window.lang.strings("copy");
|
|
const icon = document.createElement("i");
|
|
icon.classList.add("icon", "ri-file-copy-line");
|
|
button.appendChild(icon);
|
|
button.onclick = () => {
|
|
if (typeof text === "string") {
|
|
toClipboard(text);
|
|
} else {
|
|
toClipboard(text());
|
|
}
|
|
icon.classList.remove("ri-file-copy-line");
|
|
icon.classList.add("ri-check-line");
|
|
button.classList.remove(...baseClasses);
|
|
button.classList.add("~positive");
|
|
setTimeout(() => {
|
|
icon.classList.remove("ri-check-line");
|
|
icon.classList.add("ri-file-copy-line");
|
|
button.classList.remove("~positive");
|
|
button.classList.add(...baseClasses);
|
|
}, 800);
|
|
window.notifications.customPositive("copied", "", notif);
|
|
};
|
|
}
|
|
|
|
export function CopyButton(text: string | (() => string), baseClasses?: string[], notif?: string): HTMLButtonElement {
|
|
const button = document.createElement("button");
|
|
SetupCopyButton(button, text, baseClasses, notif);
|
|
return button;
|
|
}
|