Compare commits

...

3 commits

Author SHA1 Message Date
bfd61c2e50 Rewrite AddInstanceFlow
Now with spinner!
2025-02-03 03:52:11 +01:00
2be0658ed9 Rewrite AddInstanceDialog
Object oriented it is
2025-02-03 03:15:14 +01:00
5534bc3942 Make the CSP changes on config 2025-02-03 01:11:29 +01:00
10 changed files with 254 additions and 100 deletions

View file

@ -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,
callback: (
host: string, host: string,
secure: boolean, secure: boolean,
autoQueryMetadata: 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 { return {
showAddInstanceDialog, host: parsedHost.host,
hideAddInstanceDialog secure: parsedHost.secure,
autoQueryMetadata: this.autoQueryMetadata.checked
}; };
} }
#handleSubmit(resolve: (data: AddInstanceDialogData) => void) {
this.form.addEventListener("submit", e => {
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();
});
}
}

View file

@ -1,14 +1,27 @@
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 {
addDialog: AddInstanceDialog;
spinnerDialog: Dialog;
detailsDialog: HTMLDialogElement;
constructor(
addDialog: AddInstanceDialog | HTMLDialogElement,
spinnerDialog: HTMLDialogElement,
detailsDialog: HTMLDialogElement, detailsDialog: HTMLDialogElement,
addDialog: HTMLDialogElement ) {
): { if (addDialog instanceof AddInstanceDialog)
showAddInstanceDialog: () => void, this.addDialog = addDialog;
hideAddInstanceDialog: () => void else
} { this.addDialog = new AddInstanceDialog(addDialog, true);
this.spinnerDialog = new Dialog(spinnerDialog);
this.detailsDialog = detailsDialog;
}
async start() {
const instanceDetailsDialogCallback = ( const instanceDetailsDialogCallback = (
name: string, name: string,
host: string, host: string,
@ -31,39 +44,35 @@ export function initializeAddInstanceFlow(
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
};
} }

View file

@ -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">

View file

@ -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";

View file

@ -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>

View file

@ -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
View 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();
}
}

View file

@ -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
View 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;
}
}

View file

@ -4,6 +4,7 @@
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": true,
"strictNullChecks": true, "strictNullChecks": true,
"noImplicitOverride": true,
}, },
"include": [ "include": [
"static/**.mts", "static/**.mts",