diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c9fc53c..dc5cc95 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,20 +1,21 @@ import * as React from 'react'; -import {Bluelib, LayoutThreeCol} from "@steffo/bluelib-react"; +import {LayoutThreeCol} from "@steffo/bluelib-react"; import {Router} from "./routes/Router"; import {InstanceContextProvider} from "./components/InstanceContext"; import {LoginContextProvider} from "./components/LoginContext"; +import {InstanceBluelib} from "./components/InstanceBluelib"; function App() { return ( - + - + ); diff --git a/frontend/src/components/InstanceBluelib.tsx b/frontend/src/components/InstanceBluelib.tsx new file mode 100644 index 0000000..5d8df20 --- /dev/null +++ b/frontend/src/components/InstanceBluelib.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import * as ReactDOM from "react-dom" +import {useInstance} from "./InstanceContext"; +import {Bluelib} from "@steffo/bluelib-react"; + + +interface InstanceBluelibProps { + children: React.ReactNode +} + + +export function InstanceBluelib({children}: InstanceBluelibProps): JSX.Element { + const instance = useInstance() + + return ( + + {children} + + ) +} diff --git a/frontend/src/components/InstanceContext.tsx b/frontend/src/components/InstanceContext.tsx index 8f881e8..f8ddb4a 100644 --- a/frontend/src/components/InstanceContext.tsx +++ b/frontend/src/components/InstanceContext.tsx @@ -4,50 +4,103 @@ import {useNotNullContext} from "../hooks/useNotNullContext"; import {useFormState} from "@steffo/bluelib-react"; import {FormState} from "@steffo/bluelib-react/dist/hooks/useFormState"; import {Validity} from "@steffo/bluelib-react/dist/types"; +import {useStorageState} from "../hooks/useStorageState"; +import {useState} from "react"; +import {useAbortEffect} from "../hooks/useCancellable"; +import {InstanceDetails} from "../types"; +import {CHECK_TIMEOUT_MS} from "../constants"; -export const InstanceContext = React.createContext | null>(null) +export interface InstanceContextData { + value: string, + setValue: React.Dispatch, + details: InstanceDetails | null | undefined, + validity: Validity, - -export async function instanceValidator(value: string, abort: AbortSignal): Promise { - if(value === "") return undefined - - await new Promise(r => setTimeout(r, 250)) - if(abort.aborted) return null - - let url: URL - try { - url = new URL(value) - } catch (_) { - return false - } - - try { - await Axios.get("/api/core/version", {baseURL: url.toString(), signal: abort}) - } catch(_) { - return false - } - - return true } +export const InstanceContext = React.createContext(null) + + export interface InstanceContextProviderProps { children: React.ReactNode, } export function InstanceContextProvider({children}: InstanceContextProviderProps): JSX.Element { - const instance = useFormState(process.env.REACT_APP_DEFAULT_INSTANCE ?? "https://prod.sophon.steffo.eu", instanceValidator) + const [instance, setInstance] = + useStorageState(localStorage, "instance", process.env.REACT_APP_DEFAULT_INSTANCE ?? "https://prod.sophon.steffo.eu") + + const [details, setDetails] = + useState(null) + + const [error, setError] = + useState(null) + + const fetchDetails = + React.useCallback( + async (signal: AbortSignal): Promise => { + if(instance === "") { + return undefined + } + + let url: URL + try { + url = new URL(instance) + } catch (_) { + throw new Error("Invalid URL.") + } + + await new Promise(r => setTimeout(r, CHECK_TIMEOUT_MS)) + if(signal.aborted) return null + + const response = await Axios.get("/api/core/instance", {baseURL: url.toString(), signal}) + return response.data + }, + [instance] + ) + + useAbortEffect( + React.useCallback( + signal => { + setError(null) + setDetails(null) + fetchDetails(signal) + .then(det => setDetails(det)) + .catch(err => setError(err)) + }, + [setError, fetchDetails, setDetails] + ) + ) + + const validity = + React.useMemo( + () => { + if(details === undefined) return undefined + if(error !== null) return false + if(details === null) return null + return true + }, + [details, error] + ) return ( - + ) } export function useInstance() { - return useNotNullContext>(InstanceContext) + return useNotNullContext(InstanceContext) } diff --git a/frontend/src/components/InstanceDescriptionBox.tsx b/frontend/src/components/InstanceDescriptionBox.tsx new file mode 100644 index 0000000..bc48030 --- /dev/null +++ b/frontend/src/components/InstanceDescriptionBox.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import {Box, Heading} from "@steffo/bluelib-react"; +import {useInstance} from "./InstanceContext"; + + +export function InstanceDescriptionBox(): JSX.Element | null { + const instance = useInstance() + + if(instance.details?.description) { + return ( + + + Welcome + +

+ {instance.details.description} +

+
+ ) + } + else { + return null + } +} diff --git a/frontend/src/components/InstanceNameHeading.tsx b/frontend/src/components/InstanceNameHeading.tsx deleted file mode 100644 index 684e7a5..0000000 --- a/frontend/src/components/InstanceNameHeading.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import * as React from "react" -import {Heading} from "@steffo/bluelib-react" -import {useInstance, useInstanceAxios} from "./InstanceContext"; -import {InstanceDetails} from "../types"; -import {Link} from "./Link"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faSpinner, faTimesCircle, faUniversity} from "@fortawesome/free-solid-svg-icons"; -import {Loading} from "./Loading"; - - -export function InstanceNameHeading(): JSX.Element { - const instance = useInstance() - const api = useInstanceAxios() - - const [details, setDetails] = React.useState(null) - const [error, setError] = React.useState(null) - - React.useEffect( - () => { - if(instance.validity === true) { - const controller = new AbortController() - - setError(null) - api.get("/api/core/instance", {signal: controller.signal}) - .then(r => setDetails(r.data)) - .catch(e => { - if(!controller.signal.aborted) { - setError(e) - } - }) - - return () => { - controller.abort() - } - } - }, - [api, setDetails, setError] - ) - - if(!instance.validity) { - return ( - - - Sophon - - - ) - } - if(error) { - return ( - - -  Error - - - ) - } - else if(details === null) { - return ( - - - - - - ) - } - else { - return ( - - -  {details.name} - - - ) - } - -} diff --git a/frontend/src/components/InstanceBox.tsx b/frontend/src/components/InstanceSelectBox.tsx similarity index 86% rename from frontend/src/components/InstanceBox.tsx rename to frontend/src/components/InstanceSelectBox.tsx index 05188cb..d77bc79 100644 --- a/frontend/src/components/InstanceBox.tsx +++ b/frontend/src/components/InstanceSelectBox.tsx @@ -6,7 +6,7 @@ import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faExclamationTriangle, faServer, faTimesCircle} from "@fortawesome/free-solid-svg-icons"; -export function InstanceBox(): JSX.Element { +export function InstanceSelectBox(): JSX.Element { const instance = useInstance() const login = useLogin() @@ -64,7 +64,13 @@ export function InstanceBox(): JSX.Element { {statePanel} - + instance.setValue(v)} + validity={login.userData ? undefined : instance.validity} + disabled={!canChange} + /> ) diff --git a/frontend/src/components/InstanceTitle.tsx b/frontend/src/components/InstanceTitle.tsx new file mode 100644 index 0000000..cf02b09 --- /dev/null +++ b/frontend/src/components/InstanceTitle.tsx @@ -0,0 +1,32 @@ +import * as React from "react" +import {Heading} from "@steffo/bluelib-react" +import {useInstance, useInstanceAxios} from "./InstanceContext"; +import {InstanceDetails} from "../types"; +import {Link} from "./Link"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faSpinner, faTimesCircle, faUniversity} from "@fortawesome/free-solid-svg-icons"; +import {Loading} from "./Loading"; + + +export function InstanceTitle(): JSX.Element { + const instance = useInstance() + + if(instance.details?.name) { + return ( + + +  {instance.details.name} + + + ) + } + else { + return ( + + + Sophon + + + ) + } +} diff --git a/frontend/src/components/LoginContext.tsx b/frontend/src/components/LoginContext.tsx index 84d8e14..6bc4012 100644 --- a/frontend/src/components/LoginContext.tsx +++ b/frontend/src/components/LoginContext.tsx @@ -5,6 +5,7 @@ import {useNotNullContext} from "../hooks/useNotNullContext"; import {Validity} from "@steffo/bluelib-react/dist/types"; import {useFormState} from "@steffo/bluelib-react"; import {useStorageState} from "../hooks/useStorageState"; +import {CHECK_TIMEOUT_MS} from "../constants"; export interface UserData { @@ -118,7 +119,7 @@ export function useUsernameFormState() { async (value: string, abort: AbortSignal): Promise => { if(value === "") return undefined - await new Promise(r => setTimeout(r, 250)) + await new Promise(r => setTimeout(r, CHECK_TIMEOUT_MS)) if(abort.aborted) return null try { diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts new file mode 100644 index 0000000..e11856c --- /dev/null +++ b/frontend/src/constants.ts @@ -0,0 +1 @@ +export const CHECK_TIMEOUT_MS = 350 diff --git a/frontend/src/hooks/useCancellable.ts b/frontend/src/hooks/useCancellable.ts new file mode 100644 index 0000000..74c9d45 --- /dev/null +++ b/frontend/src/hooks/useCancellable.ts @@ -0,0 +1,19 @@ +import * as React from "react"; + + +export type AbortableEffect = (abort: AbortSignal) => void + + +export function useAbortEffect(effect: AbortableEffect) { + React.useEffect( + () => { + const abort = new AbortController() + effect(abort.signal) + + return () => { + abort.abort() + } + }, + [effect] + ) +} diff --git a/frontend/src/routes/LoginPage.tsx b/frontend/src/routes/LoginPage.tsx index 99820c6..0e800d9 100644 --- a/frontend/src/routes/LoginPage.tsx +++ b/frontend/src/routes/LoginPage.tsx @@ -1,5 +1,5 @@ import * as React from "react" -import {InstanceBox} from "../components/InstanceBox"; +import {InstanceSelectBox} from "../components/InstanceSelectBox"; import {Chapter} from "@steffo/bluelib-react"; import {LoginBox} from "../components/LoginBox"; import {useLogin} from "../components/LoginContext"; @@ -12,7 +12,7 @@ export function LoginPage(): JSX.Element { return (
- + {userData ? : diff --git a/frontend/src/routes/Router.jsx b/frontend/src/routes/Router.jsx index 3838933..0350961 100644 --- a/frontend/src/routes/Router.jsx +++ b/frontend/src/routes/Router.jsx @@ -3,13 +3,13 @@ import * as Reach from "@reach/router" import { LoginPage } from "./LoginPage" import { SelectResearchGroupPage } from "./SelectResearchGroupPage" import { ErrorCatcherBox, NotFoundBox } from "../components/ErrorBox" -import { InstanceNameHeading } from "../components/InstanceNameHeading" +import { InstanceTitle } from "../components/InstanceTitle" export function Router() { return <> - + diff --git a/frontend/src/routes/SelectResearchGroupPage.tsx b/frontend/src/routes/SelectResearchGroupPage.tsx index 8b012ad..d50319a 100644 --- a/frontend/src/routes/SelectResearchGroupPage.tsx +++ b/frontend/src/routes/SelectResearchGroupPage.tsx @@ -1,10 +1,12 @@ import * as React from "react" import {ResearchGroupListBox} from "../components/ResearchGroupListBox"; +import {InstanceDescriptionBox} from "../components/InstanceDescriptionBox"; export function SelectResearchGroupPage(): JSX.Element { return (
+
) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ef1bb4d..a50be31 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -36,6 +36,7 @@ export interface User { export interface InstanceDetails { name: string, + version: string, description?: string, theme: "sophon" | "paper" | "royalblue" | "hacker", }