Compare commits

..

85 commits

Author SHA1 Message Date
kio
21d7738c52 Update Dockerfile
All checks were successful
Build & Test / build-run (push) Successful in 1m15s
Build Docker Container / publish-docker (push) Successful in 2m27s
2025-02-13 20:29:35 +00:00
kio
40ea027ddf Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Successful in 40s
Build Docker Container / publish-docker (push) Failing after 6s
2025-02-13 19:15:25 +00:00
kio
0ff55afcad Update .forgejo/workflows/docker.yaml
Some checks failed
Build Docker Container / publish-docker (push) Failing after 6s
Build & Test / build-run (push) Successful in 40s
2025-02-13 19:13:14 +00:00
Kio
98a9cad7f4 Merge pull request 'Slim down the weight of packages we ship' (#7) from Dockerfile-redo into main
Some checks failed
Build & Test / build-run (push) Successful in 40s
Build Docker Container / publish-docker (push) Failing after 5s
Reviewed-on: #7
2025-02-13 19:09:10 +00:00
kio
7870658c82 Update Dockerfile
All checks were successful
Build & Test / build-run (push) Successful in 42s
2025-02-13 19:06:58 +00:00
kio
b1c2611b68 Update Dockerfile
All checks were successful
Build & Test / build-run (push) Successful in 42s
2025-02-13 17:12:56 +00:00
kio
e644a5634a Slim down the weight of packages we ship
All checks were successful
Build & Test / build-run (push) Successful in 41s
2025-02-13 16:52:35 +00:00
e527da497f Merge pull request 'Add about section' (#6) from about into main
All checks were successful
Build & Test / build-run (push) Successful in 1m15s
Build Docker Container / publish-docker (push) Successful in 4m58s
Reviewed-on: #6
2025-02-13 06:11:57 +00:00
d72ebcdbef Add about dialog to config as well
All checks were successful
Build & Test / build-run (push) Successful in 40s
2025-02-13 07:00:14 +01:00
dd53074458 Open/close about dialog 2025-02-13 07:00:14 +01:00
fd63c0b12a Almost finished about dialog 2025-02-13 07:00:14 +01:00
3f52ae2147 Populate nekomata pfps
Now that there's an init function I should probably fail early on invalid knownsoftware json
2025-02-13 07:00:14 +01:00
bf963c4ab8 Wip about page 2025-02-13 07:00:14 +01:00
kio
da67b54f6e Update .forgejo/workflows/docker.yaml
Some checks failed
Build Docker Container / publish-docker (push) Has been cancelled
Build & Test / build-run (push) Has been cancelled
2025-02-13 05:43:26 +00:00
kio
e82b85d0d0 Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Successful in 5m2s
2025-02-13 05:33:55 +00:00
kio
f92ab0e44e Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 2m5s
2025-02-13 05:15:22 +00:00
kio
66234b7a66 Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 4s
2025-02-13 05:13:21 +00:00
kio
24d2850fc6 Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 4s
2025-02-13 05:12:35 +00:00
kio
30706aba84 Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 5s
2025-02-13 05:10:59 +00:00
kio
6babd3187b Update .forgejo/workflows/docker.yaml
Some checks failed
Build Docker Container / publish-docker (push) Failing after 0s
Build & Test / build-run (push) Has been cancelled
2025-02-13 05:10:26 +00:00
kio
42c9cbbc56 Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 5s
2025-02-13 04:57:51 +00:00
kio
1810e4869e Update .forgejo/workflows/docker.yaml
Some checks failed
Build Docker Container / publish-docker (push) Waiting to run
Build & Test / build-run (push) Has been cancelled
2025-02-13 04:49:45 +00:00
kio
866823e04f Update .forgejo/workflows/docker.yaml
Some checks failed
Build Docker Container / publish-docker (push) Waiting to run
Build & Test / build-run (push) Has been cancelled
2025-02-13 04:49:34 +00:00
kio
9490488331 Update .forgejo/workflows/docker.yaml
Some checks are pending
Build Docker Container / publish-docker (push) Waiting to run
Build & Test / build-run (push) Successful in 40s
2025-02-13 04:21:37 +00:00
kio
2d38927bc4 I FORGOT THE DOLLAR SIGN?????
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 6s
2025-02-13 03:38:51 +00:00
kio
27ebde8afe Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 5s
2025-02-13 03:36:40 +00:00
kio
b475692302 Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 5s
2025-02-13 03:32:53 +00:00
kio
2eabacc03d Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 2s
2025-02-13 03:32:18 +00:00
kio
8b256dc95d Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 2s
2025-02-13 03:31:36 +00:00
Kio
589322fe6d Merge pull request 'build docker' (#5) from docker-ci into main
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 7s
Reviewed-on: #5
2025-02-13 03:30:06 +00:00
Kio
2865b78bdb Merge branch 'main' into docker-ci
Some checks failed
Build & Test / build-run (push) Has been cancelled
2025-02-13 03:29:26 +00:00
Kio
c0467ccfd0 Merge pull request 'Add Dockerfile' (#4) from add-dockerfile into main
All checks were successful
Build & Test / build-run (push) Successful in 40s
Reviewed-on: #4
2025-02-13 03:29:13 +00:00
kio
79d7fa9ac5 build docker
All checks were successful
Build & Test / build-run (push) Successful in 44s
2025-02-13 03:28:16 +00:00
kio
b8764d99dd Add Dockerfile
All checks were successful
Build & Test / build-run (push) Successful in 40s
2025-02-13 03:20:15 +00:00
1057bf80dc Hotfix forks text showing when it shouldn't
All checks were successful
Build & Test / build-run (push) Successful in 40s
2025-02-13 03:27:39 +01:00
969538b8b4 Add sections to the CSS
All checks were successful
Build & Test / build-run (push) Successful in 43s
2025-02-13 02:22:43 +01:00
027faf8371 Now sorting
All checks were successful
Build & Test / build-run (push) Successful in 43s
2025-02-13 01:41:20 +01:00
bc7e388f04 TO THE SHADOW REALM WITH YOU, ICESHRIMP.NET FORKOF FIELD
All checks were successful
Build & Test / build-run (push) Successful in 41s
2025-02-13 01:28:44 +01:00
e7748a71da Instance name drop
All checks were successful
Build & Test / build-run (push) Successful in 41s
2025-02-12 22:27:32 +01:00
b408f66f9d Use hidden properly everywhere
All checks were successful
Build & Test / build-run (push) Successful in 45s
2025-02-12 21:59:35 +01:00
c1f61cfe9b Hide image instead of setting it to blank 2025-02-12 21:55:17 +01:00
becaf79690 Add managing preferredFor from editing
All checks were successful
Build & Test / build-run (push) Successful in 45s
2025-02-12 06:49:22 +01:00
68f54b341b This is no longer necessary with LSV1
All checks were successful
Build & Test / build-run (push) Successful in 42s
2025-02-11 23:08:20 +01:00
5e3817c1a7 Add migration
All checks were successful
Build & Test / build-run (push) Successful in 42s
2025-02-11 22:11:56 +01:00
f0617522e3 Implement "Destroy all data" button
All checks were successful
Build & Test / build-run (push) Successful in 49s
Which resets the storage manager, not the entire localstorage. Not that localstorage should be touched outside of the storage manager, but it means I can keep my backups for debugging in there.
2025-02-09 19:13:54 +01:00
7a39fbd418 Re-add setup deno (oops)
All checks were successful
Build & Test / build-run (push) Successful in 41s
2025-02-04 00:11:32 +01:00
8850e08f5d Try using the rust image again
Some checks failed
Build & Test / build-run (push) Failing after 23s
Manually install node this time
2025-02-04 00:08:38 +01:00
kio
9a4e2b3ca5 Remove unneccesary lines
All checks were successful
Build & Test / build-run (push) Successful in 58s
2025-02-03 22:45:31 +00:00
kio
9f5e6115e9 openssl-libs-static
All checks were successful
Build & Test / build-run (push) Successful in 58s
2025-02-03 22:41:02 +00:00
kio
22bbbf0955 add musl-dev
Some checks failed
Build & Test / build-run (push) Failing after 53s
2025-02-03 22:37:46 +00:00
kio
563462c1c5 Update .forgejo/workflows/ci.yaml
Some checks failed
Build & Test / build-run (push) Failing after 29s
2025-02-03 22:36:15 +00:00
kio
cfae51c43f Update .forgejo/workflows/ci.yaml
Some checks failed
Build & Test / build-run (push) Failing after 24s
2025-02-03 22:35:17 +00:00
kio
beaa76f996 Literal paths
Some checks failed
Build & Test / build-run (push) Failing after 36s
2025-02-03 22:28:00 +00:00
kio
3244cecc6a ingest cargo env vars
Some checks failed
Build & Test / build-run (push) Failing after 19s
2025-02-03 22:26:44 +00:00
kio
b4c70c0d16 automate the install
Some checks failed
Build & Test / build-run (push) Failing after 32s
2025-02-03 22:23:06 +00:00
kio
079b91a6c1 Update .forgejo/workflows/ci.yaml
Some checks failed
Build & Test / build-run (push) Failing after 5s
2025-02-03 22:21:58 +00:00
kio
aa3e1ab526 Change explicit install of Rust/Cargo to rustup
Some checks failed
Build & Test / build-run (push) Failing after 1s
2025-02-03 22:21:31 +00:00
kio
3fad4f8d6e add openssl-dev
Some checks failed
Build & Test / build-run (push) Failing after 32s
2025-02-03 22:17:16 +00:00
kio
93f8ba4226 add openssl
Some checks failed
Build & Test / build-run (push) Failing after 16s
2025-02-03 22:16:03 +00:00
kio
764529f36b add pkgconfig
Some checks failed
Build & Test / build-run (push) Failing after 16s
2025-02-03 22:15:07 +00:00
kio
b2cbd4cc5d add cargo
Some checks failed
Build & Test / build-run (push) Failing after 17s
2025-02-03 22:14:18 +00:00
kio
7aa1b483e1 add nodejs
Some checks failed
Build & Test / build-run (push) Failing after 8s
2025-02-03 22:13:43 +00:00
kio
72faa8901c fix command
Some checks failed
Build & Test / build-run (push) Failing after 8s
2025-02-03 22:13:04 +00:00
kio
da6d60f94c Use alpine instead?
Some checks failed
Build & Test / build-run (push) Failing after 8s
2025-02-03 22:12:21 +00:00
3671085acc Use circleci's image instead
Some checks failed
Build & Test / build-run (push) Failing after 27s
2025-02-03 22:36:46 +01:00
bc8d0a3f92 ok add node ig thanks actions/checkout
Some checks failed
Build & Test / build-run (push) Failing after 21s
2025-02-03 22:28:37 +01:00
93e1ac85cd Use the Rust docker image
Some checks failed
Build & Test / build-run (push) Failing after 3s
2025-02-03 22:26:07 +01:00
c3b5666bd5 Typo
Some checks failed
Build & Test / build-run (push) Failing after 17s
2025-02-03 22:18:57 +01:00
0ba211c054 Add setup rust toolchain to ci
Some checks failed
Build & Test / build-run (push) Failing after 3s
2025-02-03 22:16:29 +01:00
b3c049a8fa Rename because oops
Some checks failed
Build & Test / build-run (push) Failing after 7s
2025-02-03 22:12:40 +01:00
kio
5dde457d45 use literal instead of relative
Some checks failed
Test build & run / build-run (push) Failing after 8s
2025-02-03 21:11:06 +00:00
kio
532cd614ce Update CI
Some checks failed
Test build & run / build-run (push) Failing after 15s
2025-02-03 21:09:59 +00:00
be021c4b16 Add CI
Some checks are pending
Test build & run / build-run (push) Waiting to run
Uhhhh hoping it runs ig?
2025-02-03 21:56:13 +01:00
26a48f23a5 Sharkey has mastoapi 2025-02-03 20:14:51 +01:00
30b28b8aba This was bothering me 2025-02-03 20:07:19 +01:00
28bbdac90a Most people do not care about this 2025-02-03 20:03:32 +01:00
940fc92856 Refresh list on edit 2025-02-03 19:37:15 +01:00
93c9e5154f Only delete one 2025-02-03 19:24:34 +01:00
516473edeb Some more fixes 2025-02-03 19:03:18 +01:00
7e1416a721 Editing & saving changes & fixes 2025-02-03 19:00:15 +01:00
f451b1fbc3 Rewrite InstanceDetailsDialog
Also misc cleanup. Almost done with this
2025-02-03 17:23:45 +01:00
bfd61c2e50 Rewrite AddInstanceFlow
Now with spinner!
2025-02-03 03:52:11 +01:00
2be0658ed9 Rewrite AddInstanceDialog
Object oriented it is
2025-02-03 03:15:14 +01:00
5534bc3942 Make the CSP changes on config 2025-02-03 01:11:29 +01:00
3f9624fe91 Merge pull request 'no-more-proxy' (#3) from no-more-proxy into main
Reviewed-on: #3
2025-02-03 00:03:50 +00:00
27 changed files with 1073 additions and 286 deletions

View 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

View 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
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
// This file handles the "Add an instance" dialog
import { FormDialog, ONCE } from "./dialog.mjs";
import { findButtonOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs";
export function parseHost(host: string): { host: string, secure: boolean } | null {
@ -13,43 +14,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();
});
}
}

View file

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

View file

@ -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">&ThickSpace;By Nekomata</p>
<p class="margin-auto-top">&ThickSpace;<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">&ThickSpace;(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>

View file

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

View file

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

View file

@ -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">&ThickSpace;By Nekomata</p>
<p class="margin-auto-top">&ThickSpace;<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">&ThickSpace;(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>

View file

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

View file

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

View file

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

View file

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

View file

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

@ -0,0 +1,78 @@
/* Sourced and modified from https://cssloaders.github.io/ */
.spinner {
display: block;
animation: rotate 1s infinite;
height: 50px;
width: 50px;
}
.spinner:before,
.spinner:after {
border-radius: 50%;
content: "";
display: block;
height: 20px;
width: 20px;
}
.spinner:before {
animation: ball1 1s infinite;
background-color: var(--red);
box-shadow: 30px 0 0 var(--blue);
margin-bottom: 10px;
}
.spinner:after {
animation: ball2 1s infinite;
background-color: var(--blue);
box-shadow: 30px 0 0 var(--red);
}
@keyframes rotate {
0% {
transform: rotate(0deg) scale(0.8)
}
50% {
transform: rotate(360deg) scale(1.2)
}
100% {
transform: rotate(720deg) scale(0.8)
}
}
@keyframes ball1 {
0% {
box-shadow: 30px 0 0 var(--blue);
}
50% {
box-shadow: 0 0 0 var(--blue);
margin-bottom: 0;
transform: translate(15px, 15px);
}
100% {
box-shadow: 30px 0 0 var(--blue);
margin-bottom: 10px;
}
}
@keyframes ball2 {
0% {
box-shadow: 30px 0 0 var(--red);
}
50% {
box-shadow: 0 0 0 var(--red);
margin-top: -20px;
transform: translate(15px, 15px);
}
100% {
box-shadow: 30px 0 0 var(--red);
margin-top: 0;
}
}

View file

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

View file

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