Compare commits
No commits in common. "main" and "no-more-proxy" have entirely different histories.
main
...
no-more-pr
17 changed files with 242 additions and 624 deletions
|
@ -1,24 +0,0 @@
|
||||||
name: Build & Test
|
|
||||||
|
|
||||||
on: [push]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-run:
|
|
||||||
runs-on: docker
|
|
||||||
container:
|
|
||||||
image: rust
|
|
||||||
steps:
|
|
||||||
- name: Update package repos
|
|
||||||
run: apt update
|
|
||||||
- name: Install Node using apt
|
|
||||||
run: apt install nodejs -y
|
|
||||||
- name: Checkout repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Setup Deno
|
|
||||||
uses: https://github.com/denoland/setup-deno@v2
|
|
||||||
with:
|
|
||||||
deno-version: v2.x
|
|
||||||
- name: Build using Cargo
|
|
||||||
run: cargo build --verbose
|
|
||||||
- name: Run unit tests
|
|
||||||
run: cargo test --verbose
|
|
|
@ -136,8 +136,7 @@
|
||||||
],
|
],
|
||||||
"groups": [
|
"groups": [
|
||||||
"misskey-compliant",
|
"misskey-compliant",
|
||||||
"misskey-v13",
|
"misskey-v13"
|
||||||
"mastodon-compliant-api"
|
|
||||||
],
|
],
|
||||||
"forkOf": "misskey"
|
"forkOf": "misskey"
|
||||||
},
|
},
|
||||||
|
|
|
@ -98,17 +98,3 @@ impl<'r> FromParam<'r> for KnownInstanceSoftware<'r> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// If this test fails, known-software.json is invalid
|
|
||||||
#[test]
|
|
||||||
fn known_software_is_valid() {
|
|
||||||
assert!(!KNOWN_SOFTWARE.groups.is_empty());
|
|
||||||
assert!(!KNOWN_SOFTWARE.software.is_empty());
|
|
||||||
assert!(!KNOWN_SOFTWARE_NAMES.is_empty());
|
|
||||||
assert!(!KNOWN_SOFTWARE_NODEINFO_NAMES.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
// 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 {
|
||||||
|
@ -14,67 +13,43 @@ export function parseHost(host: string): { host: string, secure: boolean } | nul
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AddInstanceDialogData = {
|
export function initializeAddInstanceDialog(
|
||||||
host: string,
|
dialog: HTMLDialogElement,
|
||||||
secure: boolean,
|
callback: (
|
||||||
autoQueryMetadata: boolean,
|
host: string,
|
||||||
};
|
secure: boolean,
|
||||||
|
autoQueryMetadata: boolean,
|
||||||
|
) => void
|
||||||
|
): {
|
||||||
|
showAddInstanceDialog: () => void,
|
||||||
|
hideAddInstanceDialog: () => void,
|
||||||
|
} {
|
||||||
|
const showAddInstanceDialog = () => dialog.showModal();
|
||||||
|
const hideAddInstanceDialog = () => dialog.close();
|
||||||
|
|
||||||
export class AddInstanceDialog extends FormDialog {
|
const form = findFormOrFail(dialog, ".addInstanceForm");
|
||||||
protected instanceHost: HTMLInputElement;
|
const instanceHost = findInputOrFail(form, "#instanceHost");
|
||||||
protected autoQueryMetadata: HTMLInputElement;
|
const autoQueryMetadata = findInputOrFail(form, "#autoQueryMetadata");
|
||||||
protected closeButton: HTMLButtonElement;
|
const closeButton = findButtonOrFail(form, ".close");
|
||||||
|
|
||||||
constructor(dialog: HTMLDialogElement, initializeDOM: boolean = true) {
|
instanceHost.addEventListener("input", e => {
|
||||||
super(dialog, findFormOrFail(dialog, ".addInstanceForm"));
|
if (parseHost(instanceHost.value) === null)
|
||||||
|
instanceHost.setCustomValidity("Invalid instance hostname or URL");
|
||||||
|
else
|
||||||
|
instanceHost.setCustomValidity("");
|
||||||
|
});
|
||||||
|
|
||||||
this.instanceHost = findInputOrFail(this.form, "#instanceHost");
|
form.addEventListener("submit", e => {
|
||||||
this.autoQueryMetadata = findInputOrFail(this.form, "#autoQueryMetadata");
|
// A sane browser doesn't allow for submitting the form if the above validation fails
|
||||||
this.closeButton = findButtonOrFail(this.form, ".close");
|
const { host, secure } = parseHost(instanceHost.value)!;
|
||||||
|
callback(host, secure, autoQueryMetadata.checked);
|
||||||
|
form.reset();
|
||||||
|
});
|
||||||
|
|
||||||
if (initializeDOM) this.initializeDOM();
|
closeButton.addEventListener("click", e => hideAddInstanceDialog());
|
||||||
}
|
|
||||||
|
|
||||||
protected override initializeDOM() {
|
return {
|
||||||
super.initializeDOM();
|
showAddInstanceDialog,
|
||||||
|
hideAddInstanceDialog
|
||||||
this.instanceHost.addEventListener("input", e => this.#getDataIfValid());
|
};
|
||||||
this.closeButton.addEventListener("click", e => this.close());
|
|
||||||
}
|
|
||||||
|
|
||||||
#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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
#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);
|
|
||||||
}
|
|
||||||
|
|
||||||
async present(): Promise<AddInstanceDialogData> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.cancelOnceClosed(reject);
|
|
||||||
this.#handleSubmit(resolve);
|
|
||||||
this.open();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,73 +1,69 @@
|
||||||
import { AddInstanceDialog } from "./add_an_instance.mjs";
|
import { initializeAddInstanceDialog } from "./add_an_instance.mjs";
|
||||||
import { dialogDetailsToInstance, InstanceDetailsDialog, InstanceDetailsDialogData } 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 class AddInstanceFlow {
|
export function initializeAddInstanceFlow(
|
||||||
addDialog: AddInstanceDialog;
|
detailsDialog: HTMLDialogElement,
|
||||||
spinnerDialog: Dialog;
|
addDialog: HTMLDialogElement
|
||||||
detailsDialog: InstanceDetailsDialog;
|
): {
|
||||||
|
showAddInstanceDialog: () => void,
|
||||||
constructor(
|
hideAddInstanceDialog: () => void
|
||||||
addDialog: AddInstanceDialog | HTMLDialogElement,
|
} {
|
||||||
spinnerDialog: HTMLDialogElement,
|
const instanceDetailsDialogCallback = (
|
||||||
detailsDialog: InstanceDetailsDialog | HTMLDialogElement,
|
name: string,
|
||||||
) {
|
host: string,
|
||||||
if (addDialog instanceof AddInstanceDialog)
|
hostSecure: boolean,
|
||||||
this.addDialog = addDialog;
|
software: string,
|
||||||
else
|
icon: string | null
|
||||||
this.addDialog = new AddInstanceDialog(addDialog, true);
|
) => {
|
||||||
|
const instance: Instance = {
|
||||||
this.spinnerDialog = new Dialog(spinnerDialog);
|
name,
|
||||||
|
origin: `http${hostSecure ? "s" : ""}://${host}`,
|
||||||
if (detailsDialog instanceof InstanceDetailsDialog)
|
software,
|
||||||
this.detailsDialog = detailsDialog;
|
iconURL: icon ?? undefined
|
||||||
else
|
|
||||||
this.detailsDialog = new InstanceDetailsDialog(detailsDialog, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(autoSave: boolean) {
|
|
||||||
const {
|
|
||||||
autoQueryMetadata,
|
|
||||||
host,
|
|
||||||
secure,
|
|
||||||
} = await this.addDialog.present();
|
|
||||||
|
|
||||||
const detailsDialogData: InstanceDetailsDialogData = {
|
|
||||||
name: host,
|
|
||||||
host,
|
|
||||||
hostSecure: secure,
|
|
||||||
software: "",
|
|
||||||
iconURL: null,
|
|
||||||
preferredFor: []
|
|
||||||
};
|
};
|
||||||
|
storageManager.storage.instances.push(instance);
|
||||||
|
storageManager.save();
|
||||||
|
console.log("Successfully added new instance:", instance);
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
showInstanceDetailsDialog,
|
||||||
|
hideInstanceDetailsDialog,
|
||||||
|
populateInstanceDetailsDialog
|
||||||
|
} = initializeInstanceDetailsDialog(detailsDialog, instanceDetailsDialogCallback);
|
||||||
|
|
||||||
|
const addInstanceDialogCallback = async (
|
||||||
|
host: string,
|
||||||
|
secure: boolean,
|
||||||
|
autoQueryMetadata: boolean,
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
if (!autoQueryMetadata) throw null; // Skip to catch block
|
if (!autoQueryMetadata) throw new Error("Don't");
|
||||||
|
|
||||||
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) // I guess TS is too stupid to understand this?
|
|| !(typeof iconURL === "string" || iconURL === null)
|
||||||
)
|
)
|
||||||
throw new Error("Invalid API response");
|
throw new Error("Invalid API response");
|
||||||
|
populateInstanceDetailsDialog(name, host, secure, software, iconURL as string | null);
|
||||||
detailsDialogData.name = name;
|
} catch {
|
||||||
detailsDialogData.software = software;
|
populateInstanceDetailsDialog(host, host, secure, "", null);
|
||||||
detailsDialogData.iconURL = iconURL as string | null;
|
} finally {
|
||||||
} catch { }
|
showInstanceDetailsDialog();
|
||||||
this.spinnerDialog.close();
|
}
|
||||||
|
|
||||||
const finalData = await this.detailsDialog.present(detailsDialogData);
|
|
||||||
const instance = dialogDetailsToInstance(finalData, {});
|
|
||||||
|
|
||||||
storageManager.storage.instances.push(instance);
|
|
||||||
if (autoSave) storageManager.save();
|
|
||||||
console.log("Successfully added new instance:", instance);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
showAddInstanceDialog,
|
||||||
|
hideAddInstanceDialog
|
||||||
|
} = initializeAddInstanceDialog(addDialog, addInstanceDialogCallback);
|
||||||
|
|
||||||
|
return {
|
||||||
|
showAddInstanceDialog,
|
||||||
|
hideAddInstanceDialog
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,11 +6,13 @@
|
||||||
<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" src="/static/config.mjs"></script>
|
<script type="module">
|
||||||
|
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">
|
||||||
|
@ -25,7 +27,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 id="startAddInstanceFlow">Add an instance</button>
|
<button onclick="showAddInstanceDialog()">Add an instance</button>
|
||||||
</center>
|
</center>
|
||||||
</div>
|
</div>
|
||||||
<div class="half-width align-self-start">
|
<div class="half-width align-self-start">
|
||||||
|
@ -50,8 +52,9 @@
|
||||||
<br>
|
<br>
|
||||||
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
|
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
|
||||||
<label for="autoQueryMetadata">
|
<label for="autoQueryMetadata">
|
||||||
<abbr
|
<abbr title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.
|
||||||
title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.">
|
We do this on the backend to avoid CORS problems.
|
||||||
|
We do not track or save any requests or data.">
|
||||||
Automatically query metadata
|
Automatically query metadata
|
||||||
</abbr>
|
</abbr>
|
||||||
</label>
|
</label>
|
||||||
|
@ -101,15 +104,14 @@ Unchecking this is not recommended, and this option only exists for exceptional
|
||||||
<br>
|
<br>
|
||||||
<label for="defaultsList">Default option for:</label><br>
|
<label for="defaultsList">Default option for:</label><br>
|
||||||
<select id="defaultsList" class="full-width" multiple>
|
<select id="defaultsList" class="full-width" multiple>
|
||||||
<option id="noDefaults" value="" disabled>(None, use the "Redirect always" button to set!)</option>
|
<option value="" disabled>(None, use the "Redirect always" button to set!)</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="removeDefaults" type="button" disabled>Remove</button>
|
<button id="removeDefaults" disabled>Remove</button>
|
||||||
<br><br>
|
<br><br>
|
||||||
<button type="submit">OK</button>
|
<button type="submit">OK</button>
|
||||||
<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,35 +1,23 @@
|
||||||
import { AddInstanceFlow } from "./add_instance_flow.mjs";
|
import { parseHost } from "./add_an_instance.mjs";
|
||||||
import { dialogDetailsFromInstance, dialogDetailsToInstance, InstanceDetailsDialog } from "./confirm_instance_details.mjs";
|
import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
|
||||||
|
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs";
|
||||||
import { findButtonOrFail, findDialogOrFail, findOlOrFail } from "./dom.mjs";
|
import { findButtonOrFail, findDialogOrFail, findOlOrFail } from "./dom.mjs";
|
||||||
import storageManager, { Instance } from "./storage_manager.mjs";
|
import storageManager from "./storage_manager.mjs";
|
||||||
|
|
||||||
let reordering = false;
|
let reordering = false;
|
||||||
let unsaved = 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 startAddInstanceFlowButton = findButtonOrFail(document.body, "#startAddInstanceFlow");
|
|
||||||
const addDialog = findDialogOrFail(document.body, "#addInstance");
|
|
||||||
const spinnerDialog = findDialogOrFail(document.body, "#spinner");
|
|
||||||
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
|
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
|
||||||
|
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");
|
||||||
const resetButton = findButtonOrFail(document.body, "#reset");
|
|
||||||
|
|
||||||
let instanceDetailsDialog = new InstanceDetailsDialog(detailsDialog, true);
|
saveButton.addEventListener("click", e => {
|
||||||
let addInstanceFlow = new AddInstanceFlow(addDialog, spinnerDialog, instanceDetailsDialog);
|
storageManager.save();
|
||||||
|
|
||||||
startAddInstanceFlowButton.addEventListener("click", e => {
|
|
||||||
addInstanceFlow.start(false).then(_ => {
|
|
||||||
updateInstanceList();
|
|
||||||
unsavedChanges();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
saveButton.addEventListener("click", e => saveChanges());
|
|
||||||
|
|
||||||
reorderButton.addEventListener("click", () => {
|
reorderButton.addEventListener("click", () => {
|
||||||
reordering = !reordering;
|
reordering = !reordering;
|
||||||
if (!reordering) applyReordering();
|
if (!reordering) applyReordering();
|
||||||
|
@ -37,47 +25,22 @@ reorderButton.addEventListener("click", () => {
|
||||||
reorderButton.innerText = reordering ? "Finish reordering" : "Reorder";
|
reorderButton.innerText = reordering ? "Finish reordering" : "Reorder";
|
||||||
});
|
});
|
||||||
|
|
||||||
resetButton.addEventListener("click", e => {
|
export const getMainDialog = () => findDialogOrFail(document.body, "#mainDialog");
|
||||||
storageManager.reset();
|
|
||||||
updateInstanceList();
|
const {
|
||||||
unsavedChanges();
|
showInstanceDetailsDialog,
|
||||||
});
|
hideInstanceDetailsDialog,
|
||||||
|
populateInstanceDetailsDialog,
|
||||||
|
} = initializeInstanceDetailsDialog(detailsDialog, () => { });
|
||||||
|
|
||||||
|
export const {
|
||||||
|
showAddInstanceDialog,
|
||||||
|
hideAddInstanceDialog
|
||||||
|
} = initializeAddInstanceFlow(detailsDialog, addDialog);
|
||||||
|
|
||||||
updateInstanceList();
|
updateInstanceList();
|
||||||
storageManager.addSaveCallback(updateInstanceList);
|
storageManager.addSaveCallback(updateInstanceList);
|
||||||
|
|
||||||
mainDialog.show();
|
|
||||||
|
|
||||||
function saveChanges() {
|
|
||||||
storageManager.save();
|
|
||||||
unsaved = false;
|
|
||||||
saveButton.classList.remove("pulse-red");
|
|
||||||
}
|
|
||||||
|
|
||||||
function unsavedChanges() {
|
|
||||||
if (!unsaved) {
|
|
||||||
unsaved = true;
|
|
||||||
saveButton.classList.add("pulse-red");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function editInstance(instance: Instance) {
|
|
||||||
const data = dialogDetailsFromInstance(instance);
|
|
||||||
const newData = await instanceDetailsDialog.present(data);
|
|
||||||
dialogDetailsToInstance(newData, instance);
|
|
||||||
updateInstanceList();
|
|
||||||
unsavedChanges();
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteInstance(instance: Instance) {
|
|
||||||
storageManager.storage.instances.splice(
|
|
||||||
storageManager.storage.instances.indexOf(instance),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
updateInstanceList();
|
|
||||||
unsavedChanges();
|
|
||||||
}
|
|
||||||
|
|
||||||
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";
|
||||||
|
@ -123,11 +86,26 @@ function updateInstanceList() {
|
||||||
const editLink = document.createElement("a");
|
const editLink = document.createElement("a");
|
||||||
editLink.innerText = `Edit`;
|
editLink.innerText = `Edit`;
|
||||||
editLink.href = "#";
|
editLink.href = "#";
|
||||||
editLink.addEventListener("click", e => editInstance(instance));
|
editLink.addEventListener("click", e => {
|
||||||
|
const host = parseHost(instance.origin)!;
|
||||||
|
populateInstanceDetailsDialog(
|
||||||
|
instance.name,
|
||||||
|
host.host,
|
||||||
|
host.secure,
|
||||||
|
instance.software,
|
||||||
|
instance.iconURL ?? null
|
||||||
|
);
|
||||||
|
showInstanceDetailsDialog();
|
||||||
|
});
|
||||||
const deleteLink = document.createElement("a");
|
const deleteLink = document.createElement("a");
|
||||||
deleteLink.innerText = `Delete`;
|
deleteLink.innerText = `Delete`;
|
||||||
deleteLink.href = "#";
|
deleteLink.href = "#";
|
||||||
deleteLink.addEventListener("click", e => deleteInstance(instance));
|
deleteLink.addEventListener("click", e => {
|
||||||
|
storageManager.storage.instances.splice(
|
||||||
|
storageManager.storage.instances.indexOf(instance)
|
||||||
|
);
|
||||||
|
updateInstanceList();
|
||||||
|
});
|
||||||
label.append(editLink, " ", deleteLink);
|
label.append(editLink, " ", deleteLink);
|
||||||
}
|
}
|
||||||
li.appendChild(label);
|
li.appendChild(label);
|
||||||
|
@ -152,5 +130,4 @@ function applyReordering() {
|
||||||
indices.push(parseInt(option));
|
indices.push(parseInt(option));
|
||||||
}
|
}
|
||||||
storageManager.storage.instances = indices.map(i => storageManager.storage.instances[i]);
|
storageManager.storage.instances = indices.map(i => storageManager.storage.instances[i]);
|
||||||
unsavedChanges();
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,184 +1,81 @@
|
||||||
// This file handles the "Confirm instance details" dialog
|
// This file handles the "Confirm instance details" dialog
|
||||||
|
|
||||||
import { parseHost } from "./add_an_instance.mjs";
|
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findSelectOrFail } from "./dom.mjs";
|
||||||
import { FormDialog, ONCE } from "./dialog.mjs";
|
|
||||||
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findOptionOrFail, findSelectOrFail } from "./dom.mjs";
|
|
||||||
import knownSoftware from "./known_software.mjs";
|
import knownSoftware from "./known_software.mjs";
|
||||||
import { Instance } from "./storage_manager.mjs";
|
|
||||||
|
|
||||||
const blankImage = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
|
const blankImage = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
|
||||||
|
|
||||||
export function mergeHost(host: string, secure: boolean): string {
|
export function initializeInstanceDetailsDialog(
|
||||||
return `http${secure ? "s" : ""}://${host}`;
|
dialog: HTMLDialogElement,
|
||||||
}
|
callback: (
|
||||||
|
instanceName: string,
|
||||||
|
instanceHost: string,
|
||||||
|
instanceHostSecure: boolean,
|
||||||
|
instanceSoftware: string,
|
||||||
|
instanceIcon: string | null
|
||||||
|
) => void
|
||||||
|
): {
|
||||||
|
showInstanceDetailsDialog: () => void,
|
||||||
|
hideInstanceDetailsDialog: () => void,
|
||||||
|
populateInstanceDetailsDialog: (
|
||||||
|
instanceNameValue: string,
|
||||||
|
instanceHostValue: string,
|
||||||
|
instanceHostSecureValue: boolean,
|
||||||
|
instanceSoftwareValue: string,
|
||||||
|
instanceIconValue: string | null
|
||||||
|
) => void
|
||||||
|
} {
|
||||||
|
const showInstanceDetailsDialog = () => dialog.showModal();
|
||||||
|
const hideInstanceDetailsDialog = () => dialog.close();
|
||||||
|
|
||||||
export type InstanceDetailsDialogData = {
|
const form = findFormOrFail(dialog, ".instanceDetailsForm");
|
||||||
name: string,
|
const instanceName = findInputOrFail(form, "#instanceName");
|
||||||
host: string,
|
const instanceHost = findInputOrFail(form, "#instanceHost");
|
||||||
hostSecure: boolean,
|
const instanceHostSecure = findInputOrFail(form, "#instanceHostSecure");
|
||||||
software: string,
|
const instanceSoftware = findSelectOrFail(form, "#instanceSoftware");
|
||||||
iconURL: string | null,
|
const instanceIcon = findImageOrFail(form, "#instanceIcon");
|
||||||
preferredFor: string[],
|
const closeButton = findButtonOrFail(form, ".close");
|
||||||
};
|
|
||||||
|
for (const [name, software] of Object.entries(knownSoftware.software)) {
|
||||||
|
const option = new Option(software.name, name);
|
||||||
|
instanceSoftware.appendChild(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
instanceIcon.src = blankImage;
|
||||||
|
|
||||||
|
const populateInstanceDetailsDialog = (
|
||||||
|
instanceNameValue: string,
|
||||||
|
instanceHostValue: string,
|
||||||
|
instanceHostSecureValue: boolean,
|
||||||
|
instanceSoftwareValue: string,
|
||||||
|
instanceIconValue: string | null
|
||||||
|
) => {
|
||||||
|
instanceName.value = instanceNameValue;
|
||||||
|
instanceHost.value = instanceHostValue;
|
||||||
|
instanceHostSecure.checked = instanceHostSecureValue;
|
||||||
|
instanceSoftware.value = instanceSoftwareValue;
|
||||||
|
instanceIcon.src = instanceIconValue ?? blankImage;
|
||||||
|
};
|
||||||
|
|
||||||
|
form.addEventListener("submit", e => {
|
||||||
|
callback(
|
||||||
|
instanceName.value,
|
||||||
|
instanceHost.value,
|
||||||
|
instanceHostSecure.checked,
|
||||||
|
instanceSoftware.value,
|
||||||
|
instanceIcon.src
|
||||||
|
);
|
||||||
|
form.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
closeButton.addEventListener("click", e => {
|
||||||
|
instanceIcon.src = blankImage;
|
||||||
|
hideInstanceDetailsDialog();
|
||||||
|
});
|
||||||
|
|
||||||
export function dialogDetailsFromInstance(instance: Instance): InstanceDetailsDialogData {
|
|
||||||
const host = parseHost(instance.origin)!;
|
|
||||||
return {
|
return {
|
||||||
name: instance.name,
|
showInstanceDetailsDialog,
|
||||||
host: host.host,
|
hideInstanceDetailsDialog,
|
||||||
hostSecure: host.secure,
|
populateInstanceDetailsDialog
|
||||||
software: instance.software,
|
|
||||||
iconURL: instance.iconURL ?? null,
|
|
||||||
preferredFor: instance.preferredFor,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dialogDetailsToInstance(data: InstanceDetailsDialogData, instance: Partial<Instance>): Instance {
|
|
||||||
instance.name = data.name;
|
|
||||||
instance.origin = mergeHost(data.host, data.hostSecure);
|
|
||||||
instance.software = data.software;
|
|
||||||
instance.iconURL = data.iconURL ?? undefined;
|
|
||||||
instance.preferredFor = data.preferredFor;
|
|
||||||
return instance as Instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InstanceDetailsDialog extends FormDialog {
|
|
||||||
protected instanceName: HTMLInputElement;
|
|
||||||
protected instanceHost: HTMLInputElement;
|
|
||||||
protected instanceHostSecure: HTMLInputElement;
|
|
||||||
protected instanceSoftware: HTMLSelectElement;
|
|
||||||
protected instanceIcon: HTMLImageElement;
|
|
||||||
protected closeButton: HTMLButtonElement;
|
|
||||||
protected defaultsList?: {
|
|
||||||
list: HTMLSelectElement,
|
|
||||||
removeButton: HTMLButtonElement,
|
|
||||||
noDefaultsOption: HTMLOptionElement,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(dialog: HTMLDialogElement, initializeDOM: boolean = true) {
|
|
||||||
super(dialog, findFormOrFail(dialog, ".instanceDetailsForm"));
|
|
||||||
|
|
||||||
this.instanceName = findInputOrFail(this.form, "#instanceName");
|
|
||||||
this.instanceHost = findInputOrFail(this.form, "#instanceHost");
|
|
||||||
this.instanceHostSecure = findInputOrFail(this.form, "#instanceHostSecure");
|
|
||||||
this.instanceSoftware = findSelectOrFail(this.form, "#instanceSoftware");
|
|
||||||
this.instanceIcon = findImageOrFail(this.form, "#instanceIcon");
|
|
||||||
this.closeButton = findButtonOrFail(this.form, ".close");
|
|
||||||
try {
|
|
||||||
this.defaultsList = {
|
|
||||||
list: findSelectOrFail(this.form, "#defaultsList"),
|
|
||||||
removeButton: findButtonOrFail(this.form, "#removeDefaults"),
|
|
||||||
noDefaultsOption: findOptionOrFail(this.form, "#noDefaults"),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
this.defaultsList = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initializeDOM) this.initializeDOM();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override initializeDOM() {
|
|
||||||
super.initializeDOM();
|
|
||||||
|
|
||||||
for (const [name, software] of Object.entries(knownSoftware.software)) {
|
|
||||||
const option = new Option(software.name, name);
|
|
||||||
this.instanceSoftware.appendChild(option);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.instanceIcon.src = blankImage;
|
|
||||||
|
|
||||||
this.closeButton.addEventListener("click", e => this.close());
|
|
||||||
|
|
||||||
if (this.defaultsList) {
|
|
||||||
this.defaultsList.list.addEventListener("change", e => this.#handleListSelectionChange());
|
|
||||||
this.defaultsList.removeButton.addEventListener("click", e => this.#removeSelectedListOptions());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#getRemainingListOptions(): string[] {
|
|
||||||
if (!this.defaultsList) return [];
|
|
||||||
|
|
||||||
const items: string[] = [];
|
|
||||||
|
|
||||||
for (const option of this.defaultsList.list.options) {
|
|
||||||
if (option == this.defaultsList.noDefaultsOption) continue;
|
|
||||||
items.push(option.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
#removeSelectedListOptions() {
|
|
||||||
if (!this.defaultsList) return;
|
|
||||||
|
|
||||||
// Copy using spread because this breaks when the list changes mid-iteration
|
|
||||||
for (const option of [...this.defaultsList.list.selectedOptions]) {
|
|
||||||
option.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#handleListChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
#populateDefaultsList(items: string[]) {
|
|
||||||
if (!this.defaultsList) return;
|
|
||||||
|
|
||||||
while (this.defaultsList.list.children.length > 1)
|
|
||||||
this.defaultsList.list.removeChild(this.defaultsList.list.lastChild!);
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const option = document.createElement("option");
|
|
||||||
option.value = item;
|
|
||||||
option.innerText = knownSoftware.software[item]?.name ?? knownSoftware.groups[item]?.name ?? item;
|
|
||||||
this.defaultsList.list.appendChild(option);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#handleListChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
#handleListChange() {
|
|
||||||
this.#handleListEmpty();
|
|
||||||
this.#handleListSelectionChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
#handleListSelectionChange() {
|
|
||||||
if (!this.defaultsList) return;
|
|
||||||
this.defaultsList.removeButton.disabled = this.defaultsList.list.selectedOptions.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#handleListEmpty() {
|
|
||||||
if (!this.defaultsList) return;
|
|
||||||
if (this.defaultsList.list.children.length == 1) {
|
|
||||||
this.defaultsList.noDefaultsOption.removeAttribute("hidden");
|
|
||||||
} else {
|
|
||||||
this.defaultsList.noDefaultsOption.setAttribute("hidden", "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#handleSubmit(data: InstanceDetailsDialogData, resolve: (data: InstanceDetailsDialogData) => void) {
|
|
||||||
this.form.addEventListener("submit", e => {
|
|
||||||
data.name = this.instanceName.value;
|
|
||||||
data.host = this.instanceHost.value;
|
|
||||||
data.hostSecure = this.instanceHostSecure.checked;
|
|
||||||
data.software = this.instanceSoftware.value;
|
|
||||||
data.preferredFor = this.#getRemainingListOptions();
|
|
||||||
|
|
||||||
resolve(data);
|
|
||||||
this.close();
|
|
||||||
}, ONCE);
|
|
||||||
}
|
|
||||||
|
|
||||||
async present(data: InstanceDetailsDialogData): Promise<InstanceDetailsDialogData> {
|
|
||||||
this.instanceName.value = data.name;
|
|
||||||
this.instanceHost.value = data.host;
|
|
||||||
this.instanceHostSecure.checked = data.hostSecure;
|
|
||||||
this.instanceSoftware.value = data.software;
|
|
||||||
this.instanceIcon.src = data.iconURL ?? blankImage;
|
|
||||||
this.#populateDefaultsList(data.preferredFor);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.cancelOnceClosed(reject);
|
|
||||||
this.#handleSubmit(data, resolve);
|
|
||||||
this.open();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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="startAddInstanceFlow">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">
|
||||||
|
@ -54,8 +54,9 @@
|
||||||
<br>
|
<br>
|
||||||
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
|
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
|
||||||
<label for="autoQueryMetadata">
|
<label for="autoQueryMetadata">
|
||||||
<abbr
|
<abbr title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.
|
||||||
title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.">
|
We do this on the backend to avoid CORS problems.
|
||||||
|
We do not track or save any requests or data.">
|
||||||
Automatically query metadata
|
Automatically query metadata
|
||||||
</abbr>
|
</abbr>
|
||||||
</label>
|
</label>
|
||||||
|
@ -107,7 +108,6 @@ 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,37 +1,20 @@
|
||||||
import { AddInstanceFlow } from "./add_instance_flow.mjs";
|
import { initializeAddInstanceFlow } 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 RADIO_BUTTON_NAME = "instanceSelect";
|
const radioButtonName = "instanceSelect";
|
||||||
|
|
||||||
let addInstanceFlow: AddInstanceFlow | undefined;
|
|
||||||
|
|
||||||
const mainDialog = findDialogOrFail(document.body, "#mainDialog");
|
const mainDialog = findDialogOrFail(document.body, "#mainDialog");
|
||||||
const startAddInstanceFlowButton = findButtonOrFail(document.body, "#startAddInstanceFlow");
|
const showAddInstanceDialogButton = findButtonOrFail(document.body, "#showAddInstanceDialog");
|
||||||
const addDialog = findDialogOrFail(document.body, "#addInstance");
|
|
||||||
const spinnerDialog = findDialogOrFail(document.body, "#spinner");
|
|
||||||
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
|
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
|
||||||
|
const addDialog = findDialogOrFail(document.body, "#addInstance");
|
||||||
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");
|
||||||
|
|
||||||
// Don't bother initializing if we're performing autoredirect
|
showAddInstanceDialogButton.addEventListener("click", e => showAddInstanceDialog());
|
||||||
if (!autoRedirect()) {
|
|
||||||
createInstanceSelectOptions();
|
|
||||||
storageManager.addSaveCallback(createInstanceSelectOptions);
|
|
||||||
updateNoInstanceHint();
|
|
||||||
storageManager.addSaveCallback(updateNoInstanceHint);
|
|
||||||
|
|
||||||
pathText.innerText = getTargetPath();
|
|
||||||
|
|
||||||
addInstanceFlow = new AddInstanceFlow(addDialog, spinnerDialog, detailsDialog);
|
|
||||||
|
|
||||||
mainDialog.show();
|
|
||||||
};
|
|
||||||
|
|
||||||
startAddInstanceFlowButton.addEventListener("click", e => addInstanceFlow?.start(true));
|
|
||||||
|
|
||||||
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
|
||||||
|
@ -45,6 +28,26 @@ redirectAlwaysButton.addEventListener("click", e => {
|
||||||
redirect(option);
|
redirect(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let showAddInstanceDialog = () => { };
|
||||||
|
let hideAddInstanceDialog = () => { };
|
||||||
|
|
||||||
|
// Don't bother initializing if we're performing autoredirect
|
||||||
|
if (!autoRedirect()) {
|
||||||
|
createInstanceSelectOptions();
|
||||||
|
storageManager.addSaveCallback(createInstanceSelectOptions);
|
||||||
|
updateNoInstanceHint();
|
||||||
|
storageManager.addSaveCallback(updateNoInstanceHint);
|
||||||
|
|
||||||
|
pathText.innerText = getTargetPath();
|
||||||
|
|
||||||
|
({
|
||||||
|
showAddInstanceDialog,
|
||||||
|
hideAddInstanceDialog
|
||||||
|
} = initializeAddInstanceFlow(detailsDialog, addDialog));
|
||||||
|
|
||||||
|
mainDialog.show();
|
||||||
|
};
|
||||||
|
|
||||||
function updateNoInstanceHint() {
|
function updateNoInstanceHint() {
|
||||||
findParagraphOrFail(document.body, "#no-instance").style.display =
|
findParagraphOrFail(document.body, "#no-instance").style.display =
|
||||||
storageManager.storage.instances.length > 0
|
storageManager.storage.instances.length > 0
|
||||||
|
@ -61,7 +64,7 @@ function createInstanceSelectOptions() {
|
||||||
radio.id = instance.origin;
|
radio.id = instance.origin;
|
||||||
radio.value = instance.origin;
|
radio.value = instance.origin;
|
||||||
radio.type = "radio";
|
radio.type = "radio";
|
||||||
radio.name = RADIO_BUTTON_NAME;
|
radio.name = radioButtonName;
|
||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
label.htmlFor = instance.origin;
|
label.htmlFor = instance.origin;
|
||||||
label.innerText = instance.name + " ";
|
label.innerText = instance.name + " ";
|
||||||
|
@ -108,7 +111,7 @@ function getTargetPath(): string {
|
||||||
|
|
||||||
function getSelectedOption(): string | null {
|
function getSelectedOption(): string | null {
|
||||||
try {
|
try {
|
||||||
return findInputOrFail(instanceSelectForm, `input[name="${RADIO_BUTTON_NAME}"]:checked`).value;
|
return findInputOrFail(instanceSelectForm, `input[name="${radioButtonName}"]:checked`).value;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -127,6 +130,7 @@ function autoRedirect(): boolean {
|
||||||
function setAutoRedirect(option: string) {
|
function setAutoRedirect(option: string) {
|
||||||
const instance = storageManager.storage.instances.find(e => e.origin === option);
|
const instance = storageManager.storage.instances.find(e => e.origin === option);
|
||||||
if (!instance) throw new Error("Invalid argument");
|
if (!instance) throw new Error("Invalid argument");
|
||||||
|
instance.preferredFor ??= [];
|
||||||
instance.preferredFor.push(getTargetSoftwareOrGroup());
|
instance.preferredFor.push(getTargetSoftwareOrGroup());
|
||||||
storageManager.save();
|
storageManager.save();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
import { LocalStorage } from "./storage_manager.mjs"
|
|
||||||
|
|
||||||
type InstanceV0 = {
|
|
||||||
name: string,
|
|
||||||
origin: string,
|
|
||||||
software: string,
|
|
||||||
iconURL?: string,
|
|
||||||
preferredFor?: string[],
|
|
||||||
}
|
|
||||||
type LocalStorageV0 = {
|
|
||||||
version: undefined,
|
|
||||||
instances: InstanceV0[],
|
|
||||||
}
|
|
||||||
|
|
||||||
type LocalStorageV1 = LocalStorage;
|
|
||||||
|
|
||||||
function migrate0to1(s: LocalStorageV0): LocalStorageV1 {
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
instances: s.instances.map(i => ({
|
|
||||||
preferredFor: i.preferredFor ?? [],
|
|
||||||
...i
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type AnyLocalStorage = LocalStorageV0 | LocalStorageV1;
|
|
||||||
|
|
||||||
export default function migrate(storage: AnyLocalStorage): LocalStorage {
|
|
||||||
switch (storage.version) {
|
|
||||||
case undefined:
|
|
||||||
storage = migrate0to1(storage);
|
|
||||||
case 1:
|
|
||||||
default:
|
|
||||||
return storage;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,46 +0,0 @@
|
||||||
export const ONCE = { once: true };
|
|
||||||
export const CANCELLED = Symbol("Cancelled");
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected cancelOnceClosed(reject: (reason?: any) => void) {
|
|
||||||
this.dialog.addEventListener("close", e => reject(CANCELLED), ONCE);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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,13 +1,6 @@
|
||||||
// I would've LOVED to use generics for this but unfortunately that's not possible.
|
// I would've LOVED to use generics for this but unfortunately that's not possible.
|
||||||
// Type safety, but at what cost... >~< thanks TypeScript
|
// Type safety, but at what cost... >~< thanks TypeScript
|
||||||
|
|
||||||
export function findOptionOrFail(on: Element, selector: string): HTMLOptionElement {
|
|
||||||
const element = on.querySelector(selector);
|
|
||||||
if (!(element instanceof HTMLOptionElement))
|
|
||||||
throw new Error(`${selector} isn't an option`);
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findOlOrFail(on: Element, selector: string): HTMLOListElement {
|
export function findOlOrFail(on: Element, selector: string): HTMLOListElement {
|
||||||
const element = on.querySelector(selector);
|
const element = on.querySelector(selector);
|
||||||
if (!(element instanceof HTMLOListElement))
|
if (!(element instanceof HTMLOListElement))
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
@import url("/static/spinner.css");
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--red: #cb0b0b;
|
--red: #cb0b0b;
|
||||||
--blue: #2081c3;
|
--blue: #2081c3;
|
||||||
|
@ -184,18 +182,4 @@ abbr[title] {
|
||||||
|
|
||||||
.buttonPanel>* {
|
.buttonPanel>* {
|
||||||
margin-top: min(var(--xl), 6vh);
|
margin-top: min(var(--xl), 6vh);
|
||||||
}
|
|
||||||
|
|
||||||
.pulse-red {
|
|
||||||
animation: 1s ease-in-out 0s infinite alternate both running pulse-red-anim;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-red-anim {
|
|
||||||
0% {
|
|
||||||
box-shadow: 0px 0px 0px var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
box-shadow: 0px 0px 20px var(--red);
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,78 +0,0 @@
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,5 +1,3 @@
|
||||||
import migrate from "./data_migration.mjs";
|
|
||||||
|
|
||||||
export type Instance = {
|
export type Instance = {
|
||||||
/**
|
/**
|
||||||
* The instance's (nick)name
|
* The instance's (nick)name
|
||||||
|
@ -23,19 +21,18 @@ export type Instance = {
|
||||||
software: string,
|
software: string,
|
||||||
/**
|
/**
|
||||||
* The instance's icon URL
|
* The instance's icon URL
|
||||||
* @example undefined
|
*
|
||||||
* @example "https://void.lgbt/favicon.png"
|
* Make sure to sanitize this! Could lead to XSS
|
||||||
*/
|
*/
|
||||||
iconURL?: string,
|
iconURL?: string,
|
||||||
/**
|
/**
|
||||||
* The list of software names and groups the user prefers to autoredirect to this instance
|
* The list of software names and groups the user prefers to autoredirect to this instance
|
||||||
* @example ["sharkey", "misskey-compliant"]
|
* @example ["sharkey", "misskey-compliant"]
|
||||||
*/
|
*/
|
||||||
preferredFor: string[],
|
preferredFor?: string[],
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocalStorage = {
|
type LocalStorage = {
|
||||||
version: number,
|
|
||||||
instances: Instance[],
|
instances: Instance[],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,14 +46,12 @@ export default new class StorageManager {
|
||||||
|
|
||||||
default(): LocalStorage {
|
default(): LocalStorage {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
|
||||||
instances: []
|
instances: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
load() {
|
load() {
|
||||||
const data = JSON.parse(window.localStorage.getItem("storage") ?? "null") ?? this.default();
|
this.storage = JSON.parse(window.localStorage.getItem("storage") ?? "null") ?? this.default();
|
||||||
this.storage = migrate(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
|
@ -67,8 +62,4 @@ export default new class StorageManager {
|
||||||
addSaveCallback(callback: () => void) {
|
addSaveCallback(callback: () => void) {
|
||||||
this.saveCallbacks.push(callback);
|
this.saveCallbacks.push(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.storage = this.default();
|
|
||||||
}
|
|
||||||
}();
|
}();
|
|
@ -4,7 +4,6 @@
|
||||||
"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