Merge pull request 'feat/redirect' (#2) from feat/redirect into main
Reviewed-on: https://git.gay/Nekomata/FeDirect/pulls/2
This commit is contained in:
commit
beab5a52a5
5 changed files with 190 additions and 15 deletions
|
@ -32,6 +32,7 @@
|
||||||
"name": "Akkoma",
|
"name": "Akkoma",
|
||||||
"nodeinfoName": "akkoma",
|
"nodeinfoName": "akkoma",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
|
"akkoma",
|
||||||
"akko"
|
"akko"
|
||||||
],
|
],
|
||||||
"groups": [
|
"groups": [
|
||||||
|
@ -93,6 +94,7 @@
|
||||||
"nodeinfoName": "mastodon",
|
"nodeinfoName": "mastodon",
|
||||||
"buildMetadata": "glitch",
|
"buildMetadata": "glitch",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
|
"glitch-soc",
|
||||||
"glitch"
|
"glitch"
|
||||||
],
|
],
|
||||||
"groups": [
|
"groups": [
|
||||||
|
@ -155,7 +157,8 @@
|
||||||
"name": "Iceshrimp.NET",
|
"name": "Iceshrimp.NET",
|
||||||
"nodeinfoName": "Iceshrimp.NET",
|
"nodeinfoName": "Iceshrimp.NET",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"iceshrimp-dotnet"
|
"iceshrimp-dotnet",
|
||||||
|
"iceshrimp.net"
|
||||||
],
|
],
|
||||||
"groups": [
|
"groups": [
|
||||||
"misskey-compliant",
|
"misskey-compliant",
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
--red: #cb0b0b;
|
--red: #cb0b0b;
|
||||||
--blue: #2081c3;
|
--blue: #2081c3;
|
||||||
--transparent-black: #0008;
|
--transparent-black: #0008;
|
||||||
|
--xl: 4em;
|
||||||
--large: 2em;
|
--large: 2em;
|
||||||
--medium: 1em;
|
--medium: 1em;
|
||||||
}
|
}
|
||||||
|
@ -53,6 +54,10 @@ abbr[title] {
|
||||||
text-decoration-color: var(--blue);
|
text-decoration-color: var(--blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.align-start {
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
.flex-vcenter {
|
.flex-vcenter {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -61,6 +66,14 @@ abbr[title] {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex-hcenter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.flex-row {
|
.flex-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
@ -71,11 +84,27 @@ abbr[title] {
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.flex-column-reverse {
|
.flex-column-reverse {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex-vevenly {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wfit-content {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
.half-width {
|
.half-width {
|
||||||
min-width: 50%;
|
min-width: 50%;
|
||||||
}
|
}
|
||||||
|
@ -120,3 +149,16 @@ abbr[title] {
|
||||||
left: 50%;
|
left: 50%;
|
||||||
translate: -50% -50%;
|
translate: -50% -50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineIcon {
|
||||||
|
height: var(--medium);
|
||||||
|
vertical-align: text-top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonPanel>* {
|
||||||
|
margin-top: min(var(--xl), 6vh);
|
||||||
|
}
|
|
@ -20,20 +20,23 @@
|
||||||
<h1>FeDirect</h1>
|
<h1>FeDirect</h1>
|
||||||
<p class="margin-auto-top">  By Nekomata</p>
|
<p class="margin-auto-top">  By Nekomata</p>
|
||||||
</div>
|
</div>
|
||||||
<img src="/static/nekomata_small.png" alt="Nekomata Logo" style="height: 4em;" />
|
<img src="/static/nekomata_small.png" alt="Nekomata Logo" class="logo" />
|
||||||
</header>
|
</header>
|
||||||
<div class="flex-row">
|
<div class="flex-row">
|
||||||
<div class="half-width">
|
<center class="half-width">
|
||||||
<form>
|
<form id="instanceSelectForm" class="align-start wfit-content"></form>
|
||||||
<input id="radio" type="radio" />
|
|
||||||
<label for="radio">
|
|
||||||
Instances and stuff go here!
|
|
||||||
</label>
|
|
||||||
</form>
|
|
||||||
<br>
|
<br>
|
||||||
<button onclick="showAddInstanceDialog()">Add an instance</button>
|
<button onclick="showAddInstanceDialog()">Add an instance</button>
|
||||||
|
</center>
|
||||||
|
<div class="half-width">
|
||||||
|
<div class="flex-hcenter">
|
||||||
|
<div class="flex-column buttonPanel">
|
||||||
|
<button id="redirect">Redirect</button>
|
||||||
|
<button id="redirectAlways">Redirect always</button>
|
||||||
|
<a href="/">Manage instances</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="half-width"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,14 +1,130 @@
|
||||||
import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
|
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 {
|
const radioButtonName = "instanceSelect";
|
||||||
return document.getElementById('mainDialog') as HTMLDialogElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
|
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
|
||||||
const addDialog = findDialogOrFail(document.body, "#addInstance");
|
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 {
|
export const {
|
||||||
showAddInstanceDialog,
|
showAddInstanceDialog,
|
||||||
hideAddInstanceDialog
|
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();
|
||||||
|
}
|
||||||
|
|
|
@ -25,6 +25,11 @@ export type Instance = {
|
||||||
* Make sure to sanitize this! Could lead to XSS
|
* Make sure to sanitize this! Could lead to XSS
|
||||||
*/
|
*/
|
||||||
iconURL?: string,
|
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 = {
|
type LocalStorage = {
|
||||||
|
@ -33,6 +38,7 @@ type LocalStorage = {
|
||||||
|
|
||||||
export default new class StorageManager {
|
export default new class StorageManager {
|
||||||
storage: LocalStorage;
|
storage: LocalStorage;
|
||||||
|
saveCallbacks: (() => void)[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.load();
|
this.load();
|
||||||
|
@ -50,5 +56,10 @@ export default new class StorageManager {
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
window.localStorage.setItem("storage", JSON.stringify(this.storage));
|
window.localStorage.setItem("storage", JSON.stringify(this.storage));
|
||||||
|
this.saveCallbacks.forEach(c => c());
|
||||||
|
}
|
||||||
|
|
||||||
|
addSaveCallback(callback: () => void) {
|
||||||
|
this.saveCallbacks.push(callback);
|
||||||
}
|
}
|
||||||
}();
|
}();
|
Loading…
Add table
Reference in a new issue