mirror of
https://github.com/Steffo99/sophon.git
synced 2024-12-23 07:14:21 +00:00
✨ Complete instance customization
This commit is contained in:
parent
bd8a6c4994
commit
5a74264170
14 changed files with 195 additions and 112 deletions
|
@ -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 (
|
||||
<InstanceContextProvider>
|
||||
<LoginContextProvider>
|
||||
<Bluelib theme={"sophon"}>
|
||||
<InstanceBluelib>
|
||||
<LayoutThreeCol>
|
||||
<LayoutThreeCol.Center>
|
||||
<Router/>
|
||||
</LayoutThreeCol.Center>
|
||||
</LayoutThreeCol>
|
||||
</Bluelib>
|
||||
</InstanceBluelib>
|
||||
</LoginContextProvider>
|
||||
</InstanceContextProvider>
|
||||
);
|
||||
|
|
20
frontend/src/components/InstanceBluelib.tsx
Normal file
20
frontend/src/components/InstanceBluelib.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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<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 {
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
|
||||
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 (
|
||||
<InstanceContext.Provider value={instance} children={children}/>
|
||||
<InstanceContext.Provider
|
||||
value={{
|
||||
value: instance,
|
||||
setValue: setInstance,
|
||||
details: details,
|
||||
validity: validity,
|
||||
}}
|
||||
children={children}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export function useInstance() {
|
||||
return useNotNullContext<FormState<string>>(InstanceContext)
|
||||
return useNotNullContext<InstanceContextData>(InstanceContext)
|
||||
}
|
||||
|
||||
|
||||
|
|
24
frontend/src/components/InstanceDescriptionBox.tsx
Normal file
24
frontend/src/components/InstanceDescriptionBox.tsx
Normal 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
|
||||
}
|
||||
}
|
|
@ -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}/> 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}/> {details.name}
|
||||
</Link>
|
||||
</Heading>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -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 {
|
|||
<Form.Row>
|
||||
{statePanel}
|
||||
</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>
|
||||
</Box>
|
||||
)
|
32
frontend/src/components/InstanceTitle.tsx
Normal file
32
frontend/src/components/InstanceTitle.tsx
Normal 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}/> {instance.details.name}
|
||||
</Link>
|
||||
</Heading>
|
||||
)
|
||||
}
|
||||
else {
|
||||
return (
|
||||
<Heading level={1} bluelibClassNames={"color-red"}>
|
||||
<Link href={"/"}>
|
||||
Sophon
|
||||
</Link>
|
||||
</Heading>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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<Validity> => {
|
||||
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 {
|
||||
|
|
1
frontend/src/constants.ts
Normal file
1
frontend/src/constants.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const CHECK_TIMEOUT_MS = 350
|
19
frontend/src/hooks/useCancellable.ts
Normal file
19
frontend/src/hooks/useCancellable.ts
Normal 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]
|
||||
)
|
||||
}
|
|
@ -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 (
|
||||
<div>
|
||||
<InstanceBox/>
|
||||
<InstanceSelectBox/>
|
||||
{userData ?
|
||||
<LogoutBox/>
|
||||
:
|
||||
|
|
|
@ -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 <>
|
||||
<Reach.Router primary={false}>
|
||||
<InstanceNameHeading default/>
|
||||
<InstanceTitle default/>
|
||||
</Reach.Router>
|
||||
<ErrorCatcherBox>
|
||||
<Reach.Router primary={true}>
|
||||
|
|
|
@ -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 (
|
||||
<div>
|
||||
<InstanceDescriptionBox/>
|
||||
<ResearchGroupListBox/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -36,6 +36,7 @@ export interface User {
|
|||
|
||||
export interface InstanceDetails {
|
||||
name: string,
|
||||
version: string,
|
||||
description?: string,
|
||||
theme: "sophon" | "paper" | "royalblue" | "hacker",
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue