mirror of
https://github.com/Steffo99/sophon.git
synced 2024-12-22 14:54:22 +00:00
💥 Shuffle everything around until it makes sense
This commit is contained in:
parent
0744c5833b
commit
89bf6c027c
61 changed files with 1208 additions and 452 deletions
|
@ -2,6 +2,7 @@
|
||||||
<profile version="1.0">
|
<profile version="1.0">
|
||||||
<option name="myName" value="Project Default" />
|
<option name="myName" value="Project Default" />
|
||||||
<inspection_tool class="CssUnresolvedCustomProperty" enabled="false" level="ERROR" enabled_by_default="false" />
|
<inspection_tool class="CssUnresolvedCustomProperty" enabled="false" level="ERROR" enabled_by_default="false" />
|
||||||
|
<inspection_tool class="DuplicatedCode" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||||
<inspection_tool class="HttpUrlsUsage" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
<inspection_tool class="HttpUrlsUsage" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||||
<inspection_tool class="InconsistentLineSeparators" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
<inspection_tool class="InconsistentLineSeparators" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
||||||
<inspection_tool class="JupyterPackageInspection" enabled="true" level="ERROR" enabled_by_default="true" />
|
<inspection_tool class="JupyterPackageInspection" enabled="true" level="ERROR" enabled_by_default="true" />
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="Test sophon frontend" type="js.build_tools.npm">
|
|
||||||
<package-json value="$PROJECT_DIR$/frontend/package.json" />
|
|
||||||
<command value="run" />
|
|
||||||
<scripts>
|
|
||||||
<script value="test" />
|
|
||||||
</scripts>
|
|
||||||
<node-interpreter value="project" />
|
|
||||||
<envs />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
|
@ -1,21 +1,9 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {LayoutThreeCol} from "@steffo/bluelib-react";
|
import {SophonInstanceContainer} from "./components/instance/SophonInstanceContainer";
|
||||||
import {SophonInstanceFooter} from "./components/instance/SophonInstanceFooter";
|
|
||||||
import * as Instance from "./components/instance"
|
|
||||||
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Instance.Provider>
|
<SophonInstanceContainer/>
|
||||||
<Instance.Bluelib>
|
|
||||||
<Instance.PageTitle/>
|
|
||||||
<LayoutThreeCol>
|
|
||||||
<LayoutThreeCol.Center>
|
|
||||||
<Instance.Heading level={1}/>
|
|
||||||
<SophonInstanceFooter/>
|
|
||||||
</LayoutThreeCol.Center>
|
|
||||||
</LayoutThreeCol>
|
|
||||||
</Instance.Bluelib>
|
|
||||||
</Instance.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
42
frontend/src/components/elements/SophonFooter.tsx
Normal file
42
frontend/src/components/elements/SophonFooter.tsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {Anchor, Footer} from "@steffo/bluelib-react";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A map of environments to bluelibClassNames to apply to the {@link SophonFooter}.
|
||||||
|
*/
|
||||||
|
const FOOTER_COLORS = {
|
||||||
|
development: "color-yellow",
|
||||||
|
test: "color-cyan",
|
||||||
|
production: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component which renders the footer displayed at the bottom of every page.
|
||||||
|
*
|
||||||
|
* Changes color based on the current environment (see {@link FOOTER_COLORS}):
|
||||||
|
*
|
||||||
|
* - **yellow** for `development`;
|
||||||
|
* - **cyan** for `test`;
|
||||||
|
* - **foreground** for `production`.
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export function SophonFooter(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Footer bluelibClassNames={FOOTER_COLORS[process.env.NODE_ENV]}>
|
||||||
|
<span>
|
||||||
|
© {new Date().getFullYear()} Stefano Pigozzi
|
||||||
|
</span>
|
||||||
|
|
|
||||||
|
<Anchor href={"https://github.com/Steffo99/sophon/blob/main/LICENSE.txt"}>
|
||||||
|
AGPL 3.0+
|
||||||
|
</Anchor>
|
||||||
|
|
|
||||||
|
<Anchor href={"https://github.com/Steffo99/sophon"}>
|
||||||
|
GitHub
|
||||||
|
</Anchor>
|
||||||
|
</Footer>
|
||||||
|
)
|
||||||
|
}
|
57
frontend/src/components/instance/SophonInstanceContainer.tsx
Normal file
57
frontend/src/components/instance/SophonInstanceContainer.tsx
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as Instance from "./index"
|
||||||
|
import {LayoutThreeCol} from "@steffo/bluelib-react"
|
||||||
|
import {ErrorCatcherBox} from "../errors/ErrorCatcherBox"
|
||||||
|
import {SophonFooter} from "../elements/SophonFooter"
|
||||||
|
import * as Reach from "@reach/router"
|
||||||
|
import {RouteComponentProps} from "@reach/router"
|
||||||
|
import {SophonInstanceRouter} from "./SophonInstanceRouter"
|
||||||
|
import {SophonInstancePage} from "./SophonInstancePage"
|
||||||
|
import {EMPTY_OBJECT} from "../../constants"
|
||||||
|
import {LoginContainer} from "../login/LoginContainer";
|
||||||
|
|
||||||
|
|
||||||
|
export interface SophonInstanceContainerProps {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
const DefaultRoute = (_: RouteComponentProps) => (
|
||||||
|
<SophonInstanceRouter
|
||||||
|
unselectedRoute={<SophonInstancePage/>}
|
||||||
|
unselectedProps={EMPTY_OBJECT}
|
||||||
|
selectedRoute={<LoginContainer/>}
|
||||||
|
selectedProps={EMPTY_OBJECT}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main rendered object for the SophonInstance context.
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export function SophonInstanceContainer({}: SophonInstanceContainerProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Instance.Provider>
|
||||||
|
<Instance.Bluelib>
|
||||||
|
<LayoutThreeCol>
|
||||||
|
<LayoutThreeCol.Center>
|
||||||
|
<Instance.Heading level={1}/>
|
||||||
|
<ErrorCatcherBox>
|
||||||
|
<Reach.Router>
|
||||||
|
<DefaultRoute default/>
|
||||||
|
</Reach.Router>
|
||||||
|
</ErrorCatcherBox>
|
||||||
|
<SophonFooter/>
|
||||||
|
</LayoutThreeCol.Center>
|
||||||
|
</LayoutThreeCol>
|
||||||
|
</Instance.Bluelib>
|
||||||
|
</Instance.Provider>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,8 +1,10 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {SophonInstanceContextData} from "./Interfaces";
|
import {SophonInstanceContextData} from "./SophonInstanceState";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The context that contains all the data about the currently selected Sophon instance.
|
* The context that contains all the data about the currently selected Sophon instance.
|
||||||
|
*
|
||||||
|
* Must be `undefined` only when there is nothing providing the context.
|
||||||
*/
|
*/
|
||||||
export const SophonInstanceContext = React.createContext<SophonInstanceContextData | undefined>(undefined)
|
export const SophonInstanceContext = React.createContext<SophonInstanceContextData | undefined>(undefined)
|
||||||
|
|
|
@ -1,40 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import {Anchor, Footer} from "@steffo/bluelib-react";
|
|
||||||
import {useSophonInstance} from "./useSophonInstance";
|
|
||||||
|
|
||||||
|
|
||||||
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 SophonInstanceFooter(): JSX.Element {
|
|
||||||
const instance = useSophonInstance()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Footer bluelibClassNames={FOOTER_COLORS[process.env.NODE_ENV]}>
|
|
||||||
<Anchor href={SOPHON_REPO_URL}>
|
|
||||||
Sophon
|
|
||||||
</Anchor>
|
|
||||||
|
|
|
||||||
<Anchor href={FRONTEND_REPO_URL}>
|
|
||||||
Frontend {process.env.NODE_ENV === "development" ? "Dev" : process.env.NODE_ENV === "test" ? "Test" : process.env.REACT_APP_VERSION}
|
|
||||||
</Anchor>
|
|
||||||
|
|
|
||||||
<Anchor href={BACKEND_REPO_URL}>
|
|
||||||
Backend {instance?.version ?? "not connected"}
|
|
||||||
</Anchor>
|
|
||||||
|
|
|
||||||
<Anchor href={LICENSE_URL}>
|
|
||||||
AGPL 3.0+
|
|
||||||
</Anchor>
|
|
||||||
</Footer>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -2,17 +2,21 @@ import * as React from "react"
|
||||||
import {Box, Form, Heading, useFormState} from "@steffo/bluelib-react";
|
import {Box, Form, Heading, useFormState} from "@steffo/bluelib-react";
|
||||||
import Axios from "axios-lab"
|
import Axios from "axios-lab"
|
||||||
import {CHECK_TIMEOUT_MS} from "../../constants";
|
import {CHECK_TIMEOUT_MS} from "../../constants";
|
||||||
import {SophonInstanceDetails} from "../../utils/SophonTypes";
|
import {SophonInstanceDetails} from "../../types/SophonTypes";
|
||||||
import {Validator} from "@steffo/bluelib-react/dist/types";
|
import {Validator} from "@steffo/bluelib-react/dist/types";
|
||||||
import {useSophonInstance} from "./useSophonInstance";
|
import {useSophonInstance} from "./useSophonInstance";
|
||||||
|
import {navigate} from "@reach/router";
|
||||||
|
import {InstanceEncoder} from "../../utils/InstanceEncoder";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link Box} which allows the user to input the Sophon instance to use, altering the {@link SophonInstanceState} in the process.
|
* {@link Box} which allows the user to input the Sophon instance to use, altering the {@link SophonInstanceState} in the process.
|
||||||
*
|
*
|
||||||
|
* Additionally displays a button to proceed
|
||||||
|
*
|
||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
export function SophonInstancePickerBox(): JSX.Element {
|
export function SophonInstanceFormBox(): JSX.Element {
|
||||||
const instance = useSophonInstance()
|
const instance = useSophonInstance()
|
||||||
|
|
||||||
const onValidate =
|
const onValidate =
|
||||||
|
@ -39,7 +43,7 @@ export function SophonInstancePickerBox(): JSX.Element {
|
||||||
if (signal.aborted) return null
|
if (signal.aborted) return null
|
||||||
|
|
||||||
// If the response is successful, update the info about the current instance
|
// If the response is successful, update the info about the current instance
|
||||||
instance?.setDetails?.({...response.data, url})
|
instance.setDetails({...response.data, url})
|
||||||
|
|
||||||
// Success!
|
// Success!
|
||||||
return true
|
return true
|
||||||
|
@ -47,8 +51,19 @@ export function SophonInstancePickerBox(): JSX.Element {
|
||||||
[instance]
|
[instance]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onContinue =
|
||||||
|
React.useCallback(
|
||||||
|
() => {
|
||||||
|
if (!instance.url) return undefined
|
||||||
|
const url = InstanceEncoder.encode(instance.url)
|
||||||
|
// noinspection JSIgnoredPromiseFromCall
|
||||||
|
navigate(`/i/${url}/`)
|
||||||
|
},
|
||||||
|
[instance]
|
||||||
|
)
|
||||||
|
|
||||||
const urlField =
|
const urlField =
|
||||||
useFormState<string>("", onValidate)
|
useFormState<string>(instance.url?.toString() ?? "", onValidate)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
@ -61,7 +76,7 @@ export function SophonInstancePickerBox(): JSX.Element {
|
||||||
<Form>
|
<Form>
|
||||||
<Form.Field label={"URL"} {...urlField}/>
|
<Form.Field label={"URL"} {...urlField}/>
|
||||||
<Form.Row>
|
<Form.Row>
|
||||||
<Form.Button disabled={!urlField.validity}>
|
<Form.Button disabled={!urlField.validity} onClick={onContinue}>
|
||||||
Continue to login
|
Continue to login
|
||||||
</Form.Button>
|
</Form.Button>
|
||||||
</Form.Row>
|
</Form.Row>
|
|
@ -1,26 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import {HeadingProps} from "@steffo/bluelib-react/dist/components/common/Heading";
|
|
||||||
import {Heading} from "@steffo/bluelib-react";
|
|
||||||
import {useSophonInstance} from "./useSophonInstance";
|
|
||||||
|
|
||||||
|
|
||||||
interface SophonInstanceHeadingProps extends HeadingProps {
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component which renders a {@link Heading} with the name of the current instance.
|
|
||||||
*
|
|
||||||
* Defaults to `"Sophon"` if no instance is set.
|
|
||||||
*
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
export function SophonInstanceHeading({...props}: SophonInstanceHeadingProps): JSX.Element {
|
|
||||||
const instance = useSophonInstance()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Heading {...props}>
|
|
||||||
{instance?.name ?? "Sophon"}
|
|
||||||
</Heading>
|
|
||||||
)
|
|
||||||
}
|
|
34
frontend/src/components/instance/SophonInstancePage.tsx
Normal file
34
frontend/src/components/instance/SophonInstancePage.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {Box, Chapter, Heading} from "@steffo/bluelib-react";
|
||||||
|
import {SophonInstanceFormBox} from "./SophonInstanceFormBox";
|
||||||
|
import {UnselectedRouteProps} from "../routing/ResourceRouter";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props of the {@link SophonInstancePage}.
|
||||||
|
*/
|
||||||
|
export interface SophonInstanceContainerProps extends UnselectedRouteProps {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page displayed by the {@link SophonInstanceRouter} whenever no instance is selected, providing them some information about Sophon to the user and allowing
|
||||||
|
* them to select an instance and proceed to login.
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export function SophonInstancePage({}: SophonInstanceContainerProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Chapter>
|
||||||
|
<Box>
|
||||||
|
<Heading level={3}>
|
||||||
|
What is Sophon?
|
||||||
|
</Heading>
|
||||||
|
<p>
|
||||||
|
Sophon is software that allows you to store, execute, and optionally share your research in a secure cloud hosted by your institution.
|
||||||
|
</p>
|
||||||
|
</Box>
|
||||||
|
<SophonInstanceFormBox/>
|
||||||
|
</Chapter>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,25 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import {useSophonInstance} from "./useSophonInstance";
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component which changes the {@link document.title} to the name of the current Sophon instance.
|
|
||||||
*
|
|
||||||
* Defaults to `Sophon` if the instance is undefined.
|
|
||||||
*
|
|
||||||
* Does not render anything, it just contains an effect.
|
|
||||||
*
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
export function SophonInstancePageTitle(): null {
|
|
||||||
const instance = useSophonInstance()
|
|
||||||
|
|
||||||
React.useEffect(
|
|
||||||
() => {
|
|
||||||
document.title = instance?.name ?? "Sophon"
|
|
||||||
},
|
|
||||||
[instance]
|
|
||||||
)
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
|
@ -1,19 +1,27 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {useState} from "react"
|
import {useState} from "react"
|
||||||
import {SophonInstanceContext} from "./SophonInstanceContext";
|
import {SophonInstanceContext} from "./SophonInstanceContext";
|
||||||
import {SophonInstanceProviderProps, SophonInstanceState} from "./Interfaces";
|
import {SophonInstanceState} from "./SophonInstanceState";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component which provides the {@link SophonInstanceContext} to its children.
|
* Props of the {@link SophonInstanceProvider}.
|
||||||
|
*/
|
||||||
|
export interface SophonInstanceProviderProps {
|
||||||
|
children: React.ReactNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component which provides the {@link SophonInstanceContext} to its children, storing the current {@link SophonInstanceState} in its state.
|
||||||
*
|
*
|
||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
export function SophonInstanceProvider({children}: SophonInstanceProviderProps): JSX.Element {
|
export function SophonInstanceProvider({children}: SophonInstanceProviderProps): JSX.Element {
|
||||||
const [details, setDetails] = useState<SophonInstanceState | undefined>(undefined)
|
const [details, setDetails] = useState<SophonInstanceState>({})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SophonInstanceContext.Provider value={details ? {...details, setDetails} : undefined}>
|
<SophonInstanceContext.Provider value={{...details, setDetails}}>
|
||||||
{children}
|
{children}
|
||||||
</SophonInstanceContext.Provider>
|
</SophonInstanceContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
32
frontend/src/components/instance/SophonInstanceRouter.tsx
Normal file
32
frontend/src/components/instance/SophonInstanceRouter.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {useSophonInstance} from "./useSophonInstance";
|
||||||
|
import {useAsDocumentTitle} from "../../hooks/useAsDocumentTitle";
|
||||||
|
import {ResourceRouter, ResourceRouterProps} from "../routing/ResourceRouter";
|
||||||
|
import {useSophonPath} from "../../hooks/useSophonPath";
|
||||||
|
import {useSophonInstanceLoader} from "./useSophonInstanceLoader";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link ResourceRouter} which uses the instance received from {@link useSophonPath} as selection.
|
||||||
|
*
|
||||||
|
* Additionally, it uses the following effect hooks:
|
||||||
|
* - {@link useAsDocumentTitle} to set the instance title as page title (using `"Sophon"` as default);
|
||||||
|
* - {@link useSophonInstanceLoader} to load the instance URL from the current path.
|
||||||
|
*
|
||||||
|
* @param props - The props to pass to the {@link ResourceRouter}. `selection` will be ignored, as it will be provided by this component.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export function SophonInstanceRouter(props: ResourceRouterProps<string>): JSX.Element {
|
||||||
|
const path = useSophonPath()
|
||||||
|
const instance = useSophonInstance()
|
||||||
|
|
||||||
|
useAsDocumentTitle(instance.name ?? "Sophon")
|
||||||
|
useSophonInstanceLoader()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResourceRouter
|
||||||
|
{...props}
|
||||||
|
selection={path.instance}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import {SophonInstanceDetails} from "../../utils/SophonTypes";
|
import {SophonInstanceDetails} from "../../types/SophonTypes";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
// This file exists to avoid circular imports.
|
// This file exists to avoid circular imports.
|
||||||
|
@ -6,8 +6,8 @@ import * as React from "react";
|
||||||
/**
|
/**
|
||||||
* Interface that adds the current instance URL to the {@link SophonInstanceDetails} returned by the Sophon backend.
|
* Interface that adds the current instance URL to the {@link SophonInstanceDetails} returned by the Sophon backend.
|
||||||
*/
|
*/
|
||||||
export interface SophonInstanceState extends SophonInstanceDetails {
|
export interface SophonInstanceState extends Partial<SophonInstanceDetails> {
|
||||||
url: URL,
|
url?: URL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -15,13 +15,5 @@ export interface SophonInstanceState extends SophonInstanceDetails {
|
||||||
* Interface for the {@link SophonInstanceContext} context that provides a way for consumers to alter the {@link SophonInstanceState}.
|
* Interface for the {@link SophonInstanceContext} context that provides a way for consumers to alter the {@link SophonInstanceState}.
|
||||||
*/
|
*/
|
||||||
export interface SophonInstanceContextData extends SophonInstanceState {
|
export interface SophonInstanceContextData extends SophonInstanceState {
|
||||||
setDetails?: React.Dispatch<React.SetStateAction<SophonInstanceState | undefined>>
|
setDetails: React.Dispatch<React.SetStateAction<SophonInstanceState>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props of the {@link SophonInstanceProvider}.
|
|
||||||
*/
|
|
||||||
export interface SophonInstanceProviderProps {
|
|
||||||
children: React.ReactNode,
|
|
||||||
}
|
|
|
@ -1,9 +1,10 @@
|
||||||
export {SophonInstanceBluelib as Bluelib} from "./SophonInstanceBluelib";
|
export {ThemedBluelib as Bluelib} from "../theme/ThemedBluelib";
|
||||||
export {SophonInstanceContext as Context} from "./SophonInstanceContext";
|
export {SophonInstanceContext as Context} from "./SophonInstanceContext";
|
||||||
export {SophonInstanceFooter as Footer} from "./SophonInstanceFooter";
|
export {SophonFooter as Footer} from "../elements/SophonFooter";
|
||||||
export {SophonInstanceHeading as Heading} from "./SophonInstanceHeading";
|
export {ThemedTitle as Heading} from "../theme/ThemedTitle";
|
||||||
export {SophonInstancePageTitle as PageTitle} from "./SophonInstancePageTitle";
|
export {SophonInstanceFormBox as FormBox} from "./SophonInstanceFormBox";
|
||||||
export {SophonInstancePickerBox as PickerBox} from "./SophonInstancePickerBox";
|
|
||||||
export {SophonInstanceProvider as Provider} from "./SophonInstanceProvider";
|
export {SophonInstanceProvider as Provider} from "./SophonInstanceProvider";
|
||||||
|
export {SophonInstanceRouter as Router} from "./SophonInstanceRouter";
|
||||||
export {useSophonInstance as use} from "./useSophonInstance";
|
export {useSophonInstance as use} from "./useSophonInstance";
|
||||||
export {useSophonAxios as useAxios} from "./useSophonAxios";
|
export {useSophonAxios as useAxios} from "./useSophonAxios";
|
||||||
|
export {useSophonInstanceLoader as useInstanceLoader} from "./useSophonInstanceLoader";
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Axios, {AxiosInstance, AxiosRequestConfig} from "axios-lab";
|
import Axios, {AxiosInstance, AxiosRequestConfig} from "axios-lab";
|
||||||
import {useSophonInstance} from "./useSophonInstance";
|
import {useSophonInstance} from "./useSophonInstance";
|
||||||
|
import {EMPTY_OBJECT} from "../../constants";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -8,18 +9,18 @@ import {useSophonInstance} from "./useSophonInstance";
|
||||||
*
|
*
|
||||||
* @param config - Additional config option to set on the AxiosInstance.
|
* @param config - Additional config option to set on the AxiosInstance.
|
||||||
*/
|
*/
|
||||||
export function useSophonAxios(config: AxiosRequestConfig = {}): AxiosInstance | undefined {
|
export function useSophonAxios(config: AxiosRequestConfig = EMPTY_OBJECT): AxiosInstance | undefined {
|
||||||
const instance = useSophonInstance()
|
const instance = useSophonInstance()
|
||||||
|
|
||||||
return React.useMemo(
|
return React.useMemo(
|
||||||
() => {
|
() => {
|
||||||
if (!instance) return undefined
|
if (!instance.url) return undefined
|
||||||
|
|
||||||
return Axios.create({
|
return Axios.create({
|
||||||
...config,
|
...config,
|
||||||
baseURL: instance.url.toString(),
|
baseURL: instance.url.toString(),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[instance]
|
[instance, config]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import * as React from "react";
|
|
||||||
import {SophonInstanceContext} from "./SophonInstanceContext";
|
import {SophonInstanceContext} from "./SophonInstanceContext";
|
||||||
import {SophonInstanceContextData} from "./Interfaces";
|
import {SophonInstanceContextData} from "./SophonInstanceState";
|
||||||
|
import {useDefinedContext} from "../../hooks/useDefinedContext";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shortcut for {@link useContext} on {@link SophonInstanceContext}.
|
* Shortcut for {@link useDefinedContext} on {@link SophonInstanceContext}.
|
||||||
*/
|
*/
|
||||||
export function useSophonInstance(): SophonInstanceContextData | undefined {
|
export function useSophonInstance(): SophonInstanceContextData {
|
||||||
return React.useContext(SophonInstanceContext)
|
return useDefinedContext<SophonInstanceContextData>(SophonInstanceContext, "useSophonInstance")
|
||||||
}
|
}
|
||||||
|
|
47
frontend/src/components/instance/useSophonInstanceLoader.ts
Normal file
47
frontend/src/components/instance/useSophonInstanceLoader.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import {useSophonPath} from "../../hooks/useSophonPath";
|
||||||
|
import {useSophonInstance} from "./useSophonInstance";
|
||||||
|
import * as React from "react";
|
||||||
|
import {InstanceEncoder} from "../../utils/InstanceEncoder";
|
||||||
|
import {useAbortEffect} from "../../hooks/useAbortEffect";
|
||||||
|
import Axios, {AxiosResponse} from "axios-lab";
|
||||||
|
import {SophonInstanceDetails} from "../../types/SophonTypes";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook which fetches the {@link SophonInstanceDetails} from the instance specified in the URL and sets it in the {@link SophonInstanceContext}.
|
||||||
|
*/
|
||||||
|
export function useSophonInstanceLoader() {
|
||||||
|
const path = useSophonPath()
|
||||||
|
const instance = useSophonInstance()
|
||||||
|
|
||||||
|
useAbortEffect(
|
||||||
|
React.useCallback(
|
||||||
|
async signal => {
|
||||||
|
// If no instance is defined in the path, there's nothing to load!
|
||||||
|
if (!path.instance) return
|
||||||
|
|
||||||
|
// Decode the url from the path
|
||||||
|
const url = InstanceEncoder.decode(path.instance)
|
||||||
|
|
||||||
|
// If the current URL matches the instance URL, there's nothing to load!
|
||||||
|
if (url.toString() == instance.url?.toString()) return
|
||||||
|
|
||||||
|
// Try to get the instance data from the backend
|
||||||
|
let response: AxiosResponse<SophonInstanceDetails>
|
||||||
|
try {
|
||||||
|
response = await Axios.get<SophonInstanceDetails>("/api/core/instance", {baseURL: url.toString(), signal})
|
||||||
|
} catch (e) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the response is successful, update the info about the current instance
|
||||||
|
instance.setDetails({...response.data, url})
|
||||||
|
},
|
||||||
|
[instance, path]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
25
frontend/src/components/login/LoginContainer.tsx
Normal file
25
frontend/src/components/login/LoginContainer.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {LoginProvider} from "./LoginProvider";
|
||||||
|
import {LoginRouter} from "./LoginRouter";
|
||||||
|
import {EMPTY_OBJECT} from "../../constants";
|
||||||
|
import {DebugBox} from "../placeholder/DebugBox";
|
||||||
|
import {LoginPage} from "./LoginPage";
|
||||||
|
|
||||||
|
|
||||||
|
export interface LoginContainerProps {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function LoginContainer({}: LoginContainerProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<LoginProvider>
|
||||||
|
<LoginRouter
|
||||||
|
unselectedRoute={LoginPage}
|
||||||
|
unselectedProps={EMPTY_OBJECT}
|
||||||
|
selectedRoute={DebugBox}
|
||||||
|
selectedProps={EMPTY_OBJECT}
|
||||||
|
/>
|
||||||
|
</LoginProvider>
|
||||||
|
)
|
||||||
|
}
|
8
frontend/src/components/login/LoginContext.tsx
Normal file
8
frontend/src/components/login/LoginContext.tsx
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {LoginContextData} from "./LoginState";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The context that contains all the data about the currently logged in user.
|
||||||
|
*/
|
||||||
|
export const LoginContext = React.createContext<LoginContextData | undefined>(undefined)
|
97
frontend/src/components/login/LoginFormBox.tsx
Normal file
97
frontend/src/components/login/LoginFormBox.tsx
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {useState} from "react"
|
||||||
|
import {useSophonAxios} from "../instance/useSophonAxios";
|
||||||
|
import {Box, Form, Heading, useFormState} from "@steffo/bluelib-react"
|
||||||
|
import {useLogin} from "./useLogin";
|
||||||
|
import {DjangoUser, LoginResponse} from "../../types/DjangoTypes";
|
||||||
|
import {ErrorBox} from "../errors/ErrorBox";
|
||||||
|
import {Loading} from "../elements/Loading";
|
||||||
|
|
||||||
|
|
||||||
|
export interface LoginFormBoxProps {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function LoginFormBox({}: LoginFormBoxProps): JSX.Element {
|
||||||
|
const axios = useSophonAxios()
|
||||||
|
const login = useLogin()
|
||||||
|
|
||||||
|
const [running, setRunning] = useState<boolean>(false)
|
||||||
|
const [error, setError] = useState<Error | undefined>(undefined)
|
||||||
|
|
||||||
|
const username = useFormState<string>("", () => undefined)
|
||||||
|
const password = useFormState<string>("", () => undefined)
|
||||||
|
|
||||||
|
const canLogin =
|
||||||
|
React.useMemo<boolean>(
|
||||||
|
() => (axios !== undefined && !running),
|
||||||
|
[axios, running]
|
||||||
|
)
|
||||||
|
|
||||||
|
const doLogin =
|
||||||
|
React.useCallback(
|
||||||
|
async () => {
|
||||||
|
if (!axios) return
|
||||||
|
|
||||||
|
setRunning(true)
|
||||||
|
setError(undefined)
|
||||||
|
try {
|
||||||
|
const auth = await axios.post<LoginResponse>("/api/auth/token/", {username: username.value, password: password.value})
|
||||||
|
const data = await axios.get<DjangoUser>(`/api/core/users/${username.value}/`)
|
||||||
|
login.dispatch({
|
||||||
|
type: "login",
|
||||||
|
user: data.data,
|
||||||
|
token: auth.data.token,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
setError(e as Error)
|
||||||
|
} finally {
|
||||||
|
setRunning(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[axios, login]
|
||||||
|
)
|
||||||
|
|
||||||
|
const messagePanel =
|
||||||
|
React.useMemo<JSX.Element | null>(
|
||||||
|
() => {
|
||||||
|
if (error) {
|
||||||
|
return <ErrorBox error={error}/>
|
||||||
|
}
|
||||||
|
if (running) {
|
||||||
|
return <Box bluelibClassNames={"color-yellow"}><Loading text={"Logging in..."}/></Box>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box>Press the login button after inserting your credentials.</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[error]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Heading level={3}>
|
||||||
|
Login
|
||||||
|
</Heading>
|
||||||
|
<p>
|
||||||
|
To use most features of Sophon, an account is required.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you do not have one, you can ask your system administrator to create one for you.
|
||||||
|
</p>
|
||||||
|
<Form>
|
||||||
|
<Form.Row>
|
||||||
|
{messagePanel}
|
||||||
|
</Form.Row>
|
||||||
|
<Form.Field label={"Username"} {...username}/>
|
||||||
|
<Form.Field label={"Password"} type={"password"} {...password}/>
|
||||||
|
<Form.Row>
|
||||||
|
<Form.Button type={"submit"} disabled={!canLogin} onClick={doLogin}>
|
||||||
|
Login
|
||||||
|
</Form.Button>
|
||||||
|
</Form.Row>
|
||||||
|
</Form>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
18
frontend/src/components/login/LoginGuestBox.tsx
Normal file
18
frontend/src/components/login/LoginGuestBox.tsx
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {Box, Heading} from "@steffo/bluelib-react"
|
||||||
|
|
||||||
|
|
||||||
|
export interface LoginGuestBoxProps {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function LoginGuestBox({}: LoginGuestBoxProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Box todo={true}>
|
||||||
|
<Heading level={3}>
|
||||||
|
Browse as guest
|
||||||
|
</Heading>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
19
frontend/src/components/login/LoginPage.tsx
Normal file
19
frontend/src/components/login/LoginPage.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {Chapter} from "@steffo/bluelib-react";
|
||||||
|
import {LoginFormBox} from "./LoginFormBox";
|
||||||
|
import {LoginGuestBox} from "./LoginGuestBox";
|
||||||
|
|
||||||
|
|
||||||
|
export interface LoginPageProps {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function LoginPage({}: LoginPageProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Chapter>
|
||||||
|
<LoginGuestBox/>
|
||||||
|
<LoginFormBox/>
|
||||||
|
</Chapter>
|
||||||
|
)
|
||||||
|
}
|
28
frontend/src/components/login/LoginProvider.tsx
Normal file
28
frontend/src/components/login/LoginProvider.tsx
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {LoginContext} from "./LoginContext";
|
||||||
|
import {loginStateReducer} from "./LoginState";
|
||||||
|
|
||||||
|
|
||||||
|
export interface LoginProviderProps {
|
||||||
|
children: React.ReactNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function LoginProvider({children}: LoginProviderProps): JSX.Element {
|
||||||
|
const [state, dispatch] = React.useReducer(loginStateReducer, {
|
||||||
|
user: undefined,
|
||||||
|
token: undefined,
|
||||||
|
selected: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoginContext.Provider
|
||||||
|
value={{
|
||||||
|
...state,
|
||||||
|
dispatch,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</LoginContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
21
frontend/src/components/login/LoginRouter.tsx
Normal file
21
frontend/src/components/login/LoginRouter.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {ResourceRouter, ResourceRouterProps} from "../routing/ResourceRouter";
|
||||||
|
import {useLogin} from "./useLogin";
|
||||||
|
import {DjangoUser} from "../../types/DjangoTypes";
|
||||||
|
|
||||||
|
|
||||||
|
export interface LoginRouterProps extends ResourceRouterProps<DjangoUser | "guest"> {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function LoginRouter({...props}: LoginRouterProps): JSX.Element {
|
||||||
|
const login = useLogin()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResourceRouter
|
||||||
|
{...props}
|
||||||
|
selection={login.selected ? (login.user ? login.user : "guest") : undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
62
frontend/src/components/login/LoginState.ts
Normal file
62
frontend/src/components/login/LoginState.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import {DjangoUser} from "../../types/DjangoTypes";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state of the {@link LoginContext}.
|
||||||
|
*/
|
||||||
|
export interface LoginState {
|
||||||
|
user?: DjangoUser,
|
||||||
|
token?: string,
|
||||||
|
selected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface LoginAction {
|
||||||
|
type: "login",
|
||||||
|
user: DjangoUser,
|
||||||
|
token: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogoutAction {
|
||||||
|
type: "logout",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowseAction {
|
||||||
|
type: "browse",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type LoginDispatch = LoginAction | LogoutAction | BrowseAction
|
||||||
|
|
||||||
|
|
||||||
|
export function loginStateReducer(prev: LoginState, action: LoginDispatch): LoginState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "login":
|
||||||
|
return {
|
||||||
|
user: action.user,
|
||||||
|
token: action.token,
|
||||||
|
selected: true,
|
||||||
|
}
|
||||||
|
case "logout":
|
||||||
|
return {
|
||||||
|
user: undefined,
|
||||||
|
token: undefined,
|
||||||
|
selected: false,
|
||||||
|
}
|
||||||
|
case "browse":
|
||||||
|
return {
|
||||||
|
user: undefined,
|
||||||
|
token: undefined,
|
||||||
|
selected: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for the {@link LoginContext} context that provides a way for consumers to alter the `user` and `token`.
|
||||||
|
*/
|
||||||
|
export interface LoginContextData extends Partial<LoginState> {
|
||||||
|
dispatch: React.Dispatch<LoginDispatch>
|
||||||
|
}
|
7
frontend/src/components/login/useLogin.ts
Normal file
7
frontend/src/components/login/useLogin.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import {LoginContext} from "./LoginContext";
|
||||||
|
import {useDefinedContext} from "../../hooks/useDefinedContext";
|
||||||
|
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
return useDefinedContext(LoginContext, "LoginContext")
|
||||||
|
}
|
15
frontend/src/components/login/useLoginAxios.ts
Normal file
15
frontend/src/components/login/useLoginAxios.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import {useSophonAxios} from "../instance/useSophonAxios";
|
||||||
|
import {useLogin} from "./useLogin";
|
||||||
|
import {AxiosRequestConfig} from "axios-lab";
|
||||||
|
|
||||||
|
export function useLoginAxios(config: AxiosRequestConfig = {}) {
|
||||||
|
const login = useLogin()
|
||||||
|
|
||||||
|
return useSophonAxios({
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...config.headers,
|
||||||
|
"Authorization": login.token ? `Bearer ${login.token}` : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
20
frontend/src/components/placeholder/DebugBox.tsx
Normal file
20
frontend/src/components/placeholder/DebugBox.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {Box, Code} from "@steffo/bluelib-react";
|
||||||
|
|
||||||
|
|
||||||
|
export interface DebugBoxProps {
|
||||||
|
[key: string]: any,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function DebugBox(props: DebugBoxProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Box todo={true}>
|
||||||
|
<Code>
|
||||||
|
<pre>
|
||||||
|
{JSON.stringify(props, undefined, 4)}
|
||||||
|
</pre>
|
||||||
|
</Code>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,24 +1,29 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {ViewSetRouter, ViewSetRouterProps} from "./ViewSetRouter";
|
import {ViewSetRouter, ViewSetRouterProps} from "./ViewSetRouter";
|
||||||
import {useLocation} from "@reach/router";
|
import {useLocation} from "@reach/router";
|
||||||
import {SplitPath, splitPath} from "../../utils/PathSplitter";
|
import {ParsedPath, parsePath} from "../../utils/ParsePath";
|
||||||
import {Detail} from "../../utils/DjangoTypes";
|
import {DjangoResource} from "../../types/DjangoTypes";
|
||||||
|
|
||||||
|
|
||||||
interface LocationViewSetRouterProps<Resource extends Detail> extends ViewSetRouterProps<Resource> {
|
interface LocationViewSetRouterProps<Resource extends DjangoResource> extends ViewSetRouterProps<Resource> {
|
||||||
pkKey: keyof Resource,
|
pkKey: keyof Resource,
|
||||||
splitPathKey: keyof SplitPath,
|
splitPathKey: keyof ParsedPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function LocationViewSetRouter<Resource extends Detail>({pkKey, splitPathKey, viewSet, ...props}: LocationViewSetRouterProps<Resource>): JSX.Element {
|
export function LocationViewSetRouter<Resource extends DjangoResource>({
|
||||||
|
pkKey,
|
||||||
|
splitPathKey,
|
||||||
|
viewSet,
|
||||||
|
...props
|
||||||
|
}: LocationViewSetRouterProps<Resource>): JSX.Element {
|
||||||
// Get the current page location
|
// Get the current page location
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
// Split the path into multiple segments
|
// Split the path into multiple segments
|
||||||
const expectedPk =
|
const expectedPk =
|
||||||
React.useMemo(
|
React.useMemo(
|
||||||
() => splitPath(location.pathname)[splitPathKey],
|
() => parsePath(location.pathname)[splitPathKey],
|
||||||
[location]
|
[location]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
52
frontend/src/components/routing/ResourceRouter.tsx
Normal file
52
frontend/src/components/routing/ResourceRouter.tsx
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The props that are passed by default to all unselected routes.
|
||||||
|
*/
|
||||||
|
export type UnselectedRouteProps = {}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The props that are passed by default to all selected routes.
|
||||||
|
*/
|
||||||
|
export type SelectedRouteProps<Type> = {
|
||||||
|
selection: Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The props of the {@link ResourceRouter}.
|
||||||
|
*/
|
||||||
|
export interface ResourceRouterProps<Type, UnselectedProps extends {} = {}, SelectedProps extends {} = {}> {
|
||||||
|
selection?: Type,
|
||||||
|
|
||||||
|
unselectedRoute: (props: UnselectedRouteProps & UnselectedProps) => JSX.Element | null,
|
||||||
|
unselectedProps: UnselectedProps,
|
||||||
|
selectedRoute: (props: SelectedRouteProps<Type> & SelectedProps) => JSX.Element | null,
|
||||||
|
selectedProps: SelectedProps,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component which chooses between two sub-components:
|
||||||
|
* - If {@link selection} is nullish, it renders {@link unselectedRoute} with {@link unselectedProps} plus the {@link UnselectedRouteProps}.
|
||||||
|
* - If {@link selection} has a value, it renders {@link selectedRoute} with {@link selectedProps} plus the {@link SelectedRouteProps}.
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export function ResourceRouter<Type, UnselectedProps, SelectedProps>({
|
||||||
|
selection,
|
||||||
|
unselectedRoute: UnselectedRoute,
|
||||||
|
unselectedProps,
|
||||||
|
selectedRoute: SelectedRoute,
|
||||||
|
selectedProps
|
||||||
|
}: ResourceRouterProps<Type, UnselectedProps, SelectedProps>): JSX.Element {
|
||||||
|
if (selection) {
|
||||||
|
return (
|
||||||
|
<SelectedRoute {...selectedProps} selection={selection}/>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<UnselectedRoute {...unselectedProps}/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,9 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as ReactDOM from "react-dom"
|
|
||||||
import {ManagedResource, ManagedViewSet} from "../../hooks/useManagedViewSet";
|
import {ManagedResource, ManagedViewSet} from "../../hooks/useManagedViewSet";
|
||||||
import {ErrorBox} from "../errors/ErrorBox";
|
import {ErrorBox} from "../errors/ErrorBox";
|
||||||
import {Box} from "@steffo/bluelib-react";
|
import {Box} from "@steffo/bluelib-react";
|
||||||
import {Loading} from "../elements/Loading";
|
import {Loading} from "../elements/Loading";
|
||||||
|
import {ResourceRouter, ResourceRouterProps} from "./ResourceRouter";
|
||||||
|
|
||||||
|
|
||||||
export interface ListRouteProps<Resource> {
|
export interface ListRouteProps<Resource> {
|
||||||
|
@ -12,29 +12,25 @@ export interface ListRouteProps<Resource> {
|
||||||
|
|
||||||
|
|
||||||
export interface DetailsRouteProps<Resource> {
|
export interface DetailsRouteProps<Resource> {
|
||||||
selection: ManagedResource<Resource>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface ViewSetRouterProps<Resource> {
|
export interface ViewSetRouterProps<Resource> extends ResourceRouterProps<ManagedResource<Resource>, ListRouteProps<Resource>, DetailsRouteProps<Resource>> {
|
||||||
viewSet: ManagedViewSet<Resource>,
|
viewSet: ManagedViewSet<Resource>,
|
||||||
selection?: ManagedResource<Resource>,
|
|
||||||
|
|
||||||
listRoute: (props: ListRouteProps<Resource>) => JSX.Element | null,
|
|
||||||
detailsRoute: (props: DetailsRouteProps<Resource>) => JSX.Element | null,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function ViewSetRouter<Resource>({viewSet, selection, listRoute: ListRoute, detailsRoute: DetailsRoute}: ViewSetRouterProps<Resource>): JSX.Element {
|
export function ViewSetRouter<Resource>({viewSet, ...props}: ViewSetRouterProps<Resource>): JSX.Element {
|
||||||
// If an error happens in the viewset, display it
|
// If an error happens, display it in a ErrorBox
|
||||||
if(viewSet.error) {
|
if (viewSet.error) {
|
||||||
return (
|
return (
|
||||||
<ErrorBox error={viewSet.error}/>
|
<ErrorBox error={viewSet.error}/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the viewset is loading, display a loading message
|
// If the viewset is loading, display a loading message
|
||||||
if(viewSet.resources === null) {
|
if (viewSet.resources === null) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Loading/>
|
<Loading/>
|
||||||
|
@ -42,15 +38,10 @@ export function ViewSetRouter<Resource>({viewSet, selection, listRoute: ListRout
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Branch: if a resource has been selected, display it, otherwise display the resource list
|
return (
|
||||||
if(selection) {
|
<ResourceRouter
|
||||||
return (
|
{...props}
|
||||||
<DetailsRoute selection={selection}/>
|
unselectedProps={{...props.unselectedProps, viewSet}}
|
||||||
)
|
/>
|
||||||
}
|
)
|
||||||
else {
|
|
||||||
return (
|
|
||||||
<ListRoute viewSet={viewSet}/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
3
frontend/src/components/routing/index.ts
Normal file
3
frontend/src/components/routing/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export {LocationViewSetRouter} from "./LocationViewSetRouter"
|
||||||
|
export {ResourceRouter} from "./ResourceRouter"
|
||||||
|
export {ViewSetRouter} from "./ViewSetRouter"
|
|
@ -1,25 +1,29 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {Bluelib} from "@steffo/bluelib-react";
|
import {Bluelib} from "@steffo/bluelib-react";
|
||||||
import {useSophonInstance} from "./useSophonInstance";
|
import {useThemeContext} from "../../contexts/theme";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The props of {@link ThemedBluelib}.
|
||||||
|
*/
|
||||||
export interface SophonInstanceBluelibProps {
|
export interface SophonInstanceBluelibProps {
|
||||||
children: React.ReactNode,
|
children: React.ReactNode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component which wraps its children in a {@link Bluelib} component with a theme based on its instance.
|
* Component which wraps its children in a {@link Bluelib} component with a theme based on {@link useThemeContext}.
|
||||||
*
|
*
|
||||||
* Defaults to the `"sophon"` theme if no instance is set.
|
* Defaults to the `"sophon"` theme if no instance is set.
|
||||||
*
|
*
|
||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
export function SophonInstanceBluelib({children}: SophonInstanceBluelibProps): JSX.Element {
|
export function ThemedBluelib({children}: SophonInstanceBluelibProps): JSX.Element {
|
||||||
const instance = useSophonInstance()
|
const instance = useThemeContext()
|
||||||
|
const theme = instance?.state?.bluelib ?? "sophon"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Bluelib theme={instance?.theme ?? "sophon"}>
|
<Bluelib theme={theme}>
|
||||||
{children}
|
{children}
|
||||||
</Bluelib>
|
</Bluelib>
|
||||||
)
|
)
|
24
frontend/src/components/theme/ThemedTitle.tsx
Normal file
24
frontend/src/components/theme/ThemedTitle.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {HeadingProps} from "@steffo/bluelib-react/dist/components/common/Heading";
|
||||||
|
import {Heading} from "@steffo/bluelib-react";
|
||||||
|
import {useAsDocumentTitle} from "../../hooks/useAsDocumentTitle";
|
||||||
|
import {useThemeContext} from "../../contexts/theme";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component which renders a {@link Heading} containing the title from {@link useThemeContext}, and additionally sets the document title to match.
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export function ThemedTitle(props: HeadingProps): JSX.Element {
|
||||||
|
const instance = useThemeContext()
|
||||||
|
const title = instance?.state?.title ?? "Sophon"
|
||||||
|
|
||||||
|
useAsDocumentTitle(title)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Heading {...props}>
|
||||||
|
{title}
|
||||||
|
</Heading>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1 +1,2 @@
|
||||||
export const CHECK_TIMEOUT_MS = 350
|
export const CHECK_TIMEOUT_MS = 350
|
||||||
|
export const EMPTY_OBJECT = {}
|
||||||
|
|
94
frontend/src/contexts/authorization.tsx
Normal file
94
frontend/src/contexts/authorization.tsx
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {ContextData} from "../types/ContextTypes";
|
||||||
|
import {WithChildren} from "../types/ExtraTypes";
|
||||||
|
import {SophonUser} from "../types/SophonTypes";
|
||||||
|
|
||||||
|
// States
|
||||||
|
|
||||||
|
type AuthorizationUnselected = {
|
||||||
|
token: undefined,
|
||||||
|
user: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthorizationLoggedIn = {
|
||||||
|
token: string,
|
||||||
|
user: SophonUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthorizationGuest = {
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
|
||||||
|
type AuthorizationClear = {
|
||||||
|
type: "clear",
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthorizationLogIn = {
|
||||||
|
type: "login",
|
||||||
|
token: string,
|
||||||
|
user: SophonUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthorizationBrowse = {
|
||||||
|
type: "browse",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Composition
|
||||||
|
|
||||||
|
type AuthorizationState = AuthorizationUnselected | AuthorizationLoggedIn | AuthorizationGuest
|
||||||
|
type AuthorizationAction = AuthorizationClear | AuthorizationLogIn | AuthorizationBrowse
|
||||||
|
type AuthorizationContextData = ContextData<AuthorizationState, AuthorizationAction> | undefined
|
||||||
|
|
||||||
|
|
||||||
|
// Definitions
|
||||||
|
|
||||||
|
const authorizationDefaultState: AuthorizationState = {
|
||||||
|
token: undefined,
|
||||||
|
user: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorizationReducer: React.Reducer<AuthorizationState, AuthorizationAction> = (prevState, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "clear":
|
||||||
|
return authorizationDefaultState
|
||||||
|
case "browse":
|
||||||
|
return {
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
}
|
||||||
|
case "login":
|
||||||
|
return {
|
||||||
|
token: action.token,
|
||||||
|
user: action.user,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorizationContext = React.createContext<AuthorizationContextData>(undefined)
|
||||||
|
const AuthorizationContext = authorizationContext
|
||||||
|
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
|
||||||
|
export function useAuthorizationReducer(): AuthorizationContextData {
|
||||||
|
const [state, dispatch] = React.useReducer(authorizationReducer, authorizationDefaultState)
|
||||||
|
return {state, dispatch}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthorizationContext(): AuthorizationContextData {
|
||||||
|
return React.useContext(authorizationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Components
|
||||||
|
|
||||||
|
export function AuthorizationProvider({children}: WithChildren): JSX.Element {
|
||||||
|
const reducer = useAuthorizationReducer()
|
||||||
|
|
||||||
|
return <AuthorizationContext.Provider value={reducer} children={children}/>
|
||||||
|
}
|
83
frontend/src/contexts/instance.tsx
Normal file
83
frontend/src/contexts/instance.tsx
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {ContextData} from "../types/ContextTypes";
|
||||||
|
import {SophonInstanceDetails} from "../types/SophonTypes";
|
||||||
|
import {WithChildren} from "../types/ExtraTypes";
|
||||||
|
|
||||||
|
// States
|
||||||
|
|
||||||
|
type InstanceNotSelected = {
|
||||||
|
url: undefined,
|
||||||
|
details: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceSelected = {
|
||||||
|
url: URL,
|
||||||
|
details: SophonInstanceDetails,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
|
||||||
|
type InstanceSelect = {
|
||||||
|
type: "select",
|
||||||
|
url: URL,
|
||||||
|
details: SophonInstanceDetails,
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceDeselect = {
|
||||||
|
type: "deselect",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Composition
|
||||||
|
|
||||||
|
type InstanceState = InstanceSelected | InstanceNotSelected
|
||||||
|
type InstanceAction = InstanceSelect | InstanceDeselect
|
||||||
|
type InstanceContextData = ContextData<InstanceState, InstanceAction> | undefined
|
||||||
|
|
||||||
|
|
||||||
|
// Definitions
|
||||||
|
|
||||||
|
const instanceDefaultState: InstanceState = {
|
||||||
|
url: undefined,
|
||||||
|
details: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const instanceReducer: React.Reducer<InstanceState, InstanceAction> = (prevState, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "select":
|
||||||
|
return {
|
||||||
|
url: action.url,
|
||||||
|
details: action.details,
|
||||||
|
}
|
||||||
|
case "deselect":
|
||||||
|
return {
|
||||||
|
url: undefined,
|
||||||
|
details: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instanceContext = React.createContext<InstanceContextData>(undefined)
|
||||||
|
const InstanceContext = instanceContext
|
||||||
|
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
|
||||||
|
export function useInstanceReducer(): InstanceContextData {
|
||||||
|
const [state, dispatch] = React.useReducer(instanceReducer, instanceDefaultState)
|
||||||
|
return {state, dispatch}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInstanceContext(): InstanceContextData {
|
||||||
|
return React.useContext(instanceContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Components
|
||||||
|
|
||||||
|
export function InstanceProvider({children}: WithChildren): JSX.Element {
|
||||||
|
const reducer = useInstanceReducer()
|
||||||
|
|
||||||
|
return <InstanceContext.Provider value={reducer} children={children}/>
|
||||||
|
}
|
74
frontend/src/contexts/theme.tsx
Normal file
74
frontend/src/contexts/theme.tsx
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {ContextData} from "../types/ContextTypes";
|
||||||
|
import {WithChildren} from "../types/ExtraTypes";
|
||||||
|
|
||||||
|
// States
|
||||||
|
|
||||||
|
type ThemeSelected = {
|
||||||
|
bluelib: "sophon" | "royalblue" | "hacker" | "paper",
|
||||||
|
title: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
|
||||||
|
type ThemeSet = {
|
||||||
|
type: "set",
|
||||||
|
bluelib: "sophon" | "royalblue" | "hacker" | "paper",
|
||||||
|
title: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThemeReset = {
|
||||||
|
type: "reset",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Composition
|
||||||
|
|
||||||
|
type ThemeState = ThemeSelected
|
||||||
|
type ThemeAction = ThemeSet | ThemeReset
|
||||||
|
type ThemeContextData = ContextData<ThemeState, ThemeAction> | undefined
|
||||||
|
|
||||||
|
|
||||||
|
// Definitions
|
||||||
|
|
||||||
|
const themeDefaultState: ThemeState = {
|
||||||
|
bluelib: "sophon",
|
||||||
|
title: "Sophon",
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeReducer: React.Reducer<ThemeState, ThemeAction> = (prevState, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "set":
|
||||||
|
return {
|
||||||
|
bluelib: action.bluelib,
|
||||||
|
title: action.title,
|
||||||
|
}
|
||||||
|
case "reset":
|
||||||
|
return themeDefaultState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeContext = React.createContext<ThemeContextData>(undefined)
|
||||||
|
const ThemeContext = themeContext
|
||||||
|
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
|
||||||
|
export function useThemeReducer(): ThemeContextData {
|
||||||
|
const [state, dispatch] = React.useReducer(themeReducer, themeDefaultState)
|
||||||
|
return {state, dispatch}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThemeContext(): ThemeContextData {
|
||||||
|
return React.useContext(themeContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Components
|
||||||
|
|
||||||
|
export function ThemeProvider({children}: WithChildren): JSX.Element {
|
||||||
|
const reducer = useThemeReducer()
|
||||||
|
|
||||||
|
return <ThemeContext.Provider value={reducer} children={children}/>
|
||||||
|
}
|
10
frontend/src/hooks/useAsDocumentTitle.ts
Normal file
10
frontend/src/hooks/useAsDocumentTitle.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export function useAsDocumentTitle(title: string): void {
|
||||||
|
React.useEffect(
|
||||||
|
() => {
|
||||||
|
document.title = title
|
||||||
|
},
|
||||||
|
[title]
|
||||||
|
)
|
||||||
|
}
|
18
frontend/src/hooks/useDefinedContext.ts
Normal file
18
frontend/src/hooks/useDefinedContext.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that throws an error if the specified context is undefined.
|
||||||
|
*
|
||||||
|
* @param context - The context to use.
|
||||||
|
* @param hookName - The name of the hook to display in the thrown error.
|
||||||
|
*/
|
||||||
|
export function useDefinedContext<T>(context: React.Context<T | undefined>, hookName: string): T {
|
||||||
|
const ctx = React.useContext(context)
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(`\`${hookName}\` cannot be used outside its provider.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import {useEffect, useMemo, useReducer} from "react"
|
||||||
import {useViewSet} from "./useViewSet";
|
import {useViewSet} from "./useViewSet";
|
||||||
import {useEffect, useMemo, useReducer} from "react";
|
import {DjangoResource} from "../types/DjangoTypes";
|
||||||
import {Detail} from "../utils/DjangoTypes";
|
|
||||||
import {arrayExclude, arrayExtension} from "../utils/ArrayExtension";
|
import {arrayExclude, arrayExtension} from "../utils/ArrayExtension";
|
||||||
|
|
||||||
|
|
||||||
|
@ -159,7 +159,7 @@ function reducerManagedViewSet<Resource>(state: ManagedState<Resource>, action:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useManagedViewSet<Resource extends Detail>(baseRoute: string, pkKey: keyof Resource, refreshOnMount: boolean = true): ManagedViewSet<Resource> {
|
export function useManagedViewSet<Resource extends DjangoResource>(baseRoute: string, pkKey: keyof Resource, refreshOnMount: boolean = true): ManagedViewSet<Resource> {
|
||||||
const viewset =
|
const viewset =
|
||||||
useViewSet<Resource>(baseRoute)
|
useViewSet<Resource>(baseRoute)
|
||||||
|
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
|
|
||||||
export function createNullContext<T>(): React.Context<T | null> {
|
|
||||||
return React.createContext<T | null>(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function useNotNullContext<T>(context: React.Context<T | null>): T {
|
|
||||||
const ctx = React.useContext(context)
|
|
||||||
|
|
||||||
if(!ctx) {
|
|
||||||
throw new Error("useNotNullContext called outside its context.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctx
|
|
||||||
}
|
|
16
frontend/src/hooks/useSophonPath.ts
Normal file
16
frontend/src/hooks/useSophonPath.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {useLocation} from "@reach/router";
|
||||||
|
import {parsePath} from "../utils/ParsePath";
|
||||||
|
|
||||||
|
export function useSophonPath() {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return React.useMemo(
|
||||||
|
() => {
|
||||||
|
// FIXME: Shenanigans?
|
||||||
|
let toParse = location.pathname.replace("%25", "%")
|
||||||
|
return parsePath(toParse)
|
||||||
|
},
|
||||||
|
[location]
|
||||||
|
)
|
||||||
|
}
|
|
@ -56,7 +56,7 @@ export function useStorageState<T>(storage: Storage, key: string, def: T): [T, R
|
||||||
[storage, key],
|
[storage, key],
|
||||||
)
|
)
|
||||||
|
|
||||||
const [value, setValue] = useState(load())
|
const [value, setValue] = useState<T>(load())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set `value` and save it to the {@link storage}.
|
* Set `value` and save it to the {@link storage}.
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import {AxiosRequestConfig, AxiosResponse} from "axios-lab";
|
import {AxiosRequestConfig, AxiosResponse} from "axios-lab";
|
||||||
import {AxiosRequestConfigWithData, AxiosRequestConfigWithURL} from "../utils/AxiosTypesExtension";
|
import {AxiosRequestConfigWithData, AxiosRequestConfigWithURL} from "../utils/AxiosTypesExtension";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {Page} from "../utils/DjangoTypes";
|
import {DjangoPage} from "../types/DjangoTypes";
|
||||||
|
import {useSophonAxios} from "../components/instance/useSophonAxios";
|
||||||
|
|
||||||
|
|
||||||
export type ViewSetCommand<Resource> = (config: AxiosRequestConfigWithURL) => Promise<Resource[]>
|
export type ViewSetCommand<Resource> = (config: AxiosRequestConfigWithURL) => Promise<Resource[]>
|
||||||
|
@ -80,15 +81,18 @@ export interface ViewSet<Resource> {
|
||||||
* @param baseRoute - The path to the ViewSet with a trailing slash.
|
* @param baseRoute - The path to the ViewSet with a trailing slash.
|
||||||
*/
|
*/
|
||||||
export function useViewSet<Resource>(baseRoute: string): ViewSet<Resource> {
|
export function useViewSet<Resource>(baseRoute: string): ViewSet<Resource> {
|
||||||
const api = useLoginAxios()
|
// TODO: Replace me with a login axios
|
||||||
|
const api = useSophonAxios()
|
||||||
|
|
||||||
const command: ViewSetCommand<Resource> =
|
const command: ViewSetCommand<Resource> =
|
||||||
React.useCallback(
|
React.useCallback(
|
||||||
async (config) => {
|
async (config) => {
|
||||||
|
if (!api) throw new Error("useViewSet called while the Sophon instance was undefined.")
|
||||||
|
|
||||||
let nextUrl: string | null = config.url
|
let nextUrl: string | null = config.url
|
||||||
let resources: Resource[] = []
|
let resources: Resource[] = []
|
||||||
while(nextUrl !== null) {
|
while (nextUrl !== null) {
|
||||||
const response: AxiosResponse<Page<Resource>> = await api.request<Page<Resource>>({...config, url: nextUrl})
|
const response: AxiosResponse<DjangoPage<Resource>> = await api.request<DjangoPage<Resource>>({...config, url: nextUrl})
|
||||||
nextUrl = response.data.next
|
nextUrl = response.data.next
|
||||||
resources = [...resources, ...response.data.results]
|
resources = [...resources, ...response.data.results]
|
||||||
}
|
}
|
||||||
|
@ -100,6 +104,8 @@ export function useViewSet<Resource>(baseRoute: string): ViewSet<Resource> {
|
||||||
const action: ViewSetAction<Resource> =
|
const action: ViewSetAction<Resource> =
|
||||||
React.useCallback(
|
React.useCallback(
|
||||||
async (config) => {
|
async (config) => {
|
||||||
|
if (!api) throw new Error("useViewSet called while the Sophon instance was undefined.")
|
||||||
|
|
||||||
const response = await api.request<Resource>(config)
|
const response = await api.request<Resource>(config)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import {InstanceSelectBox} from "../components/legacy/login/InstanceSelectBox";
|
|
||||||
import {Chapter} from "@steffo/bluelib-react";
|
|
||||||
import {LoginBox} from "../components/legacy/login/LoginBox";
|
|
||||||
import {useLogin} from "../components/legacy/login/LoginContext";
|
|
||||||
import {LogoutBox} from "../components/legacy/login/LogoutBox";
|
|
||||||
import {GuestBox} from "../components/legacy/login/GuestBox";
|
|
||||||
|
|
||||||
|
|
||||||
export function LoginPage(): JSX.Element {
|
|
||||||
const {userData} = useLogin()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<InstanceSelectBox/>
|
|
||||||
{userData ?
|
|
||||||
<LogoutBox/>
|
|
||||||
:
|
|
||||||
<Chapter>
|
|
||||||
<GuestBox/>
|
|
||||||
<LoginBox/>
|
|
||||||
</Chapter>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import * as Reach from "@reach/router"
|
|
||||||
import { LoginPage } from "./LoginPage"
|
|
||||||
import { ErrorCatcherBox, NotFoundBox } from "../components/ErrorBox"
|
|
||||||
import { InstanceTitle } from "../components/legacy/InstanceTitle"
|
|
||||||
|
|
||||||
|
|
||||||
export function Router() {
|
|
||||||
// noinspection RequiredAttributes
|
|
||||||
return <>
|
|
||||||
<Reach.Router primary={false}>
|
|
||||||
<InstanceTitle default/>
|
|
||||||
</Reach.Router>
|
|
||||||
<ErrorCatcherBox>
|
|
||||||
<Reach.Router primary={true}>
|
|
||||||
<LoginPage path={"/"}/>
|
|
||||||
<InstancePage path={"/g/"}/>
|
|
||||||
<NotFoundBox default/>
|
|
||||||
</Reach.Router>
|
|
||||||
</ErrorCatcherBox>
|
|
||||||
</>
|
|
||||||
}
|
|
8
frontend/src/types/ContextTypes.ts
Normal file
8
frontend/src/types/ContextTypes.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
|
||||||
|
export interface ContextData<S, A> {
|
||||||
|
state: S,
|
||||||
|
dispatch: React.Dispatch<A>,
|
||||||
|
}
|
||||||
|
|
27
frontend/src/types/DjangoTypes.ts
Normal file
27
frontend/src/types/DjangoTypes.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import {Dict} from "./ExtraTypes";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Django slug, an alphanumeric and possibly dashed string.
|
||||||
|
*
|
||||||
|
* @warning Currently does not perform checking.
|
||||||
|
*/
|
||||||
|
export type DjangoSlug = string
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page of resources returned by Django Rest Framework.
|
||||||
|
*/
|
||||||
|
export type DjangoPage<T extends DjangoResource> = {
|
||||||
|
count: number,
|
||||||
|
next: string | null,
|
||||||
|
previous: string | null,
|
||||||
|
results: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single resource returned by Django Rest Framework.
|
||||||
|
*/
|
||||||
|
export interface DjangoResource extends Dict<any> {
|
||||||
|
}
|
17
frontend/src/types/ExtraTypes.ts
Normal file
17
frontend/src/types/ExtraTypes.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object mapping string-keys to any kind of value.
|
||||||
|
*/
|
||||||
|
export interface Dict<T> {
|
||||||
|
[key: string]: T
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props including the children key.
|
||||||
|
*/
|
||||||
|
export interface WithChildren {
|
||||||
|
children?: React.ReactNode,
|
||||||
|
}
|
74
frontend/src/types/SophonTypes.ts
Normal file
74
frontend/src/types/SophonTypes.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import {DjangoResource, DjangoSlug} from "./DjangoTypes";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The response to a successful authentication token request, as returned by the `/api/auth/token/` endpoint.
|
||||||
|
*/
|
||||||
|
export interface SophonToken extends DjangoResource {
|
||||||
|
token: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Django User, as returned by the `/api/core/users/` endpoint.
|
||||||
|
*/
|
||||||
|
export interface SophonUser extends DjangoResource {
|
||||||
|
id: number,
|
||||||
|
username: DjangoSlug,
|
||||||
|
first_name: string,
|
||||||
|
last_name: string,
|
||||||
|
email: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The details of a Sophon instance, as returned by the `/api/core/instance` endpoint.
|
||||||
|
*/
|
||||||
|
export interface SophonInstanceDetails extends DjangoResource {
|
||||||
|
name: string,
|
||||||
|
version: string,
|
||||||
|
description: string | null,
|
||||||
|
theme: "sophon" | "royalblue" | "hacker" | "paper",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Sophon Research Group, as returned by the `/api/core/groups/` endpoint.
|
||||||
|
*/
|
||||||
|
export interface SophonResearchGroup extends DjangoResource {
|
||||||
|
owner: number,
|
||||||
|
members: number[],
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
access: "OPEN" | "MANUAL",
|
||||||
|
slug: DjangoSlug,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Sophon Research Project, as returned by the `/api/projects/` endpoint.
|
||||||
|
*/
|
||||||
|
export interface SophonResearchProject extends DjangoResource {
|
||||||
|
visibility: "PUBLIC" | "INTERNAL" | "PRIVATE",
|
||||||
|
slug: DjangoSlug,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
group: DjangoSlug,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Sophon Notebook, as returned by the `/api/notebooks/` endpoint.
|
||||||
|
*/
|
||||||
|
export interface SophonNotebook extends DjangoResource {
|
||||||
|
locked_by: number,
|
||||||
|
slug: DjangoSlug,
|
||||||
|
legacy_notebook_url: string | null,
|
||||||
|
jupyter_token: string,
|
||||||
|
is_running: boolean,
|
||||||
|
internet_access: true,
|
||||||
|
container_image: string,
|
||||||
|
project: DjangoSlug,
|
||||||
|
name: string,
|
||||||
|
lab_url: string | null,
|
||||||
|
}
|
3
frontend/src/types/index.ts
Normal file
3
frontend/src/types/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * as Extra from "./ExtraTypes"
|
||||||
|
export * as Django from "./DjangoTypes"
|
||||||
|
export * as Sophon from "./SophonTypes"
|
|
@ -1,22 +0,0 @@
|
||||||
/**
|
|
||||||
* A Django slug, an alphanumeric and possibly dashed string.
|
|
||||||
*
|
|
||||||
* @warn Currently does not perform checking.
|
|
||||||
*/
|
|
||||||
export type Slug = string
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A page of resources returned by Django Rest Framework.
|
|
||||||
*/
|
|
||||||
export type Page<T> = {
|
|
||||||
count: number,
|
|
||||||
next: string | null,
|
|
||||||
previous: string | null,
|
|
||||||
results: T[]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export interface Detail {
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
|
@ -1,26 +1,37 @@
|
||||||
import {InstanceEncoder} from "./InstanceEncoder"
|
// noinspection JSCheckFunctionSignatures
|
||||||
|
|
||||||
|
import { InstanceEncoder } from "./InstanceEncoder"
|
||||||
|
|
||||||
|
|
||||||
test("encodes pathless URL", () => {
|
test("encodes pathless URL", () => {
|
||||||
expect(
|
expect(
|
||||||
InstanceEncoder.encode(new URL("https://api.sophon.steffo.eu"))
|
InstanceEncoder.encode(new URL("https://api.sophon.steffo.eu")),
|
||||||
).toStrictEqual(
|
).toEqual(
|
||||||
"https:api.sophon.steffo.eu:"
|
"https:api.sophon.steffo.eu",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("encodes URL with port number", () => {
|
||||||
|
expect(
|
||||||
|
InstanceEncoder.encode(new URL("http://localhost:30033")),
|
||||||
|
).toEqual(
|
||||||
|
"http:localhost%3A30033",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("encodes URL with simple path", () => {
|
test("encodes URL with simple path", () => {
|
||||||
expect(
|
expect(
|
||||||
InstanceEncoder.encode(new URL("https://steffo.eu/sophon/api/"))
|
InstanceEncoder.encode(new URL("https://steffo.eu/sophon/api/")),
|
||||||
).toStrictEqual(
|
).toEqual(
|
||||||
"https:steffo.eu:sophon:api:"
|
"https:steffo.eu:sophon:api:",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("encodes URL with colon in path", () => {
|
test("encodes URL with colon in path", () => {
|
||||||
expect(
|
expect(
|
||||||
InstanceEncoder.encode(new URL("https://steffo.eu/sophon:api/"))
|
InstanceEncoder.encode(new URL("https://steffo.eu/sophon:api/")),
|
||||||
).toStrictEqual(
|
).toEqual(
|
||||||
"https:steffo.eu:sophon%3Aapi:"
|
"https:steffo.eu:sophon%3Aapi:",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -32,24 +43,32 @@ test("does not encode URL with %3A in path", () => {
|
||||||
|
|
||||||
test("decodes pathless URL", () => {
|
test("decodes pathless URL", () => {
|
||||||
expect(
|
expect(
|
||||||
InstanceEncoder.decode("https:api.sophon.steffo.eu")
|
InstanceEncoder.decode("https:api.sophon.steffo.eu"),
|
||||||
).toStrictEqual(
|
).toEqual(
|
||||||
new URL("https://api.sophon.steffo.eu")
|
"https://api.sophon.steffo.eu",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("decodes URL with port number", () => {
|
||||||
|
expect(
|
||||||
|
InstanceEncoder.decode("http:localhost%3A30033"),
|
||||||
|
).toEqual(
|
||||||
|
"http://localhost:30033",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("decodes URL with simple path", () => {
|
test("decodes URL with simple path", () => {
|
||||||
expect(
|
expect(
|
||||||
InstanceEncoder.decode("https:steffo.eu:sophon:api:")
|
InstanceEncoder.decode("https:steffo.eu:sophon:api:"),
|
||||||
).toStrictEqual(
|
).toEqual(
|
||||||
new URL("https://steffo.eu/sophon/api/")
|
"https://steffo.eu/sophon/api/",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("decodes URL with colon in path", () => {
|
test("decodes URL with colon in path", () => {
|
||||||
expect(
|
expect(
|
||||||
InstanceEncoder.decode("https:steffo.eu:sophon%3Aapi:")
|
InstanceEncoder.decode("https:steffo.eu:sophon%3Aapi:"),
|
||||||
).toStrictEqual(
|
).toEqual(
|
||||||
new URL("https://steffo.eu/sophon:api/")
|
"https://steffo.eu/sophon:api/",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -5,9 +5,10 @@
|
||||||
*/
|
*/
|
||||||
export class InstanceEncoder {
|
export class InstanceEncoder {
|
||||||
static encode(url: URL): string {
|
static encode(url: URL): string {
|
||||||
let str = url.toString()
|
// Convert the URL to a string
|
||||||
|
let str: string = url.toString()
|
||||||
// Check if it is possible to encode the url
|
// Check if it is possible to encode the url
|
||||||
if(str.includes("%3A")) {
|
if (str.includes("%3A")) {
|
||||||
throw new Error("URL is impossible to encode")
|
throw new Error("URL is impossible to encode")
|
||||||
}
|
}
|
||||||
// Replace all : with %3A
|
// Replace all : with %3A
|
||||||
|
@ -16,6 +17,7 @@ export class InstanceEncoder {
|
||||||
str = str.replace(/^(.+?)%3A[/][/]/, "$1:")
|
str = str.replace(/^(.+?)%3A[/][/]/, "$1:")
|
||||||
// Replace all other slashes with :
|
// Replace all other slashes with :
|
||||||
str = str.replaceAll("/", ":")
|
str = str.replaceAll("/", ":")
|
||||||
|
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,6 +28,9 @@ export class InstanceEncoder {
|
||||||
str = str.replaceAll(":", "/")
|
str = str.replaceAll(":", "/")
|
||||||
// Restore percent-encoded :
|
// Restore percent-encoded :
|
||||||
str = str.replaceAll("%3A", ":")
|
str = str.replaceAll("%3A", ":")
|
||||||
return new URL(str)
|
// Convert the string to an URL
|
||||||
|
const url = new URL(str)
|
||||||
|
|
||||||
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,68 +0,0 @@
|
||||||
/**
|
|
||||||
* A primary-key mapped storage for string-indexable objects.
|
|
||||||
*/
|
|
||||||
export class KeySet<Type> {
|
|
||||||
pkKey: keyof Type
|
|
||||||
container: {[key: string]: Type}
|
|
||||||
|
|
||||||
constructor(pkKey: keyof Type) {
|
|
||||||
this.pkKey = pkKey
|
|
||||||
this.container = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the `pk` of an object for this {@link KeySet}.
|
|
||||||
*
|
|
||||||
* @param obj - The object to get the `pk` of.
|
|
||||||
* @throws Error - If the obtained `pk` is not a `string`.
|
|
||||||
*/
|
|
||||||
pk(obj: Type): string {
|
|
||||||
const pk = obj[this.pkKey]
|
|
||||||
if(typeof pk !== "string") {
|
|
||||||
throw new Error(`Failed to get pk from ${obj}`)
|
|
||||||
}
|
|
||||||
return pk
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add or replace an object to this {@link KeySet}.
|
|
||||||
*
|
|
||||||
* @param obj - The object to add/replace.
|
|
||||||
* @throws Error - If the obtained `pk` is not a `string`.
|
|
||||||
*/
|
|
||||||
put(obj: Type): void {
|
|
||||||
const pk = this.pk(obj)
|
|
||||||
this.container[pk] = obj
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@link put Put} all the objects in the array to this {@link KeySet}.
|
|
||||||
*
|
|
||||||
* @param objs - The array of objects to {@link put}.
|
|
||||||
* @throws Error - If the obtained `pk` is not a `string`.
|
|
||||||
*/
|
|
||||||
putAll(objs: Type[]): void {
|
|
||||||
objs.forEach(obj => this.put(obj))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove and return an object from this {@link KeySet}.
|
|
||||||
*
|
|
||||||
* @param pk - The key of the object to remove.
|
|
||||||
*/
|
|
||||||
pop(pk: string): Type {
|
|
||||||
const val = this.container[pk]
|
|
||||||
delete this.container[pk]
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the object with a certain `pk`.
|
|
||||||
*
|
|
||||||
* @param pk - The key of the object to get.
|
|
||||||
*/
|
|
||||||
get(pk: string): Type {
|
|
||||||
const val = this.container[pk]
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +1,9 @@
|
||||||
import {splitPath} from "./PathSplitter"
|
import { parsePath } from "./ParsePath"
|
||||||
|
|
||||||
|
|
||||||
test("splits empty path", () => {
|
test("splits empty path", () => {
|
||||||
expect(
|
expect(
|
||||||
splitPath("/")
|
parsePath("/"),
|
||||||
).toMatchObject(
|
).toMatchObject(
|
||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
|
@ -11,7 +11,7 @@ test("splits empty path", () => {
|
||||||
|
|
||||||
test("splits instance path", () => {
|
test("splits instance path", () => {
|
||||||
expect(
|
expect(
|
||||||
splitPath("/i/https:api:sophon:steffo:eu:")
|
parsePath("/i/https:api:sophon:steffo:eu:"),
|
||||||
).toMatchObject(
|
).toMatchObject(
|
||||||
{
|
{
|
||||||
instance: "https:api:sophon:steffo:eu:"
|
instance: "https:api:sophon:steffo:eu:"
|
||||||
|
@ -21,7 +21,7 @@ test("splits instance path", () => {
|
||||||
|
|
||||||
test("splits username path", () => {
|
test("splits username path", () => {
|
||||||
expect(
|
expect(
|
||||||
splitPath("/i/https:api:sophon:steffo:eu:/u/steffo")
|
parsePath("/i/https:api:sophon:steffo:eu:/u/steffo"),
|
||||||
).toMatchObject(
|
).toMatchObject(
|
||||||
{
|
{
|
||||||
instance: "https:api:sophon:steffo:eu:",
|
instance: "https:api:sophon:steffo:eu:",
|
||||||
|
@ -32,7 +32,7 @@ test("splits username path", () => {
|
||||||
|
|
||||||
test("splits userid path", () => {
|
test("splits userid path", () => {
|
||||||
expect(
|
expect(
|
||||||
splitPath("/i/https:api:sophon:steffo:eu:/u/1")
|
parsePath("/i/https:api:sophon:steffo:eu:/u/1"),
|
||||||
).toMatchObject(
|
).toMatchObject(
|
||||||
{
|
{
|
||||||
instance: "https:api:sophon:steffo:eu:",
|
instance: "https:api:sophon:steffo:eu:",
|
||||||
|
@ -43,7 +43,7 @@ test("splits userid path", () => {
|
||||||
|
|
||||||
test("splits research group path", () => {
|
test("splits research group path", () => {
|
||||||
expect(
|
expect(
|
||||||
splitPath("/i/https:api:sophon:steffo:eu:/g/testers")
|
parsePath("/i/https:api:sophon:steffo:eu:/g/testers"),
|
||||||
).toMatchObject(
|
).toMatchObject(
|
||||||
{
|
{
|
||||||
instance: "https:api:sophon:steffo:eu:",
|
instance: "https:api:sophon:steffo:eu:",
|
||||||
|
@ -54,7 +54,7 @@ test("splits research group path", () => {
|
||||||
|
|
||||||
test("splits research project path", () => {
|
test("splits research project path", () => {
|
||||||
expect(
|
expect(
|
||||||
splitPath("/i/https:api:sophon:steffo:eu:/g/testers/p/test")
|
parsePath("/i/https:api:sophon:steffo:eu:/g/testers/p/test"),
|
||||||
).toMatchObject(
|
).toMatchObject(
|
||||||
{
|
{
|
||||||
instance: "https:api:sophon:steffo:eu:",
|
instance: "https:api:sophon:steffo:eu:",
|
||||||
|
@ -66,7 +66,7 @@ test("splits research project path", () => {
|
||||||
|
|
||||||
test("splits research project path", () => {
|
test("splits research project path", () => {
|
||||||
expect(
|
expect(
|
||||||
splitPath("/i/https:api:sophon:steffo:eu:/g/testers/p/test/n/testerino")
|
parsePath("/i/https:api:sophon:steffo:eu:/g/testers/p/test/n/testerino"),
|
||||||
).toMatchObject(
|
).toMatchObject(
|
||||||
{
|
{
|
||||||
instance: "https:api:sophon:steffo:eu:",
|
instance: "https:api:sophon:steffo:eu:",
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* Possible contents of the path.
|
* Possible contents of the path.
|
||||||
*/
|
*/
|
||||||
export interface SplitPath {
|
export interface ParsedPath {
|
||||||
/**
|
/**
|
||||||
* The URL of the Sophon instance.
|
* The URL of the Sophon instance.
|
||||||
*/
|
*/
|
||||||
|
@ -32,21 +32,27 @@ export interface SplitPath {
|
||||||
* The notebook slug.
|
* The notebook slug.
|
||||||
*/
|
*/
|
||||||
notebook?: string,
|
notebook?: string,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passed the login page (either by browsing as guest or by logging in).
|
||||||
|
*/
|
||||||
|
loggedIn?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split the URL path into various components.
|
* Split the URL path into various components.
|
||||||
* @param path - The path to split.
|
* @param path - The path to split.
|
||||||
*/
|
*/
|
||||||
export function splitPath(path: string): SplitPath {
|
export function parsePath(path: string): ParsedPath {
|
||||||
let result: SplitPath = {}
|
let result: ParsedPath = {}
|
||||||
|
|
||||||
result.instance = path.match(/[/]i[/]([^/]+)/) ?.[1]
|
result.instance = path.match(/[/]i[/]([^/]+)/)?.[1]
|
||||||
result.userId = path.match(/[/]u[/]([0-9]+)/) ?.[1]
|
result.userId = path.match(/[/]u[/]([0-9]+)/)?.[1]
|
||||||
result.userName = path.match(/[/]u[/]([A-Za-z0-9_-]+)/)?.[1]
|
result.userName = path.match(/[/]u[/]([A-Za-z0-9_-]+)/)?.[1]
|
||||||
result.researchGroup = path.match(/[/]g[/]([A-Za-z0-9_-]+)/)?.[1]
|
result.researchGroup = path.match(/[/]g[/]([A-Za-z0-9_-]+)/)?.[1]
|
||||||
result.researchProject = path.match(/[/]p[/]([A-Za-z0-9_-]+)/)?.[1]
|
result.researchProject = path.match(/[/]p[/]([A-Za-z0-9_-]+)/)?.[1]
|
||||||
result.notebook = path.match(/[/]n[/]([A-Za-z0-9_-]+)/)?.[1]
|
result.notebook = path.match(/[/]n[/]([A-Za-z0-9_-]+)/)?.[1]
|
||||||
|
result.loggedIn = Boolean(path.match(/[/]l[/]/))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
|
@ -1,66 +0,0 @@
|
||||||
import {Slug} from "./DjangoTypes";
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Django User.
|
|
||||||
*/
|
|
||||||
export interface DjangoUser {
|
|
||||||
id: number,
|
|
||||||
username: Slug,
|
|
||||||
first_name: string,
|
|
||||||
last_name: string,
|
|
||||||
email: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The details of a Sophon instance.
|
|
||||||
*/
|
|
||||||
export interface SophonInstanceDetails {
|
|
||||||
name: string,
|
|
||||||
version: string,
|
|
||||||
description: string | null,
|
|
||||||
theme: "sophon" | "paper" | "royalblue" | "hacker",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Sophon Research Group.
|
|
||||||
*/
|
|
||||||
export interface SophonResearchGroup {
|
|
||||||
owner: number,
|
|
||||||
members: number[],
|
|
||||||
name: string,
|
|
||||||
description: string,
|
|
||||||
access: "OPEN" | "MANUAL",
|
|
||||||
slug: Slug,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Sophon Research Project.
|
|
||||||
*/
|
|
||||||
export interface SophonResearchProject {
|
|
||||||
visibility: "PUBLIC" | "INTERNAL" | "PRIVATE",
|
|
||||||
slug: Slug,
|
|
||||||
name: string,
|
|
||||||
description: string,
|
|
||||||
group: Slug,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Sophon Notebook.
|
|
||||||
*/
|
|
||||||
export interface SophonNotebook {
|
|
||||||
locked_by: number,
|
|
||||||
slug: Slug,
|
|
||||||
legacy_notebook_url: string | null,
|
|
||||||
jupyter_token: string,
|
|
||||||
is_running: boolean,
|
|
||||||
internet_access: true,
|
|
||||||
container_image: string,
|
|
||||||
project: Slug,
|
|
||||||
name: string,
|
|
||||||
lab_url: string | null,
|
|
||||||
}
|
|
Loading…
Reference in a new issue