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.
Files changed (147) hide show
  1. package/.eslintrc.json +47 -0
  2. package/.github/workflows/contributing.yaml +8 -0
  3. package/.github/workflows/main.yml +36 -0
  4. package/.prettierignore +5 -0
  5. package/.prettierrc.json +4 -0
  6. package/.vscode/launch.json +22 -0
  7. package/.vscode/settings.json +30 -0
  8. package/CHANGELOG.md +1864 -0
  9. package/CONTRIBUTING.md +3 -0
  10. package/LICENSE.md +3 -0
  11. package/README.md +94 -0
  12. package/codecov.yml +11 -0
  13. package/package.json +139 -0
  14. package/src/api/createApi.ts +84 -0
  15. package/src/api/endpoints/authFactor.ts +31 -0
  16. package/src/api/endpoints/index.ts +9 -0
  17. package/src/api/endpoints/klass.ts +87 -0
  18. package/src/api/endpoints/school.ts +34 -0
  19. package/src/api/endpoints/session.ts +40 -0
  20. package/src/api/endpoints/user.ts +70 -0
  21. package/src/api/index.ts +4 -0
  22. package/src/api/models.ts +144 -0
  23. package/src/api/tagTypes.ts +12 -0
  24. package/src/api/urls.ts +13 -0
  25. package/src/components/App.css +38 -0
  26. package/src/components/App.tsx +150 -0
  27. package/src/components/ClickableTooltip.tsx +43 -0
  28. package/src/components/CopyIconButton.test.tsx +16 -0
  29. package/src/components/CopyIconButton.tsx +27 -0
  30. package/src/components/Countdown.tsx +42 -0
  31. package/src/components/ElevatedAppBar.tsx +41 -0
  32. package/src/components/Image.tsx +41 -0
  33. package/src/components/InputFileButton.tsx +27 -0
  34. package/src/components/ItemizedList.tsx +61 -0
  35. package/src/components/OrderedGrid.tsx +92 -0
  36. package/src/components/ScrollIntoViewLink.tsx +23 -0
  37. package/src/components/SyncError.tsx +14 -0
  38. package/src/components/TablePagination.tsx +132 -0
  39. package/src/components/YouTubeVideo.tsx +26 -0
  40. package/src/components/form/ApiAutocompleteField.tsx +180 -0
  41. package/src/components/form/AutocompleteField.tsx +124 -0
  42. package/src/components/form/CheckboxField.tsx +81 -0
  43. package/src/components/form/CountryField.tsx +68 -0
  44. package/src/components/form/DatePickerField.tsx +119 -0
  45. package/src/components/form/EmailField.tsx +38 -0
  46. package/src/components/form/FirstNameField.tsx +40 -0
  47. package/src/components/form/Form.tsx +82 -0
  48. package/src/components/form/OtpField.tsx +28 -0
  49. package/src/components/form/PasswordField.tsx +71 -0
  50. package/src/components/form/RepeatField.tsx +115 -0
  51. package/src/components/form/SubmitButton.tsx +47 -0
  52. package/src/components/form/TextField.tsx +103 -0
  53. package/src/components/form/UkCountyField.tsx +67 -0
  54. package/src/components/form/index.tsx +28 -0
  55. package/src/components/index.ts +26 -0
  56. package/src/components/page/Banner.tsx +84 -0
  57. package/src/components/page/Notification.tsx +71 -0
  58. package/src/components/page/Page.tsx +73 -0
  59. package/src/components/page/Section.tsx +21 -0
  60. package/src/components/page/TabBar.tsx +131 -0
  61. package/src/components/page/index.ts +10 -0
  62. package/src/components/router/Link.tsx +22 -0
  63. package/src/components/router/LinkButton.tsx +21 -0
  64. package/src/components/router/LinkIconButton.tsx +21 -0
  65. package/src/components/router/LinkListItem.tsx +21 -0
  66. package/src/components/router/LinkTab.tsx +21 -0
  67. package/src/components/router/Navigate.tsx +33 -0
  68. package/src/components/router/index.tsx +12 -0
  69. package/src/components/table/CellStack.tsx +19 -0
  70. package/src/components/table/Table.tsx +55 -0
  71. package/src/components/table/index.tsx +10 -0
  72. package/src/features/InactiveDialog.tsx +40 -0
  73. package/src/features/ScreenTimeDialog.tsx +33 -0
  74. package/src/features/index.ts +4 -0
  75. package/src/fonts/Inter-VariableFont_slnt,wght.ttf +0 -0
  76. package/src/fonts/SpaceGrotesk-VariableFont_wght.ttf +0 -0
  77. package/src/hooks/api.tsx +37 -0
  78. package/src/hooks/auth.tsx +87 -0
  79. package/src/hooks/general.ts +110 -0
  80. package/src/hooks/index.ts +4 -0
  81. package/src/hooks/router.tsx +168 -0
  82. package/src/index.ts +2 -0
  83. package/src/middlewares/index.ts +1 -0
  84. package/src/middlewares/session.ts +16 -0
  85. package/src/public/images/brain.svg +1 -0
  86. package/src/schemas/user.ts +4 -0
  87. package/src/scripts/freshDesk.js +473 -0
  88. package/src/scripts/index.ts +1 -0
  89. package/src/server.js +181 -0
  90. package/src/settings/custom.ts +22 -0
  91. package/src/settings/index.ts +5 -0
  92. package/src/settings/vite.ts +26 -0
  93. package/src/setupTests.ts +1 -0
  94. package/src/slices/createSlice.ts +8 -0
  95. package/src/slices/index.ts +2 -0
  96. package/src/slices/session.ts +32 -0
  97. package/src/theme/ThemedBox.tsx +265 -0
  98. package/src/theme/colors.ts +57 -0
  99. package/src/theme/components/MuiAccordion.tsx +13 -0
  100. package/src/theme/components/MuiAutocomplete.tsx +11 -0
  101. package/src/theme/components/MuiButton.ts +70 -0
  102. package/src/theme/components/MuiCardActions.tsx +12 -0
  103. package/src/theme/components/MuiCheckbox.ts +12 -0
  104. package/src/theme/components/MuiContainer.ts +19 -0
  105. package/src/theme/components/MuiDialog.tsx +16 -0
  106. package/src/theme/components/MuiFormControlLabel.ts +18 -0
  107. package/src/theme/components/MuiFormHelperText.ts +12 -0
  108. package/src/theme/components/MuiGrid2.ts +16 -0
  109. package/src/theme/components/MuiInputBase.ts +14 -0
  110. package/src/theme/components/MuiLink.ts +41 -0
  111. package/src/theme/components/MuiList.ts +12 -0
  112. package/src/theme/components/MuiListItemText.ts +18 -0
  113. package/src/theme/components/MuiMenu.ts +14 -0
  114. package/src/theme/components/MuiMenuItem.ts +15 -0
  115. package/src/theme/components/MuiSelect.ts +16 -0
  116. package/src/theme/components/MuiTab.ts +29 -0
  117. package/src/theme/components/MuiTable.ts +29 -0
  118. package/src/theme/components/MuiTableBody.ts +15 -0
  119. package/src/theme/components/MuiTableHead.ts +26 -0
  120. package/src/theme/components/MuiTabs.ts +26 -0
  121. package/src/theme/components/MuiTextField.ts +86 -0
  122. package/src/theme/components/MuiToolbar.ts +11 -0
  123. package/src/theme/components/MuiTypography.ts +12 -0
  124. package/src/theme/components/_components.ts +93 -0
  125. package/src/theme/components/index.ts +57 -0
  126. package/src/theme/index.ts +25 -0
  127. package/src/theme/palette.ts +98 -0
  128. package/src/theme/spacing.ts +8 -0
  129. package/src/theme/typography.ts +101 -0
  130. package/src/utils/api.test.ts +19 -0
  131. package/src/utils/api.tsx +327 -0
  132. package/src/utils/auth.ts +17 -0
  133. package/src/utils/form.ts +153 -0
  134. package/src/utils/general.test.ts +42 -0
  135. package/src/utils/general.ts +498 -0
  136. package/src/utils/router.test.ts +156 -0
  137. package/src/utils/router.ts +67 -0
  138. package/src/utils/schema.ts +80 -0
  139. package/src/utils/store.ts +31 -0
  140. package/src/utils/test.tsx +82 -0
  141. package/src/utils/theme.tsx +82 -0
  142. package/src/utils/window.ts +9 -0
  143. package/src/vite-env.d.ts +1 -0
  144. package/tsconfig.json +31 -0
  145. package/tsconfig.node.json +11 -0
  146. package/types/fixes.d.ts +18 -0
  147. 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&apos;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
@@ -0,0 +1,4 @@
1
+ export * from "./InactiveDialog"
2
+ export { default as InactiveDialog } from "./InactiveDialog"
3
+ export * from "./ScreenTimeDialog"
4
+ export { default as ScreenTimeDialog } from "./ScreenTimeDialog"
@@ -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,4 @@
1
+ export * from "./api"
2
+ export * from "./auth"
3
+ export * from "./general"
4
+ export * from "./router"
@@ -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,2 @@
1
+ export * from "./theme"
2
+ export { default as theme } from "./theme"
@@ -0,0 +1 @@
1
+ export * from "./session"