Compare commits
56 commits
config-pag
...
main
Author | SHA1 | Date | |
---|---|---|---|
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 | |||
cc15a4b29f | |||
c7ea3326cb | |||
01d0b6a8bd | |||
28a4ac2ca3 | |||
4793aa3a7a | |||
1449e2f5df | |||
c856ab9900 | |||
6684943989 | |||
c1021077bc |
26 changed files with 1065 additions and 327 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
|
328
Cargo.lock
generated
328
Cargo.lock
generated
|
@ -1,6 +1,6 @@
|
||||||
# This file is automatically @generated by Cargo.
|
# This file is automatically @generated by Cargo.
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "addr2line"
|
name = "addr2line"
|
||||||
|
@ -185,6 +185,29 @@ version = "0.8.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssparser"
|
||||||
|
version = "0.34.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
|
||||||
|
dependencies = [
|
||||||
|
"cssparser-macros",
|
||||||
|
"dtoa-short",
|
||||||
|
"itoa",
|
||||||
|
"phf",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssparser-macros"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
|
@ -194,6 +217,17 @@ dependencies = [
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_more"
|
||||||
|
version = "0.99.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "devise"
|
name = "devise"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
|
@ -238,6 +272,27 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dtoa"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dtoa-short"
|
||||||
|
version = "0.3.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
|
||||||
|
dependencies = [
|
||||||
|
"dtoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ego-tree"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.13.0"
|
version = "1.13.0"
|
||||||
|
@ -275,16 +330,32 @@ version = "2.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "favicon-scraper"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e2d1e19588bba8f650edac55fc88e1f2db227d18920d9e71322b46530f04a2"
|
||||||
|
dependencies = [
|
||||||
|
"futures",
|
||||||
|
"imagesize",
|
||||||
|
"reqwest",
|
||||||
|
"scraper",
|
||||||
|
"serde",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fedirect"
|
name = "fedirect"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"favicon-scraper",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rocket",
|
"rocket",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -332,6 +403,16 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futf"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
||||||
|
dependencies = [
|
||||||
|
"mac",
|
||||||
|
"new_debug_unreachable",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
|
@ -409,6 +490,15 @@ dependencies = [
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fxhash"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generator"
|
name = "generator"
|
||||||
version = "0.7.5"
|
version = "0.7.5"
|
||||||
|
@ -422,6 +512,15 @@ dependencies = [
|
||||||
"windows",
|
"windows",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getopts"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
|
@ -501,6 +600,20 @@ version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc"
|
checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "html5ever"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e15626aaf9c351bc696217cbe29cb9b5e86c43f8a46b5e2f5c6c5cf7cb904ce"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"mac",
|
||||||
|
"markup5ever",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "0.2.12"
|
version = "0.2.12"
|
||||||
|
@ -804,6 +917,12 @@ dependencies = [
|
||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "imagesize"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.7.0"
|
version = "2.7.0"
|
||||||
|
@ -909,6 +1028,26 @@ dependencies = [
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mac"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markup5ever"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82c88c6129bd24319e62a0359cb6b958fa7e8be6e19bb1663bc396b90883aca5"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"phf",
|
||||||
|
"phf_codegen",
|
||||||
|
"string_cache",
|
||||||
|
"string_cache_codegen",
|
||||||
|
"tendril",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
@ -986,6 +1125,12 @@ dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "new_debug_unreachable"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.46.0"
|
version = "0.46.0"
|
||||||
|
@ -1129,6 +1274,77 @@ version = "2.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
|
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||||
|
dependencies = [
|
||||||
|
"phf_macros",
|
||||||
|
"phf_shared 0.11.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_codegen"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator 0.11.3",
|
||||||
|
"phf_shared 0.11.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_generator"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared 0.10.0",
|
||||||
|
"rand",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_generator"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared 0.11.3",
|
||||||
|
"rand",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_macros"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator 0.11.3",
|
||||||
|
"phf_shared 0.11.3",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher 0.3.11",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher 1.0.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.16"
|
version = "0.2.16"
|
||||||
|
@ -1162,6 +1378,12 @@ dependencies = [
|
||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "precomputed-hash"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.93"
|
version = "1.0.93"
|
||||||
|
@ -1530,6 +1752,21 @@ version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scraper"
|
||||||
|
version = "0.22.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15"
|
||||||
|
dependencies = [
|
||||||
|
"cssparser",
|
||||||
|
"ego-tree",
|
||||||
|
"getopts",
|
||||||
|
"html5ever",
|
||||||
|
"precomputed-hash",
|
||||||
|
"selectors",
|
||||||
|
"tendril",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
|
@ -1553,6 +1790,25 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "selectors"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cssparser",
|
||||||
|
"derive_more",
|
||||||
|
"fxhash",
|
||||||
|
"log",
|
||||||
|
"new_debug_unreachable",
|
||||||
|
"phf",
|
||||||
|
"phf_codegen",
|
||||||
|
"precomputed-hash",
|
||||||
|
"servo_arc",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
|
@ -1612,6 +1868,15 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "servo_arc"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae65c4249478a2647db249fb43e23cec56a2c8974a427e7bd8cb5a1d0964921a"
|
||||||
|
dependencies = [
|
||||||
|
"stable_deref_trait",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
@ -1636,6 +1901,18 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "0.3.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.9"
|
version = "0.4.9"
|
||||||
|
@ -1691,6 +1968,32 @@ dependencies = [
|
||||||
"loom",
|
"loom",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "string_cache"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b"
|
||||||
|
dependencies = [
|
||||||
|
"new_debug_unreachable",
|
||||||
|
"once_cell",
|
||||||
|
"parking_lot",
|
||||||
|
"phf_shared 0.10.0",
|
||||||
|
"precomputed-hash",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "string_cache_codegen"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator 0.10.0",
|
||||||
|
"phf_shared 0.10.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
|
@ -1763,6 +2066,17 @@ dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tendril"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
||||||
|
dependencies = [
|
||||||
|
"futf",
|
||||||
|
"mac",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thread_local"
|
name = "thread_local"
|
||||||
version = "1.1.8"
|
version = "1.1.8"
|
||||||
|
@ -2039,6 +2353,12 @@ version = "1.0.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
|
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
|
@ -2062,6 +2382,12 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf16_iter"
|
name = "utf16_iter"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
|
|
|
@ -5,9 +5,11 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bytes = "1.9.0"
|
bytes = "1.9.0"
|
||||||
|
favicon-scraper = "0.3.1"
|
||||||
reqwest = { version = "0.12.12", features = ["stream"] }
|
reqwest = { version = "0.12.12", features = ["stream"] }
|
||||||
rocket = { version = "0.5.1", features = ["json"] }
|
rocket = { version = "0.5.1", features = ["json"] }
|
||||||
semver = "1.0.24"
|
semver = "1.0.24"
|
||||||
serde = { version = "1.0.217", features = ["derive"] }
|
serde = { version = "1.0.217", features = ["derive"] }
|
||||||
serde_json = "1.0.135"
|
serde_json = "1.0.135"
|
||||||
|
tokio = { version = "1.43.0", features = ["process"] }
|
||||||
url = "2.5.4"
|
url = "2.5.4"
|
||||||
|
|
|
@ -2,11 +2,13 @@
|
||||||
|
|
||||||
Fedi links that open on your preferred instance!
|
Fedi links that open on your preferred instance!
|
||||||
|
|
||||||
## Building
|
## Building and Running
|
||||||
|
|
||||||
To compile TypeScript, the build script assumes Deno is installed.
|
To compile TypeScript, the build script assumes Deno is installed.
|
||||||
|
|
||||||
When you have Deno and Rust installed, simply use Cargo to build the project
|
1. [Install Rust via rustup](https://rustup.rs/)
|
||||||
|
2. [Install Deno](https://deno.com/)
|
||||||
|
3. Use Cargo to build the project:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# For example, to build for release, you can do
|
# For example, to build for release, you can do
|
||||||
|
|
|
@ -136,7 +136,8 @@
|
||||||
],
|
],
|
||||||
"groups": [
|
"groups": [
|
||||||
"misskey-compliant",
|
"misskey-compliant",
|
||||||
"misskey-v13"
|
"misskey-v13",
|
||||||
|
"mastodon-compliant-api"
|
||||||
],
|
],
|
||||||
"forkOf": "misskey"
|
"forkOf": "misskey"
|
||||||
},
|
},
|
||||||
|
|
2
rust-toolchain.toml
Normal file
2
rust-toolchain.toml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[toolchain]
|
||||||
|
channel = "nightly"
|
|
@ -1,9 +1,14 @@
|
||||||
|
use std::net::ToSocketAddrs;
|
||||||
|
|
||||||
|
use favicon_scraper::{Icon, IconKind};
|
||||||
use rocket::serde::json::Json;
|
use rocket::serde::json::Json;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::known_software::KNOWN_SOFTWARE_NODEINFO_NAMES;
|
use crate::known_software::KNOWN_SOFTWARE_NODEINFO_NAMES;
|
||||||
|
|
||||||
|
const MINIMUM_ICON_SIZE: usize = 16;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct InstanceInfo {
|
pub struct InstanceInfo {
|
||||||
name: String,
|
name: String,
|
||||||
|
@ -12,28 +17,6 @@ pub struct InstanceInfo {
|
||||||
icon_url: Option<String>,
|
icon_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct InstanceManifest {
|
|
||||||
name: Option<String>,
|
|
||||||
short_name: Option<String>,
|
|
||||||
icons: Option<Vec<InstanceIcon>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct InstanceIcon {
|
|
||||||
src: String,
|
|
||||||
sizes: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InstanceIcon {
|
|
||||||
fn get_size(&self) -> Option<usize> {
|
|
||||||
let (x, y) = self.sizes.split_once("x")?;
|
|
||||||
let x: usize = x.parse().ok()?;
|
|
||||||
let y: usize = y.parse().ok()?;
|
|
||||||
Some(x.max(y))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct NodeInfoDiscovery {
|
struct NodeInfoDiscovery {
|
||||||
links: Vec<NodeInfoLink>,
|
links: Vec<NodeInfoLink>,
|
||||||
|
@ -62,40 +45,59 @@ struct NodeInfoMetadata {
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_info_from_manifest(url: Url) -> Option<[Option<String>; 3]> {
|
/// Scrapes icons and returns the smallest one where the smallest axis is bigger than or equal to [MINIMUM_ICON_SIZE]
|
||||||
// FIXME: Iceshrimp.NET doesn't have a manifest...
|
async fn find_icon(host: &str) -> Option<String> {
|
||||||
let response = reqwest::get(url.clone()).await.ok()?.text().await.ok()?;
|
let icons: Vec<Icon> = favicon_scraper::scrape(host)
|
||||||
let manifest: InstanceManifest = serde_json::from_str(&response).ok()?;
|
.await
|
||||||
Some([
|
.ok()?
|
||||||
manifest.name,
|
.into_iter()
|
||||||
manifest.short_name,
|
.filter(|i| i.size.width.min(i.size.height) >= MINIMUM_ICON_SIZE)
|
||||||
manifest
|
.collect();
|
||||||
.icons
|
|
||||||
.as_ref()
|
let priority = |kind: &IconKind| match kind {
|
||||||
.and_then(|icons| icons.iter().min_by_key(|icon| icon.get_size()))
|
IconKind::LinkedInHTML => 0,
|
||||||
.map(|icon| icon.src.to_owned()),
|
IconKind::LinkedInManifest => 1,
|
||||||
])
|
IconKind::HardcodedURL => 2,
|
||||||
|
_ => 3,
|
||||||
|
};
|
||||||
|
let preferred_kind = icons
|
||||||
|
.iter()
|
||||||
|
.map(|i| i.kind)
|
||||||
|
.min_by(|x, y| priority(x).cmp(&priority(y)))?; // None if icons is empty
|
||||||
|
|
||||||
|
icons
|
||||||
|
.into_iter()
|
||||||
|
.filter(|i| i.kind == preferred_kind)
|
||||||
|
.min_by_key(|i| i.size)
|
||||||
|
.map(|i| i.url.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/instance_info/<secure>/<host>")]
|
#[get("/instance_info/<secure>/<host>")]
|
||||||
pub async fn instance_info(secure: bool, host: &str) -> Option<Json<InstanceInfo>> {
|
pub async fn instance_info(secure: bool, host: &str) -> Option<Json<InstanceInfo>> {
|
||||||
let mut url = Url::parse(&format!(
|
let mut url = Url::parse(if secure {
|
||||||
"http{}://{host}/manifest.json",
|
"https://temp.host/"
|
||||||
if secure { "s" } else { "" }
|
} else {
|
||||||
))
|
"http://temp.host/"
|
||||||
.ok()?;
|
})
|
||||||
// I'm not sure if you can sneak in a path, but better safe than sorry
|
.unwrap();
|
||||||
// I don't really care about username/password/port, those are fine
|
url.set_host(Some(host)).ok()?; // Using this to catch malformed hosts
|
||||||
if url.path() != "/manifest.json" {
|
let host = url.host_str()?.to_owned(); // Shadow the original host in case things were filtered out
|
||||||
|
|
||||||
|
// Check if the host is globally routable.
|
||||||
|
// This should help filter out a bunch of invalid or potentially malicious requests
|
||||||
|
let host_with_port = format!("{host}:{}", url.port_or_known_default()?);
|
||||||
|
if !host_with_port
|
||||||
|
.to_socket_addrs()
|
||||||
|
.ok()?
|
||||||
|
.next()?
|
||||||
|
.ip()
|
||||||
|
.is_global()
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let [name, short_name, icon_url] = get_info_from_manifest(url.clone())
|
|
||||||
.await
|
let icon_url = find_icon(url.as_str()).await;
|
||||||
.unwrap_or_default();
|
|
||||||
let icon_url = icon_url
|
|
||||||
.and_then(|i| url.join(&i).ok())
|
|
||||||
.map(|u| u.to_string());
|
|
||||||
// FIXME: Iceshrimp.NET doesn't have a nodeinfo discovery file either.............
|
|
||||||
url.set_path("/.well-known/nodeinfo");
|
url.set_path("/.well-known/nodeinfo");
|
||||||
let response = reqwest::get(url.clone()).await.ok()?.text().await.ok()?;
|
let response = reqwest::get(url.clone()).await.ok()?.text().await.ok()?;
|
||||||
let nodeinfo_discovery: NodeInfoDiscovery = serde_json::from_str(&response).ok()?;
|
let nodeinfo_discovery: NodeInfoDiscovery = serde_json::from_str(&response).ok()?;
|
||||||
|
@ -112,11 +114,12 @@ pub async fn instance_info(secure: bool, host: &str) -> Option<Json<InstanceInfo
|
||||||
.and_then(|v| fork_map.get(v.build.as_str()))
|
.and_then(|v| fork_map.get(v.build.as_str()))
|
||||||
.unwrap_or(software_name)
|
.unwrap_or(software_name)
|
||||||
.to_owned();
|
.to_owned();
|
||||||
|
|
||||||
Some(Json(InstanceInfo {
|
Some(Json(InstanceInfo {
|
||||||
name: name
|
name: nodeinfo
|
||||||
.or(short_name)
|
.metadata
|
||||||
.or(nodeinfo.metadata.and_then(|m| m.name))
|
.and_then(|m| m.name)
|
||||||
.unwrap_or(url.host_str().unwrap().to_owned()),
|
.unwrap_or(host.to_owned()),
|
||||||
software,
|
software,
|
||||||
icon_url,
|
icon_url,
|
||||||
}))
|
}))
|
||||||
|
|
|
@ -4,5 +4,9 @@ pub mod instance_info;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
|
|
||||||
pub fn get_routes() -> Vec<Route> {
|
pub fn get_routes() -> Vec<Route> {
|
||||||
routes![instance_info::instance_info, proxy::proxy]
|
routes![
|
||||||
|
instance_info::instance_info,
|
||||||
|
// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
#![feature(ip)]
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate rocket;
|
extern crate rocket;
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
// This file handles the "Add an instance" dialog
|
// This file handles the "Add an instance" dialog
|
||||||
|
|
||||||
|
import { FormDialog, ONCE } from "./dialog.mjs";
|
||||||
import { findButtonOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs";
|
import { findButtonOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs";
|
||||||
|
|
||||||
export function parseHost(host: string): { host: string, secure: boolean } | null {
|
export function parseHost(host: string): { host: string, secure: boolean } | null {
|
||||||
|
@ -13,43 +14,67 @@ export function parseHost(host: string): { host: string, secure: boolean } | nul
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initializeAddInstanceDialog(
|
export type AddInstanceDialogData = {
|
||||||
dialog: HTMLDialogElement,
|
|
||||||
callback: (
|
|
||||||
host: string,
|
host: string,
|
||||||
secure: boolean,
|
secure: boolean,
|
||||||
autoQueryMetadata: boolean,
|
autoQueryMetadata: boolean,
|
||||||
) => void
|
};
|
||||||
): {
|
|
||||||
showAddInstanceDialog: () => void,
|
|
||||||
hideAddInstanceDialog: () => void,
|
|
||||||
} {
|
|
||||||
const showAddInstanceDialog = () => dialog.showModal();
|
|
||||||
const hideAddInstanceDialog = () => dialog.close();
|
|
||||||
|
|
||||||
const form = findFormOrFail(dialog, ".addInstanceForm");
|
export class AddInstanceDialog extends FormDialog {
|
||||||
const instanceHost = findInputOrFail(form, "#instanceHost");
|
protected instanceHost: HTMLInputElement;
|
||||||
const autoQueryMetadata = findInputOrFail(form, "#autoQueryMetadata");
|
protected autoQueryMetadata: HTMLInputElement;
|
||||||
const closeButton = findButtonOrFail(form, ".close");
|
protected closeButton: HTMLButtonElement;
|
||||||
|
|
||||||
instanceHost.addEventListener("input", e => {
|
constructor(dialog: HTMLDialogElement, initializeDOM: boolean = true) {
|
||||||
if (parseHost(instanceHost.value) === null)
|
super(dialog, findFormOrFail(dialog, ".addInstanceForm"));
|
||||||
instanceHost.setCustomValidity("Invalid instance hostname or URL");
|
|
||||||
else
|
|
||||||
instanceHost.setCustomValidity("");
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener("submit", e => {
|
this.instanceHost = findInputOrFail(this.form, "#instanceHost");
|
||||||
// A sane browser doesn't allow for submitting the form if the above validation fails
|
this.autoQueryMetadata = findInputOrFail(this.form, "#autoQueryMetadata");
|
||||||
const { host, secure } = parseHost(instanceHost.value)!;
|
this.closeButton = findButtonOrFail(this.form, ".close");
|
||||||
callback(host, secure, autoQueryMetadata.checked);
|
|
||||||
form.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
closeButton.addEventListener("click", e => hideAddInstanceDialog());
|
if (initializeDOM) this.initializeDOM();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
showAddInstanceDialog,
|
host: parsedHost.host,
|
||||||
hideAddInstanceDialog
|
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 { AddInstanceDialog } from "./add_an_instance.mjs";
|
||||||
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs";
|
import { dialogDetailsToInstance, InstanceDetailsDialog, InstanceDetailsDialogData } from "./confirm_instance_details.mjs";
|
||||||
|
import { Dialog } from "./dialog.mjs";
|
||||||
import storageManager, { Instance } from "./storage_manager.mjs";
|
import storageManager, { Instance } from "./storage_manager.mjs";
|
||||||
|
|
||||||
export function initializeAddInstanceFlow(
|
export class AddInstanceFlow {
|
||||||
detailsDialog: HTMLDialogElement,
|
addDialog: AddInstanceDialog;
|
||||||
addDialog: HTMLDialogElement
|
spinnerDialog: Dialog;
|
||||||
): {
|
detailsDialog: InstanceDetailsDialog;
|
||||||
showAddInstanceDialog: () => void,
|
|
||||||
hideAddInstanceDialog: () => void
|
|
||||||
} {
|
|
||||||
const instanceDetailsDialogCallback = (
|
|
||||||
name: string,
|
|
||||||
host: string,
|
|
||||||
hostSecure: boolean,
|
|
||||||
software: string,
|
|
||||||
icon: string | null
|
|
||||||
) => {
|
|
||||||
const instance: Instance = {
|
|
||||||
name,
|
|
||||||
origin: `http${hostSecure ? "s" : ""}://${host}`,
|
|
||||||
software,
|
|
||||||
iconURL: icon ?? undefined
|
|
||||||
};
|
|
||||||
storageManager.storage.instances.push(instance);
|
|
||||||
storageManager.save();
|
|
||||||
console.log("Successfully added new instance:", instance);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
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 {
|
const {
|
||||||
showInstanceDetailsDialog,
|
autoQueryMetadata,
|
||||||
hideInstanceDetailsDialog,
|
host,
|
||||||
populateInstanceDetailsDialog
|
secure,
|
||||||
} = initializeInstanceDetailsDialog(detailsDialog, instanceDetailsDialogCallback);
|
} = await this.addDialog.present();
|
||||||
|
|
||||||
|
const detailsDialogData: InstanceDetailsDialogData = {
|
||||||
|
name: host,
|
||||||
|
host,
|
||||||
|
hostSecure: secure,
|
||||||
|
software: "",
|
||||||
|
iconURL: null,
|
||||||
|
preferredFor: []
|
||||||
|
};
|
||||||
|
|
||||||
const addInstanceDialogCallback = async (
|
|
||||||
host: string,
|
|
||||||
secure: boolean,
|
|
||||||
autoQueryMetadata: boolean,
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
if (!autoQueryMetadata) throw new Error("Don't");
|
if (!autoQueryMetadata) throw null; // Skip to catch block
|
||||||
|
|
||||||
|
this.spinnerDialog.open();
|
||||||
|
|
||||||
const { name, software, iconURL } =
|
const { name, software, iconURL } =
|
||||||
await fetch(`/api/instance_info/${secure}/${encodeURIComponent(host)}`)
|
await fetch(`/api/instance_info/${secure}/${encodeURIComponent(host)}`)
|
||||||
.then(r => r.json());
|
.then(r => r.json());
|
||||||
if (
|
if (
|
||||||
typeof name !== "string"
|
typeof name !== "string"
|
||||||
|| typeof software !== "string"
|
|| typeof software !== "string"
|
||||||
|| !(typeof iconURL === "string" || iconURL === null)
|
|| !(typeof iconURL === "string" || iconURL === null) // I guess TS is too stupid to understand this?
|
||||||
)
|
)
|
||||||
throw new Error("Invalid API response");
|
throw new Error("Invalid API response");
|
||||||
populateInstanceDetailsDialog(name, host, secure, software, iconURL as string | null);
|
|
||||||
} catch {
|
|
||||||
populateInstanceDetailsDialog(host, host, secure, "", null);
|
|
||||||
} finally {
|
|
||||||
showInstanceDetailsDialog();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
detailsDialogData.name = name;
|
||||||
showAddInstanceDialog,
|
detailsDialogData.software = software;
|
||||||
hideAddInstanceDialog
|
detailsDialogData.iconURL = iconURL as string | null;
|
||||||
} = initializeAddInstanceDialog(addDialog, addInstanceDialogCallback);
|
} catch { }
|
||||||
|
this.spinnerDialog.close();
|
||||||
|
|
||||||
return {
|
const finalData = await this.detailsDialog.present(detailsDialogData);
|
||||||
showAddInstanceDialog,
|
const instance = dialogDetailsToInstance(finalData, {});
|
||||||
hideAddInstanceDialog
|
|
||||||
};
|
storageManager.storage.instances.push(instance);
|
||||||
|
if (autoSave) storageManager.save();
|
||||||
|
console.log("Successfully added new instance:", instance);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,13 +6,11 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>FeDirect</title>
|
<title>FeDirect</title>
|
||||||
<link rel="stylesheet" href="/static/main.css">
|
<link rel="stylesheet" href="/static/main.css">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *;">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<script type="module">
|
<script type="module" src="/static/config.mjs"></script>
|
||||||
Object.assign(globalThis, await import("/static/config.mjs"));
|
|
||||||
getMainDialog().show(); // Don't show until the page is ready
|
|
||||||
</script>
|
|
||||||
<div class="flex-vcenter">
|
<div class="flex-vcenter">
|
||||||
<dialog id="mainDialog" class="half-width half-height">
|
<dialog id="mainDialog" class="half-width half-height">
|
||||||
<header class="separator-bottom margin-large-bottom">
|
<header class="separator-bottom margin-large-bottom">
|
||||||
|
@ -27,7 +25,7 @@
|
||||||
<center class="half-width">
|
<center class="half-width">
|
||||||
<ol id="instanceList" class="align-start wfit-content"></ol>
|
<ol id="instanceList" class="align-start wfit-content"></ol>
|
||||||
<br>
|
<br>
|
||||||
<button onclick="showAddInstanceDialog()">Add an instance</button>
|
<button id="startAddInstanceFlow">Add an instance</button>
|
||||||
</center>
|
</center>
|
||||||
</div>
|
</div>
|
||||||
<div class="half-width align-self-start">
|
<div class="half-width align-self-start">
|
||||||
|
@ -52,9 +50,8 @@
|
||||||
<br>
|
<br>
|
||||||
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
|
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
|
||||||
<label for="autoQueryMetadata">
|
<label for="autoQueryMetadata">
|
||||||
<abbr title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.
|
<abbr
|
||||||
We do this on the backend to avoid CORS problems.
|
title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.">
|
||||||
We do not track or save any requests or data.">
|
|
||||||
Automatically query metadata
|
Automatically query metadata
|
||||||
</abbr>
|
</abbr>
|
||||||
</label>
|
</label>
|
||||||
|
@ -104,14 +101,15 @@ Unchecking this is not recommended, and this option only exists for exceptional
|
||||||
<br>
|
<br>
|
||||||
<label for="defaultsList">Default option for:</label><br>
|
<label for="defaultsList">Default option for:</label><br>
|
||||||
<select id="defaultsList" class="full-width" multiple>
|
<select id="defaultsList" class="full-width" multiple>
|
||||||
<option 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>
|
</select>
|
||||||
<button id="removeDefaults" disabled>Remove</button>
|
<button id="removeDefaults" type="button" disabled>Remove</button>
|
||||||
<br><br>
|
<br><br>
|
||||||
<button type="submit">OK</button>
|
<button type="submit">OK</button>
|
||||||
<button type="reset" class="close">Cancel</button>
|
<button type="reset" class="close">Cancel</button>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<dialog id="spinner"><span class="spinner"></span></dialog>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -1,22 +1,34 @@
|
||||||
import { parseHost } from "./add_an_instance.mjs";
|
import { AddInstanceFlow } from "./add_instance_flow.mjs";
|
||||||
import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
|
import { dialogDetailsFromInstance, dialogDetailsToInstance, InstanceDetailsDialog } from "./confirm_instance_details.mjs";
|
||||||
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs";
|
|
||||||
import { findButtonOrFail, findDialogOrFail, findOlOrFail } from "./dom.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 reordering = false;
|
||||||
|
let unsaved = false;
|
||||||
// Dragging code is a heavily modified version of https://stackoverflow.com/a/28962290
|
// Dragging code is a heavily modified version of https://stackoverflow.com/a/28962290
|
||||||
let elementBeingDragged: HTMLLIElement | undefined;
|
let elementBeingDragged: HTMLLIElement | undefined;
|
||||||
|
|
||||||
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
|
const mainDialog = findDialogOrFail(document.body, "#mainDialog");
|
||||||
|
const startAddInstanceFlowButton = findButtonOrFail(document.body, "#startAddInstanceFlow");
|
||||||
const addDialog = findDialogOrFail(document.body, "#addInstance");
|
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 instanceList = findOlOrFail(document.body, "#instanceList");
|
||||||
const saveButton = findButtonOrFail(document.body, "#save");
|
const saveButton = findButtonOrFail(document.body, "#save");
|
||||||
const reorderButton = findButtonOrFail(document.body, "#reorder");
|
const reorderButton = findButtonOrFail(document.body, "#reorder");
|
||||||
|
const resetButton = findButtonOrFail(document.body, "#reset");
|
||||||
|
|
||||||
saveButton.addEventListener("click", e => {
|
let instanceDetailsDialog = new InstanceDetailsDialog(detailsDialog, true);
|
||||||
storageManager.save();
|
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", () => {
|
reorderButton.addEventListener("click", () => {
|
||||||
reordering = !reordering;
|
reordering = !reordering;
|
||||||
|
@ -25,22 +37,47 @@ reorderButton.addEventListener("click", () => {
|
||||||
reorderButton.innerText = reordering ? "Finish reordering" : "Reorder";
|
reorderButton.innerText = reordering ? "Finish reordering" : "Reorder";
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getMainDialog = () => findDialogOrFail(document.body, "#mainDialog");
|
resetButton.addEventListener("click", e => {
|
||||||
|
storageManager.reset();
|
||||||
const {
|
updateInstanceList();
|
||||||
showInstanceDetailsDialog,
|
unsavedChanges();
|
||||||
hideInstanceDetailsDialog,
|
});
|
||||||
populateInstanceDetailsDialog,
|
|
||||||
} = initializeInstanceDetailsDialog(detailsDialog, () => { });
|
|
||||||
|
|
||||||
export const {
|
|
||||||
showAddInstanceDialog,
|
|
||||||
hideAddInstanceDialog
|
|
||||||
} = initializeAddInstanceFlow(detailsDialog, addDialog);
|
|
||||||
|
|
||||||
updateInstanceList();
|
updateInstanceList();
|
||||||
storageManager.addSaveCallback(updateInstanceList);
|
storageManager.addSaveCallback(updateInstanceList);
|
||||||
|
|
||||||
|
mainDialog.show();
|
||||||
|
|
||||||
|
function saveChanges() {
|
||||||
|
storageManager.save();
|
||||||
|
unsaved = false;
|
||||||
|
saveButton.classList.remove("pulse-red");
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsavedChanges() {
|
||||||
|
if (!unsaved) {
|
||||||
|
unsaved = true;
|
||||||
|
saveButton.classList.add("pulse-red");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editInstance(instance: Instance) {
|
||||||
|
const data = dialogDetailsFromInstance(instance);
|
||||||
|
const newData = await instanceDetailsDialog.present(data);
|
||||||
|
dialogDetailsToInstance(newData, instance);
|
||||||
|
updateInstanceList();
|
||||||
|
unsavedChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteInstance(instance: Instance) {
|
||||||
|
storageManager.storage.instances.splice(
|
||||||
|
storageManager.storage.instances.indexOf(instance),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
updateInstanceList();
|
||||||
|
unsavedChanges();
|
||||||
|
}
|
||||||
|
|
||||||
function updateInstanceList() {
|
function updateInstanceList() {
|
||||||
instanceList.replaceChildren(); // Erase all child nodes
|
instanceList.replaceChildren(); // Erase all child nodes
|
||||||
instanceList.style.listStyleType = reordering ? "\"≡ \"" : "disc";
|
instanceList.style.listStyleType = reordering ? "\"≡ \"" : "disc";
|
||||||
|
@ -86,26 +123,11 @@ function updateInstanceList() {
|
||||||
const editLink = document.createElement("a");
|
const editLink = document.createElement("a");
|
||||||
editLink.innerText = `Edit`;
|
editLink.innerText = `Edit`;
|
||||||
editLink.href = "#";
|
editLink.href = "#";
|
||||||
editLink.addEventListener("click", e => {
|
editLink.addEventListener("click", e => editInstance(instance));
|
||||||
const host = parseHost(instance.origin)!;
|
|
||||||
populateInstanceDetailsDialog(
|
|
||||||
instance.name,
|
|
||||||
host.host,
|
|
||||||
host.secure,
|
|
||||||
instance.software,
|
|
||||||
instance.iconURL ?? null
|
|
||||||
);
|
|
||||||
showInstanceDetailsDialog();
|
|
||||||
});
|
|
||||||
const deleteLink = document.createElement("a");
|
const deleteLink = document.createElement("a");
|
||||||
deleteLink.innerText = `Delete`;
|
deleteLink.innerText = `Delete`;
|
||||||
deleteLink.href = "#";
|
deleteLink.href = "#";
|
||||||
deleteLink.addEventListener("click", e => {
|
deleteLink.addEventListener("click", e => deleteInstance(instance));
|
||||||
storageManager.storage.instances.splice(
|
|
||||||
storageManager.storage.instances.indexOf(instance)
|
|
||||||
);
|
|
||||||
updateInstanceList();
|
|
||||||
});
|
|
||||||
label.append(editLink, " ", deleteLink);
|
label.append(editLink, " ", deleteLink);
|
||||||
}
|
}
|
||||||
li.appendChild(label);
|
li.appendChild(label);
|
||||||
|
@ -130,4 +152,5 @@ function applyReordering() {
|
||||||
indices.push(parseInt(option));
|
indices.push(parseInt(option));
|
||||||
}
|
}
|
||||||
storageManager.storage.instances = indices.map(i => storageManager.storage.instances[i]);
|
storageManager.storage.instances = indices.map(i => storageManager.storage.instances[i]);
|
||||||
|
unsavedChanges();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,88 +1,185 @@
|
||||||
// This file handles the "Confirm instance details" dialog
|
// This file handles the "Confirm instance details" dialog
|
||||||
|
|
||||||
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findSelectOrFail } from "./dom.mjs";
|
import { parseHost } from "./add_an_instance.mjs";
|
||||||
import { resize } from "./image.mjs";
|
import { FormDialog, ONCE } from "./dialog.mjs";
|
||||||
import knownSoftware from "./known_software.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(
|
export type InstanceDetailsDialogData = {
|
||||||
dialog: HTMLDialogElement,
|
name: string,
|
||||||
callback: (
|
host: string,
|
||||||
instanceName: string,
|
hostSecure: boolean,
|
||||||
instanceHost: string,
|
software: string,
|
||||||
instanceHostSecure: boolean,
|
iconURL: string | null,
|
||||||
instanceSoftware: string,
|
preferredFor: 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");
|
export function dialogDetailsFromInstance(instance: Instance): InstanceDetailsDialogData {
|
||||||
const instanceName = findInputOrFail(form, "#instanceName");
|
const host = parseHost(instance.origin)!;
|
||||||
const instanceHost = findInputOrFail(form, "#instanceHost");
|
return {
|
||||||
const instanceHostSecure = findInputOrFail(form, "#instanceHostSecure");
|
name: instance.name,
|
||||||
const instanceSoftware = findSelectOrFail(form, "#instanceSoftware");
|
host: host.host,
|
||||||
const instanceIcon = findImageOrFail(form, "#instanceIcon");
|
hostSecure: host.secure,
|
||||||
const closeButton = findButtonOrFail(form, ".close");
|
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)) {
|
for (const [name, software] of Object.entries(knownSoftware.software)) {
|
||||||
const option = new Option(software.name, name);
|
const option = new Option(software.name, name);
|
||||||
instanceSoftware.appendChild(option);
|
this.instanceSoftware.appendChild(option);
|
||||||
}
|
}
|
||||||
|
|
||||||
instanceIcon.src = blankImage;
|
this.instanceIcon.hidden = true;
|
||||||
|
|
||||||
const populateInstanceDetailsDialog = (
|
this.closeButton.addEventListener("click", e => this.close());
|
||||||
instanceNameValue: string,
|
|
||||||
instanceHostValue: string,
|
|
||||||
instanceHostSecureValue: boolean,
|
|
||||||
instanceSoftwareValue: string,
|
|
||||||
instanceIconValue: string | null
|
|
||||||
) => {
|
|
||||||
instanceName.value = instanceNameValue;
|
|
||||||
instanceHost.value = instanceHostValue;
|
|
||||||
instanceHostSecure.checked = instanceHostSecureValue;
|
|
||||||
instanceSoftware.value = instanceSoftwareValue;
|
|
||||||
instanceIcon.src = instanceIconValue === null ? blankImage : `/api/proxy/${encodeURIComponent(instanceIconValue)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
form.addEventListener("submit", e => {
|
if (this.defaultsList) {
|
||||||
let image: string | null = null;
|
this.defaultsList.list.addEventListener("change", e => this.#handleListSelectionChange());
|
||||||
if (instanceIcon.src !== blankImage) {
|
this.defaultsList.removeButton.addEventListener("click", e => this.#removeSelectedListOptions());
|
||||||
try {
|
|
||||||
image = resize(instanceIcon);
|
|
||||||
} catch { }
|
|
||||||
}
|
}
|
||||||
callback(
|
}
|
||||||
instanceName.value,
|
|
||||||
instanceHost.value,
|
#getRemainingListOptions(): string[] {
|
||||||
instanceHostSecure.checked,
|
if (!this.defaultsList) return [];
|
||||||
instanceSoftware.value,
|
|
||||||
image
|
const items: string[] = [];
|
||||||
);
|
|
||||||
form.reset();
|
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();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
closeButton.addEventListener("click", e => {
|
|
||||||
instanceIcon.src = blankImage;
|
|
||||||
hideInstanceDetailsDialog();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
showInstanceDetailsDialog,
|
|
||||||
hideInstanceDetailsDialog,
|
|
||||||
populateInstanceDetailsDialog
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,13 +6,11 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>FeDirect</title>
|
<title>FeDirect</title>
|
||||||
<link rel="stylesheet" href="/static/main.css">
|
<link rel="stylesheet" href="/static/main.css">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *;">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<script type="module">
|
<script type="module" src="/static/crossroad.mjs"></script>
|
||||||
Object.assign(globalThis, await import("/static/crossroad.mjs"));
|
|
||||||
getMainDialog().show(); // Don't show until the page is ready
|
|
||||||
</script>
|
|
||||||
<div class="flex-vcenter">
|
<div class="flex-vcenter">
|
||||||
<dialog id="mainDialog" class="half-width half-height">
|
<dialog id="mainDialog" class="half-width half-height">
|
||||||
<header class="separator-bottom margin-large-bottom">
|
<header class="separator-bottom margin-large-bottom">
|
||||||
|
@ -26,12 +24,15 @@
|
||||||
<div class="flex-vcenter full-height">
|
<div class="flex-vcenter full-height">
|
||||||
<center class="half-width">
|
<center class="half-width">
|
||||||
You're about to go to
|
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" />
|
<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>
|
<p id="noInstance">You currently don't have any instances. You should add one!</p>
|
||||||
<form id="instanceSelectForm" class="align-start wfit-content"></form>
|
<form id="instanceSelectForm" class="align-start wfit-content"></form>
|
||||||
<br>
|
<br>
|
||||||
<button onclick="showAddInstanceDialog()">Add an instance</button>
|
<button id="startAddInstanceFlow">Add an instance</button>
|
||||||
</center>
|
</center>
|
||||||
</div>
|
</div>
|
||||||
<div class="half-width align-self-start">
|
<div class="half-width align-self-start">
|
||||||
|
@ -56,9 +57,8 @@
|
||||||
<br>
|
<br>
|
||||||
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
|
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
|
||||||
<label for="autoQueryMetadata">
|
<label for="autoQueryMetadata">
|
||||||
<abbr title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.
|
<abbr
|
||||||
We do this on the backend to avoid CORS problems.
|
title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.">
|
||||||
We do not track or save any requests or data.">
|
|
||||||
Automatically query metadata
|
Automatically query metadata
|
||||||
</abbr>
|
</abbr>
|
||||||
</label>
|
</label>
|
||||||
|
@ -110,6 +110,7 @@ Unchecking this is not recommended, and this option only exists for exceptional
|
||||||
<button type="reset" class="close">Cancel</button>
|
<button type="reset" class="close">Cancel</button>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<dialog id="spinner"><span class="spinner"></span></dialog>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -1,15 +1,45 @@
|
||||||
import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
|
import { AddInstanceFlow } from "./add_instance_flow.mjs";
|
||||||
import { findButtonOrFail, findDialogOrFail, findFormOrFail, findInputOrFail, findParagraphOrFail, findPreOrFail } from "./dom.mjs";
|
import { findButtonOrFail, findDialogOrFail, findFormOrFail, findInputOrFail, findParagraphOrFail, findPreOrFail, findSpanOrFail } from "./dom.mjs";
|
||||||
import knownSoftware from "./known_software.mjs";
|
import knownSoftware, { getName } from "./known_software.mjs";
|
||||||
import storageManager from "./storage_manager.mjs";
|
import storageManager from "./storage_manager.mjs";
|
||||||
|
|
||||||
const radioButtonName = "instanceSelect";
|
const RADIO_BUTTON_NAME = "instanceSelect";
|
||||||
|
|
||||||
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
|
let addInstanceFlow: AddInstanceFlow | undefined;
|
||||||
|
|
||||||
|
const mainDialog = findDialogOrFail(document.body, "#mainDialog");
|
||||||
|
const startAddInstanceFlowButton = findButtonOrFail(document.body, "#startAddInstanceFlow");
|
||||||
const addDialog = findDialogOrFail(document.body, "#addInstance");
|
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 instanceSelectForm = findFormOrFail(document.body, "#instanceSelectForm");
|
||||||
const redirectButton = findButtonOrFail(document.body, "#redirect");
|
const redirectButton = findButtonOrFail(document.body, "#redirect");
|
||||||
const redirectAlwaysButton = findButtonOrFail(document.body, "#redirectAlways");
|
const redirectAlwaysButton = findButtonOrFail(document.body, "#redirectAlways");
|
||||||
|
const noInstanceParagraph = findParagraphOrFail(document.body, "#noInstance");
|
||||||
|
const pathText = findPreOrFail(document.body, "#path");
|
||||||
|
const aOrAnText = findSpanOrFail(document.body, "#aOrAn");
|
||||||
|
const destinationText = findSpanOrFail(document.body, "#destination");
|
||||||
|
|
||||||
|
// 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 => {
|
redirectButton.addEventListener("click", e => {
|
||||||
// Can be assumed to not fail because the button is disabled if there are no options and the first one is selected by default
|
// Can be assumed to not fail because the button is disabled if there are no options and the first one is selected by default
|
||||||
|
@ -23,35 +53,9 @@ redirectAlwaysButton.addEventListener("click", e => {
|
||||||
redirect(option);
|
redirect(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getMainDialog = () => findDialogOrFail(document.body, "#mainDialog");
|
|
||||||
|
|
||||||
export const {
|
|
||||||
showAddInstanceDialog,
|
|
||||||
hideAddInstanceDialog
|
|
||||||
} = ((): {
|
|
||||||
showAddInstanceDialog: () => void,
|
|
||||||
hideAddInstanceDialog: () => void
|
|
||||||
} => {
|
|
||||||
// Don't bother initializing if we're performing autoredirect
|
|
||||||
if (autoRedirect()) return {
|
|
||||||
showAddInstanceDialog: () => { },
|
|
||||||
hideAddInstanceDialog: () => { }
|
|
||||||
}
|
|
||||||
createInstanceSelectOptions();
|
|
||||||
storageManager.addSaveCallback(createInstanceSelectOptions);
|
|
||||||
updateNoInstanceHint();
|
|
||||||
storageManager.addSaveCallback(updateNoInstanceHint);
|
|
||||||
|
|
||||||
findPreOrFail(document.body, "#path").innerText = getTargetPath();
|
|
||||||
|
|
||||||
return initializeAddInstanceFlow(detailsDialog, addDialog);
|
|
||||||
})();
|
|
||||||
|
|
||||||
function updateNoInstanceHint() {
|
function updateNoInstanceHint() {
|
||||||
findParagraphOrFail(document.body, "#no-instance").style.display =
|
noInstanceParagraph.hidden =
|
||||||
storageManager.storage.instances.length > 0
|
storageManager.storage.instances.length > 0;
|
||||||
? "none"
|
|
||||||
: "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInstanceSelectOptions() {
|
function createInstanceSelectOptions() {
|
||||||
|
@ -63,7 +67,7 @@ function createInstanceSelectOptions() {
|
||||||
radio.id = instance.origin;
|
radio.id = instance.origin;
|
||||||
radio.value = instance.origin;
|
radio.value = instance.origin;
|
||||||
radio.type = "radio";
|
radio.type = "radio";
|
||||||
radio.name = radioButtonName;
|
radio.name = RADIO_BUTTON_NAME;
|
||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
label.htmlFor = instance.origin;
|
label.htmlFor = instance.origin;
|
||||||
label.innerText = instance.name + " ";
|
label.innerText = instance.name + " ";
|
||||||
|
@ -110,7 +114,7 @@ function getTargetPath(): string {
|
||||||
|
|
||||||
function getSelectedOption(): string | null {
|
function getSelectedOption(): string | null {
|
||||||
try {
|
try {
|
||||||
return findInputOrFail(instanceSelectForm, `input[name="${radioButtonName}"]:checked`).value;
|
return findInputOrFail(instanceSelectForm, `input[name="${RADIO_BUTTON_NAME}"]:checked`).value;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -129,7 +133,6 @@ function autoRedirect(): boolean {
|
||||||
function setAutoRedirect(option: string) {
|
function setAutoRedirect(option: string) {
|
||||||
const instance = storageManager.storage.instances.find(e => e.origin === option);
|
const instance = storageManager.storage.instances.find(e => e.origin === option);
|
||||||
if (!instance) throw new Error("Invalid argument");
|
if (!instance) throw new Error("Invalid argument");
|
||||||
instance.preferredFor ??= [];
|
|
||||||
instance.preferredFor.push(getTargetSoftwareOrGroup());
|
instance.preferredFor.push(getTargetSoftwareOrGroup());
|
||||||
storageManager.save();
|
storageManager.save();
|
||||||
}
|
}
|
||||||
|
@ -140,5 +143,3 @@ function redirect(to: string) {
|
||||||
url.pathname = getTargetPath();
|
url.pathname = getTargetPath();
|
||||||
location.href = url.toString();
|
location.href = url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export { storageManager };
|
|
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,20 @@
|
||||||
// I would've LOVED to use generics for this but unfortunately that's not possible.
|
// I would've LOVED to use generics for this but unfortunately that's not possible.
|
||||||
// Type safety, but at what cost... >~< thanks TypeScript
|
// Type safety, but at what cost... >~< thanks TypeScript
|
||||||
|
|
||||||
|
export function 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 {
|
export function findOlOrFail(on: Element, selector: string): HTMLOListElement {
|
||||||
const element = on.querySelector(selector);
|
const element = on.querySelector(selector);
|
||||||
if (!(element instanceof HTMLOListElement))
|
if (!(element instanceof HTMLOListElement))
|
||||||
|
|
|
@ -2,7 +2,7 @@ export function resize(image: HTMLImageElement, width: number = 16, height: numb
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
canvas.style.display = "none";
|
canvas.hidden = true;
|
||||||
document.body.appendChild(canvas);
|
document.body.appendChild(canvas);
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (ctx === null) throw Error("Resize failed");
|
if (ctx === null) throw Error("Resize failed");
|
||||||
|
|
|
@ -16,4 +16,8 @@ type KnownSoftware = {
|
||||||
groups: Record<string, Group>,
|
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;
|
export default await fetch("/known-software.json").then(r => r.json()) as KnownSoftware;
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
@import url("/static/spinner.css");
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--red: #cb0b0b;
|
--red: #cb0b0b;
|
||||||
--blue: #2081c3;
|
--blue: #2081c3;
|
||||||
|
@ -11,6 +13,7 @@ html,
|
||||||
body {
|
body {
|
||||||
background: linear-gradient(300deg, var(--red), var(--blue));
|
background: linear-gradient(300deg, var(--red), var(--blue));
|
||||||
background-size: 100vw 100vh;
|
background-size: 100vw 100vh;
|
||||||
|
background-attachment: fixed;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
@ -142,6 +145,10 @@ abbr[title] {
|
||||||
border-bottom: solid 1px var(--transparent-black);
|
border-bottom: solid 1px var(--transparent-black);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.margin-none {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.margin-auto-top {
|
.margin-auto-top {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
@ -182,3 +189,17 @@ abbr[title] {
|
||||||
.buttonPanel>* {
|
.buttonPanel>* {
|
||||||
margin-top: min(var(--xl), 6vh);
|
margin-top: min(var(--xl), 6vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pulse-red {
|
||||||
|
animation: 1s ease-in-out 0s infinite alternate both running pulse-red-anim;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-red-anim {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0px 0px 0px var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
box-shadow: 0px 0px 20px var(--red);
|
||||||
|
}
|
||||||
|
}
|
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 = {
|
export type Instance = {
|
||||||
/**
|
/**
|
||||||
* The instance's (nick)name
|
* The instance's (nick)name
|
||||||
|
@ -21,18 +23,19 @@ export type Instance = {
|
||||||
software: string,
|
software: string,
|
||||||
/**
|
/**
|
||||||
* The instance's icon URL
|
* The instance's icon URL
|
||||||
*
|
* @example undefined
|
||||||
* Make sure to sanitize this! Could lead to XSS
|
* @example "https://void.lgbt/favicon.png"
|
||||||
*/
|
*/
|
||||||
iconURL?: string,
|
iconURL?: string,
|
||||||
/**
|
/**
|
||||||
* The list of software names and groups the user prefers to autoredirect to this instance
|
* The list of software names and groups the user prefers to autoredirect to this instance
|
||||||
* @example ["sharkey", "misskey-compliant"]
|
* @example ["sharkey", "misskey-compliant"]
|
||||||
*/
|
*/
|
||||||
preferredFor?: string[],
|
preferredFor: string[],
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocalStorage = {
|
export type LocalStorage = {
|
||||||
|
version: number,
|
||||||
instances: Instance[],
|
instances: Instance[],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,12 +49,14 @@ export default new class StorageManager {
|
||||||
|
|
||||||
default(): LocalStorage {
|
default(): LocalStorage {
|
||||||
return {
|
return {
|
||||||
|
version: 1,
|
||||||
instances: []
|
instances: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
load() {
|
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() {
|
save() {
|
||||||
|
@ -62,4 +67,8 @@ export default new class StorageManager {
|
||||||
addSaveCallback(callback: () => void) {
|
addSaveCallback(callback: () => void) {
|
||||||
this.saveCallbacks.push(callback);
|
this.saveCallbacks.push(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.storage = this.default();
|
||||||
|
}
|
||||||
}();
|
}();
|
|
@ -4,6 +4,7 @@
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"static/**.mts",
|
"static/**.mts",
|
||||||
|
|
Loading…
Add table
Reference in a new issue