Compare commits
85 commits
no-more-pr
...
main
Author | SHA1 | Date | |
---|---|---|---|
21d7738c52 | |||
40ea027ddf | |||
0ff55afcad | |||
98a9cad7f4 | |||
7870658c82 | |||
b1c2611b68 | |||
e644a5634a | |||
e527da497f | |||
d72ebcdbef | |||
dd53074458 | |||
fd63c0b12a | |||
3f52ae2147 | |||
bf963c4ab8 | |||
da67b54f6e | |||
e82b85d0d0 | |||
f92ab0e44e | |||
66234b7a66 | |||
24d2850fc6 | |||
30706aba84 | |||
6babd3187b | |||
42c9cbbc56 | |||
1810e4869e | |||
866823e04f | |||
9490488331 | |||
2d38927bc4 | |||
27ebde8afe | |||
b475692302 | |||
2eabacc03d | |||
8b256dc95d | |||
589322fe6d | |||
2865b78bdb | |||
c0467ccfd0 | |||
79d7fa9ac5 | |||
b8764d99dd | |||
1057bf80dc | |||
969538b8b4 | |||
027faf8371 | |||
bc7e388f04 | |||
e7748a71da | |||
b408f66f9d | |||
c1f61cfe9b | |||
becaf79690 | |||
68f54b341b | |||
5e3817c1a7 | |||
f0617522e3 | |||
7a39fbd418 | |||
8850e08f5d | |||
9a4e2b3ca5 | |||
9f5e6115e9 | |||
22bbbf0955 | |||
563462c1c5 | |||
cfae51c43f | |||
beaa76f996 | |||
3244cecc6a | |||
b4c70c0d16 | |||
079b91a6c1 | |||
aa3e1ab526 | |||
3fad4f8d6e | |||
93f8ba4226 | |||
764529f36b | |||
b2cbd4cc5d | |||
7aa1b483e1 | |||
72faa8901c | |||
da6d60f94c | |||
3671085acc | |||
bc8d0a3f92 | |||
93e1ac85cd | |||
c3b5666bd5 | |||
0ba211c054 | |||
b3c049a8fa | |||
5dde457d45 | |||
532cd614ce | |||
be021c4b16 | |||
26a48f23a5 | |||
30b28b8aba | |||
28bbdac90a | |||
940fc92856 | |||
93c9e5154f | |||
516473edeb | |||
7e1416a721 | |||
f451b1fbc3 | |||
bfd61c2e50 | |||
2be0658ed9 | |||
5534bc3942 | |||
3f9624fe91 |
27 changed files with 1073 additions and 286 deletions
24
.forgejo/workflows/ci.yaml
Normal file
24
.forgejo/workflows/ci.yaml
Normal file
|
@ -0,0 +1,24 @@
|
|||
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
|
21
.forgejo/workflows/docker.yaml
Normal file
21
.forgejo/workflows/docker.yaml
Normal file
|
@ -0,0 +1,21 @@
|
|||
name: Build Docker Container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
env:
|
||||
REGISTRY: kitsunes.dev
|
||||
PUBLISH_AS: nekomata/fedirect
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Login to Docker
|
||||
run: docker login -u kio -p ${{ secrets.LOGINKEY }} ${{ env.REGISTRY }}
|
||||
- name: build docker repository
|
||||
run: docker build --no-cache --pull . -t ${{ env.REGISTRY }}/${{ env.PUBLISH_AS }}:latest
|
||||
- name: Push to Repo Server
|
||||
run: docker push ${{ env.REGISTRY }}/${{ env.PUBLISH_AS }}:latest
|
3
Cargo.lock
generated
3
Cargo.lock
generated
|
@ -346,7 +346,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fedirect"
|
||||
version = "0.1.0"
|
||||
version = "0.1.0-alpha"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"favicon-scraper",
|
||||
|
@ -1527,6 +1527,7 @@ dependencies = [
|
|||
"base64",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.4.7",
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
[package]
|
||||
name = "fedirect"
|
||||
version = "0.1.0"
|
||||
version = "0.1.0-alpha"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
bytes = "1.9.0"
|
||||
favicon-scraper = "0.3.1"
|
||||
reqwest = { version = "0.12.12", features = ["stream"] }
|
||||
reqwest = { version = "0.12.12", features = ["stream", "blocking"] }
|
||||
rocket = { version = "0.5.1", features = ["json"] }
|
||||
semver = "1.0.24"
|
||||
serde = { version = "1.0.217", features = ["derive"] }
|
||||
|
|
15
Dockerfile
Normal file
15
Dockerfile
Normal file
|
@ -0,0 +1,15 @@
|
|||
FROM rust:alpine AS build
|
||||
RUN apk add deno pkgconfig libressl-dev musl-dev
|
||||
|
||||
WORKDIR /FeDirect
|
||||
|
||||
COPY --link . ./
|
||||
|
||||
RUN cargo build --release
|
||||
|
||||
FROM scratch
|
||||
COPY --from=build /FeDirect/known-software.json /FeDirect/target/release/fedirect /
|
||||
COPY --from=build /FeDirect/static/ /static
|
||||
ENV ROCKET_ADDRESS=0.0.0.0
|
||||
WORKDIR /
|
||||
CMD ["/fedirect"]
|
|
@ -136,7 +136,8 @@
|
|||
],
|
||||
"groups": [
|
||||
"misskey-compliant",
|
||||
"misskey-v13"
|
||||
"misskey-v13",
|
||||
"mastodon-compliant-api"
|
||||
],
|
||||
"forkOf": "misskey"
|
||||
},
|
||||
|
@ -164,8 +165,7 @@
|
|||
"misskey-compliant",
|
||||
"misskey-v12",
|
||||
"iceshrimp"
|
||||
],
|
||||
"forkOf": "misskey"
|
||||
]
|
||||
},
|
||||
"firefish": {
|
||||
"name": "Firefish",
|
||||
|
|
54
src/api/about.rs
Normal file
54
src/api/about.rs
Normal file
|
@ -0,0 +1,54 @@
|
|||
use reqwest::blocking::Client;
|
||||
use rocket::serde::json::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Gets the FeDirect version
|
||||
#[get("/about/version")]
|
||||
pub async fn version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Avatars {
|
||||
charlotte: Option<String>,
|
||||
kio: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MisskeyUser {
|
||||
#[serde(rename = "avatarUrl")]
|
||||
avatar_url: String,
|
||||
}
|
||||
|
||||
fn get_avatar(client: &Client, origin: &str, user_id: &str) -> Option<String> {
|
||||
let body = json!({
|
||||
"userId": user_id
|
||||
})
|
||||
.to_string();
|
||||
let user: MisskeyUser = client
|
||||
.post(format!("https://{origin}/api/users/show"))
|
||||
.header("content-type", "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
.ok()?
|
||||
.json()
|
||||
.ok()?;
|
||||
Some(user.avatar_url)
|
||||
}
|
||||
|
||||
pub static AVATARS: OnceLock<Avatars> = OnceLock::new();
|
||||
|
||||
pub fn init() {
|
||||
let client = Client::new();
|
||||
let charlotte = get_avatar(&client, "eepy.moe", "9xt2s326nxev039h");
|
||||
let kio = get_avatar(&client, "kitsunes.club", "9810gvfne3");
|
||||
AVATARS.set(Avatars { charlotte, kio }).unwrap();
|
||||
}
|
||||
|
||||
/// Gets (relatively) up-to-date Nekomata avatars
|
||||
#[get("/about/nekomata_avatars")]
|
||||
pub async fn nekomata_avatars() -> Json<&'static Avatars> {
|
||||
Json(AVATARS.get().unwrap())
|
||||
}
|
|
@ -1,11 +1,18 @@
|
|||
use rocket::Route;
|
||||
|
||||
pub mod about;
|
||||
pub mod instance_info;
|
||||
pub mod proxy;
|
||||
|
||||
pub fn init() {
|
||||
about::init();
|
||||
}
|
||||
|
||||
pub fn get_routes() -> Vec<Route> {
|
||||
routes![
|
||||
instance_info::instance_info,
|
||||
about::version,
|
||||
about::nekomata_avatars,
|
||||
// Proxy is temporarily disabled as it's not needed
|
||||
// proxy::proxy
|
||||
]
|
||||
|
|
|
@ -98,3 +98,17 @@ 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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,6 +44,8 @@ fn route_for_unknown_instance_software(instance: &str, route: PathBuf) -> (Conte
|
|||
|
||||
#[launch]
|
||||
fn rocket() -> _ {
|
||||
api::init();
|
||||
|
||||
rocket::build()
|
||||
.mount("/static", FileServer::from("static").rank(0))
|
||||
.mount("/api", api::get_routes())
|
||||
|
|
27
static/about.mts
Normal file
27
static/about.mts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Dialog } from "./dialog.mjs";
|
||||
import { findAnchorOrFail, findButtonOrFail, findDialogOrFail, findImageOrFail, findSpanOrFail } from "./dom.mjs";
|
||||
|
||||
const openButton = findAnchorOrFail(document.body, "#aboutLink");
|
||||
const dialog = findDialogOrFail(document.body, "#about")
|
||||
const versionParagraph = findSpanOrFail(dialog, "#version");
|
||||
const charlotteImage = findImageOrFail(dialog, "#charlotteAvatar");
|
||||
const kioImage = findImageOrFail(dialog, "#kioAvatar");
|
||||
const closeButton = findButtonOrFail(dialog, ".close");
|
||||
|
||||
const aboutDialog = new Dialog(dialog);
|
||||
|
||||
openButton.addEventListener("click", e => aboutDialog.open());
|
||||
closeButton.addEventListener("click", e => aboutDialog.close());
|
||||
|
||||
populateVersion();
|
||||
populateUsers();
|
||||
|
||||
async function populateVersion() {
|
||||
versionParagraph.innerText = await fetch("/api/about/version").then(r => r.text());
|
||||
}
|
||||
|
||||
async function populateUsers() {
|
||||
const { charlotte, kio } = await fetch("/api/about/nekomata_avatars").then(r => r.json());
|
||||
if (charlotte) charlotteImage.src = charlotte;
|
||||
if (kio) kioImage.src = kio;
|
||||
}
|
|
@ -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,67 @@ 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();
|
||||
export 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"));
|
||||
|
||||
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();
|
||||
});
|
||||
this.instanceHost = findInputOrFail(this.form, "#instanceHost");
|
||||
this.autoQueryMetadata = findInputOrFail(this.form, "#autoQueryMetadata");
|
||||
this.closeButton = findButtonOrFail(this.form, ".close");
|
||||
|
||||
closeButton.addEventListener("click", e => hideAddInstanceDialog());
|
||||
if (initializeDOM) this.initializeDOM();
|
||||
}
|
||||
|
||||
return {
|
||||
showAddInstanceDialog,
|
||||
hideAddInstanceDialog
|
||||
};
|
||||
protected override initializeDOM() {
|
||||
super.initializeDOM();
|
||||
|
||||
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,69 +1,73 @@
|
|||
import { initializeAddInstanceDialog } from "./add_an_instance.mjs";
|
||||
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs";
|
||||
import { AddInstanceDialog } from "./add_an_instance.mjs";
|
||||
import { dialogDetailsToInstance, InstanceDetailsDialog, InstanceDetailsDialogData } 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: InstanceDetailsDialog;
|
||||
|
||||
constructor(
|
||||
addDialog: AddInstanceDialog | HTMLDialogElement,
|
||||
spinnerDialog: HTMLDialogElement,
|
||||
detailsDialog: InstanceDetailsDialog | HTMLDialogElement,
|
||||
) {
|
||||
if (addDialog instanceof AddInstanceDialog)
|
||||
this.addDialog = addDialog;
|
||||
else
|
||||
this.addDialog = new AddInstanceDialog(addDialog, true);
|
||||
|
||||
this.spinnerDialog = new Dialog(spinnerDialog);
|
||||
|
||||
if (detailsDialog instanceof InstanceDetailsDialog)
|
||||
this.detailsDialog = detailsDialog;
|
||||
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 {
|
||||
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 {
|
||||
showInstanceDetailsDialog();
|
||||
}
|
||||
|
||||
detailsDialogData.name = name;
|
||||
detailsDialogData.software = software;
|
||||
detailsDialogData.iconURL = iconURL as string | null;
|
||||
} catch { }
|
||||
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,19 +6,19 @@
|
|||
<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>
|
||||
<script type="module" src="/static/about.mjs"></script>
|
||||
<div class="flex-vcenter">
|
||||
<dialog id="mainDialog" class="half-width half-height">
|
||||
<header class="separator-bottom margin-large-bottom">
|
||||
<div class="flex-row">
|
||||
<h1>FeDirect</h1>
|
||||
<p class="margin-auto-top">  By Nekomata</p>
|
||||
<p class="margin-auto-top">  <a id="aboutLink" href="#"
|
||||
title="About FeDirect & Nekomata">By Nekomata</a></p>
|
||||
</div>
|
||||
<img src="/static/nekomata_small.png" alt="Nekomata Logo" class="logo" />
|
||||
</header>
|
||||
|
@ -27,7 +27,7 @@
|
|||
<center class="half-width">
|
||||
<ol id="instanceList" class="align-start wfit-content"></ol>
|
||||
<br>
|
||||
<button onclick="showAddInstanceDialog()">Add an instance</button>
|
||||
<button id="startAddInstanceFlow">Add an instance</button>
|
||||
</center>
|
||||
</div>
|
||||
<div class="half-width align-self-start">
|
||||
|
@ -52,9 +52,8 @@
|
|||
<br>
|
||||
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
|
||||
<label for="autoQueryMetadata">
|
||||
<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.">
|
||||
<abbr
|
||||
title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.">
|
||||
Automatically query metadata
|
||||
</abbr>
|
||||
</label>
|
||||
|
@ -104,14 +103,45 @@ Unchecking this is not recommended, and this option only exists for exceptional
|
|||
<br>
|
||||
<label for="defaultsList">Default option for:</label><br>
|
||||
<select id="defaultsList" class="full-width" multiple>
|
||||
<option value="" disabled>(None, use the "Redirect always" button to set!)</option>
|
||||
<option id="noDefaults" value="" disabled>(None, use the "Redirect always" button to set!)</option>
|
||||
</select>
|
||||
<button id="removeDefaults" disabled>Remove</button>
|
||||
<button id="removeDefaults" type="button" disabled>Remove</button>
|
||||
<br><br>
|
||||
<button type="submit">OK</button>
|
||||
<button type="reset" class="close">Cancel</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="about" class="half-width-max half-height">
|
||||
<center>
|
||||
<div class="flex-row wfit-content">
|
||||
<h1>About FeDirect</h1>
|
||||
<p class="margin-auto-top">  (v<span id="version"></span>)</p>
|
||||
</div>
|
||||
<p class="margin-none-top wrap-balance">
|
||||
FeDirect links the Fediverse together by allowing you to create generic links that
|
||||
link people to their native instance!
|
||||
</p>
|
||||
<a href="https://kitsunes.dev/Nekomata/FeDirect">Source code</a>
|
||||
<h2>About Nekomata</h2>
|
||||
<div class="circlingMembers">
|
||||
<center class="absolute-centered member charlotte align-content-center flex-column">
|
||||
<img id="charlotteAvatar" class="xl-size" alt="Charlotte's avatar" />
|
||||
<p class="margin-none">Charlotte</p>
|
||||
<a href="https://eepy.moe/@CenTdemeern1" class="margin-none">@CenTdemeern1@eepy.moe</a>
|
||||
<p class="margin-none">Programming, design</p>
|
||||
</center>
|
||||
<center class="absolute-centered member kio align-content-center flex-column">
|
||||
<img id="kioAvatar" class="xl-size" alt="Charlotte's avatar" />
|
||||
<p class="margin-none">Kio</p>
|
||||
<a href="https://kitsunes.club/@Kio" class="margin-none">@Kio@kitsunes.club</a>
|
||||
<p class="margin-none">Funding, hosting, design</p>
|
||||
</center>
|
||||
<img src="/static/nekomata_small.png" alt="Nekomata Logo" class="absolute-centered xl-size-max" />
|
||||
</div>
|
||||
<button class="close">Close</button>
|
||||
</center>
|
||||
</dialog>
|
||||
<dialog id="spinner"><span class="spinner"></span></dialog>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,23 +1,35 @@
|
|||
import { parseHost } from "./add_an_instance.mjs";
|
||||
import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
|
||||
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs";
|
||||
import { AddInstanceFlow } from "./add_instance_flow.mjs";
|
||||
import { dialogDetailsFromInstance, dialogDetailsToInstance, InstanceDetailsDialog } from "./confirm_instance_details.mjs";
|
||||
import { findButtonOrFail, findDialogOrFail, findOlOrFail } from "./dom.mjs";
|
||||
import storageManager from "./storage_manager.mjs";
|
||||
import storageManager, { Instance } from "./storage_manager.mjs";
|
||||
|
||||
let reordering = false;
|
||||
let unsaved = false;
|
||||
// Dragging code is a heavily modified version of https://stackoverflow.com/a/28962290
|
||||
let elementBeingDragged: HTMLLIElement | undefined;
|
||||
|
||||
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
|
||||
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 instanceList = findOlOrFail(document.body, "#instanceList");
|
||||
const saveButton = findButtonOrFail(document.body, "#save");
|
||||
const reorderButton = findButtonOrFail(document.body, "#reorder");
|
||||
const resetButton = findButtonOrFail(document.body, "#reset");
|
||||
|
||||
saveButton.addEventListener("click", e => {
|
||||
storageManager.save();
|
||||
let instanceDetailsDialog = new InstanceDetailsDialog(detailsDialog, true);
|
||||
let addInstanceFlow = new AddInstanceFlow(addDialog, spinnerDialog, instanceDetailsDialog);
|
||||
|
||||
startAddInstanceFlowButton.addEventListener("click", e => {
|
||||
addInstanceFlow.start(false).then(_ => {
|
||||
updateInstanceList();
|
||||
unsavedChanges();
|
||||
});
|
||||
});
|
||||
|
||||
saveButton.addEventListener("click", e => saveChanges());
|
||||
|
||||
reorderButton.addEventListener("click", () => {
|
||||
reordering = !reordering;
|
||||
if (!reordering) applyReordering();
|
||||
|
@ -25,22 +37,47 @@ reorderButton.addEventListener("click", () => {
|
|||
reorderButton.innerText = reordering ? "Finish reordering" : "Reorder";
|
||||
});
|
||||
|
||||
export const getMainDialog = () => findDialogOrFail(document.body, "#mainDialog");
|
||||
|
||||
const {
|
||||
showInstanceDetailsDialog,
|
||||
hideInstanceDetailsDialog,
|
||||
populateInstanceDetailsDialog,
|
||||
} = initializeInstanceDetailsDialog(detailsDialog, () => { });
|
||||
|
||||
export const {
|
||||
showAddInstanceDialog,
|
||||
hideAddInstanceDialog
|
||||
} = initializeAddInstanceFlow(detailsDialog, addDialog);
|
||||
resetButton.addEventListener("click", e => {
|
||||
storageManager.reset();
|
||||
updateInstanceList();
|
||||
unsavedChanges();
|
||||
});
|
||||
|
||||
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() {
|
||||
instanceList.replaceChildren(); // Erase all child nodes
|
||||
instanceList.style.listStyleType = reordering ? "\"≡ \"" : "disc";
|
||||
|
@ -86,26 +123,11 @@ function updateInstanceList() {
|
|||
const editLink = document.createElement("a");
|
||||
editLink.innerText = `Edit`;
|
||||
editLink.href = "#";
|
||||
editLink.addEventListener("click", e => {
|
||||
const host = parseHost(instance.origin)!;
|
||||
populateInstanceDetailsDialog(
|
||||
instance.name,
|
||||
host.host,
|
||||
host.secure,
|
||||
instance.software,
|
||||
instance.iconURL ?? null
|
||||
);
|
||||
showInstanceDetailsDialog();
|
||||
});
|
||||
editLink.addEventListener("click", e => editInstance(instance));
|
||||
const deleteLink = document.createElement("a");
|
||||
deleteLink.innerText = `Delete`;
|
||||
deleteLink.href = "#";
|
||||
deleteLink.addEventListener("click", e => {
|
||||
storageManager.storage.instances.splice(
|
||||
storageManager.storage.instances.indexOf(instance)
|
||||
);
|
||||
updateInstanceList();
|
||||
});
|
||||
deleteLink.addEventListener("click", e => deleteInstance(instance));
|
||||
label.append(editLink, " ", deleteLink);
|
||||
}
|
||||
li.appendChild(label);
|
||||
|
@ -130,4 +152,5 @@ function applyReordering() {
|
|||
indices.push(parseInt(option));
|
||||
}
|
||||
storageManager.storage.instances = indices.map(i => storageManager.storage.instances[i]);
|
||||
unsavedChanges();
|
||||
}
|
||||
|
|
|
@ -1,81 +1,185 @@
|
|||
// This file handles the "Confirm instance details" dialog
|
||||
|
||||
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findSelectOrFail } from "./dom.mjs";
|
||||
import knownSoftware from "./known_software.mjs";
|
||||
import { parseHost } from "./add_an_instance.mjs";
|
||||
import { FormDialog, ONCE } from "./dialog.mjs";
|
||||
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findOptionOrFail, findSelectOrFail } from "./dom.mjs";
|
||||
import knownSoftware, { getName } from "./known_software.mjs";
|
||||
import { Instance } from "./storage_manager.mjs";
|
||||
|
||||
const blankImage = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
|
||||
export function mergeHost(host: string, secure: boolean): string {
|
||||
return `http${secure ? "s" : ""}://${host}`;
|
||||
}
|
||||
|
||||
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 ?? 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 type InstanceDetailsDialogData = {
|
||||
name: string,
|
||||
host: string,
|
||||
hostSecure: boolean,
|
||||
software: string,
|
||||
iconURL: string | null,
|
||||
preferredFor: string[],
|
||||
};
|
||||
|
||||
export function dialogDetailsFromInstance(instance: Instance): InstanceDetailsDialogData {
|
||||
const host = parseHost(instance.origin)!;
|
||||
return {
|
||||
showInstanceDetailsDialog,
|
||||
hideInstanceDetailsDialog,
|
||||
populateInstanceDetailsDialog
|
||||
name: instance.name,
|
||||
host: host.host,
|
||||
hostSecure: host.secure,
|
||||
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.hidden = true;
|
||||
|
||||
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 = getName(knownSoftware, item) ?? 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.hidden = false;
|
||||
} else {
|
||||
this.defaultsList.noDefaultsOption.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
#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;
|
||||
if (data.iconURL !== null) {
|
||||
this.instanceIcon.src = data.iconURL;
|
||||
this.instanceIcon.hidden = false;
|
||||
} else this.instanceIcon.hidden = true;
|
||||
this.#populateDefaultsList(data.preferredFor);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.cancelOnceClosed(reject);
|
||||
this.#handleSubmit(data, resolve);
|
||||
this.open();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,12 +11,14 @@
|
|||
|
||||
<body>
|
||||
<script type="module" src="/static/crossroad.mjs"></script>
|
||||
<script type="module" src="/static/about.mjs"></script>
|
||||
<div class="flex-vcenter">
|
||||
<dialog id="mainDialog" class="half-width half-height">
|
||||
<header class="separator-bottom margin-large-bottom">
|
||||
<div class="flex-row">
|
||||
<h1>FeDirect</h1>
|
||||
<p class="margin-auto-top">  By Nekomata</p>
|
||||
<p class="margin-auto-top">  <a id="aboutLink" href="#"
|
||||
title="About FeDirect & Nekomata">By Nekomata</a></p>
|
||||
</div>
|
||||
<img src="/static/nekomata_small.png" alt="Nekomata Logo" class="logo" />
|
||||
</header>
|
||||
|
@ -24,12 +26,25 @@
|
|||
<div class="flex-vcenter full-height">
|
||||
<center class="half-width">
|
||||
You're about to go to
|
||||
<pre id="path" class="inline-block"></pre>.<br>
|
||||
<pre id="path" class="inline-block margin-none"></pre>
|
||||
on <span id="aOrAn"></span>
|
||||
<span id="destination" class="inline-block margin-none"></span>
|
||||
instance.<br>
|
||||
<img src="/static/down_arrow.svg" alt="" class="medium-height" />
|
||||
<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>
|
||||
<p id="noInstance">You currently don't have any instances. You should add one!</p>
|
||||
<form id="instanceSelectForm" class="align-start wfit-content">
|
||||
<ol id="preferredList" class="margin-none" hidden></ol>
|
||||
<div id="forks" hidden>
|
||||
<p class="align-center margin-none-bottom">Other <span id="forkOf"></span> forks</p>
|
||||
<ol id="forksList" class="margin-none"></ol>
|
||||
</div>
|
||||
<div id="others" hidden>
|
||||
<p class="align-center margin-none-bottom">Other instances</p>
|
||||
<ol id="othersList" class="margin-none"></ol>
|
||||
</div>
|
||||
</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">
|
||||
|
@ -54,9 +69,8 @@
|
|||
<br>
|
||||
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
|
||||
<label for="autoQueryMetadata">
|
||||
<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.">
|
||||
<abbr
|
||||
title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.">
|
||||
Automatically query metadata
|
||||
</abbr>
|
||||
</label>
|
||||
|
@ -108,6 +122,37 @@ Unchecking this is not recommended, and this option only exists for exceptional
|
|||
<button type="reset" class="close">Cancel</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="about" class="half-width-max half-height">
|
||||
<center>
|
||||
<div class="flex-row wfit-content">
|
||||
<h1>About FeDirect</h1>
|
||||
<p class="margin-auto-top">  (v<span id="version"></span>)</p>
|
||||
</div>
|
||||
<p class="margin-none-top wrap-balance">
|
||||
FeDirect links the Fediverse together by allowing you to create generic links that
|
||||
link people to their native instance!
|
||||
</p>
|
||||
<a href="https://kitsunes.dev/Nekomata/FeDirect">Source code</a>
|
||||
<h2>About Nekomata</h2>
|
||||
<div class="circlingMembers">
|
||||
<center class="absolute-centered member charlotte align-content-center flex-column">
|
||||
<img id="charlotteAvatar" class="xl-size" alt="Charlotte's avatar" />
|
||||
<p class="margin-none">Charlotte</p>
|
||||
<a href="https://eepy.moe/@CenTdemeern1" class="margin-none">@CenTdemeern1@eepy.moe</a>
|
||||
<p class="margin-none">Programming, design</p>
|
||||
</center>
|
||||
<center class="absolute-centered member kio align-content-center flex-column">
|
||||
<img id="kioAvatar" class="xl-size" alt="Charlotte's avatar" />
|
||||
<p class="margin-none">Kio</p>
|
||||
<a href="https://kitsunes.club/@Kio" class="margin-none">@Kio@kitsunes.club</a>
|
||||
<p class="margin-none">Funding, hosting, design</p>
|
||||
</center>
|
||||
<img src="/static/nekomata_small.png" alt="Nekomata Logo" class="absolute-centered xl-size-max" />
|
||||
</div>
|
||||
<button class="close">Close</button>
|
||||
</center>
|
||||
</dialog>
|
||||
<dialog id="spinner"><span class="spinner"></span></dialog>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,20 +1,51 @@
|
|||
import { initializeAddInstanceFlow } 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";
|
||||
import { AddInstanceFlow } from "./add_instance_flow.mjs";
|
||||
import { findButtonOrFail, findDialogOrFail, findDivOrFail, findFormOrFail, findInputOrFail, findOlOrFail, findParagraphOrFail, findPreOrFail, findSpanOrFail } from "./dom.mjs";
|
||||
import knownSoftware, { getName } from "./known_software.mjs";
|
||||
import storageManager, { Instance } from "./storage_manager.mjs";
|
||||
|
||||
const radioButtonName = "instanceSelect";
|
||||
const RADIO_BUTTON_NAME = "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 preferredList = findOlOrFail(document.body, "#preferredList");
|
||||
const forksDiv = findDivOrFail(document.body, "#forks");
|
||||
const forkOfSpan = findSpanOrFail(document.body, "#forkOf");
|
||||
const forksList = findOlOrFail(document.body, "#forksList");
|
||||
const othersDiv = findDivOrFail(document.body, "#others");
|
||||
const othersList = findOlOrFail(document.body, "#othersList");
|
||||
const redirectButton = findButtonOrFail(document.body, "#redirect");
|
||||
const redirectAlwaysButton = findButtonOrFail(document.body, "#redirectAlways");
|
||||
const noInstanceParagraph = findParagraphOrFail(document.body, "#noInstance");
|
||||
const pathText = findPreOrFail(document.body, "#path");
|
||||
const aOrAnText = findSpanOrFail(document.body, "#aOrAn");
|
||||
const destinationText = findSpanOrFail(document.body, "#destination");
|
||||
|
||||
showAddInstanceDialogButton.addEventListener("click", e => showAddInstanceDialog());
|
||||
// Don't bother initializing if we're performing autoredirect
|
||||
if (!autoRedirect()) {
|
||||
createInstanceSelectOptions();
|
||||
storageManager.addSaveCallback(createInstanceSelectOptions);
|
||||
updateNoInstanceHint();
|
||||
storageManager.addSaveCallback(updateNoInstanceHint);
|
||||
|
||||
pathText.innerText = getTargetPath();
|
||||
const targetID = getTargetSoftwareOrGroup();
|
||||
const targetName = getName(knownSoftware, targetID) ?? targetID;
|
||||
aOrAnText.innerText = "aeiou".includes(targetName[0].toLowerCase()) ? "an" : "a";
|
||||
destinationText.innerText = targetName;
|
||||
|
||||
|
||||
addInstanceFlow = new AddInstanceFlow(addDialog, spinnerDialog, detailsDialog);
|
||||
|
||||
mainDialog.show();
|
||||
};
|
||||
|
||||
startAddInstanceFlowButton.addEventListener("click", e => addInstanceFlow?.start(true));
|
||||
|
||||
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,61 +59,117 @@ redirectAlwaysButton.addEventListener("click", e => {
|
|||
redirect(option);
|
||||
});
|
||||
|
||||
let showAddInstanceDialog = () => { };
|
||||
let hideAddInstanceDialog = () => { };
|
||||
function updateNoInstanceHint() {
|
||||
noInstanceParagraph.hidden =
|
||||
storageManager.storage.instances.length > 0;
|
||||
}
|
||||
|
||||
// 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();
|
||||
type PreferenceGroups = {
|
||||
preferred: Instance[],
|
||||
forks?: {
|
||||
list: Instance[],
|
||||
of: string,
|
||||
},
|
||||
others: Instance[],
|
||||
};
|
||||
|
||||
function updateNoInstanceHint() {
|
||||
findParagraphOrFail(document.body, "#no-instance").style.display =
|
||||
storageManager.storage.instances.length > 0
|
||||
? "none"
|
||||
: "";
|
||||
function sortInstancesIntoPreferenceGroups(): PreferenceGroups {
|
||||
const targetID = getTargetSoftwareOrGroup();
|
||||
const pGroups: PreferenceGroups = {
|
||||
preferred: [],
|
||||
others: [],
|
||||
};
|
||||
// If targetID is a group
|
||||
if (knownSoftware.groups[targetID]) {
|
||||
for (const instance of storageManager.storage.instances) {
|
||||
const software = knownSoftware.software[instance.software];
|
||||
// If the instance's software is in the target group
|
||||
if (software.groups.includes(targetID)) {
|
||||
pGroups.preferred.push(instance);
|
||||
} else {
|
||||
pGroups.others.push(instance);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const isFork = knownSoftware.software[targetID].forkOf !== undefined;
|
||||
const forkOf = knownSoftware.software[targetID].forkOf ?? targetID;
|
||||
const hasForks = isFork || Object.values(knownSoftware.software).some(s => s.forkOf === forkOf);
|
||||
if (hasForks) {
|
||||
pGroups.forks = {
|
||||
list: [],
|
||||
of: forkOf,
|
||||
};
|
||||
}
|
||||
for (const instance of storageManager.storage.instances) {
|
||||
if (instance.software === targetID) {
|
||||
pGroups.preferred.push(instance);
|
||||
continue;
|
||||
}
|
||||
const software = knownSoftware.software[instance.software];
|
||||
// Checking pGroups.forks is the TypeScript safe way of checking hasForks
|
||||
if (pGroups.forks && (instance.software === forkOf || software.forkOf === forkOf)) {
|
||||
pGroups.forks.list.push(instance);
|
||||
continue;
|
||||
}
|
||||
pGroups.others.push(instance);
|
||||
}
|
||||
}
|
||||
return pGroups;
|
||||
}
|
||||
|
||||
function constructOptionFromInstance(instance: Instance): HTMLDivElement {
|
||||
const div = document.createElement("div");
|
||||
div.setAttribute("x-option", instance.origin);
|
||||
const radio = document.createElement("input");
|
||||
radio.id = instance.origin;
|
||||
radio.value = instance.origin;
|
||||
radio.type = "radio";
|
||||
radio.name = RADIO_BUTTON_NAME;
|
||||
const label = document.createElement("label");
|
||||
label.htmlFor = instance.origin;
|
||||
label.innerText = instance.name + " ";
|
||||
if (instance.iconURL) {
|
||||
const img = new Image();
|
||||
img.src = instance.iconURL;
|
||||
img.alt = `${instance.name} icon`;
|
||||
img.className = "inlineIcon medium-height";
|
||||
label.append(img, " ");
|
||||
}
|
||||
const small = document.createElement("small");
|
||||
const softwareName = knownSoftware.software[instance.software].name;
|
||||
small.innerText = `(${softwareName})`;
|
||||
label.appendChild(small);
|
||||
div.appendChild(radio);
|
||||
div.appendChild(label);
|
||||
return div;
|
||||
}
|
||||
|
||||
function createInstanceSelectOptions() {
|
||||
instanceSelectForm.replaceChildren(); // Erase all child nodes
|
||||
for (const instance of storageManager.storage.instances) {
|
||||
const div = document.createElement("div");
|
||||
div.setAttribute("x-option", instance.origin);
|
||||
const radio = document.createElement("input");
|
||||
radio.id = instance.origin;
|
||||
radio.value = instance.origin;
|
||||
radio.type = "radio";
|
||||
radio.name = radioButtonName;
|
||||
const label = document.createElement("label");
|
||||
label.htmlFor = instance.origin;
|
||||
label.innerText = instance.name + " ";
|
||||
if (instance.iconURL) {
|
||||
const img = new Image();
|
||||
img.src = instance.iconURL;
|
||||
img.alt = `${instance.name} icon`;
|
||||
img.className = "inlineIcon medium-height";
|
||||
label.append(img, " ");
|
||||
}
|
||||
const small = document.createElement("small");
|
||||
const softwareName = knownSoftware.software[instance.software].name;
|
||||
small.innerText = `(${softwareName})`;
|
||||
label.appendChild(small);
|
||||
div.appendChild(radio);
|
||||
div.appendChild(label);
|
||||
instanceSelectForm.appendChild(div);
|
||||
// Erase all child nodes
|
||||
preferredList.replaceChildren();
|
||||
forksList.replaceChildren();
|
||||
othersList.replaceChildren();
|
||||
|
||||
const { preferred, forks, others } = sortInstancesIntoPreferenceGroups();
|
||||
|
||||
preferredList.hidden = preferred.length === 0;
|
||||
for (const instance of preferred) {
|
||||
preferredList.appendChild(constructOptionFromInstance(instance));
|
||||
}
|
||||
|
||||
forksDiv.hidden = forks === undefined || forks?.list.length === 0;
|
||||
if (forks) {
|
||||
forkOfSpan.innerText = getName(knownSoftware, forks.of) ?? forks.of;
|
||||
for (const instance of forks.list) {
|
||||
forksList.appendChild(constructOptionFromInstance(instance));
|
||||
}
|
||||
}
|
||||
|
||||
othersDiv.hidden = others.length === 0;
|
||||
for (const instance of others) {
|
||||
othersList.appendChild(constructOptionFromInstance(instance));
|
||||
}
|
||||
|
||||
const firstInput = instanceSelectForm.querySelector("input");
|
||||
if (firstInput) firstInput.checked = true;
|
||||
setRedirectButtonState(firstInput !== null);
|
||||
|
@ -111,7 +198,7 @@ function getTargetPath(): string {
|
|||
|
||||
function getSelectedOption(): string | null {
|
||||
try {
|
||||
return findInputOrFail(instanceSelectForm, `input[name="${radioButtonName}"]:checked`).value;
|
||||
return findInputOrFail(instanceSelectForm, `input[name="${RADIO_BUTTON_NAME}"]:checked`).value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
@ -130,7 +217,6 @@ function autoRedirect(): boolean {
|
|||
function setAutoRedirect(option: string) {
|
||||
const instance = storageManager.storage.instances.find(e => e.origin === option);
|
||||
if (!instance) throw new Error("Invalid argument");
|
||||
instance.preferredFor ??= [];
|
||||
instance.preferredFor.push(getTargetSoftwareOrGroup());
|
||||
storageManager.save();
|
||||
}
|
||||
|
|
37
static/data_migration.mts
Normal file
37
static/data_migration.mts
Normal file
|
@ -0,0 +1,37 @@
|
|||
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;
|
||||
}
|
||||
}
|
46
static/dialog.mts
Normal file
46
static/dialog.mts
Normal file
|
@ -0,0 +1,46 @@
|
|||
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,6 +1,34 @@
|
|||
// 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 findAnchorOrFail(on: Element, selector: string): HTMLAnchorElement {
|
||||
const element = on.querySelector(selector);
|
||||
if (!(element instanceof HTMLAnchorElement))
|
||||
throw new Error(`${selector} isn't an a`);
|
||||
return element;
|
||||
}
|
||||
|
||||
export function findDivOrFail(on: Element, selector: string): HTMLDivElement {
|
||||
const element = on.querySelector(selector);
|
||||
if (!(element instanceof HTMLDivElement))
|
||||
throw new Error(`${selector} isn't a div`);
|
||||
return element;
|
||||
}
|
||||
|
||||
export function findSpanOrFail(on: Element, selector: string): HTMLSpanElement {
|
||||
const element = on.querySelector(selector);
|
||||
if (!(element instanceof HTMLSpanElement))
|
||||
throw new Error(`${selector} isn't a span`);
|
||||
return element;
|
||||
}
|
||||
|
||||
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 {
|
||||
const element = on.querySelector(selector);
|
||||
if (!(element instanceof HTMLOListElement))
|
||||
|
|
|
@ -2,7 +2,7 @@ export function resize(image: HTMLImageElement, width: number = 16, height: numb
|
|||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.style.display = "none";
|
||||
canvas.hidden = true;
|
||||
document.body.appendChild(canvas);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx === null) throw Error("Resize failed");
|
||||
|
|
|
@ -16,4 +16,8 @@ type KnownSoftware = {
|
|||
groups: Record<string, Group>,
|
||||
}
|
||||
|
||||
export function getName(knownSoftware: KnownSoftware, id: string): string | undefined {
|
||||
return knownSoftware.software[id]?.name ?? knownSoftware.groups[id].name;
|
||||
}
|
||||
|
||||
export default await fetch("/known-software.json").then(r => r.json()) as KnownSoftware;
|
||||
|
|
102
static/main.css
102
static/main.css
|
@ -1,3 +1,7 @@
|
|||
@import url("/static/spinner.css");
|
||||
|
||||
/* Variables */
|
||||
|
||||
:root {
|
||||
--red: #cb0b0b;
|
||||
--blue: #2081c3;
|
||||
|
@ -7,6 +11,8 @@
|
|||
--medium: 1em;
|
||||
}
|
||||
|
||||
/* Generic styling for elements */
|
||||
|
||||
html,
|
||||
body {
|
||||
background: linear-gradient(300deg, var(--red), var(--blue));
|
||||
|
@ -55,10 +61,20 @@ abbr[title] {
|
|||
text-decoration-color: var(--blue);
|
||||
}
|
||||
|
||||
/* Generic styling properties */
|
||||
|
||||
.wrap-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.align-start {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
|
@ -127,6 +143,10 @@ abbr[title] {
|
|||
min-height: 50%;
|
||||
}
|
||||
|
||||
.half-width-max {
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
@ -139,22 +159,53 @@ abbr[title] {
|
|||
height: var(--medium);
|
||||
}
|
||||
|
||||
.xl-size {
|
||||
width: var(--xl);
|
||||
height: var(--xl);
|
||||
}
|
||||
|
||||
.xl-size-max {
|
||||
max-width: var(--xl);
|
||||
max-height: var(--xl);
|
||||
}
|
||||
|
||||
.separator-bottom {
|
||||
border-bottom: solid 1px var(--transparent-black);
|
||||
}
|
||||
|
||||
.margin-none {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.margin-auto-top {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.margin-none-top {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.margin-large-bottom {
|
||||
margin-bottom: var(--large);
|
||||
}
|
||||
|
||||
.margin-none-bottom {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.square {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.absolute-centered {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
translate: -50% -50%;
|
||||
}
|
||||
|
||||
/* Specialized elements */
|
||||
|
||||
.iconContainer {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
|
@ -183,3 +234,54 @@ abbr[title] {
|
|||
.buttonPanel>* {
|
||||
margin-top: min(var(--xl), 6vh);
|
||||
}
|
||||
|
||||
.circlingMembers {
|
||||
position: relative;
|
||||
width: 24em;
|
||||
height: 24em;
|
||||
animation-play-state: running;
|
||||
transition: animation-play-state 1s;
|
||||
}
|
||||
|
||||
.circlingMembers:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.member {
|
||||
animation: 8s infinite linear orbit;
|
||||
animation-play-state: inherit;
|
||||
}
|
||||
|
||||
.member.charlotte {
|
||||
--orbit-translate-x: 8em;
|
||||
}
|
||||
|
||||
.member.kio {
|
||||
--orbit-translate-x: -8em;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes orbit {
|
||||
0% {
|
||||
transform: rotate(0deg) translateX(var(--orbit-translate-x)) rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg) translateX(var(--orbit-translate-x)) rotate(-360deg);
|
||||
}
|
||||
}
|
78
static/spinner.css
Normal file
78
static/spinner.css
Normal file
|
@ -0,0 +1,78 @@
|
|||
/* Sourced and modified from https://cssloaders.github.io/ */
|
||||
|
||||
.spinner {
|
||||
display: block;
|
||||
animation: rotate 1s infinite;
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.spinner:before,
|
||||
.spinner:after {
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
display: block;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.spinner:before {
|
||||
animation: ball1 1s infinite;
|
||||
background-color: var(--red);
|
||||
box-shadow: 30px 0 0 var(--blue);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.spinner:after {
|
||||
animation: ball2 1s infinite;
|
||||
background-color: var(--blue);
|
||||
box-shadow: 30px 0 0 var(--red);
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(0.8)
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(360deg) scale(1.2)
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(720deg) scale(0.8)
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ball1 {
|
||||
0% {
|
||||
box-shadow: 30px 0 0 var(--blue);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 0 var(--blue);
|
||||
margin-bottom: 0;
|
||||
transform: translate(15px, 15px);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 30px 0 0 var(--blue);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ball2 {
|
||||
0% {
|
||||
box-shadow: 30px 0 0 var(--red);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 0 var(--red);
|
||||
margin-top: -20px;
|
||||
transform: translate(15px, 15px);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 30px 0 0 var(--red);
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
import migrate from "./data_migration.mjs";
|
||||
|
||||
export type Instance = {
|
||||
/**
|
||||
* The instance's (nick)name
|
||||
|
@ -21,18 +23,19 @@ export type Instance = {
|
|||
software: string,
|
||||
/**
|
||||
* The instance's icon URL
|
||||
*
|
||||
* Make sure to sanitize this! Could lead to XSS
|
||||
* @example undefined
|
||||
* @example "https://void.lgbt/favicon.png"
|
||||
*/
|
||||
iconURL?: string,
|
||||
/**
|
||||
* The list of software names and groups the user prefers to autoredirect to this instance
|
||||
* @example ["sharkey", "misskey-compliant"]
|
||||
*/
|
||||
preferredFor?: string[],
|
||||
preferredFor: string[],
|
||||
}
|
||||
|
||||
type LocalStorage = {
|
||||
export type LocalStorage = {
|
||||
version: number,
|
||||
instances: Instance[],
|
||||
}
|
||||
|
||||
|
@ -46,12 +49,14 @@ export default new class StorageManager {
|
|||
|
||||
default(): LocalStorage {
|
||||
return {
|
||||
version: 1,
|
||||
instances: []
|
||||
}
|
||||
}
|
||||
|
||||
load() {
|
||||
this.storage = JSON.parse(window.localStorage.getItem("storage") ?? "null") ?? this.default();
|
||||
const data = JSON.parse(window.localStorage.getItem("storage") ?? "null") ?? this.default();
|
||||
this.storage = migrate(data);
|
||||
}
|
||||
|
||||
save() {
|
||||
|
@ -62,4 +67,8 @@ export default new class StorageManager {
|
|||
addSaveCallback(callback: () => void) {
|
||||
this.saveCallbacks.push(callback);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.storage = this.default();
|
||||
}
|
||||
}();
|
|
@ -4,6 +4,7 @@
|
|||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitOverride": true,
|
||||
},
|
||||
"include": [
|
||||
"static/**.mts",
|
||||
|
|
Loading…
Add table
Reference in a new issue