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
import { FormDialog, ONCE } from "./dialog.mjs";
import { findButtonOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs";
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(
dialog: HTMLDialogElement,
callback: (
host: string,
secure: boolean,
autoQueryMetadata: boolean,
) => void
): {
showAddInstanceDialog: () => void,
hideAddInstanceDialog: () => void,
} {
const showAddInstanceDialog = () => dialog.showModal();
const hideAddInstanceDialog = () => dialog.close();
type AddInstanceDialogData = {
host: string,
secure: boolean,
autoQueryMetadata: boolean,
};
const form = findFormOrFail(dialog, ".addInstanceForm");
const instanceHost = findInputOrFail(form, "#instanceHost");
const autoQueryMetadata = findInputOrFail(form, "#autoQueryMetadata");
const closeButton = findButtonOrFail(form, ".close");
export class AddInstanceDialog extends FormDialog {
protected instanceHost: HTMLInputElement;
protected autoQueryMetadata: HTMLInputElement;
protected closeButton: HTMLButtonElement;
instanceHost.addEventListener("input", e => {
if (parseHost(instanceHost.value) === null)
instanceHost.setCustomValidity("Invalid instance hostname or URL");
else
instanceHost.setCustomValidity("");
});
constructor(dialog: HTMLDialogElement, initializeDOM: boolean = true) {
super(dialog, findFormOrFail(dialog, ".addInstanceForm"));
this.instanceHost = findInputOrFail(this.form, "#instanceHost");
this.autoQueryMetadata = findInputOrFail(this.form, "#autoQueryMetadata");
this.closeButton = findButtonOrFail(this.form, ".close");
form.addEventListener("submit", e => {
// 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();
});
if (initializeDOM) this.initializeDOM();
}
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 {
showAddInstanceDialog,
hideAddInstanceDialog
};
#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,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 { Dialog } from "./dialog.mjs";
import storageManager, { Instance } from "./storage_manager.mjs";
export function initializeAddInstanceFlow(
detailsDialog: HTMLDialogElement,
addDialog: HTMLDialogElement
): {
showAddInstanceDialog: () => void,
hideAddInstanceDialog: () => void
} {
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
export class AddInstanceFlow {
addDialog: AddInstanceDialog;
spinnerDialog: Dialog;
detailsDialog: HTMLDialogElement;
constructor(
addDialog: AddInstanceDialog | HTMLDialogElement,
spinnerDialog: HTMLDialogElement,
detailsDialog: HTMLDialogElement,
) {
if (addDialog instanceof AddInstanceDialog)
this.addDialog = addDialog;
else
this.addDialog = new AddInstanceDialog(addDialog, true);
this.spinnerDialog = new Dialog(spinnerDialog);
this.detailsDialog = detailsDialog;
}
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 {
showInstanceDetailsDialog,
hideInstanceDetailsDialog,
populateInstanceDetailsDialog
} = initializeInstanceDetailsDialog(detailsDialog, instanceDetailsDialogCallback);
const {
showInstanceDetailsDialog,
hideInstanceDetailsDialog,
populateInstanceDetailsDialog
} = initializeInstanceDetailsDialog(this.detailsDialog, instanceDetailsDialogCallback);
const {
autoQueryMetadata,
host,
secure,
} = await this.addDialog.prompt();
const addInstanceDialogCallback = async (
host: string,
secure: boolean,
autoQueryMetadata: boolean,
) => {
try {
if (!autoQueryMetadata) throw new Error("Don't");
if (!autoQueryMetadata) throw null; // Skip to catch block
this.spinnerDialog.open();
const { name, software, iconURL } =
await fetch(`/api/instance_info/${secure}/${encodeURIComponent(host)}`)
.then(r => r.json());
if (
typeof name !== "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");
populateInstanceDetailsDialog(name, host, secure, software, iconURL as string | null);
} catch {
populateInstanceDetailsDialog(host, host, secure, "", null);
} finally {
this.spinnerDialog.close();
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">
<title>FeDirect</title>
<link rel="stylesheet" href="/static/main.css">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *;">
</head>
<body>
<script type="module">
Object.assign(globalThis, await import("/static/config.mjs"));
getMainDialog().show(); // Don't show until the page is ready
</script>
<script type="module" src="/static/config.mjs"></script>
<div class="flex-vcenter">
<dialog id="mainDialog" class="half-width half-height">
<header class="separator-bottom margin-large-bottom">
@ -27,7 +25,7 @@
<center class="half-width">
<ol id="instanceList" class="align-start wfit-content"></ol>
<br>
<button onclick="showAddInstanceDialog()">Add an instance</button>
<button id="showAddInstanceDialog">Add an instance</button>
</center>
</div>
<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
let elementBeingDragged: HTMLLIElement | undefined;
const mainDialog = findDialogOrFail(document.body, "#mainDialog");
const showAddInstanceDialogButton = findButtonOrFail(document.body, "#showAddInstanceDialog");
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
const addDialog = findDialogOrFail(document.body, "#addInstance");
const instanceList = findOlOrFail(document.body, "#instanceList");
const saveButton = findButtonOrFail(document.body, "#save");
const reorderButton = findButtonOrFail(document.body, "#reorder");
showAddInstanceDialogButton.addEventListener("click", e => showAddInstanceDialog());
saveButton.addEventListener("click", e => {
storageManager.save();
});
@ -25,15 +29,13 @@ reorderButton.addEventListener("click", () => {
reorderButton.innerText = reordering ? "Finish reordering" : "Reorder";
});
export const getMainDialog = () => findDialogOrFail(document.body, "#mainDialog");
const {
showInstanceDetailsDialog,
hideInstanceDetailsDialog,
populateInstanceDetailsDialog,
} = initializeInstanceDetailsDialog(detailsDialog, () => { });
export const {
const {
showAddInstanceDialog,
hideAddInstanceDialog
} = initializeAddInstanceFlow(detailsDialog, addDialog);
@ -41,6 +43,8 @@ export const {
updateInstanceList();
storageManager.addSaveCallback(updateInstanceList);
mainDialog.show();
function updateInstanceList() {
instanceList.replaceChildren(); // Erase all child nodes
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>
<form id="instanceSelectForm" class="align-start wfit-content"></form>
<br>
<button id="showAddInstanceDialog">Add an instance</button>
<button id="startAddInstanceFlow">Add an instance</button>
</center>
</div>
<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>
</form>
</dialog>
<dialog id="spinner"><span class="spinner"></span></dialog>
</body>
</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 knownSoftware from "./known_software.mjs";
import storageManager from "./storage_manager.mjs";
const radioButtonName = "instanceSelect";
let addInstanceFlow: AddInstanceFlow | undefined;
const mainDialog = findDialogOrFail(document.body, "#mainDialog");
const showAddInstanceDialogButton = findButtonOrFail(document.body, "#showAddInstanceDialog");
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
const startAddInstanceFlowButton = findButtonOrFail(document.body, "#startAddInstanceFlow");
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 redirectButton = findButtonOrFail(document.body, "#redirect");
const redirectAlwaysButton = findButtonOrFail(document.body, "#redirectAlways");
const pathText = findPreOrFail(document.body, "#path");
showAddInstanceDialogButton.addEventListener("click", e => showAddInstanceDialog());
startAddInstanceFlowButton.addEventListener("click", e => addInstanceFlow?.start());
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
@ -28,9 +30,6 @@ redirectAlwaysButton.addEventListener("click", e => {
redirect(option);
});
let showAddInstanceDialog = () => { };
let hideAddInstanceDialog = () => { };
// Don't bother initializing if we're performing autoredirect
if (!autoRedirect()) {
createInstanceSelectOptions();
@ -40,10 +39,7 @@ if (!autoRedirect()) {
pathText.innerText = getTargetPath();
({
showAddInstanceDialog,
hideAddInstanceDialog
} = initializeAddInstanceFlow(detailsDialog, addDialog));
addInstanceFlow = new AddInstanceFlow(addDialog, spinnerDialog, detailsDialog);
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 {
--red: #cb0b0b;
--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,
"checkJs": true,
"strictNullChecks": true,
"noImplicitOverride": true,
},
"include": [
"static/**.mts",