diff --git a/known-software.json b/known-software.json index 828ad18..eba5902 100644 --- a/known-software.json +++ b/known-software.json @@ -32,6 +32,7 @@ "name": "Akkoma", "nodeinfoName": "akkoma", "aliases": [ + "akkoma", "akko" ], "groups": [ @@ -93,6 +94,7 @@ "nodeinfoName": "mastodon", "buildMetadata": "glitch", "aliases": [ + "glitch-soc", "glitch" ], "groups": [ @@ -155,7 +157,8 @@ "name": "Iceshrimp.NET", "nodeinfoName": "Iceshrimp.NET", "aliases": [ - "iceshrimp-dotnet" + "iceshrimp-dotnet", + "iceshrimp.net" ], "groups": [ "misskey-compliant", diff --git a/static/crossroad.css b/static/crossroad.css index 92bcd3c..544bc65 100644 --- a/static/crossroad.css +++ b/static/crossroad.css @@ -2,6 +2,7 @@ --red: #cb0b0b; --blue: #2081c3; --transparent-black: #0008; + --xl: 4em; --large: 2em; --medium: 1em; } @@ -53,6 +54,10 @@ abbr[title] { text-decoration-color: var(--blue); } +.align-start { + text-align: start; +} + .flex-vcenter { display: flex; flex-direction: column; @@ -61,6 +66,14 @@ abbr[title] { height: 100%; } +.flex-hcenter { + display: flex; + flex-direction: row; + justify-content: center; + width: 100%; + height: 100%; +} + .flex-row { display: flex; flex-direction: row; @@ -71,11 +84,27 @@ abbr[title] { flex-direction: row-reverse; } +.flex-column { + display: flex; + flex-direction: column; +} + .flex-column-reverse { display: flex; flex-direction: column-reverse; } +.flex-vevenly { + display: flex; + flex-direction: column; + justify-content: space-evenly; + height: 100%; +} + +.wfit-content { + width: fit-content; +} + .half-width { min-width: 50%; } @@ -119,4 +148,17 @@ abbr[title] { top: 50%; left: 50%; translate: -50% -50%; +} + +.logo { + height: 4em; +} + +.inlineIcon { + height: var(--medium); + vertical-align: text-top; +} + +.buttonPanel>* { + margin-top: min(var(--xl), 6vh); } \ No newline at end of file diff --git a/static/crossroad.html b/static/crossroad.html index 0ab8ba3..9fe5c11 100644 --- a/static/crossroad.html +++ b/static/crossroad.html @@ -20,20 +20,23 @@

FeDirect

  By Nekomata

- Nekomata Logo +
-
-
- - -
+
+

+
+
+
+
+ + + Manage instances +
+
-
diff --git a/static/crossroad.mts b/static/crossroad.mts index 8d212c4..e7daae0 100644 --- a/static/crossroad.mts +++ b/static/crossroad.mts @@ -1,14 +1,130 @@ import { initializeAddInstanceFlow } from "./add_instance_flow.mjs"; -import { findDialogOrFail } from "./dom.mjs"; +import { findButtonOrFail, findDialogOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs"; +import knownSoftware from "./known_software.mjs"; +import storageManager from "./storage_manager.mjs"; -export function getMainDialog(): HTMLDialogElement { - return document.getElementById('mainDialog') as HTMLDialogElement; -} +const radioButtonName = "instanceSelect"; const detailsDialog = findDialogOrFail(document.body, "#instanceDetails"); const addDialog = findDialogOrFail(document.body, "#addInstance"); +const instanceSelectForm = findFormOrFail(document.body, "#instanceSelectForm"); +const redirectButton = findButtonOrFail(document.body, "#redirect"); +const redirectAlwaysButton = findButtonOrFail(document.body, "#redirectAlways"); + +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 + redirect(getSelectedOption()!); +}); + +redirectAlwaysButton.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 + const option = getSelectedOption()!; + setAutoRedirect(option); + redirect(option); +}); + +export const getMainDialog = () => findDialogOrFail(document.body, "#mainDialog"); export const { showAddInstanceDialog, hideAddInstanceDialog -} = initializeAddInstanceFlow(detailsDialog, addDialog); +} = ((): { + showAddInstanceDialog: () => void, + hideAddInstanceDialog: () => void +} => { + // Don't bother initializing if we're performing autoredirect + if (autoRedirect()) return { + showAddInstanceDialog: () => { }, + hideAddInstanceDialog: () => { } + } + createInstanceSelectOptions(); + storageManager.addSaveCallback(createInstanceSelectOptions); + return initializeAddInstanceFlow(detailsDialog, addDialog) +})(); + +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"; + 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); + } + const firstInput = instanceSelectForm.querySelector("input"); + if (firstInput) firstInput.checked = true; + setRedirectButtonState(firstInput !== null); +} + +function setRedirectButtonState(enabled: boolean) { + redirectButton.disabled = !enabled; + redirectAlwaysButton.disabled = !enabled; +} + +function getTargetSoftwareOrGroup(): string { + const currentURL = URL.parse(location.href)!; + const target = currentURL.pathname.match(/\/+([^\/]*)\/?/)?.[1]; + if (target == null) throw new Error("Crossroad was served on an invalid path (likely a backend routing mistake)"); + const softwareName = Object.entries(knownSoftware.software).find(([name, software]) => software.aliases.includes(target))?.[0]; + if (softwareName) return softwareName; + const groupName = Object.entries(knownSoftware.groups).find(([name, group]) => group.aliases.includes(target))?.[0]; + if (groupName) return groupName; + throw new Error("Could not identify target software or group"); +} + +function getTargetPath(): string { + const currentURL = URL.parse(location.href)!; + return currentURL.pathname.replace(/\/+[^\/]*\/?/, "/"); +} + +function getSelectedOption(): string | null { + try { + return findInputOrFail(instanceSelectForm, `input[name="${radioButtonName}"]:checked`).value; + } catch { + return null; + } +} + +function autoRedirect(): boolean { + const targetSoftware = getTargetSoftwareOrGroup(); + const preferredFor = storageManager.storage.instances.find(instance => instance.preferredFor?.includes(targetSoftware)); + if (preferredFor) { + redirect(preferredFor.origin); + return true; + } + return false; +} + +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(); +} + +function redirect(to: string) { + const url = URL.parse(to); + if (url === null) throw new Error("Couldn't parse destination"); + url.pathname = getTargetPath(); + location.href = url.toString(); +} diff --git a/static/storage_manager.mts b/static/storage_manager.mts index da88774..7ab3957 100644 --- a/static/storage_manager.mts +++ b/static/storage_manager.mts @@ -25,6 +25,11 @@ export type Instance = { * Make sure to sanitize this! Could lead to XSS */ iconURL?: string, + /** + * The list of software names and groups the user prefers to autoredirect to this instance + * @example ["sharkey", "misskey-compliant"] + */ + preferredFor?: string[], } type LocalStorage = { @@ -33,6 +38,7 @@ type LocalStorage = { export default new class StorageManager { storage: LocalStorage; + saveCallbacks: (() => void)[] = []; constructor() { this.load(); @@ -50,5 +56,10 @@ export default new class StorageManager { save() { window.localStorage.setItem("storage", JSON.stringify(this.storage)); + this.saveCallbacks.forEach(c => c()); + } + + addSaveCallback(callback: () => void) { + this.saveCallbacks.push(callback); } }(); \ No newline at end of file