codeforlife 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +47 -0
- package/.github/workflows/contributing.yaml +8 -0
- package/.github/workflows/main.yml +36 -0
- package/.prettierignore +5 -0
- package/.prettierrc.json +4 -0
- package/.vscode/launch.json +22 -0
- package/.vscode/settings.json +30 -0
- package/CHANGELOG.md +1864 -0
- package/CONTRIBUTING.md +3 -0
- package/LICENSE.md +3 -0
- package/README.md +94 -0
- package/codecov.yml +11 -0
- package/package.json +139 -0
- package/src/api/createApi.ts +84 -0
- package/src/api/endpoints/authFactor.ts +31 -0
- package/src/api/endpoints/index.ts +9 -0
- package/src/api/endpoints/klass.ts +87 -0
- package/src/api/endpoints/school.ts +34 -0
- package/src/api/endpoints/session.ts +40 -0
- package/src/api/endpoints/user.ts +70 -0
- package/src/api/index.ts +4 -0
- package/src/api/models.ts +144 -0
- package/src/api/tagTypes.ts +12 -0
- package/src/api/urls.ts +13 -0
- package/src/components/App.css +38 -0
- package/src/components/App.tsx +150 -0
- package/src/components/ClickableTooltip.tsx +43 -0
- package/src/components/CopyIconButton.test.tsx +16 -0
- package/src/components/CopyIconButton.tsx +27 -0
- package/src/components/Countdown.tsx +42 -0
- package/src/components/ElevatedAppBar.tsx +41 -0
- package/src/components/Image.tsx +41 -0
- package/src/components/InputFileButton.tsx +27 -0
- package/src/components/ItemizedList.tsx +61 -0
- package/src/components/OrderedGrid.tsx +92 -0
- package/src/components/ScrollIntoViewLink.tsx +23 -0
- package/src/components/SyncError.tsx +14 -0
- package/src/components/TablePagination.tsx +132 -0
- package/src/components/YouTubeVideo.tsx +26 -0
- package/src/components/form/ApiAutocompleteField.tsx +180 -0
- package/src/components/form/AutocompleteField.tsx +124 -0
- package/src/components/form/CheckboxField.tsx +81 -0
- package/src/components/form/CountryField.tsx +68 -0
- package/src/components/form/DatePickerField.tsx +119 -0
- package/src/components/form/EmailField.tsx +38 -0
- package/src/components/form/FirstNameField.tsx +40 -0
- package/src/components/form/Form.tsx +82 -0
- package/src/components/form/OtpField.tsx +28 -0
- package/src/components/form/PasswordField.tsx +71 -0
- package/src/components/form/RepeatField.tsx +115 -0
- package/src/components/form/SubmitButton.tsx +47 -0
- package/src/components/form/TextField.tsx +103 -0
- package/src/components/form/UkCountyField.tsx +67 -0
- package/src/components/form/index.tsx +28 -0
- package/src/components/index.ts +26 -0
- package/src/components/page/Banner.tsx +84 -0
- package/src/components/page/Notification.tsx +71 -0
- package/src/components/page/Page.tsx +73 -0
- package/src/components/page/Section.tsx +21 -0
- package/src/components/page/TabBar.tsx +131 -0
- package/src/components/page/index.ts +10 -0
- package/src/components/router/Link.tsx +22 -0
- package/src/components/router/LinkButton.tsx +21 -0
- package/src/components/router/LinkIconButton.tsx +21 -0
- package/src/components/router/LinkListItem.tsx +21 -0
- package/src/components/router/LinkTab.tsx +21 -0
- package/src/components/router/Navigate.tsx +33 -0
- package/src/components/router/index.tsx +12 -0
- package/src/components/table/CellStack.tsx +19 -0
- package/src/components/table/Table.tsx +55 -0
- package/src/components/table/index.tsx +10 -0
- package/src/features/InactiveDialog.tsx +40 -0
- package/src/features/ScreenTimeDialog.tsx +33 -0
- package/src/features/index.ts +4 -0
- package/src/fonts/Inter-VariableFont_slnt,wght.ttf +0 -0
- package/src/fonts/SpaceGrotesk-VariableFont_wght.ttf +0 -0
- package/src/hooks/api.tsx +37 -0
- package/src/hooks/auth.tsx +87 -0
- package/src/hooks/general.ts +110 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/router.tsx +168 -0
- package/src/index.ts +2 -0
- package/src/middlewares/index.ts +1 -0
- package/src/middlewares/session.ts +16 -0
- package/src/public/images/brain.svg +1 -0
- package/src/schemas/user.ts +4 -0
- package/src/scripts/freshDesk.js +473 -0
- package/src/scripts/index.ts +1 -0
- package/src/server.js +181 -0
- package/src/settings/custom.ts +22 -0
- package/src/settings/index.ts +5 -0
- package/src/settings/vite.ts +26 -0
- package/src/setupTests.ts +1 -0
- package/src/slices/createSlice.ts +8 -0
- package/src/slices/index.ts +2 -0
- package/src/slices/session.ts +32 -0
- package/src/theme/ThemedBox.tsx +265 -0
- package/src/theme/colors.ts +57 -0
- package/src/theme/components/MuiAccordion.tsx +13 -0
- package/src/theme/components/MuiAutocomplete.tsx +11 -0
- package/src/theme/components/MuiButton.ts +70 -0
- package/src/theme/components/MuiCardActions.tsx +12 -0
- package/src/theme/components/MuiCheckbox.ts +12 -0
- package/src/theme/components/MuiContainer.ts +19 -0
- package/src/theme/components/MuiDialog.tsx +16 -0
- package/src/theme/components/MuiFormControlLabel.ts +18 -0
- package/src/theme/components/MuiFormHelperText.ts +12 -0
- package/src/theme/components/MuiGrid2.ts +16 -0
- package/src/theme/components/MuiInputBase.ts +14 -0
- package/src/theme/components/MuiLink.ts +41 -0
- package/src/theme/components/MuiList.ts +12 -0
- package/src/theme/components/MuiListItemText.ts +18 -0
- package/src/theme/components/MuiMenu.ts +14 -0
- package/src/theme/components/MuiMenuItem.ts +15 -0
- package/src/theme/components/MuiSelect.ts +16 -0
- package/src/theme/components/MuiTab.ts +29 -0
- package/src/theme/components/MuiTable.ts +29 -0
- package/src/theme/components/MuiTableBody.ts +15 -0
- package/src/theme/components/MuiTableHead.ts +26 -0
- package/src/theme/components/MuiTabs.ts +26 -0
- package/src/theme/components/MuiTextField.ts +86 -0
- package/src/theme/components/MuiToolbar.ts +11 -0
- package/src/theme/components/MuiTypography.ts +12 -0
- package/src/theme/components/_components.ts +93 -0
- package/src/theme/components/index.ts +57 -0
- package/src/theme/index.ts +25 -0
- package/src/theme/palette.ts +98 -0
- package/src/theme/spacing.ts +8 -0
- package/src/theme/typography.ts +101 -0
- package/src/utils/api.test.ts +19 -0
- package/src/utils/api.tsx +327 -0
- package/src/utils/auth.ts +17 -0
- package/src/utils/form.ts +153 -0
- package/src/utils/general.test.ts +42 -0
- package/src/utils/general.ts +498 -0
- package/src/utils/router.test.ts +156 -0
- package/src/utils/router.ts +67 -0
- package/src/utils/schema.ts +80 -0
- package/src/utils/store.ts +31 -0
- package/src/utils/test.tsx +82 -0
- package/src/utils/theme.tsx +82 -0
- package/src/utils/window.ts +9 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +31 -0
- package/tsconfig.node.json +11 -0
- package/types/fixes.d.ts +18 -0
- package/vite.config.ts +21 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ListItem, type ListItemProps } from "@mui/material"
|
|
2
|
+
import { Link } from "react-router-dom"
|
|
3
|
+
|
|
4
|
+
import { type LinkProps } from "../../utils/router"
|
|
5
|
+
|
|
6
|
+
export type LinkListItemProps<
|
|
7
|
+
Override extends "delta" | "to",
|
|
8
|
+
State extends Record<string, any> = Record<string, any>,
|
|
9
|
+
> = Omit<ListItemProps, "component"> & LinkProps<Override, State>
|
|
10
|
+
|
|
11
|
+
// https://mui.com/material-ui/integrations/routing/#list
|
|
12
|
+
const LinkListItem: {
|
|
13
|
+
(props: LinkListItemProps<"delta">): JSX.Element
|
|
14
|
+
<State extends Record<string, any> = Record<string, any>>(
|
|
15
|
+
props: LinkListItemProps<"to", State>,
|
|
16
|
+
): JSX.Element
|
|
17
|
+
} = (props: LinkListItemProps<"delta"> | LinkListItemProps<"to">) => {
|
|
18
|
+
return <ListItem {...{ ...props, component: Link }} />
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default LinkListItem
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Tab, type TabProps } from "@mui/material"
|
|
2
|
+
import { Link } from "react-router-dom"
|
|
3
|
+
|
|
4
|
+
import { type LinkProps } from "../../utils/router"
|
|
5
|
+
|
|
6
|
+
export type LinkTabProps<
|
|
7
|
+
Override extends "delta" | "to",
|
|
8
|
+
State extends Record<string, any> = Record<string, any>,
|
|
9
|
+
> = Omit<TabProps, "component"> & LinkProps<Override, State>
|
|
10
|
+
|
|
11
|
+
// https://mui.com/material-ui/integrations/routing/#tabs
|
|
12
|
+
const LinkTab: {
|
|
13
|
+
(props: LinkTabProps<"delta">): JSX.Element
|
|
14
|
+
<State extends Record<string, any> = Record<string, any>>(
|
|
15
|
+
props: LinkTabProps<"to", State>,
|
|
16
|
+
): JSX.Element
|
|
17
|
+
} = (props: LinkTabProps<"delta"> | LinkTabProps<"to">) => {
|
|
18
|
+
return <Tab {...{ ...props, component: Link }} />
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default LinkTab
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useEffect } from "react"
|
|
2
|
+
import { type To } from "react-router-dom"
|
|
3
|
+
|
|
4
|
+
import { useNavigate, type NavigateOptions } from "../../hooks"
|
|
5
|
+
|
|
6
|
+
export type NavigateProps<
|
|
7
|
+
Override extends "delta" | "to",
|
|
8
|
+
State extends Record<string, any> = Record<string, any>,
|
|
9
|
+
> = Override extends "delta"
|
|
10
|
+
? { delta: number; to?: undefined }
|
|
11
|
+
: { delta?: undefined; to: To } & NavigateOptions<State>
|
|
12
|
+
|
|
13
|
+
const Navigate: {
|
|
14
|
+
(props: NavigateProps<"delta">): JSX.Element
|
|
15
|
+
<State extends Record<string, any> = Record<string, any>>(
|
|
16
|
+
props: NavigateProps<"to", State>,
|
|
17
|
+
): JSX.Element
|
|
18
|
+
} = ({
|
|
19
|
+
delta,
|
|
20
|
+
to,
|
|
21
|
+
...options
|
|
22
|
+
}: NavigateProps<"delta"> | NavigateProps<"to">) => {
|
|
23
|
+
const navigate = useNavigate()
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (typeof delta === "number") navigate(delta)
|
|
27
|
+
else navigate(to, options)
|
|
28
|
+
}, [navigate, delta, to, options])
|
|
29
|
+
|
|
30
|
+
return <></>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default Navigate
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from "./Link"
|
|
2
|
+
export { default as Link } from "./Link"
|
|
3
|
+
export * from "./LinkButton"
|
|
4
|
+
export { default as LinkButton } from "./LinkButton"
|
|
5
|
+
export * from "./LinkIconButton"
|
|
6
|
+
export { default as LinkIconButton } from "./LinkIconButton"
|
|
7
|
+
export * from "./LinkListItem"
|
|
8
|
+
export { default as LinkListItem } from "./LinkListItem"
|
|
9
|
+
export * from "./LinkTab"
|
|
10
|
+
export { default as LinkTab } from "./LinkTab"
|
|
11
|
+
export * from "./Navigate"
|
|
12
|
+
export { default as Navigate } from "./Navigate"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type FC } from "react"
|
|
2
|
+
import {
|
|
3
|
+
Stack,
|
|
4
|
+
TableCell,
|
|
5
|
+
type TableCellProps,
|
|
6
|
+
type StackProps,
|
|
7
|
+
} from "@mui/material"
|
|
8
|
+
|
|
9
|
+
export interface CellStackProps extends StackProps {
|
|
10
|
+
cellProps?: TableCellProps
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const CellStack: FC<CellStackProps> = ({ cellProps, ...stackProps }) => (
|
|
14
|
+
<TableCell {...cellProps}>
|
|
15
|
+
<Stack {...stackProps} />
|
|
16
|
+
</TableCell>
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
export default CellStack
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type FC, type ReactNode, isValidElement } from "react"
|
|
2
|
+
import {
|
|
3
|
+
Table as MuiTable,
|
|
4
|
+
type TableProps as MuiTableProps,
|
|
5
|
+
TableBody,
|
|
6
|
+
type TableBodyProps,
|
|
7
|
+
TableCell,
|
|
8
|
+
type TableCellProps,
|
|
9
|
+
TableContainer,
|
|
10
|
+
type TableContainerProps,
|
|
11
|
+
TableHead,
|
|
12
|
+
type TableHeadProps,
|
|
13
|
+
TableRow,
|
|
14
|
+
type TableRowProps,
|
|
15
|
+
} from "@mui/material"
|
|
16
|
+
|
|
17
|
+
export interface TableProps extends MuiTableProps {
|
|
18
|
+
headers: Array<ReactNode | TableCellProps>
|
|
19
|
+
children: ReactNode
|
|
20
|
+
containerProps?: TableContainerProps
|
|
21
|
+
headProps?: TableHeadProps
|
|
22
|
+
headRowProps?: TableRowProps
|
|
23
|
+
bodyProps?: TableBodyProps
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const Table: FC<TableProps> = ({
|
|
27
|
+
headers,
|
|
28
|
+
children,
|
|
29
|
+
containerProps,
|
|
30
|
+
headProps,
|
|
31
|
+
headRowProps,
|
|
32
|
+
bodyProps,
|
|
33
|
+
...tableProps
|
|
34
|
+
}) => (
|
|
35
|
+
<TableContainer {...containerProps}>
|
|
36
|
+
<MuiTable {...tableProps}>
|
|
37
|
+
<TableHead {...headProps}>
|
|
38
|
+
<TableRow {...headRowProps}>
|
|
39
|
+
{headers.map((header, index) => {
|
|
40
|
+
const key = `table-head-cell-${index}`
|
|
41
|
+
|
|
42
|
+
return typeof header === "string" || isValidElement(header) ? (
|
|
43
|
+
<TableCell key={key}>{header}</TableCell>
|
|
44
|
+
) : (
|
|
45
|
+
<TableCell key={key} {...(header as TableCellProps)} />
|
|
46
|
+
)
|
|
47
|
+
})}
|
|
48
|
+
</TableRow>
|
|
49
|
+
</TableHead>
|
|
50
|
+
<TableBody {...bodyProps}>{children}</TableBody>
|
|
51
|
+
</MuiTable>
|
|
52
|
+
</TableContainer>
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
export default Table
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { default as Table } from "./Table"
|
|
2
|
+
export * from "./Table"
|
|
3
|
+
export { default as CellStack } from "./CellStack"
|
|
4
|
+
export * from "./CellStack"
|
|
5
|
+
export {
|
|
6
|
+
TableCell as Cell,
|
|
7
|
+
type TableCellProps as CellProps,
|
|
8
|
+
TableRow as BodyRow,
|
|
9
|
+
type TableRowProps as BodyRowProps,
|
|
10
|
+
} from "@mui/material"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type React from "react"
|
|
2
|
+
import { Button, Dialog, Typography } from "@mui/material"
|
|
3
|
+
|
|
4
|
+
import { Countdown } from "../components"
|
|
5
|
+
|
|
6
|
+
export interface InactiveDialogProps {
|
|
7
|
+
open: boolean
|
|
8
|
+
onClose: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const InactiveDialog: React.FC<InactiveDialogProps> = ({ open, onClose }) => {
|
|
12
|
+
return (
|
|
13
|
+
<Dialog open={open} onClose={onClose}>
|
|
14
|
+
<Typography variant="h5" textAlign="center">
|
|
15
|
+
Where did you go? 👀
|
|
16
|
+
</Typography>
|
|
17
|
+
<Typography textAlign="center">
|
|
18
|
+
We noticed that you have been inactive for a while. Are you still there?
|
|
19
|
+
For your online safety we will log you out in:
|
|
20
|
+
</Typography>
|
|
21
|
+
<Countdown
|
|
22
|
+
textAlign="center"
|
|
23
|
+
variant="h5"
|
|
24
|
+
seconds={60 * 2}
|
|
25
|
+
onEnd={() => {
|
|
26
|
+
onClose()
|
|
27
|
+
alert("TODO: call logout endpoint")
|
|
28
|
+
}}
|
|
29
|
+
/>
|
|
30
|
+
<Typography textAlign="center">
|
|
31
|
+
You may lose progress unless you continue or save.
|
|
32
|
+
</Typography>
|
|
33
|
+
<Button onClick={onClose} autoFocus>
|
|
34
|
+
Wait, I'm still here!
|
|
35
|
+
</Button>
|
|
36
|
+
</Dialog>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default InactiveDialog
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type React from "react"
|
|
2
|
+
import { Button, Dialog, Typography } from "@mui/material"
|
|
3
|
+
|
|
4
|
+
import { Image } from "../components"
|
|
5
|
+
import BrainImage from "../public/images/brain.svg"
|
|
6
|
+
|
|
7
|
+
export interface ScreenTimeDialogProps {
|
|
8
|
+
open: boolean
|
|
9
|
+
onClose: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ScreenTimeDialog: React.FC<ScreenTimeDialogProps> = ({
|
|
13
|
+
open,
|
|
14
|
+
onClose,
|
|
15
|
+
}) => {
|
|
16
|
+
return (
|
|
17
|
+
<Dialog open={open} onClose={onClose} maxWidth="sm">
|
|
18
|
+
<Image src={BrainImage} alt="brain" maxWidth={100} marginY={3} />
|
|
19
|
+
<Typography variant="h5" textAlign="center">
|
|
20
|
+
Time for a break?
|
|
21
|
+
</Typography>
|
|
22
|
+
<Typography textAlign="center">
|
|
23
|
+
You have been using the Code for Life website for a while. Remember to
|
|
24
|
+
take regular screen breaks to recharge those brain cells!
|
|
25
|
+
</Typography>
|
|
26
|
+
<Button onClick={onClose} autoFocus>
|
|
27
|
+
Continue
|
|
28
|
+
</Button>
|
|
29
|
+
</Dialog>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default ScreenTimeDialog
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useState, type Dispatch, type SetStateAction } from "react"
|
|
2
|
+
|
|
3
|
+
export type Pagination = { page: number; limit: number; offset: number }
|
|
4
|
+
export type SetPagination = Dispatch<
|
|
5
|
+
SetStateAction<{ page: number; limit: number }>
|
|
6
|
+
>
|
|
7
|
+
export type UsePaginationOptions = Partial<{
|
|
8
|
+
page: number
|
|
9
|
+
limit: number
|
|
10
|
+
}>
|
|
11
|
+
|
|
12
|
+
export function usePagination(
|
|
13
|
+
options?: UsePaginationOptions,
|
|
14
|
+
): [Pagination, SetPagination] {
|
|
15
|
+
const { page = 0, limit = 150 } = options || {}
|
|
16
|
+
|
|
17
|
+
const [pagination, _setPagination] = useState<Pagination>({
|
|
18
|
+
page,
|
|
19
|
+
limit,
|
|
20
|
+
offset: page * limit,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const setPagination: SetPagination = value => {
|
|
24
|
+
_setPagination(({ page: previousPage, limit: previousLimit }) => {
|
|
25
|
+
let { page, limit } =
|
|
26
|
+
typeof value === "function"
|
|
27
|
+
? value({ page: previousPage, limit: previousLimit })
|
|
28
|
+
: value
|
|
29
|
+
|
|
30
|
+
if (limit !== previousLimit) page = 0
|
|
31
|
+
|
|
32
|
+
return { page, limit, offset: page * limit }
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return [pagination, setPagination]
|
|
37
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import Cookies from "js-cookie"
|
|
2
|
+
import { useEffect, type ReactNode } from "react"
|
|
3
|
+
import { createSearchParams, useLocation, useNavigate } from "react-router-dom"
|
|
4
|
+
import { useSelector } from "react-redux"
|
|
5
|
+
|
|
6
|
+
import { type AuthFactor, type User } from "../api"
|
|
7
|
+
import { SESSION_METADATA_COOKIE_NAME } from "../settings"
|
|
8
|
+
import { selectIsLoggedIn } from "../slices/session"
|
|
9
|
+
|
|
10
|
+
export interface SessionMetadata {
|
|
11
|
+
user_id: User["id"]
|
|
12
|
+
user_type: "teacher" | "student" | "indy"
|
|
13
|
+
auth_factors: Array<AuthFactor["type"]>
|
|
14
|
+
otp_bypass_token_exists: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useSessionMetadata(): SessionMetadata | undefined {
|
|
18
|
+
return useSelector(selectIsLoggedIn)
|
|
19
|
+
? (JSON.parse(
|
|
20
|
+
Cookies.get(SESSION_METADATA_COOKIE_NAME)!,
|
|
21
|
+
) as SessionMetadata)
|
|
22
|
+
: undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type UseSessionChildrenFunction<Required extends boolean> = (
|
|
26
|
+
metadata: Required extends true
|
|
27
|
+
? SessionMetadata
|
|
28
|
+
: SessionMetadata | undefined,
|
|
29
|
+
) => ReactNode
|
|
30
|
+
|
|
31
|
+
export type UseSessionChildren<
|
|
32
|
+
UserType extends SessionMetadata["user_type"] | undefined,
|
|
33
|
+
> =
|
|
34
|
+
| ReactNode
|
|
35
|
+
| (UserType extends undefined
|
|
36
|
+
? UseSessionChildrenFunction<false>
|
|
37
|
+
: UseSessionChildrenFunction<true>)
|
|
38
|
+
|
|
39
|
+
export type UseSessionOptions<
|
|
40
|
+
UserType extends SessionMetadata["user_type"] | undefined,
|
|
41
|
+
> = Partial<{
|
|
42
|
+
userType: UserType
|
|
43
|
+
next: boolean
|
|
44
|
+
}>
|
|
45
|
+
|
|
46
|
+
export function useSession<
|
|
47
|
+
UserType extends SessionMetadata["user_type"] | undefined = undefined,
|
|
48
|
+
>(
|
|
49
|
+
children: UseSessionChildren<UserType>,
|
|
50
|
+
options: UseSessionOptions<UserType> = {},
|
|
51
|
+
) {
|
|
52
|
+
const { userType, next = true } = options
|
|
53
|
+
|
|
54
|
+
const { pathname } = useLocation()
|
|
55
|
+
const navigate = useNavigate()
|
|
56
|
+
const sessionMetadata = useSessionMetadata()
|
|
57
|
+
|
|
58
|
+
const loginRequired =
|
|
59
|
+
userType && (!sessionMetadata || sessionMetadata.user_type !== userType)
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (loginRequired) {
|
|
63
|
+
navigate({
|
|
64
|
+
pathname:
|
|
65
|
+
"/login" +
|
|
66
|
+
{
|
|
67
|
+
teacher: "/teacher",
|
|
68
|
+
student: "/student",
|
|
69
|
+
indy: "/independent",
|
|
70
|
+
}[userType],
|
|
71
|
+
search: next
|
|
72
|
+
? createSearchParams({ next: pathname }).toString()
|
|
73
|
+
: undefined,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}, [navigate, loginRequired, userType, next, pathname])
|
|
77
|
+
|
|
78
|
+
if (loginRequired) return <></>
|
|
79
|
+
|
|
80
|
+
if (typeof children === "function") {
|
|
81
|
+
return sessionMetadata
|
|
82
|
+
? (children as UseSessionChildrenFunction<true>)(sessionMetadata)
|
|
83
|
+
: (children as UseSessionChildrenFunction<false>)(sessionMetadata)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return children
|
|
87
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useState,
|
|
4
|
+
type DependencyList,
|
|
5
|
+
type Dispatch,
|
|
6
|
+
type SetStateAction,
|
|
7
|
+
} from "react"
|
|
8
|
+
|
|
9
|
+
export function useExternalScript<EventType extends keyof HTMLElementEventMap>({
|
|
10
|
+
props,
|
|
11
|
+
attrs,
|
|
12
|
+
eventTypes,
|
|
13
|
+
}: {
|
|
14
|
+
props: Partial<HTMLScriptElement> & { src: string }
|
|
15
|
+
attrs?: Record<string, string>
|
|
16
|
+
eventTypes?: EventType[]
|
|
17
|
+
}): EventType | undefined {
|
|
18
|
+
const [eventType, setEventType] = useState<EventType>()
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (
|
|
22
|
+
document.querySelector<HTMLScriptElement>(`script[src="${props.src}"]`)
|
|
23
|
+
) {
|
|
24
|
+
throw Error("already exists")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const script = document.createElement("script")
|
|
28
|
+
|
|
29
|
+
Object.entries(props).forEach(([key, value]) => {
|
|
30
|
+
// @ts-expect-error
|
|
31
|
+
script[key] = value
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
if (attrs !== undefined) {
|
|
35
|
+
Object.entries(attrs).forEach(([key, value]) => {
|
|
36
|
+
script.setAttribute(key, value)
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function eventListener(event: Event): void {
|
|
41
|
+
setEventType(event.type as EventType)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
eventTypes?.forEach(eventType => {
|
|
45
|
+
script.addEventListener(eventType, eventListener)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
document.head.appendChild(script)
|
|
49
|
+
|
|
50
|
+
return () => {
|
|
51
|
+
eventTypes?.forEach(eventType => {
|
|
52
|
+
script.removeEventListener(eventType, eventListener)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
document.head.removeChild(script)
|
|
56
|
+
}
|
|
57
|
+
}, [eventTypes, attrs, props])
|
|
58
|
+
|
|
59
|
+
return eventType
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useCountdown(
|
|
63
|
+
seconds: number,
|
|
64
|
+
interval: number = 1,
|
|
65
|
+
): [number, Dispatch<SetStateAction<number>>] {
|
|
66
|
+
if (seconds <= 0) throw Error("seconds must be > 0")
|
|
67
|
+
if (interval <= 0) throw Error("interval must be > 0")
|
|
68
|
+
|
|
69
|
+
const [_seconds, _setSeconds] = useState(seconds)
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const countdown = setInterval(() => {
|
|
73
|
+
_setSeconds(seconds => {
|
|
74
|
+
seconds = seconds - interval
|
|
75
|
+
return seconds < 0 ? 0 : seconds
|
|
76
|
+
})
|
|
77
|
+
}, interval * 1000)
|
|
78
|
+
|
|
79
|
+
return () => {
|
|
80
|
+
clearInterval(countdown)
|
|
81
|
+
}
|
|
82
|
+
}, [interval])
|
|
83
|
+
|
|
84
|
+
return [_seconds, _setSeconds]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function useEventListener<EventType extends keyof HTMLElementEventMap>(
|
|
88
|
+
element: HTMLElement,
|
|
89
|
+
type: EventType,
|
|
90
|
+
listener: (this: HTMLElement, ev: HTMLElementEventMap[EventType]) => any,
|
|
91
|
+
kwArgs: {
|
|
92
|
+
options?: boolean | AddEventListenerOptions
|
|
93
|
+
deps?: DependencyList
|
|
94
|
+
} = {},
|
|
95
|
+
): void {
|
|
96
|
+
const { options, deps = [] } = kwArgs
|
|
97
|
+
|
|
98
|
+
useEffect(
|
|
99
|
+
() => {
|
|
100
|
+
element.addEventListener(type, listener, options)
|
|
101
|
+
|
|
102
|
+
return () => {
|
|
103
|
+
element.removeEventListener(type, listener, options)
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
// TODO: simplify this hook.
|
|
107
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
108
|
+
deps,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from "react"
|
|
2
|
+
import {
|
|
3
|
+
useLocation as _useLocation,
|
|
4
|
+
useNavigate as _useNavigate,
|
|
5
|
+
useParams as _useParams,
|
|
6
|
+
useSearchParams as _useSearchParams,
|
|
7
|
+
type Location,
|
|
8
|
+
type NavigateOptions as _NavigateOptions,
|
|
9
|
+
type Params,
|
|
10
|
+
type To,
|
|
11
|
+
} from "react-router-dom"
|
|
12
|
+
import { object as objectSchema, type ObjectShape } from "yup"
|
|
13
|
+
|
|
14
|
+
import { type PageState } from "../components/page"
|
|
15
|
+
import { type ReadOnly } from "../utils/router"
|
|
16
|
+
import {
|
|
17
|
+
tryValidateSync,
|
|
18
|
+
type ObjectSchemaFromShape,
|
|
19
|
+
type TryValidateSyncOnErrorRT,
|
|
20
|
+
type TryValidateSyncOptions,
|
|
21
|
+
type TryValidateSyncRT,
|
|
22
|
+
} from "../utils/schema"
|
|
23
|
+
|
|
24
|
+
export type NavigateOptions<
|
|
25
|
+
State extends Record<string, any> = Record<string, any>,
|
|
26
|
+
> = Omit<_NavigateOptions, "state"> & {
|
|
27
|
+
state?: State & Partial<PageState>
|
|
28
|
+
next?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Navigate = {
|
|
32
|
+
<State extends Record<string, any> = Record<string, any>>(
|
|
33
|
+
to: To,
|
|
34
|
+
options?: NavigateOptions<State>,
|
|
35
|
+
): void
|
|
36
|
+
(delta: number): void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useNavigate(): Navigate {
|
|
40
|
+
const navigate = _useNavigate()
|
|
41
|
+
const searchParams = useSearchParams()
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
toOrDelta: To | number,
|
|
45
|
+
options: (NavigateOptions & { next?: boolean }) | undefined = undefined,
|
|
46
|
+
) => {
|
|
47
|
+
if (typeof toOrDelta === "number") navigate(toOrDelta)
|
|
48
|
+
else {
|
|
49
|
+
const { next = true, ..._options } = options || {}
|
|
50
|
+
|
|
51
|
+
navigate(
|
|
52
|
+
next && "next" in searchParams ? searchParams.next : toOrDelta,
|
|
53
|
+
_options,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useLocation<State = {}>() {
|
|
60
|
+
return _useLocation() as Location<null | Partial<PageState & State>>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// -----------------------------------------------------------------------------
|
|
64
|
+
// Use Search Params
|
|
65
|
+
// -----------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export function useSearchParams(): { [k: string]: string }
|
|
68
|
+
|
|
69
|
+
export function useSearchParams<
|
|
70
|
+
OnErrorRT extends TryValidateSyncOnErrorRT<ObjectSchemaFromShape<Shape>>,
|
|
71
|
+
Shape extends ObjectShape = {},
|
|
72
|
+
>(
|
|
73
|
+
shape: Shape,
|
|
74
|
+
validateOptions?: TryValidateSyncOptions<
|
|
75
|
+
ObjectSchemaFromShape<Shape>,
|
|
76
|
+
OnErrorRT
|
|
77
|
+
>,
|
|
78
|
+
): TryValidateSyncRT<ObjectSchemaFromShape<Shape>, OnErrorRT>
|
|
79
|
+
|
|
80
|
+
export function useSearchParams<
|
|
81
|
+
OnErrorRT extends TryValidateSyncOnErrorRT<ObjectSchemaFromShape<Shape>>,
|
|
82
|
+
Shape extends ObjectShape = {},
|
|
83
|
+
>(
|
|
84
|
+
shape?: Shape,
|
|
85
|
+
validateOptions?: TryValidateSyncOptions<
|
|
86
|
+
ObjectSchemaFromShape<Shape>,
|
|
87
|
+
OnErrorRT
|
|
88
|
+
>,
|
|
89
|
+
) {
|
|
90
|
+
const searchParams = Object.fromEntries(_useSearchParams()[0].entries())
|
|
91
|
+
if (!shape) return searchParams
|
|
92
|
+
|
|
93
|
+
return tryValidateSync(searchParams, objectSchema(shape), validateOptions)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// -----------------------------------------------------------------------------
|
|
97
|
+
// Use Params
|
|
98
|
+
// -----------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
export function useParams(): ReadOnly<Params<string>>
|
|
101
|
+
|
|
102
|
+
export function useParams<
|
|
103
|
+
OnErrorRT extends TryValidateSyncOnErrorRT<ObjectSchemaFromShape<Shape>>,
|
|
104
|
+
Shape extends ObjectShape = {},
|
|
105
|
+
>(
|
|
106
|
+
shape: Shape,
|
|
107
|
+
validateOptions?: TryValidateSyncOptions<
|
|
108
|
+
ObjectSchemaFromShape<Shape>,
|
|
109
|
+
OnErrorRT
|
|
110
|
+
>,
|
|
111
|
+
): TryValidateSyncRT<ObjectSchemaFromShape<Shape>, OnErrorRT>
|
|
112
|
+
|
|
113
|
+
export function useParams<
|
|
114
|
+
OnErrorRT extends TryValidateSyncOnErrorRT<ObjectSchemaFromShape<Shape>>,
|
|
115
|
+
Shape extends ObjectShape = {},
|
|
116
|
+
>(
|
|
117
|
+
shape?: Shape,
|
|
118
|
+
validateOptions?: TryValidateSyncOptions<
|
|
119
|
+
ObjectSchemaFromShape<Shape>,
|
|
120
|
+
OnErrorRT
|
|
121
|
+
>,
|
|
122
|
+
) {
|
|
123
|
+
const params = _useParams()
|
|
124
|
+
if (!shape) return params
|
|
125
|
+
|
|
126
|
+
return tryValidateSync(params, objectSchema(shape), validateOptions)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function useParamsRequired<
|
|
130
|
+
OnErrorRT extends TryValidateSyncOnErrorRT<ObjectSchemaFromShape<Shape>>,
|
|
131
|
+
Shape extends ObjectShape = {},
|
|
132
|
+
>({
|
|
133
|
+
shape,
|
|
134
|
+
children,
|
|
135
|
+
onValidationError,
|
|
136
|
+
onValidationSuccess = () => {},
|
|
137
|
+
validateOptions,
|
|
138
|
+
}: {
|
|
139
|
+
shape: Shape
|
|
140
|
+
children: (
|
|
141
|
+
data: NonNullable<
|
|
142
|
+
TryValidateSyncRT<ObjectSchemaFromShape<Shape>, OnErrorRT>
|
|
143
|
+
>,
|
|
144
|
+
) => ReactNode
|
|
145
|
+
onValidationError: (navigate: Navigate) => void
|
|
146
|
+
onValidationSuccess?: (
|
|
147
|
+
params: NonNullable<
|
|
148
|
+
TryValidateSyncRT<ObjectSchemaFromShape<Shape>, OnErrorRT>
|
|
149
|
+
>,
|
|
150
|
+
) => void
|
|
151
|
+
validateOptions?: TryValidateSyncOptions<
|
|
152
|
+
ObjectSchemaFromShape<Shape>,
|
|
153
|
+
OnErrorRT
|
|
154
|
+
>
|
|
155
|
+
}) {
|
|
156
|
+
const params = useParams(shape, validateOptions)
|
|
157
|
+
const navigate = useNavigate()
|
|
158
|
+
|
|
159
|
+
useEffect(
|
|
160
|
+
() => {
|
|
161
|
+
if (params) onValidationSuccess(params)
|
|
162
|
+
else onValidationError(navigate)
|
|
163
|
+
},
|
|
164
|
+
[], // eslint-disable-line react-hooks/exhaustive-deps
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return params ? children(params) : <></>
|
|
168
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./session"
|