1
Fork 0
mirror of https://github.com/Steffo99/sophon.git synced 2024-12-22 14:54:22 +00:00

Complete instance customization

This commit is contained in:
Steffo 2021-09-21 16:22:50 +02:00
parent 4398427865
commit 2c1ea824f1
14 changed files with 195 additions and 112 deletions

View file

@ -1,20 +1,21 @@
import * as React from 'react'; import * as React from 'react';
import {Bluelib, LayoutThreeCol} from "@steffo/bluelib-react"; import {LayoutThreeCol} from "@steffo/bluelib-react";
import {Router} from "./routes/Router"; import {Router} from "./routes/Router";
import {InstanceContextProvider} from "./components/InstanceContext"; import {InstanceContextProvider} from "./components/InstanceContext";
import {LoginContextProvider} from "./components/LoginContext"; import {LoginContextProvider} from "./components/LoginContext";
import {InstanceBluelib} from "./components/InstanceBluelib";
function App() { function App() {
return ( return (
<InstanceContextProvider> <InstanceContextProvider>
<LoginContextProvider> <LoginContextProvider>
<Bluelib theme={"sophon"}> <InstanceBluelib>
<LayoutThreeCol> <LayoutThreeCol>
<LayoutThreeCol.Center> <LayoutThreeCol.Center>
<Router/> <Router/>
</LayoutThreeCol.Center> </LayoutThreeCol.Center>
</LayoutThreeCol> </LayoutThreeCol>
</Bluelib> </InstanceBluelib>
</LoginContextProvider> </LoginContextProvider>
</InstanceContextProvider> </InstanceContextProvider>
); );

View file

@ -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 (
<Bluelib theme={instance.details?.theme ?? "sophon"}>
{children}
</Bluelib>
)
}

View file

@ -4,50 +4,103 @@ import {useNotNullContext} from "../hooks/useNotNullContext";
import {useFormState} from "@steffo/bluelib-react"; import {useFormState} from "@steffo/bluelib-react";
import {FormState} from "@steffo/bluelib-react/dist/hooks/useFormState"; import {FormState} from "@steffo/bluelib-react/dist/hooks/useFormState";
import {Validity} from "@steffo/bluelib-react/dist/types"; 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<FormState<string> | null>(null) export interface InstanceContextData {
value: string,
setValue: React.Dispatch<string>,
details: InstanceDetails | null | undefined,
validity: Validity,
export async function instanceValidator(value: string, abort: AbortSignal): Promise<Validity> {
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<InstanceContextData | null>(null)
export interface InstanceContextProviderProps { export interface InstanceContextProviderProps {
children: React.ReactNode, children: React.ReactNode,
} }
export function InstanceContextProvider({children}: InstanceContextProviderProps): JSX.Element { export function InstanceContextProvider({children}: InstanceContextProviderProps): JSX.Element {
const instance = useFormState<string>(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<InstanceDetails | null | undefined>(null)
const [error, setError] =
useState<Error | null>(null)
const fetchDetails =
React.useCallback(
async (signal: AbortSignal): Promise<null | undefined | InstanceDetails> => {
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<InstanceDetails>("/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<Validity>(
() => {
if(details === undefined) return undefined
if(error !== null) return false
if(details === null) return null
return true
},
[details, error]
)
return ( return (
<InstanceContext.Provider value={instance} children={children}/> <InstanceContext.Provider
value={{
value: instance,
setValue: setInstance,
details: details,
validity: validity,
}}
children={children}
/>
) )
} }
export function useInstance() { export function useInstance() {
return useNotNullContext<FormState<string>>(InstanceContext) return useNotNullContext<InstanceContextData>(InstanceContext)
} }

View file

@ -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 (
<Box>
<Heading level={3}>
Welcome
</Heading>
<p>
{instance.details.description}
</p>
</Box>
)
}
else {
return null
}
}

View file

@ -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<InstanceDetails | null>(null)
const [error, setError] = React.useState<Error | null>(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 (
<Heading level={1} bluelibClassNames={"color-red"}>
<Link href={"/"}>
Sophon
</Link>
</Heading>
)
}
if(error) {
return (
<Heading level={1} bluelibClassNames={"color-red"}>
<Link href={"/"}>
<FontAwesomeIcon icon={faTimesCircle}/>&nbsp;Error
</Link>
</Heading>
)
}
else if(details === null) {
return (
<Heading level={1} bluelibClassNames={"color-cyan"}>
<Link href={"/"}>
<Loading/>
</Link>
</Heading>
)
}
else {
return (
<Heading level={1}>
<Link href={"/"}>
<FontAwesomeIcon icon={faUniversity}/>&nbsp;{details.name}
</Link>
</Heading>
)
}
}

View file

@ -6,7 +6,7 @@ import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faExclamationTriangle, faServer, faTimesCircle} from "@fortawesome/free-solid-svg-icons"; import {faExclamationTriangle, faServer, faTimesCircle} from "@fortawesome/free-solid-svg-icons";
export function InstanceBox(): JSX.Element { export function InstanceSelectBox(): JSX.Element {
const instance = useInstance() const instance = useInstance()
const login = useLogin() const login = useLogin()
@ -64,7 +64,13 @@ export function InstanceBox(): JSX.Element {
<Form.Row> <Form.Row>
{statePanel} {statePanel}
</Form.Row> </Form.Row>
<Form.Field label={"URL"} {...instance} validity={login.userData ? undefined : instance.validity} disabled={!canChange}/> <Form.Field
label={"URL"}
value={instance.value}
onSimpleChange={v => instance.setValue(v)}
validity={login.userData ? undefined : instance.validity}
disabled={!canChange}
/>
</Form> </Form>
</Box> </Box>
) )

View file

@ -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 (
<Heading level={1}>
<Link href={"/"}>
<FontAwesomeIcon icon={faUniversity}/>&nbsp;{instance.details.name}
</Link>
</Heading>
)
}
else {
return (
<Heading level={1} bluelibClassNames={"color-red"}>
<Link href={"/"}>
Sophon
</Link>
</Heading>
)
}
}

View file

@ -5,6 +5,7 @@ import {useNotNullContext} from "../hooks/useNotNullContext";
import {Validity} from "@steffo/bluelib-react/dist/types"; import {Validity} from "@steffo/bluelib-react/dist/types";
import {useFormState} from "@steffo/bluelib-react"; import {useFormState} from "@steffo/bluelib-react";
import {useStorageState} from "../hooks/useStorageState"; import {useStorageState} from "../hooks/useStorageState";
import {CHECK_TIMEOUT_MS} from "../constants";
export interface UserData { export interface UserData {
@ -118,7 +119,7 @@ export function useUsernameFormState() {
async (value: string, abort: AbortSignal): Promise<Validity> => { async (value: string, abort: AbortSignal): Promise<Validity> => {
if(value === "") return undefined 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 if(abort.aborted) return null
try { try {

View file

@ -0,0 +1 @@
export const CHECK_TIMEOUT_MS = 350

View file

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

View file

@ -1,5 +1,5 @@
import * as React from "react" import * as React from "react"
import {InstanceBox} from "../components/InstanceBox"; import {InstanceSelectBox} from "../components/InstanceSelectBox";
import {Chapter} from "@steffo/bluelib-react"; import {Chapter} from "@steffo/bluelib-react";
import {LoginBox} from "../components/LoginBox"; import {LoginBox} from "../components/LoginBox";
import {useLogin} from "../components/LoginContext"; import {useLogin} from "../components/LoginContext";
@ -12,7 +12,7 @@ export function LoginPage(): JSX.Element {
return ( return (
<div> <div>
<InstanceBox/> <InstanceSelectBox/>
{userData ? {userData ?
<LogoutBox/> <LogoutBox/>
: :

View file

@ -3,13 +3,13 @@ import * as Reach from "@reach/router"
import { LoginPage } from "./LoginPage" import { LoginPage } from "./LoginPage"
import { SelectResearchGroupPage } from "./SelectResearchGroupPage" import { SelectResearchGroupPage } from "./SelectResearchGroupPage"
import { ErrorCatcherBox, NotFoundBox } from "../components/ErrorBox" import { ErrorCatcherBox, NotFoundBox } from "../components/ErrorBox"
import { InstanceNameHeading } from "../components/InstanceNameHeading" import { InstanceTitle } from "../components/InstanceTitle"
export function Router() { export function Router() {
return <> return <>
<Reach.Router primary={false}> <Reach.Router primary={false}>
<InstanceNameHeading default/> <InstanceTitle default/>
</Reach.Router> </Reach.Router>
<ErrorCatcherBox> <ErrorCatcherBox>
<Reach.Router primary={true}> <Reach.Router primary={true}>

View file

@ -1,10 +1,12 @@
import * as React from "react" import * as React from "react"
import {ResearchGroupListBox} from "../components/ResearchGroupListBox"; import {ResearchGroupListBox} from "../components/ResearchGroupListBox";
import {InstanceDescriptionBox} from "../components/InstanceDescriptionBox";
export function SelectResearchGroupPage(): JSX.Element { export function SelectResearchGroupPage(): JSX.Element {
return ( return (
<div> <div>
<InstanceDescriptionBox/>
<ResearchGroupListBox/> <ResearchGroupListBox/>
</div> </div>
) )

View file

@ -36,6 +36,7 @@ export interface User {
export interface InstanceDetails { export interface InstanceDetails {
name: string, name: string,
version: string,
description?: string, description?: string,
theme: "sophon" | "paper" | "royalblue" | "hacker", theme: "sophon" | "paper" | "royalblue" | "hacker",
} }