Merge pull request 'Finish Add Instance Flow' (#1) from feat/add-instance-2 into main
Reviewed-on: https://git.gay/Nekomata/FeDirect/pulls/1
This commit is contained in:
commit
6a12b59f87
13 changed files with 388 additions and 32 deletions
28
Cargo.lock
generated
28
Cargo.lock
generated
|
@ -279,6 +279,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
|||
name = "fedirect"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"reqwest",
|
||||
"rocket",
|
||||
"semver",
|
||||
|
@ -367,6 +368,17 @@ version = "0.3.31"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.31"
|
||||
|
@ -388,6 +400,7 @@ dependencies = [
|
|||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
|
@ -1318,11 +1331,13 @@ dependencies = [
|
|||
"system-configuration",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"windows-registry",
|
||||
]
|
||||
|
@ -2159,6 +2174,19 @@ version = "0.2.99"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.76"
|
||||
|
|
|
@ -4,7 +4,8 @@ version = "0.1.0"
|
|||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
reqwest = "0.12.12"
|
||||
bytes = "1.9.0"
|
||||
reqwest = { version = "0.12.12", features = ["stream"] }
|
||||
rocket = { version = "0.5.1", features = ["json"] }
|
||||
semver = "1.0.24"
|
||||
serde = { version = "1.0.217", features = ["derive"] }
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
use rocket::Route;
|
||||
|
||||
pub mod instance_info;
|
||||
pub mod proxy;
|
||||
|
||||
pub fn get_routes() -> Vec<Route> {
|
||||
routes![instance_info::instance_info]
|
||||
routes![instance_info::instance_info, proxy::proxy]
|
||||
}
|
||||
|
|
30
src/api/proxy.rs
Normal file
30
src/api/proxy.rs
Normal file
|
@ -0,0 +1,30 @@
|
|||
use bytes::Bytes;
|
||||
use rocket::response::stream::ByteStream;
|
||||
|
||||
/// Copy-pasted and modified from https://github.com/rwf2/Rocket/issues/1521
|
||||
mod req {
|
||||
use super::*;
|
||||
use rocket::response::Debug;
|
||||
|
||||
pub type Result<T, E = Debug<reqwest::Error>> = std::result::Result<T, E>;
|
||||
|
||||
pub async fn get(url: &str) -> Result<ByteStream![Bytes]> {
|
||||
let bytes_stream = reqwest::get(url).await?.bytes_stream();
|
||||
Ok(ByteStream! {
|
||||
for await bytes in bytes_stream {
|
||||
match bytes {
|
||||
Ok(bytes) => yield bytes,
|
||||
Err(e) => {
|
||||
eprintln!("error while streaming: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/proxy/<url>")]
|
||||
pub async fn proxy(url: &str) -> req::Result<ByteStream![Bytes]> {
|
||||
req::get(url).await
|
||||
}
|
|
@ -1,29 +1,36 @@
|
|||
// This file handles the "Add an instance" dialog
|
||||
|
||||
import { findButtonOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs";
|
||||
|
||||
export function parseHost(host: string): { host: string, secure: boolean } | null {
|
||||
let parsedInstance = URL.parse(host);
|
||||
parsedInstance ??= URL.parse("https://" + host);
|
||||
if (!parsedInstance?.host) return null;
|
||||
if (!/https?:/.test(parsedInstance.protocol)) return null;
|
||||
return {
|
||||
host: parsedInstance.host,
|
||||
secure: parsedInstance.protocol === "https:"
|
||||
};
|
||||
}
|
||||
|
||||
export function initializeAddInstanceDialog(dialog: HTMLDialogElement): {
|
||||
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();
|
||||
|
||||
const form = dialog.querySelector(".addInstanceForm");
|
||||
if (!(form instanceof HTMLFormElement))
|
||||
throw new Error(".addInstanceForm isn't a form");
|
||||
|
||||
const instanceHost = form.querySelector("#instanceHost");
|
||||
if (!(instanceHost instanceof HTMLInputElement))
|
||||
throw new Error("#instanceHost isn't an input");
|
||||
const form = findFormOrFail(dialog, ".addInstanceForm");
|
||||
const instanceHost = findInputOrFail(form, "#instanceHost");
|
||||
const autoQueryMetadata = findInputOrFail(form, "#autoQueryMetadata");
|
||||
const closeButton = findButtonOrFail(form, ".close");
|
||||
|
||||
instanceHost.addEventListener("input", e => {
|
||||
if (parseHost(instanceHost.value) === null)
|
||||
|
@ -32,19 +39,13 @@ export function initializeAddInstanceDialog(dialog: HTMLDialogElement): {
|
|||
instanceHost.setCustomValidity("");
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async e => {
|
||||
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)!;
|
||||
console.log(
|
||||
await fetch(`/api/instance_info/${secure}/${encodeURI(host)}`).then(r => r.json())
|
||||
);
|
||||
callback(host, secure, autoQueryMetadata.checked);
|
||||
form.reset();
|
||||
});
|
||||
|
||||
const closeButton = form.querySelector(".close");
|
||||
if (!(closeButton instanceof HTMLButtonElement))
|
||||
throw new Error(".close isn't a button");
|
||||
|
||||
closeButton.addEventListener("click", e => hideAddInstanceDialog());
|
||||
|
||||
return {
|
||||
|
|
69
static/add_instance_flow.mts
Normal file
69
static/add_instance_flow.mts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { initializeAddInstanceDialog } from "./add_an_instance.mjs";
|
||||
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.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
|
||||
};
|
||||
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 {
|
||||
if (!autoQueryMetadata) throw new Error("Don't");
|
||||
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)
|
||||
)
|
||||
throw new Error("Invalid API response");
|
||||
populateInstanceDetailsDialog(name, host, secure, software, iconURL as string | null);
|
||||
} catch {
|
||||
populateInstanceDetailsDialog(host, host, secure, "", null);
|
||||
} finally {
|
||||
showInstanceDetailsDialog();
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
showAddInstanceDialog,
|
||||
hideAddInstanceDialog
|
||||
} = initializeAddInstanceDialog(addDialog, addInstanceDialogCallback);
|
||||
|
||||
return {
|
||||
showAddInstanceDialog,
|
||||
hideAddInstanceDialog
|
||||
};
|
||||
}
|
88
static/confirm_instance_details.mts
Normal file
88
static/confirm_instance_details.mts
Normal file
|
@ -0,0 +1,88 @@
|
|||
// This file handles the "Confirm instance details" dialog
|
||||
|
||||
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findSelectOrFail } from "./dom.mjs";
|
||||
import { resize } from "./image.mjs";
|
||||
import knownSoftware from "./known_software.mjs";
|
||||
|
||||
const blankImage = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
|
||||
|
||||
export function initializeInstanceDetailsDialog(
|
||||
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();
|
||||
|
||||
const form = findFormOrFail(dialog, ".instanceDetailsForm");
|
||||
const instanceName = findInputOrFail(form, "#instanceName");
|
||||
const instanceHost = findInputOrFail(form, "#instanceHost");
|
||||
const instanceHostSecure = findInputOrFail(form, "#instanceHostSecure");
|
||||
const instanceSoftware = findSelectOrFail(form, "#instanceSoftware");
|
||||
const instanceIcon = findImageOrFail(form, "#instanceIcon");
|
||||
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 === null ? blankImage : `/api/proxy/${encodeURIComponent(instanceIconValue)}`;
|
||||
};
|
||||
|
||||
form.addEventListener("submit", e => {
|
||||
let image: string | null = null;
|
||||
if (instanceIcon.src !== blankImage) {
|
||||
try {
|
||||
image = resize(instanceIcon);
|
||||
} catch { }
|
||||
}
|
||||
callback(
|
||||
instanceName.value,
|
||||
instanceHost.value,
|
||||
instanceHostSecure.checked,
|
||||
instanceSoftware.value,
|
||||
image
|
||||
);
|
||||
form.reset();
|
||||
});
|
||||
|
||||
closeButton.addEventListener("click", e => {
|
||||
instanceIcon.src = blankImage;
|
||||
hideInstanceDetailsDialog();
|
||||
});
|
||||
|
||||
return {
|
||||
showInstanceDetailsDialog,
|
||||
hideInstanceDetailsDialog,
|
||||
populateInstanceDetailsDialog
|
||||
};
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
:root {
|
||||
--red: #cb0b0b;
|
||||
--blue: #2081c3;
|
||||
--transparent-black: #0008;
|
||||
--large: 2em;
|
||||
--medium: 1em;
|
||||
}
|
||||
|
@ -25,7 +26,7 @@ dialog {
|
|||
|
||||
dialog::backdrop {
|
||||
backdrop-filter: blur(5px);
|
||||
background-color: #0008;
|
||||
background-color: var(--transparent-black);
|
||||
transition: background-color 0.125s ease-out;
|
||||
|
||||
@starting-style {
|
||||
|
@ -65,6 +66,16 @@ abbr[title] {
|
|||
flex-direction: row;
|
||||
}
|
||||
|
||||
.flex-row-reverse {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.flex-column-reverse {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.half-width {
|
||||
min-width: 50%;
|
||||
}
|
||||
|
@ -73,8 +84,12 @@ abbr[title] {
|
|||
min-height: 50%;
|
||||
}
|
||||
|
||||
.full-height {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.separator-bottom {
|
||||
border-bottom: solid 1px #0008;
|
||||
border-bottom: solid 1px var(--transparent-black);
|
||||
}
|
||||
|
||||
.margin-auto-top {
|
||||
|
@ -84,3 +99,24 @@ abbr[title] {
|
|||
.margin-large-bottom {
|
||||
margin-bottom: var(--large);
|
||||
}
|
||||
|
||||
.square {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
padding: 1px;
|
||||
border: solid 1px var(--transparent-black);
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: relative;
|
||||
max-width: 64px;
|
||||
max-height: 64px;
|
||||
margin: auto;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
translate: -50% -50%;
|
||||
}
|
|
@ -47,7 +47,7 @@
|
|||
<br>
|
||||
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
|
||||
<label for="autoQueryMetadata">
|
||||
<abbr title="This makes a single API request to FeDirect to automatically try to detect instance metadata, such as the name, software, and an icon.
|
||||
<abbr 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
|
||||
|
@ -58,6 +58,50 @@ We do not track or save any requests or data.">
|
|||
<button type="reset" class="close">Cancel</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="instanceDetails">
|
||||
<h1>Confirm instance details</h1>
|
||||
<form method="dialog" class="instanceDetailsForm">
|
||||
<div class="flex-row">
|
||||
<div class="half-width">
|
||||
<label for="instanceName">Instance name</label>
|
||||
<br>
|
||||
<input id="instanceName" type="text" name="instanceName" required />
|
||||
<br><br>
|
||||
<label for="instanceHost">Instance hostname</label>
|
||||
<br>
|
||||
<input id="instanceHost" type="text" name="instanceHost" required />
|
||||
<br>
|
||||
<input type="checkbox" name="instanceHostSecure" id="instanceHostSecure" checked />
|
||||
<label for="instanceHostSecure">
|
||||
<abbr title="Whether to use HTTPS (as opposed to HTTP).
|
||||
Unchecking this is not recommended, and this option only exists for exceptional cases">
|
||||
Secure?
|
||||
</abbr>
|
||||
</label>
|
||||
<br><br>
|
||||
<label for="instanceSoftware">Instance software</label>
|
||||
<br>
|
||||
<select id="instanceSoftware" type="text" name="instanceSoftware" required>
|
||||
<option value="">(Please select)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="half-width flex-row-reverse">
|
||||
<div class="full-height flex-column-reverse">
|
||||
<div>
|
||||
<label for="iconContainer">Instance icon</label>
|
||||
<div id="iconContainer" class="square iconContainer">
|
||||
<!-- This data URI is for a transparent gif image -->
|
||||
<img id="instanceIcon" alt="Icon for the selected instance" class="icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<button type="submit">OK</button>
|
||||
<button type="reset" class="close">Cancel</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,15 +1,14 @@
|
|||
import { initializeAddInstanceDialog } from "./add_an_instance.mjs";
|
||||
import knownSoftware from "./known_software.mjs";
|
||||
import storageManager from "./storage_manager.mjs";
|
||||
console.log(knownSoftware);
|
||||
|
||||
console.log(storageManager.storage.instances);
|
||||
import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
|
||||
import { findDialogOrFail } from "./dom.mjs";
|
||||
|
||||
export function getMainDialog(): HTMLDialogElement {
|
||||
return document.getElementById('mainDialog') as HTMLDialogElement;
|
||||
}
|
||||
|
||||
const dialog = document.querySelector("#addInstance");
|
||||
if (!(dialog instanceof HTMLDialogElement))
|
||||
throw new Error("Couldn't find addInstance dialog");
|
||||
export const { showAddInstanceDialog, hideAddInstanceDialog } = initializeAddInstanceDialog(dialog);
|
||||
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
|
||||
const addDialog = findDialogOrFail(document.body, "#addInstance");
|
||||
|
||||
export const {
|
||||
showAddInstanceDialog,
|
||||
hideAddInstanceDialog
|
||||
} = initializeAddInstanceFlow(detailsDialog, addDialog);
|
||||
|
|
44
static/dom.mts
Normal file
44
static/dom.mts
Normal file
|
@ -0,0 +1,44 @@
|
|||
// I would've LOVED to use generics for this but unfortunately that's not possible.
|
||||
// Type safety, but at what cost... >~< thanks TypeScript
|
||||
|
||||
export function findDialogOrFail(on: Element, selector: string): HTMLDialogElement {
|
||||
const element = on.querySelector(selector);
|
||||
if (!(element instanceof HTMLDialogElement))
|
||||
throw new Error(`${selector} isn't a dialog`);
|
||||
return element;
|
||||
}
|
||||
|
||||
export function findFormOrFail(on: Element, selector: string): HTMLFormElement {
|
||||
const element = on.querySelector(selector);
|
||||
if (!(element instanceof HTMLFormElement))
|
||||
throw new Error(`${selector} isn't a form`);
|
||||
return element;
|
||||
}
|
||||
|
||||
export function findInputOrFail(on: Element, selector: string): HTMLInputElement {
|
||||
const element = on.querySelector(selector);
|
||||
if (!(element instanceof HTMLInputElement))
|
||||
throw new Error(`${selector} isn't an input`);
|
||||
return element;
|
||||
}
|
||||
|
||||
export function findButtonOrFail(on: Element, selector: string): HTMLButtonElement {
|
||||
const element = on.querySelector(selector);
|
||||
if (!(element instanceof HTMLButtonElement))
|
||||
throw new Error(`${selector} isn't a button`);
|
||||
return element;
|
||||
}
|
||||
|
||||
export function findSelectOrFail(on: Element, selector: string): HTMLSelectElement {
|
||||
const element = on.querySelector(selector);
|
||||
if (!(element instanceof HTMLSelectElement))
|
||||
throw new Error(`${selector} isn't a select`);
|
||||
return element;
|
||||
}
|
||||
|
||||
export function findImageOrFail(on: Element, selector: string): HTMLImageElement {
|
||||
const element = on.querySelector(selector);
|
||||
if (!(element instanceof HTMLImageElement))
|
||||
throw new Error(`${selector} isn't an image`);
|
||||
return element;
|
||||
}
|
15
static/image.mts
Normal file
15
static/image.mts
Normal file
|
@ -0,0 +1,15 @@
|
|||
export function resize(image: HTMLImageElement, width: number = 16, height: number = 16): string {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.style.display = "none";
|
||||
document.body.appendChild(canvas);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx === null) throw Error("Resize failed");
|
||||
const w = Math.min(image.width / image.height, 1) * width;
|
||||
const h = Math.min(image.height / image.width, 1) * height;
|
||||
ctx.drawImage(image, (width - w) / 2, (height - h) / 2, w, h);
|
||||
const url = canvas.toDataURL();
|
||||
document.body.removeChild(canvas);
|
||||
return url;
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
type Instance = {
|
||||
export type Instance = {
|
||||
/**
|
||||
* The instance's (nick)name
|
||||
* @example "eepy.moe"
|
||||
|
|
Loading…
Add table
Reference in a new issue