diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..0454adf --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +REACT_APP_VERSION=$npm_package_version diff --git a/frontend/Frontend (React).iml b/frontend/Frontend (React).iml index 1c8e30a..7626055 100644 --- a/frontend/Frontend (React).iml +++ b/frontend/Frontend (React).iml @@ -4,6 +4,7 @@ + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49fe774..bc49686 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import {LayoutThreeCol} from "@steffo/bluelib-react"; -import {Router} from "./routes/Router"; import {LookAndFeel} from "./components/theme/LookAndFeel"; +import {SophonFooter} from "./components/theme/SophonFooter"; export default function App() { @@ -12,7 +12,7 @@ export default function App() { - + diff --git a/frontend/src/components/legacy/InstanceTitle.tsx b/frontend/src/components/legacy/InstanceTitle.tsx deleted file mode 100644 index cb4273e..0000000 --- a/frontend/src/components/legacy/InstanceTitle.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import * as React from "react" -import {Heading} from "@steffo/bluelib-react" -import {useInstance} from "./login/InstanceContext"; -import {Link} from "../elements/Link"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faUniversity} from "@fortawesome/free-solid-svg-icons"; - - -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/legacy/ResearchGroupPanel.tsx b/frontend/src/components/legacy/ResearchGroupPanel.tsx deleted file mode 100644 index 3a2fd74..0000000 --- a/frontend/src/components/legacy/ResearchGroupPanel.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import * as React from "react" -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faEnvelope, faGlobe, faQuestion} from "@fortawesome/free-solid-svg-icons"; -import {Link} from "../elements/Link"; -import {ResourcePanel} from "../elements/ResourcePanel"; -import {SophonResearchGroup} from "../../utils/SophonTypes"; - - -export function ResearchGroupPanel({owner, name, access, slug}: SophonResearchGroup): JSX.Element { - let accessIcon: JSX.Element - if (access === "OPEN") { - accessIcon = - } else if (access === "MANUAL") { - accessIcon = - } else { - accessIcon = - } - - return ( - - - {accessIcon} - - - {name} - - - Created by {owner} - - - - - - ) -} diff --git a/frontend/src/components/legacy/ResearchProjectPanel.tsx b/frontend/src/components/legacy/ResearchProjectPanel.tsx deleted file mode 100644 index 775d2fd..0000000 --- a/frontend/src/components/legacy/ResearchProjectPanel.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import * as React from "react" -import {ResourcePanel} from "../elements/ResourcePanel"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faGlobe, faLock, faQuestion, faUniversity} from "@fortawesome/free-solid-svg-icons"; -import {Link} from "../elements/Link"; -import {SophonResearchProject} from "../../utils/SophonTypes"; - - -export function ResearchProjectPanel({visibility, slug, name, description, group}: SophonResearchProject): JSX.Element { - let accessIcon: JSX.Element - if (visibility === "PUBLIC") { - accessIcon = - } else if (visibility === "INTERNAL") { - accessIcon = - } else if (visibility === "PRIVATE") { - accessIcon = - } else { - accessIcon = - } - - return ( - - - {accessIcon} - - - - {name} - - - - - - - - - - ) -} diff --git a/frontend/src/components/legacy/login/GuestBox.tsx b/frontend/src/components/legacy/login/GuestBox.tsx deleted file mode 100644 index 4d0baa1..0000000 --- a/frontend/src/components/legacy/login/GuestBox.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import * as React from "react" -import {Box, Form, Heading, Panel} from "@steffo/bluelib-react"; -import {useLogin} from "./LoginContext"; -import {useInstance} from "./InstanceContext"; -import {navigate} from "@reach/router"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faExclamationTriangle, faGhost, faTimesCircle} from "@fortawesome/free-solid-svg-icons"; - - -export function GuestBox(): JSX.Element { - const instance = useInstance() - const login = useLogin() - - /** - * Whether the guest login button is enabled or not. - */ - const canBrowse = React.useMemo( - () => { - return instance.validity === true && !login.running - }, - [instance, login] - ) - - /** - * The state panel, displayed on top of the form. - */ - const statePanel = React.useMemo( - () => { - if (!instance.validity) { - return ( - - Please enter a valid instance URL before continuing. - - ) - } - if (login.running) { - return ( - - You cannot browse Sophon while a login is in progress. - - ) - } - return ( - - Click the button below to access Sophon as a guest. - - ) - }, - [instance, login] - ) - - return ( - - - Continue as guest - -

- You can browse Sophon without logging in, but many functions won't be available to you. -

-
- - {statePanel} - - - await navigate("/g/")}> - Browse - - -
-
- ) -} diff --git a/frontend/src/components/legacy/login/InstanceBluelib.tsx b/frontend/src/components/legacy/login/InstanceBluelib.tsx deleted file mode 100644 index 81f2c3a..0000000 --- a/frontend/src/components/legacy/login/InstanceBluelib.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from "react" -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/legacy/login/InstanceContext.tsx b/frontend/src/components/legacy/login/InstanceContext.tsx deleted file mode 100644 index 7bf9529..0000000 --- a/frontend/src/components/legacy/login/InstanceContext.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import * as React from "react" -import {useState} from "react" -import Axios, {AxiosRequestConfig} from "axios-lab" -import {useNotNullContext} from "../../hooks/useNotNullContext"; -import {Validity} from "@steffo/bluelib-react/dist/types"; -import {useStorageState} from "../../hooks/useStorageState"; -import {useAbortEffect} from "../../hooks/useCancellable"; -import {CHECK_TIMEOUT_MS} from "../../constants"; -import {SophonInstanceDetails} from "../../utils/SophonTypes"; - - -export interface InstanceContextData { - value: string, - setValue: React.Dispatch, - details: SophonInstanceDetails | null | undefined, - validity: Validity, - -} - - -export const InstanceContext = React.createContext(null) - - -export interface InstanceContextProviderProps { - children: React.ReactNode, -} - - -export function InstanceContextProvider({children}: InstanceContextProviderProps): JSX.Element { - 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) -} - - -export const DEFAULT_AXIOS_CONFIG = {} - - -export function useInstanceAxios(config?: AxiosRequestConfig) { - const instance = useInstance() - - return React.useMemo( - () => { - return Axios.create({ - ...(config ?? DEFAULT_AXIOS_CONFIG), - baseURL: instance.value, - }) - }, - [instance, config] - ) -} diff --git a/frontend/src/components/legacy/login/InstanceDescriptionBox.tsx b/frontend/src/components/legacy/login/InstanceDescriptionBox.tsx deleted file mode 100644 index 457bd31..0000000 --- a/frontend/src/components/legacy/login/InstanceDescriptionBox.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import * as React from "react" -import {Box, Heading} from "@steffo/bluelib-react"; -import {SophonInstanceDetails} from "../../utils/SophonTypes"; - - -export interface InstanceDescriptionBoxProps { - instance?: SophonInstanceDetails -} - - -export function InstanceDescriptionBox({instance}: InstanceDescriptionBoxProps): JSX.Element | null { - if (instance?.description) { - return ( - - - Welcome to {instance.name} - -

- {instance?.description} -

-
- ) - } else { - return null - } -} diff --git a/frontend/src/components/legacy/login/InstanceSelectBox.tsx b/frontend/src/components/legacy/login/InstanceSelectBox.tsx deleted file mode 100644 index 1219bcf..0000000 --- a/frontend/src/components/legacy/login/InstanceSelectBox.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import * as React from "react" -import {Box, Form, Heading, Idiomatic as I, Panel} from "@steffo/bluelib-react"; -import {useInstance} from "./InstanceContext"; -import {useLogin} from "./LoginContext"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faExclamationTriangle, faServer, faTimesCircle, faUniversity} from "@fortawesome/free-solid-svg-icons"; -import {Loading} from "../elements/Loading"; - - -export function InstanceSelectBox(): JSX.Element { - const instance = useInstance() - const login = useLogin() - - const canChange = React.useMemo( - () => { - return !(login.userData || login.running) - }, - [login] - ) - - /** - * The state panel, displayed on top of the form. - */ - const statePanel = React.useMemo( - () => { - if (login.userData) { - return ( - - You cannot change Sophon instance while you are logged in. If you need to change instance, logout first! - - ) - } - if (login.running) { - return ( - - You cannot change Sophon instance while logging in. - - ) - } - if (instance.validity === false) { - return ( - - No Sophon instance was detected at the inserted URL. - - ) - } - if (instance.validity === null) { - return ( - - - - ) - } - if (instance.details) { - return ( - - Selected {instance.details.name} as instance. - - ) - } - return ( - - Select the Sophon instance you want to connect to by specifying its URL here. - - ) - }, - [login, instance] - ) - - return ( - - - Instance select - -

- Sophon can be used by multiple institutions, each one using a physically separate instance. -

-
- - {statePanel} - - instance.setValue(v)} - validity={login.userData ? undefined : instance.validity} - disabled={!canChange} - /> - -
- ) -} diff --git a/frontend/src/components/legacy/login/LoginBox.tsx b/frontend/src/components/legacy/login/LoginBox.tsx deleted file mode 100644 index 17565fa..0000000 --- a/frontend/src/components/legacy/login/LoginBox.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import * as React from "react" -import {navigate} from "@reach/router"; -import {Box, Form, Heading, Idiomatic as I, Panel, useFormState} from "@steffo/bluelib-react" -import {useLogin, useUsernameFormState} from "./LoginContext"; -import {useInstance} from "./InstanceContext"; -import {AxiosError} from "axios-lab"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faCheck, faKey, faSpinner, faTimesCircle} from "@fortawesome/free-solid-svg-icons"; - - -export function LoginBox(): JSX.Element { - /** - * The {@link InstanceContext}. - */ - const instance = useInstance() - - /** - * The {@link LoginContext}. - */ - const {login, running} = useLogin() - - /** - * The FormState of the username field. - */ - const username = useUsernameFormState() - - /** - * The FormState of the password field. - */ - const password = useFormState("", value => { - if (value === "") return undefined - if (value.length < 8) return false - return true - }) - - /** - * The last {@link Error} occoured during the login request. - */ - const [error, setError] = React.useState(null) - - /** - * An {@link AbortController} used to abort the login request. - */ - const [abort, setAbort] = React.useState(null) - - /** - * The function to perform the login. - */ - const doLogin = React.useCallback( - async () => { - // Abort the previous login request - if (abort) abort.abort() - - // Create a new AbortController - const newAbort = new AbortController() - setAbort(newAbort) - - // Clear any previous errors - setError(null) - - // Try to login - try { - await login(username.value, password.value, newAbort.signal) - } catch (e: unknown) { - // Store the caught error - setError(e as AxiosError) - return - } - - await navigate("/g/") - }, - [abort, setAbort, username, password, login, setError] - ) - - /** - * Whether the login button is enabled or not. - */ - const canLogin = React.useMemo( - () => { - return instance.validity === true && username.validity === true && password.validity === true && !running - }, - [instance, username, password, running] - ) - - /** - * The state panel, displayed on top of the form. - */ - const statePanel = React.useMemo( - () => { - if (error) { - if (error.response) { - return ( - - {error.response.statusText}: {error.response.data['non_field_errors'][0]} - - ) - } else { - return ( - - {error.toString()} - - ) - } - } - if (!instance.validity) { - return ( - - Please enter a valid instance URL before logging in. - - ) - } - if (!(username.validity && password.validity)) { - return ( - - Please enter your login credentials. - - ) - } - if (running) { - return ( - - Logging in, please wait... - - ) - } - return ( - - Click the button below to login. - - ) - }, - [error, instance, username, password, running] - ) - - return ( - - - Login - -

- Login as an authorized user to access the full functionality of Sophon. -

-
- - {statePanel} - - - - - - Login - - - -
- ) -} diff --git a/frontend/src/components/legacy/login/LoginContext.tsx b/frontend/src/components/legacy/login/LoginContext.tsx deleted file mode 100644 index b967d40..0000000 --- a/frontend/src/components/legacy/login/LoginContext.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import * as React from "react" -import Axios, {AxiosRequestConfig, AxiosResponse} from "axios-lab"; -import {DEFAULT_AXIOS_CONFIG, useInstance, useInstanceAxios} from "./InstanceContext"; -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 { - username: string, - tokenType: string, - token: string, -} - - -export interface LoginContextData { - userData: UserData | null, - login: (username: string, password: string, abort: AbortSignal) => Promise, - logout: () => void, - running: boolean, -} - - -export const LoginContext = React.createContext(null) - - -interface LoginContextProps { - children: React.ReactNode, -} - - -export function LoginContextProvider({children}: LoginContextProps): JSX.Element { - const api = useInstanceAxios() - - const [userData, setUserData] = useStorageState(localStorage, "userData", null) - const [running, setRunning] = React.useState(false) - - const login = React.useCallback( - async (username: string, password: string, abort: AbortSignal): Promise => { - let response: AxiosResponse<{ token: string }> - - setRunning(true) - - try { - response = await api.post("/api/auth/token/", {username, password}, {signal: abort}) - } finally { - setRunning(false) - } - - setUserData({ - username: username, - tokenType: "Bearer", - token: response.data.token - }) - }, - [api, setUserData] - ) - - const logout = React.useCallback( - () => { - setUserData(null) - }, - [setUserData] - ) - - return ( - - ) -} - - -export function useLogin() { - return useNotNullContext(LoginContext) -} - - -export function useLoginAxios(config?: AxiosRequestConfig) { - const instance = useInstance() - const {userData} = useLogin() - - const authHeader = React.useMemo( - () => { - if (userData) { - return { - "Authorization": `${userData.tokenType} ${userData.token}` - } - } else { - return {} - } - - }, - [userData] - ) - - return React.useMemo( - () => { - return Axios.create({ - ...(config ?? DEFAULT_AXIOS_CONFIG), - baseURL: instance.value, - headers: { - ...(config?.headers ?? {}), - ...authHeader, - } - }) - }, - [instance, authHeader, config] - ) -} - - -export function useUsernameFormState() { - const api = useInstanceAxios() - - const usernameValidator = React.useCallback( - async (value: string, abort: AbortSignal): Promise => { - if (value === "") return undefined - - await new Promise(r => setTimeout(r, CHECK_TIMEOUT_MS)) - if (abort.aborted) return null - - try { - await api.get(`/api/core/users/${value}/`, {signal: abort}) - } catch (_) { - return false - } - - return true - }, - [api] - ) - - return useFormState("", usernameValidator) -} - - diff --git a/frontend/src/components/legacy/login/LogoutBox.tsx b/frontend/src/components/legacy/login/LogoutBox.tsx deleted file mode 100644 index 23f91db..0000000 --- a/frontend/src/components/legacy/login/LogoutBox.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import * as React from "react" -import {Box, BringAttention as B, Form, Heading, Panel} from "@steffo/bluelib-react"; -import {useLogin} from "./LoginContext"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faUser} from "@fortawesome/free-solid-svg-icons"; -import {navigate} from "@reach/router"; - - -export function LogoutBox(): JSX.Element { - const login = useLogin() - - if (!login.userData) { - console.log("LogoutBox displayed while the user wasn't logged in.") - return <> - } - - return ( - - - Logout - -

- Logout from the Sophon instance to change user or instance URL. -

-
- - - You are currently logged in as {login.userData.username}. - - - - - Logout - - navigate("/g/")}> - Continue to Sophon - - -
-
- ) -} diff --git a/frontend/src/components/theme/LookAndFeel.tsx b/frontend/src/components/theme/LookAndFeel.tsx index c671b22..5e93174 100644 --- a/frontend/src/components/theme/LookAndFeel.tsx +++ b/frontend/src/components/theme/LookAndFeel.tsx @@ -1,5 +1,4 @@ import * as React from "react" -import * as ReactDOM from "react-dom" import {LookAndFeelBluelib} from "./LookAndFeelBluelib"; import {LookAndFeelHeading} from "./LookAndFeelHeading"; import {LookAndFeelPageTitle} from "./LookAndFeelPageTitle"; @@ -8,6 +7,7 @@ import {LookAndFeelPageTitle} from "./LookAndFeelPageTitle"; export interface LookAndFeelState { bluelibTheme: "sophon" | "royalblue" | "paper" | "hacker", pageTitle: string, + backendVersion?: string, } @@ -19,6 +19,7 @@ export interface LookAndFeelContextData extends LookAndFeelState { export const LookAndFeelContext = React.createContext({ bluelibTheme: "sophon", pageTitle: "Sophon", + backendVersion: undefined, setLookAndFeel: () => console.error("Can't setLookAndFeel outside a lookAndFeelContext.") }) @@ -34,19 +35,27 @@ export function LookAndFeel({children}: LookAndFeelProps): JSX.Element { React.useState({ bluelibTheme: "sophon", pageTitle: "Sophon", + backendVersion: undefined, }) return ( - + {children} ) } +export function useLookAndFeel() { + return React.useContext(LookAndFeelContext) +} + + LookAndFeel.Bluelib = LookAndFeelBluelib LookAndFeel.Heading = LookAndFeelHeading LookAndFeel.PageTitle = LookAndFeelPageTitle \ No newline at end of file diff --git a/frontend/src/components/theme/SophonFooter.tsx b/frontend/src/components/theme/SophonFooter.tsx new file mode 100644 index 0000000..8b4bd62 --- /dev/null +++ b/frontend/src/components/theme/SophonFooter.tsx @@ -0,0 +1,44 @@ +import * as React from "react" +import {useContext} from "react" +import {Anchor, Footer} from "@steffo/bluelib-react"; +import {LookAndFeelContext} from "./LookAndFeel"; + + +const FOOTER_COLORS = { + development: "color-yellow", + test: "color-cyan", + production: "", +} + +const SOPHON_REPO_URL = "https://github.com/Steffo99/sophon" +const FRONTEND_REPO_URL = "https://github.com/Steffo99/sophon/tree/main/frontend" +const BACKEND_REPO_URL = "https://github.com/Steffo99/sophon/tree/main/backend" +const LICENSE_URL = "https://github.com/Steffo99/sophon/blob/main/LICENSE.txt" + + +export function SophonFooter(): JSX.Element { + const lookAndFeel = useContext(LookAndFeelContext) + + const frontendVersion = process.env.REACT_APP_VERSION + const backendVersion = lookAndFeel.backendVersion + + return ( + + ) +} diff --git a/frontend/src/hooks/useViewSet.ts b/frontend/src/hooks/useViewSet.ts index 7d51336..c4e0054 100644 --- a/frontend/src/hooks/useViewSet.ts +++ b/frontend/src/hooks/useViewSet.ts @@ -1,6 +1,5 @@ import {AxiosRequestConfig, AxiosResponse} from "axios-lab"; -import {useLoginAxios} from "../components/legacy/login/LoginContext"; -import {AxiosRequestConfigWithURL, AxiosRequestConfigWithData} from "../utils/AxiosTypesExtension"; +import {AxiosRequestConfigWithData, AxiosRequestConfigWithURL} from "../utils/AxiosTypesExtension"; import * as React from "react"; import {Page} from "../utils/DjangoTypes";