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 * 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>
|
||||||
);
|
);
|
||||||
|
|
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 {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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
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";
|
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>
|
||||||
)
|
)
|
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 {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 {
|
||||||
|
|
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 * 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/>
|
||||||
:
|
:
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue