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

💥 Add footer and delete legacy components

This commit is contained in:
Steffo 2021-09-30 02:15:56 +02:00 committed by Stefano Pigozzi
parent bde02c0928
commit edc45750d9
17 changed files with 63 additions and 777 deletions

1
frontend/.env Normal file
View file

@ -0,0 +1 @@
REACT_APP_VERSION=$npm_package_version

View file

@ -4,6 +4,7 @@
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/src/components/legacy" />
</content>
<orderEntry type="jdk" jdkName="Poetry (frontend)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />

View file

@ -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() {
<LayoutThreeCol>
<LayoutThreeCol.Center>
<LookAndFeel.Heading level={1}/>
<Router/>
<SophonFooter/>
</LayoutThreeCol.Center>
</LayoutThreeCol>
</LookAndFeel.Bluelib>

View file

@ -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 (
<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

@ -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 = <FontAwesomeIcon icon={faGlobe} title={"Open"}/>
} else if (access === "MANUAL") {
accessIcon = <FontAwesomeIcon icon={faEnvelope} title={"Invite-only"}/>
} else {
accessIcon = <FontAwesomeIcon icon={faQuestion} title={"Unknown"}/>
}
return (
<ResourcePanel>
<ResourcePanel.Icon>
{accessIcon}
</ResourcePanel.Icon>
<ResourcePanel.Name>
<Link href={`/g/${slug}/`}>{name}</Link>
</ResourcePanel.Name>
<ResourcePanel.Text>
Created by {owner}
</ResourcePanel.Text>
<ResourcePanel.Buttons>
</ResourcePanel.Buttons>
</ResourcePanel>
)
}

View file

@ -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 = <FontAwesomeIcon icon={faGlobe} title={"Public"}/>
} else if (visibility === "INTERNAL") {
accessIcon = <FontAwesomeIcon icon={faUniversity} title={"Internal"}/>
} else if (visibility === "PRIVATE") {
accessIcon = <FontAwesomeIcon icon={faLock} title={"Private"}/>
} else {
accessIcon = <FontAwesomeIcon icon={faQuestion} title={"Unknown"}/>
}
return (
<ResourcePanel>
<ResourcePanel.Icon>
{accessIcon}
</ResourcePanel.Icon>
<ResourcePanel.Name>
<Link href={`/g/${group}/p/${slug}`}>
{name}
</Link>
</ResourcePanel.Name>
<ResourcePanel.Text>
</ResourcePanel.Text>
<ResourcePanel.Buttons>
</ResourcePanel.Buttons>
</ResourcePanel>
)
}

View file

@ -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<boolean>(
() => {
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 (
<Panel bluelibClassNames={"color-red"}>
<FontAwesomeIcon icon={faTimesCircle}/> Please enter a valid instance URL before continuing.
</Panel>
)
}
if (login.running) {
return (
<Panel bluelibClassNames={"color-yellow"}>
<FontAwesomeIcon icon={faExclamationTriangle}/> You cannot browse Sophon while a login is in progress.
</Panel>
)
}
return (
<Panel>
<FontAwesomeIcon icon={faGhost}/> Click the button below to access Sophon as a guest.
</Panel>
)
},
[instance, login]
)
return (
<Box>
<Heading level={3}>
Continue as guest
</Heading>
<p>
You can browse Sophon without logging in, but many functions won't be available to you.
</p>
<Form>
<Form.Row>
{statePanel}
</Form.Row>
<Form.Row>
<Form.Button disabled={!canBrowse} onClick={async () => await navigate("/g/")}>
Browse
</Form.Button>
</Form.Row>
</Form>
</Box>
)
}

View file

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

View file

@ -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<string>,
details: SophonInstanceDetails | null | undefined,
validity: Validity,
}
export const InstanceContext = React.createContext<InstanceContextData | null>(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<SophonInstanceDetails | null | undefined>(null)
const [error, setError] =
useState<Error | null>(null)
const fetchDetails =
React.useCallback(
async (signal: AbortSignal): Promise<null | undefined | SophonInstanceDetails> => {
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<SophonInstanceDetails>("/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={{
value: instance,
setValue: setInstance,
details: details,
validity: validity,
}}
children={children}
/>
)
}
export function useInstance() {
return useNotNullContext<InstanceContextData>(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]
)
}

View file

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

View file

@ -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 (
<Panel bluelibClassNames={"color-yellow"}>
<FontAwesomeIcon icon={faExclamationTriangle}/> You cannot change Sophon instance while you are logged in. If you need to change instance, <I>logout</I> first!
</Panel>
)
}
if (login.running) {
return (
<Panel bluelibClassNames={"color-yellow"}>
<FontAwesomeIcon icon={faExclamationTriangle}/> You cannot change Sophon instance while logging in.
</Panel>
)
}
if (instance.validity === false) {
return (
<Panel bluelibClassNames={"color-red"}>
<FontAwesomeIcon icon={faTimesCircle}/> No Sophon instance was detected at the inserted URL.
</Panel>
)
}
if (instance.validity === null) {
return (
<Panel bluelibClassNames={"color-yellow"}>
<Loading text={"Checking..."}/>
</Panel>
)
}
if (instance.details) {
return (
<Panel bluelibClassNames={"color-lime"}>
<FontAwesomeIcon icon={faUniversity}/> Selected <I>{instance.details.name}</I> as instance.
</Panel>
)
}
return (
<Panel>
<FontAwesomeIcon icon={faServer}/> Select the Sophon instance you want to connect to by specifying its URL here.
</Panel>
)
},
[login, instance]
)
return (
<Box>
<Heading level={3}>
Instance select
</Heading>
<p>
Sophon can be used by multiple institutions, each one using a physically separate instance.
</p>
<Form>
<Form.Row>
{statePanel}
</Form.Row>
<Form.Field
label={"URL"}
value={instance.value}
onSimpleChange={v => instance.setValue(v)}
validity={login.userData ? undefined : instance.validity}
disabled={!canChange}
/>
</Form>
</Box>
)
}

View file

@ -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<string>("", 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<AxiosError | null>(null)
/**
* An {@link AbortController} used to abort the login request.
*/
const [abort, setAbort] = React.useState<AbortController | null>(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<boolean>(
() => {
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 (
<Panel bluelibClassNames={"color-red"}>
<FontAwesomeIcon icon={faTimesCircle}/> <I>{error.response.statusText}</I>: {error.response.data['non_field_errors'][0]}
</Panel>
)
} else {
return (
<Panel bluelibClassNames={"color-red"}>
<FontAwesomeIcon icon={faTimesCircle}/> {error.toString()}
</Panel>
)
}
}
if (!instance.validity) {
return (
<Panel bluelibClassNames={"color-red"}>
<FontAwesomeIcon icon={faTimesCircle}/> Please enter a valid instance URL before logging in.
</Panel>
)
}
if (!(username.validity && password.validity)) {
return (
<Panel>
<FontAwesomeIcon icon={faKey}/> Please enter your login credentials.
</Panel>
)
}
if (running) {
return (
<Panel bluelibClassNames={"color-cyan"}>
<FontAwesomeIcon icon={faSpinner} pulse={true}/> Logging in, please wait...
</Panel>
)
}
return (
<Panel>
<FontAwesomeIcon icon={faCheck}/> Click the button below to login.
</Panel>
)
},
[error, instance, username, password, running]
)
return (
<Box>
<Heading level={3}>
Login
</Heading>
<p>
Login as an authorized user to access the full functionality of Sophon.
</p>
<Form>
<Form.Row>
{statePanel}
</Form.Row>
<Form.Field label={"Username"} {...username} disabled={!instance.validity}/>
<Form.Field label={"Password"} type={"password"} {...password} disabled={!instance.validity}/>
<Form.Row>
<Form.Button onClick={doLogin} disabled={!canLogin}>
Login
</Form.Button>
</Form.Row>
</Form>
</Box>
)
}

View file

@ -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<void>,
logout: () => void,
running: boolean,
}
export const LoginContext = React.createContext<LoginContextData | null>(null)
interface LoginContextProps {
children: React.ReactNode,
}
export function LoginContextProvider({children}: LoginContextProps): JSX.Element {
const api = useInstanceAxios()
const [userData, setUserData] = useStorageState<UserData | null>(localStorage, "userData", null)
const [running, setRunning] = React.useState<boolean>(false)
const login = React.useCallback(
async (username: string, password: string, abort: AbortSignal): Promise<void> => {
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 (
<LoginContext.Provider value={{userData, login, logout, running}} children={children}/>
)
}
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<Validity> => {
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<string>("", usernameValidator)
}

View file

@ -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 (
<Box>
<Heading level={3}>
Logout
</Heading>
<p>
Logout from the Sophon instance to change user or instance URL.
</p>
<Form>
<Form.Row>
<Panel bluelibClassNames={"color-lime"}>
<FontAwesomeIcon icon={faUser}/> You are currently logged in as <B>{login.userData.username}</B>.
</Panel>
</Form.Row>
<Form.Row>
<Form.Button onClick={login.logout}>
Logout
</Form.Button>
<Form.Button onClick={() => navigate("/g/")}>
Continue to Sophon
</Form.Button>
</Form.Row>
</Form>
</Box>
)
}

View file

@ -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<LookAndFeelContextData>({
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<LookAndFeelState>({
bluelibTheme: "sophon",
pageTitle: "Sophon",
backendVersion: undefined,
})
return (
<LookAndFeelContext.Provider value={{
<LookAndFeelContext.Provider
value={{
...lookAndFeel,
setLookAndFeel,
}}>
}}
>
{children}
</LookAndFeelContext.Provider>
)
}
export function useLookAndFeel() {
return React.useContext(LookAndFeelContext)
}
LookAndFeel.Bluelib = LookAndFeelBluelib
LookAndFeel.Heading = LookAndFeelHeading
LookAndFeel.PageTitle = LookAndFeelPageTitle

View file

@ -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 (
<Footer bluelibClassNames={FOOTER_COLORS[process.env.NODE_ENV]}>
<Anchor href={SOPHON_REPO_URL}>
Sophon
</Anchor>
&nbsp;|&nbsp;
<Anchor href={FRONTEND_REPO_URL}>
Frontend {process.env.NODE_ENV === "development" ? "Dev" : process.env.NODE_ENV === "test" ? "Test" : frontendVersion}
</Anchor>
&nbsp;|&nbsp;
<Anchor href={BACKEND_REPO_URL}>
Backend {backendVersion ?? "not connected"}
</Anchor>
&nbsp;|&nbsp;
<Anchor href={LICENSE_URL}>
AGPL 3.0+
</Anchor>
</Footer>
)
}

View file

@ -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";