Compare commits
3 commits
3f9624fe91
...
bfd61c2e50
Author | SHA1 | Date | |
---|---|---|---|
bfd61c2e50 | |||
2be0658ed9 | |||
5534bc3942 |
10 changed files with 254 additions and 100 deletions
|
@ -1,5 +1,6 @@
|
||||||
// This file handles the "Add an instance" dialog
|
// This file handles the "Add an instance" dialog
|
||||||
|
|
||||||
|
import { FormDialog, ONCE } from "./dialog.mjs";
|
||||||
import { findButtonOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs";
|
import { findButtonOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs";
|
||||||
|
|
||||||
export function parseHost(host: string): { host: string, secure: boolean } | null {
|
export function parseHost(host: string): { host: string, secure: boolean } | null {
|
||||||
|
@ -13,43 +14,66 @@ export function parseHost(host: string): { host: string, secure: boolean } | nul
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initializeAddInstanceDialog(
|
type AddInstanceDialogData = {
|
||||||
dialog: HTMLDialogElement,
|
host: string,
|
||||||
callback: (
|
secure: boolean,
|
||||||
host: string,
|
autoQueryMetadata: boolean,
|
||||||
secure: boolean,
|
};
|
||||||
autoQueryMetadata: boolean,
|
|
||||||
) => void
|
|
||||||
): {
|
|
||||||
showAddInstanceDialog: () => void,
|
|
||||||
hideAddInstanceDialog: () => void,
|
|
||||||
} {
|
|
||||||
const showAddInstanceDialog = () => dialog.showModal();
|
|
||||||
const hideAddInstanceDialog = () => dialog.close();
|
|
||||||
|
|
||||||
const form = findFormOrFail(dialog, ".addInstanceForm");
|
export class AddInstanceDialog extends FormDialog {
|
||||||
const instanceHost = findInputOrFail(form, "#instanceHost");
|
protected instanceHost: HTMLInputElement;
|
||||||
const autoQueryMetadata = findInputOrFail(form, "#autoQueryMetadata");
|
protected autoQueryMetadata: HTMLInputElement;
|
||||||
const closeButton = findButtonOrFail(form, ".close");
|
protected closeButton: HTMLButtonElement;
|
||||||
|
|
||||||
instanceHost.addEventListener("input", e => {
|
constructor(dialog: HTMLDialogElement, initializeDOM: boolean = true) {
|
||||||
if (parseHost(instanceHost.value) === null)
|
super(dialog, findFormOrFail(dialog, ".addInstanceForm"));
|
||||||
instanceHost.setCustomValidity("Invalid instance hostname or URL");
|
this.instanceHost = findInputOrFail(this.form, "#instanceHost");
|
||||||
else
|
this.autoQueryMetadata = findInputOrFail(this.form, "#autoQueryMetadata");
|
||||||
instanceHost.setCustomValidity("");
|
this.closeButton = findButtonOrFail(this.form, ".close");
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener("submit", e => {
|
if (initializeDOM) this.initializeDOM();
|
||||||
// A sane browser doesn't allow for submitting the form if the above validation fails
|
}
|
||||||
const { host, secure } = parseHost(instanceHost.value)!;
|
|
||||||
callback(host, secure, autoQueryMetadata.checked);
|
|
||||||
form.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
closeButton.addEventListener("click", e => hideAddInstanceDialog());
|
#getDataIfValid(): AddInstanceDialogData | null {
|
||||||
|
const parsedHost = parseHost(this.instanceHost.value);
|
||||||
|
if (parsedHost === null) {
|
||||||
|
this.instanceHost.setCustomValidity("Invalid instance hostname or URL");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.instanceHost.setCustomValidity("");
|
||||||
|
return {
|
||||||
|
host: parsedHost.host,
|
||||||
|
secure: parsedHost.secure,
|
||||||
|
autoQueryMetadata: this.autoQueryMetadata.checked
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
#handleSubmit(resolve: (data: AddInstanceDialogData) => void) {
|
||||||
showAddInstanceDialog,
|
this.form.addEventListener("submit", e => {
|
||||||
hideAddInstanceDialog
|
const data = this.#getDataIfValid();
|
||||||
};
|
if (data === null) {
|
||||||
|
// Prevent the user from submitting the form if it's invalid and let them try again
|
||||||
|
e.preventDefault();
|
||||||
|
this.#handleSubmit(resolve);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(data);
|
||||||
|
this.close();
|
||||||
|
}, ONCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override initializeDOM() {
|
||||||
|
super.initializeDOM();
|
||||||
|
|
||||||
|
this.instanceHost.addEventListener("input", e => this.#getDataIfValid());
|
||||||
|
this.closeButton.addEventListener("click", e => this.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
async prompt(): Promise<AddInstanceDialogData> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.dialog.addEventListener("close", e => reject(), ONCE);
|
||||||
|
this.#handleSubmit(resolve);
|
||||||
|
this.open();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,69 +1,78 @@
|
||||||
import { initializeAddInstanceDialog } from "./add_an_instance.mjs";
|
import { AddInstanceDialog } from "./add_an_instance.mjs";
|
||||||
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs";
|
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs";
|
||||||
|
import { Dialog } from "./dialog.mjs";
|
||||||
import storageManager, { Instance } from "./storage_manager.mjs";
|
import storageManager, { Instance } from "./storage_manager.mjs";
|
||||||
|
|
||||||
export function initializeAddInstanceFlow(
|
export class AddInstanceFlow {
|
||||||
detailsDialog: HTMLDialogElement,
|
addDialog: AddInstanceDialog;
|
||||||
addDialog: HTMLDialogElement
|
spinnerDialog: Dialog;
|
||||||
): {
|
detailsDialog: HTMLDialogElement;
|
||||||
showAddInstanceDialog: () => void,
|
|
||||||
hideAddInstanceDialog: () => void
|
constructor(
|
||||||
} {
|
addDialog: AddInstanceDialog | HTMLDialogElement,
|
||||||
const instanceDetailsDialogCallback = (
|
spinnerDialog: HTMLDialogElement,
|
||||||
name: string,
|
detailsDialog: HTMLDialogElement,
|
||||||
host: string,
|
) {
|
||||||
hostSecure: boolean,
|
if (addDialog instanceof AddInstanceDialog)
|
||||||
software: string,
|
this.addDialog = addDialog;
|
||||||
icon: string | null
|
else
|
||||||
) => {
|
this.addDialog = new AddInstanceDialog(addDialog, true);
|
||||||
const instance: Instance = {
|
this.spinnerDialog = new Dialog(spinnerDialog);
|
||||||
name,
|
this.detailsDialog = detailsDialog;
|
||||||
origin: `http${hostSecure ? "s" : ""}://${host}`,
|
}
|
||||||
software,
|
|
||||||
iconURL: icon ?? undefined
|
async start() {
|
||||||
|
const instanceDetailsDialogCallback = (
|
||||||
|
name: string,
|
||||||
|
host: string,
|
||||||
|
hostSecure: boolean,
|
||||||
|
software: string,
|
||||||
|
icon: string | null
|
||||||
|
) => {
|
||||||
|
const instance: Instance = {
|
||||||
|
name,
|
||||||
|
origin: `http${hostSecure ? "s" : ""}://${host}`,
|
||||||
|
software,
|
||||||
|
iconURL: icon ?? undefined
|
||||||
|
};
|
||||||
|
storageManager.storage.instances.push(instance);
|
||||||
|
storageManager.save();
|
||||||
|
console.log("Successfully added new instance:", instance);
|
||||||
};
|
};
|
||||||
storageManager.storage.instances.push(instance);
|
|
||||||
storageManager.save();
|
|
||||||
console.log("Successfully added new instance:", instance);
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
showInstanceDetailsDialog,
|
showInstanceDetailsDialog,
|
||||||
hideInstanceDetailsDialog,
|
hideInstanceDetailsDialog,
|
||||||
populateInstanceDetailsDialog
|
populateInstanceDetailsDialog
|
||||||
} = initializeInstanceDetailsDialog(detailsDialog, instanceDetailsDialogCallback);
|
} = initializeInstanceDetailsDialog(this.detailsDialog, instanceDetailsDialogCallback);
|
||||||
|
|
||||||
|
const {
|
||||||
|
autoQueryMetadata,
|
||||||
|
host,
|
||||||
|
secure,
|
||||||
|
} = await this.addDialog.prompt();
|
||||||
|
|
||||||
const addInstanceDialogCallback = async (
|
|
||||||
host: string,
|
|
||||||
secure: boolean,
|
|
||||||
autoQueryMetadata: boolean,
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
if (!autoQueryMetadata) throw new Error("Don't");
|
if (!autoQueryMetadata) throw null; // Skip to catch block
|
||||||
|
|
||||||
|
this.spinnerDialog.open();
|
||||||
|
|
||||||
const { name, software, iconURL } =
|
const { name, software, iconURL } =
|
||||||
await fetch(`/api/instance_info/${secure}/${encodeURIComponent(host)}`)
|
await fetch(`/api/instance_info/${secure}/${encodeURIComponent(host)}`)
|
||||||
.then(r => r.json());
|
.then(r => r.json());
|
||||||
if (
|
if (
|
||||||
typeof name !== "string"
|
typeof name !== "string"
|
||||||
|| typeof software !== "string"
|
|| typeof software !== "string"
|
||||||
|| !(typeof iconURL === "string" || iconURL === null)
|
|| !(typeof iconURL === "string" || iconURL === null) // I guess TS is too stupid to understand this?
|
||||||
)
|
)
|
||||||
throw new Error("Invalid API response");
|
throw new Error("Invalid API response");
|
||||||
|
|
||||||
populateInstanceDetailsDialog(name, host, secure, software, iconURL as string | null);
|
populateInstanceDetailsDialog(name, host, secure, software, iconURL as string | null);
|
||||||
} catch {
|
} catch {
|
||||||
populateInstanceDetailsDialog(host, host, secure, "", null);
|
populateInstanceDetailsDialog(host, host, secure, "", null);
|
||||||
} finally {
|
} finally {
|
||||||
|
this.spinnerDialog.close();
|
||||||
showInstanceDetailsDialog();
|
showInstanceDetailsDialog();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
|
||||||
showAddInstanceDialog,
|
|
||||||
hideAddInstanceDialog
|
|
||||||
} = initializeAddInstanceDialog(addDialog, addInstanceDialogCallback);
|
|
||||||
|
|
||||||
return {
|
|
||||||
showAddInstanceDialog,
|
|
||||||
hideAddInstanceDialog
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,13 +6,11 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>FeDirect</title>
|
<title>FeDirect</title>
|
||||||
<link rel="stylesheet" href="/static/main.css">
|
<link rel="stylesheet" href="/static/main.css">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *;">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<script type="module">
|
<script type="module" src="/static/config.mjs"></script>
|
||||||
Object.assign(globalThis, await import("/static/config.mjs"));
|
|
||||||
getMainDialog().show(); // Don't show until the page is ready
|
|
||||||
</script>
|
|
||||||
<div class="flex-vcenter">
|
<div class="flex-vcenter">
|
||||||
<dialog id="mainDialog" class="half-width half-height">
|
<dialog id="mainDialog" class="half-width half-height">
|
||||||
<header class="separator-bottom margin-large-bottom">
|
<header class="separator-bottom margin-large-bottom">
|
||||||
|
@ -27,7 +25,7 @@
|
||||||
<center class="half-width">
|
<center class="half-width">
|
||||||
<ol id="instanceList" class="align-start wfit-content"></ol>
|
<ol id="instanceList" class="align-start wfit-content"></ol>
|
||||||
<br>
|
<br>
|
||||||
<button onclick="showAddInstanceDialog()">Add an instance</button>
|
<button id="showAddInstanceDialog">Add an instance</button>
|
||||||
</center>
|
</center>
|
||||||
</div>
|
</div>
|
||||||
<div class="half-width align-self-start">
|
<div class="half-width align-self-start">
|
||||||
|
|
|
@ -8,12 +8,16 @@ let reordering = false;
|
||||||
// Dragging code is a heavily modified version of https://stackoverflow.com/a/28962290
|
// Dragging code is a heavily modified version of https://stackoverflow.com/a/28962290
|
||||||
let elementBeingDragged: HTMLLIElement | undefined;
|
let elementBeingDragged: HTMLLIElement | undefined;
|
||||||
|
|
||||||
|
const mainDialog = findDialogOrFail(document.body, "#mainDialog");
|
||||||
|
const showAddInstanceDialogButton = findButtonOrFail(document.body, "#showAddInstanceDialog");
|
||||||
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
|
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
|
||||||
const addDialog = findDialogOrFail(document.body, "#addInstance");
|
const addDialog = findDialogOrFail(document.body, "#addInstance");
|
||||||
const instanceList = findOlOrFail(document.body, "#instanceList");
|
const instanceList = findOlOrFail(document.body, "#instanceList");
|
||||||
const saveButton = findButtonOrFail(document.body, "#save");
|
const saveButton = findButtonOrFail(document.body, "#save");
|
||||||
const reorderButton = findButtonOrFail(document.body, "#reorder");
|
const reorderButton = findButtonOrFail(document.body, "#reorder");
|
||||||
|
|
||||||
|
showAddInstanceDialogButton.addEventListener("click", e => showAddInstanceDialog());
|
||||||
|
|
||||||
saveButton.addEventListener("click", e => {
|
saveButton.addEventListener("click", e => {
|
||||||
storageManager.save();
|
storageManager.save();
|
||||||
});
|
});
|
||||||
|
@ -25,15 +29,13 @@ reorderButton.addEventListener("click", () => {
|
||||||
reorderButton.innerText = reordering ? "Finish reordering" : "Reorder";
|
reorderButton.innerText = reordering ? "Finish reordering" : "Reorder";
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getMainDialog = () => findDialogOrFail(document.body, "#mainDialog");
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
showInstanceDetailsDialog,
|
showInstanceDetailsDialog,
|
||||||
hideInstanceDetailsDialog,
|
hideInstanceDetailsDialog,
|
||||||
populateInstanceDetailsDialog,
|
populateInstanceDetailsDialog,
|
||||||
} = initializeInstanceDetailsDialog(detailsDialog, () => { });
|
} = initializeInstanceDetailsDialog(detailsDialog, () => { });
|
||||||
|
|
||||||
export const {
|
const {
|
||||||
showAddInstanceDialog,
|
showAddInstanceDialog,
|
||||||
hideAddInstanceDialog
|
hideAddInstanceDialog
|
||||||
} = initializeAddInstanceFlow(detailsDialog, addDialog);
|
} = initializeAddInstanceFlow(detailsDialog, addDialog);
|
||||||
|
@ -41,6 +43,8 @@ export const {
|
||||||
updateInstanceList();
|
updateInstanceList();
|
||||||
storageManager.addSaveCallback(updateInstanceList);
|
storageManager.addSaveCallback(updateInstanceList);
|
||||||
|
|
||||||
|
mainDialog.show();
|
||||||
|
|
||||||
function updateInstanceList() {
|
function updateInstanceList() {
|
||||||
instanceList.replaceChildren(); // Erase all child nodes
|
instanceList.replaceChildren(); // Erase all child nodes
|
||||||
instanceList.style.listStyleType = reordering ? "\"≡ \"" : "disc";
|
instanceList.style.listStyleType = reordering ? "\"≡ \"" : "disc";
|
||||||
|
|
|
@ -29,7 +29,7 @@
|
||||||
<p id="no-instance">You currently don't have any instances. You should add one!</p>
|
<p id="no-instance">You currently don't have any instances. You should add one!</p>
|
||||||
<form id="instanceSelectForm" class="align-start wfit-content"></form>
|
<form id="instanceSelectForm" class="align-start wfit-content"></form>
|
||||||
<br>
|
<br>
|
||||||
<button id="showAddInstanceDialog">Add an instance</button>
|
<button id="startAddInstanceFlow">Add an instance</button>
|
||||||
</center>
|
</center>
|
||||||
</div>
|
</div>
|
||||||
<div class="half-width align-self-start">
|
<div class="half-width align-self-start">
|
||||||
|
@ -108,6 +108,7 @@ Unchecking this is not recommended, and this option only exists for exceptional
|
||||||
<button type="reset" class="close">Cancel</button>
|
<button type="reset" class="close">Cancel</button>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<dialog id="spinner"><span class="spinner"></span></dialog>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -1,20 +1,22 @@
|
||||||
import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
|
import { AddInstanceFlow } from "./add_instance_flow.mjs";
|
||||||
import { findButtonOrFail, findDialogOrFail, findFormOrFail, findInputOrFail, findParagraphOrFail, findPreOrFail } from "./dom.mjs";
|
import { findButtonOrFail, findDialogOrFail, findFormOrFail, findInputOrFail, findParagraphOrFail, findPreOrFail } from "./dom.mjs";
|
||||||
import knownSoftware from "./known_software.mjs";
|
import knownSoftware from "./known_software.mjs";
|
||||||
import storageManager from "./storage_manager.mjs";
|
import storageManager from "./storage_manager.mjs";
|
||||||
|
|
||||||
const radioButtonName = "instanceSelect";
|
const radioButtonName = "instanceSelect";
|
||||||
|
|
||||||
|
let addInstanceFlow: AddInstanceFlow | undefined;
|
||||||
const mainDialog = findDialogOrFail(document.body, "#mainDialog");
|
const mainDialog = findDialogOrFail(document.body, "#mainDialog");
|
||||||
const showAddInstanceDialogButton = findButtonOrFail(document.body, "#showAddInstanceDialog");
|
const startAddInstanceFlowButton = findButtonOrFail(document.body, "#startAddInstanceFlow");
|
||||||
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
|
|
||||||
const addDialog = findDialogOrFail(document.body, "#addInstance");
|
const addDialog = findDialogOrFail(document.body, "#addInstance");
|
||||||
|
const spinnerDialog = findDialogOrFail(document.body, "#spinner");
|
||||||
|
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
|
||||||
const instanceSelectForm = findFormOrFail(document.body, "#instanceSelectForm");
|
const instanceSelectForm = findFormOrFail(document.body, "#instanceSelectForm");
|
||||||
const redirectButton = findButtonOrFail(document.body, "#redirect");
|
const redirectButton = findButtonOrFail(document.body, "#redirect");
|
||||||
const redirectAlwaysButton = findButtonOrFail(document.body, "#redirectAlways");
|
const redirectAlwaysButton = findButtonOrFail(document.body, "#redirectAlways");
|
||||||
const pathText = findPreOrFail(document.body, "#path");
|
const pathText = findPreOrFail(document.body, "#path");
|
||||||
|
|
||||||
showAddInstanceDialogButton.addEventListener("click", e => showAddInstanceDialog());
|
startAddInstanceFlowButton.addEventListener("click", e => addInstanceFlow?.start());
|
||||||
|
|
||||||
redirectButton.addEventListener("click", e => {
|
redirectButton.addEventListener("click", e => {
|
||||||
// Can be assumed to not fail because the button is disabled if there are no options and the first one is selected by default
|
// Can be assumed to not fail because the button is disabled if there are no options and the first one is selected by default
|
||||||
|
@ -28,9 +30,6 @@ redirectAlwaysButton.addEventListener("click", e => {
|
||||||
redirect(option);
|
redirect(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
let showAddInstanceDialog = () => { };
|
|
||||||
let hideAddInstanceDialog = () => { };
|
|
||||||
|
|
||||||
// Don't bother initializing if we're performing autoredirect
|
// Don't bother initializing if we're performing autoredirect
|
||||||
if (!autoRedirect()) {
|
if (!autoRedirect()) {
|
||||||
createInstanceSelectOptions();
|
createInstanceSelectOptions();
|
||||||
|
@ -40,10 +39,7 @@ if (!autoRedirect()) {
|
||||||
|
|
||||||
pathText.innerText = getTargetPath();
|
pathText.innerText = getTargetPath();
|
||||||
|
|
||||||
({
|
addInstanceFlow = new AddInstanceFlow(addDialog, spinnerDialog, detailsDialog);
|
||||||
showAddInstanceDialog,
|
|
||||||
hideAddInstanceDialog
|
|
||||||
} = initializeAddInstanceFlow(detailsDialog, addDialog));
|
|
||||||
|
|
||||||
mainDialog.show();
|
mainDialog.show();
|
||||||
};
|
};
|
||||||
|
|
41
static/dialog.mts
Normal file
41
static/dialog.mts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
export const ONCE = { once: true };
|
||||||
|
|
||||||
|
export class Dialog {
|
||||||
|
protected dialog: HTMLDialogElement;
|
||||||
|
|
||||||
|
constructor(dialog: HTMLDialogElement) {
|
||||||
|
this.dialog = dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function that should only be called once that has permanent effects on the DOM
|
||||||
|
*/
|
||||||
|
protected initializeDOM() { }
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.dialog.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.dialog.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FormDialog extends Dialog {
|
||||||
|
protected form: HTMLFormElement;
|
||||||
|
|
||||||
|
constructor(dialog: HTMLDialogElement, form: HTMLFormElement) {
|
||||||
|
super(dialog);
|
||||||
|
this.form = form;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override initializeDOM() {
|
||||||
|
super.initializeDOM();
|
||||||
|
|
||||||
|
this.dialog.addEventListener("close", e => this.reset());
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.form.reset();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,5 @@
|
||||||
|
@import url("/static/spinner.css");
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--red: #cb0b0b;
|
--red: #cb0b0b;
|
||||||
--blue: #2081c3;
|
--blue: #2081c3;
|
||||||
|
|
78
static/spinner.css
Normal file
78
static/spinner.css
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
/* Sourced and modified from https://cssloaders.github.io/ */
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: block;
|
||||||
|
animation: rotate 1s infinite;
|
||||||
|
height: 50px;
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner:before,
|
||||||
|
.spinner:after {
|
||||||
|
border-radius: 50%;
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner:before {
|
||||||
|
animation: ball1 1s infinite;
|
||||||
|
background-color: var(--red);
|
||||||
|
box-shadow: 30px 0 0 var(--blue);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner:after {
|
||||||
|
animation: ball2 1s infinite;
|
||||||
|
background-color: var(--blue);
|
||||||
|
box-shadow: 30px 0 0 var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg) scale(0.8)
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: rotate(360deg) scale(1.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(720deg) scale(0.8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ball1 {
|
||||||
|
0% {
|
||||||
|
box-shadow: 30px 0 0 var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 var(--blue);
|
||||||
|
margin-bottom: 0;
|
||||||
|
transform: translate(15px, 15px);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
box-shadow: 30px 0 0 var(--blue);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ball2 {
|
||||||
|
0% {
|
||||||
|
box-shadow: 30px 0 0 var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 var(--red);
|
||||||
|
margin-top: -20px;
|
||||||
|
transform: translate(15px, 15px);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
box-shadow: 30px 0 0 var(--red);
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"static/**.mts",
|
"static/**.mts",
|
||||||
|
|
Loading…
Add table
Reference in a new issue