From 990e7e1d7e0d84d35010275b8f1958871f1d16f4 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Sat, 11 Jun 2022 05:08:49 +0200 Subject: [PATCH] Refactor huge chunk of code --- .vscode/launch.json | 8 +- components/ActionEventList.tsx | 65 ---- components/ActionLoginTelegram.tsx | 80 ----- components/Avatar.tsx | 12 - components/EventCreate.tsx | 54 --- components/ListEvents.tsx | 18 - components/Loading.tsx | 16 - components/LogoutLink.tsx | 18 - components/README.md | 5 + components/TelegramLoginButton.tsx | 13 - components/WorkInProgress.tsx | 14 - components/auth/README.md | 3 + components/auth/base.ts | 18 + components/auth/requests.tsx | 74 ++++ components/auth/storage.ts | 46 +++ .../auth/telegram/loginButton.module.css | 3 + components/auth/telegram/loginButton.tsx | 19 ++ .../auth/telegram/processing.ts | 41 +-- .../auth/telegram/react-telegram-login.d.ts | 35 ++ components/contexts/login.tsx | 12 - components/editable/BaseEditable.tsx | 15 - components/editable/EditableDateTimeLocal.tsx | 27 -- components/editable/EditableEventDuration.tsx | 43 --- components/editable/EditableFilePicker.tsx | 19 -- components/editable/EditableMarkdown.tsx | 19 -- components/editable/EditableText.tsx | 19 -- components/editable/EditingContext.ts | 9 - components/editable/README.md | 7 - components/errors/ErrorBlock.tsx | 28 -- components/errors/ErrorBoundary.tsx | 42 --- components/errors/ErrorInline.tsx | 27 -- components/events/README.md | 3 + .../events/toolbar/toolToggleEditing.tsx | 36 ++ .../events/views/event.module.css | 16 +- components/events/views/event.tsx | 33 ++ components/extensions/FestaIcon.tsx | 13 - components/extensions/FestaMarkdown.tsx | 18 - components/extensions/README.md | 3 - components/form/FormFromTo.tsx | 37 -- components/form/FormMonorow.tsx | 14 - components/generic/README.md | 3 + components/generic/editable/README.md | 3 + components/generic/editable/base.tsx | 36 ++ components/generic/editable/inputs.tsx | 82 +++++ components/generic/errors/README.md | 3 + components/generic/errors/boundaries.tsx | 79 +++++ .../generic/errors/renderers.module.css | 6 +- components/generic/errors/renderers.tsx | 112 ++++++ components/generic/layouts/monorow.module.css | 8 + components/generic/layouts/monorow.tsx | 16 + components/generic/loading/promise.tsx | 145 ++++++++ components/generic/loading/swr.tsx | 19 ++ components/generic/loading/textInline.tsx | 21 ++ components/generic/renderers/README.md | 3 + .../renderers/datetime.tsx} | 16 +- components/generic/renderers/fontawesome.tsx | 15 + components/generic/renderers/markdown.tsx | 25 ++ components/generic/storage/base.ts | 68 ++++ components/generic/storage/json.ts | 75 ++++ .../generic/toolbar/bar.module.css | 26 +- components/generic/toolbar/bar.tsx | 50 +++ components/generic/toolbar/tool.module.css | 13 + components/generic/toolbar/tool.tsx | 22 ++ .../generic/views/content.module.css | 8 +- components/generic/views/content.tsx | 24 ++ .../generic/views/landing.module.css | 16 +- components/generic/views/landing.tsx | 33 ++ .../generic/views/notice.module.css | 0 components/generic/views/notice.tsx | 21 ++ .../generic/wip/banner.module.css | 2 +- components/generic/wip/banner.tsx | 18 + components/landing/README.md | 3 + components/landing/actions/events.module.css | 28 ++ components/landing/actions/events.tsx | 147 ++++++++ components/landing/actions/login.tsx | 53 +++ components/postcard/PostcardRenderer.tsx | 19 -- components/postcard/README.md | 3 + .../postcard/{PostcardContext.tsx => base.ts} | 16 +- components/postcard/changer.tsx | 33 ++ .../postcard/renderer.module.css | 13 +- components/postcard/renderer.tsx | 25 ++ components/postcard/storage.ts | 17 + .../toolbar/toolToggleVisibility.tsx} | 25 +- components/postcard/usePostcardImage.ts | 14 - components/postcard/useStatePostcard.ts | 14 - components/tools/ToolBar.tsx | 16 - components/tools/ToolToggleEditing.tsx | 20 -- components/view/ViewContent.tsx | 20 -- components/view/ViewEvent.tsx | 29 -- components/view/ViewLanding.tsx | 26 -- components/view/ViewNotice.tsx | 14 - hooks/swr/useEventDetailsSWR.ts | 6 - hooks/swr/useMyEventsSWR.ts | 6 - hooks/useAxios.ts | 25 -- hooks/useAxiosRequest.ts | 76 ----- hooks/useFilePickerState.ts | 21 -- hooks/useStoredLogin.ts | 40 --- next.config.js | 13 +- package.json | 2 +- pages/404.tsx | 23 +- pages/500.tsx | 24 +- pages/_app.tsx | 63 ++-- pages/api/auth/index.ts | 18 + pages/api/auth/telegram.ts | 123 +++++++ pages/api/events/[slug].ts | 136 ++++++-- pages/api/events/index.ts | 90 +++-- pages/api/events/mine.ts | 60 +++- pages/api/login/index.ts | 90 ----- pages/events/[slug].tsx | 115 ++----- pages/index.tsx | 40 ++- public/locales/it-IT/common.json | 23 +- styles/components/form-monorow.css | 22 -- styles/components/list-events.css | 12 - styles/components/telegram-login.css | 3 - styles/elements.css | 6 +- styles/flex.css | 26 ++ styles/globals.css | 17 +- types/api.ts | 5 - types/react-telegram-login.d.ts | 1 - types/user.ts | 20 -- utils/api/authenticator.ts | 98 ++++++ utils/api/bodyValidator.ts | 78 +++++ utils/api/configurator.ts | 29 ++ utils/api/executor.ts | 268 +++++++++++++++ utils/api/index.ts | 86 +++++ utils/api/queryValidator.ts | 72 ++++ utils/api/throwables.ts | 148 ++++++++ utils/authorizeUser.ts | 35 -- utils/dateFields.ts | 20 -- utils/interrupt.ts | 33 -- utils/prismaClient.ts | 18 + utils/queryString.ts | 21 -- utils/restInPeace.ts | 320 ------------------ utils/types/helpers.ts | 1 + yarn.lock | 35 +- 135 files changed, 2908 insertions(+), 1945 deletions(-) delete mode 100644 components/ActionEventList.tsx delete mode 100644 components/ActionLoginTelegram.tsx delete mode 100644 components/Avatar.tsx delete mode 100644 components/EventCreate.tsx delete mode 100644 components/ListEvents.tsx delete mode 100644 components/Loading.tsx delete mode 100644 components/LogoutLink.tsx create mode 100644 components/README.md delete mode 100644 components/TelegramLoginButton.tsx delete mode 100644 components/WorkInProgress.tsx create mode 100644 components/auth/README.md create mode 100644 components/auth/base.ts create mode 100644 components/auth/requests.tsx create mode 100644 components/auth/storage.ts create mode 100644 components/auth/telegram/loginButton.module.css create mode 100644 components/auth/telegram/loginButton.tsx rename utils/TelegramLoginDataClass.ts => components/auth/telegram/processing.ts (72%) create mode 100644 components/auth/telegram/react-telegram-login.d.ts delete mode 100644 components/contexts/login.tsx delete mode 100644 components/editable/BaseEditable.tsx delete mode 100644 components/editable/EditableDateTimeLocal.tsx delete mode 100644 components/editable/EditableEventDuration.tsx delete mode 100644 components/editable/EditableFilePicker.tsx delete mode 100644 components/editable/EditableMarkdown.tsx delete mode 100644 components/editable/EditableText.tsx delete mode 100644 components/editable/EditingContext.ts delete mode 100644 components/editable/README.md delete mode 100644 components/errors/ErrorBlock.tsx delete mode 100644 components/errors/ErrorBoundary.tsx delete mode 100644 components/errors/ErrorInline.tsx create mode 100644 components/events/README.md create mode 100644 components/events/toolbar/toolToggleEditing.tsx rename styles/components/views/event.css => components/events/views/event.module.css (77%) create mode 100644 components/events/views/event.tsx delete mode 100644 components/extensions/FestaIcon.tsx delete mode 100644 components/extensions/FestaMarkdown.tsx delete mode 100644 components/extensions/README.md delete mode 100644 components/form/FormFromTo.tsx delete mode 100644 components/form/FormMonorow.tsx create mode 100644 components/generic/README.md create mode 100644 components/generic/editable/README.md create mode 100644 components/generic/editable/base.tsx create mode 100644 components/generic/editable/inputs.tsx create mode 100644 components/generic/errors/README.md create mode 100644 components/generic/errors/boundaries.tsx rename styles/components/error.css => components/generic/errors/renderers.module.css (60%) create mode 100644 components/generic/errors/renderers.tsx create mode 100644 components/generic/layouts/monorow.module.css create mode 100644 components/generic/layouts/monorow.tsx create mode 100644 components/generic/loading/promise.tsx create mode 100644 components/generic/loading/swr.tsx create mode 100644 components/generic/loading/textInline.tsx create mode 100644 components/generic/renderers/README.md rename components/{extensions/FestaMoment.tsx => generic/renderers/datetime.tsx} (61%) create mode 100644 components/generic/renderers/fontawesome.tsx create mode 100644 components/generic/renderers/markdown.tsx create mode 100644 components/generic/storage/base.ts create mode 100644 components/generic/storage/json.ts rename styles/components/toolbar.css => components/generic/toolbar/bar.module.css (56%) create mode 100644 components/generic/toolbar/bar.tsx create mode 100644 components/generic/toolbar/tool.module.css create mode 100644 components/generic/toolbar/tool.tsx rename styles/components/views/content.css => components/generic/views/content.module.css (75%) create mode 100644 components/generic/views/content.tsx rename styles/components/views/landing.css => components/generic/views/landing.module.css (71%) create mode 100644 components/generic/views/landing.tsx rename styles/components/views/notice.css => components/generic/views/notice.module.css (100%) create mode 100644 components/generic/views/notice.tsx rename styles/components/work-in-progress.css => components/generic/wip/banner.module.css (91%) create mode 100644 components/generic/wip/banner.tsx create mode 100644 components/landing/README.md create mode 100644 components/landing/actions/events.module.css create mode 100644 components/landing/actions/events.tsx create mode 100644 components/landing/actions/login.tsx delete mode 100644 components/postcard/PostcardRenderer.tsx create mode 100644 components/postcard/README.md rename components/postcard/{PostcardContext.tsx => base.ts} (70%) create mode 100644 components/postcard/changer.tsx rename styles/components/postcard.css => components/postcard/renderer.module.css (68%) create mode 100644 components/postcard/renderer.tsx create mode 100644 components/postcard/storage.ts rename components/{tools/ToolToggleVisible.tsx => postcard/toolbar/toolToggleVisibility.tsx} (61%) delete mode 100644 components/postcard/usePostcardImage.ts delete mode 100644 components/postcard/useStatePostcard.ts delete mode 100644 components/tools/ToolBar.tsx delete mode 100644 components/tools/ToolToggleEditing.tsx delete mode 100644 components/view/ViewContent.tsx delete mode 100644 components/view/ViewEvent.tsx delete mode 100644 components/view/ViewLanding.tsx delete mode 100644 components/view/ViewNotice.tsx delete mode 100644 hooks/swr/useEventDetailsSWR.ts delete mode 100644 hooks/swr/useMyEventsSWR.ts delete mode 100644 hooks/useAxios.ts delete mode 100644 hooks/useAxiosRequest.ts delete mode 100644 hooks/useFilePickerState.ts delete mode 100644 hooks/useStoredLogin.ts create mode 100644 pages/api/auth/index.ts create mode 100644 pages/api/auth/telegram.ts delete mode 100644 pages/api/login/index.ts delete mode 100644 styles/components/form-monorow.css delete mode 100644 styles/components/telegram-login.css create mode 100644 styles/flex.css delete mode 100644 types/api.ts delete mode 100644 types/react-telegram-login.d.ts delete mode 100644 types/user.ts create mode 100644 utils/api/authenticator.ts create mode 100644 utils/api/bodyValidator.ts create mode 100644 utils/api/configurator.ts create mode 100644 utils/api/executor.ts create mode 100644 utils/api/index.ts create mode 100644 utils/api/queryValidator.ts create mode 100644 utils/api/throwables.ts delete mode 100644 utils/authorizeUser.ts delete mode 100644 utils/dateFields.ts delete mode 100644 utils/interrupt.ts delete mode 100644 utils/queryString.ts delete mode 100644 utils/restInPeace.ts create mode 100644 utils/types/helpers.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 48f32c1..760f651 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -40,7 +40,7 @@ "name": "Web page", "type": "firefox", "request": "launch", - "url": "http://local.steffo.eu", + "url": "http://nitro.home.steffo.eu", "pathMappings": [ { "url": "webpack://_n_e", @@ -55,7 +55,11 @@ "compounds": [ { "name": "Everything!", - "configurations": ["Web server", "Web page", "Prisma Studio"], + "configurations": [ + "Web server", + "Web page", + "Prisma Studio" + ], "stopAll": true, "presentation": { "group": "Full", diff --git a/components/ActionEventList.tsx b/components/ActionEventList.tsx deleted file mode 100644 index cc5d663..0000000 --- a/components/ActionEventList.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { default as classNames } from "classnames"; -import { useTranslation } from "next-i18next"; -import { HTMLProps } from "react"; -import { useMyEventsSWR } from "../hooks/swr/useMyEventsSWR"; -import { Loading } from "./Loading"; -import { ListEvents } from "./ListEvents"; -import { EventCreate } from "./EventCreate"; - - -export function ActionEventList(props: HTMLProps) { - const { t } = useTranslation() - const { data, error } = useMyEventsSWR() - - const newClassName = classNames(props.className, { - "negative": error, - }) - - let contents: JSX.Element - - if (error) { - contents = <> -

- {t("eventListError")} -

- - {JSON.stringify(error)} - - - } - else if (!data) { - contents = <> -

- -

- - } - else { - if (data.length === 0) { - contents = <> -

- {t("eventListCreateFirst")} -

- - - } - else { - contents = <> -

- {t("eventListDescription")} -

- -

- {t("eventListCreateAnother")} -

- - - } - } - - return ( -
- {contents} -
- ) -} \ No newline at end of file diff --git a/components/ActionLoginTelegram.tsx b/components/ActionLoginTelegram.tsx deleted file mode 100644 index 734124e..0000000 --- a/components/ActionLoginTelegram.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { default as axios, AxiosError } from "axios" -import { default as classNames } from "classnames" -import { useTranslation } from "next-i18next" -import { HTMLProps, useCallback, useState } from "react" -import { LoginContext } from "./contexts/login" -import { ApiError, ApiResult } from "../types/api" -import { FestaLoginData, TelegramLoginData } from "../types/user" -import { useDefinedContext } from "../utils/definedContext" -import { TelegramLoginButton } from "./TelegramLoginButton" - - -export function ActionLoginTelegram({ className, ...props }: HTMLProps) { - const { t } = useTranslation("common") - const [_, setLogin] = useDefinedContext(LoginContext) - const [working, setWorking] = useState(false) - const [error, setError] = useState(null) - - const onLogin = useCallback( - async (data: TelegramLoginData) => { - setError(null) - setWorking(true) - - try { - var response = await axios.post>("/api/login?provider=telegram", data) - } - catch (e) { - const axe = e as AxiosError - setError(axe?.response?.data as ApiError | undefined) - return - } - finally { - setWorking(false) - } - - setLogin(response.data as FestaLoginData) - localStorage.setItem("login", JSON.stringify(response.data)) - }, - [setLogin] - ) - - const newClassName = classNames(className, { - "negative": error, - }) - - let message: JSX.Element - let contents: JSX.Element - - if (error) { - message = t("formTelegramLoginError") - contents = ( -
- - {JSON.stringify(error)} - -
- ) - } - else if (working) { - message = t("formTelegramLoginWorking") - contents = <> - } - else { - message = t("formTelegramLoginDescription") - contents = ( - - ) - } - - return ( -
-

- {message} -

- {contents} -
- ) -} \ No newline at end of file diff --git a/components/Avatar.tsx b/components/Avatar.tsx deleted file mode 100644 index a9fc24f..0000000 --- a/components/Avatar.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { default as Image, ImageProps } from "next/image"; -import { default as classNames } from "classnames" - -export function Avatar(props: ImageProps) { - return ( - - ) -} \ No newline at end of file diff --git a/components/EventCreate.tsx b/components/EventCreate.tsx deleted file mode 100644 index 3c35397..0000000 --- a/components/EventCreate.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Event } from "@prisma/client" -import { useTranslation } from "next-i18next" -import { useRouter } from "next/router" -import { useState } from "react" -import { useAxiosRequest } from "../hooks/useAxiosRequest" -import { Loading } from "./Loading" -import { ErrorBlock } from "./errors/ErrorBlock" -import { FestaIcon } from "./extensions/FestaIcon" -import { faPlus } from "@fortawesome/free-solid-svg-icons" -import { FormMonorow } from "./form/FormMonorow" - -export function EventCreate() { - const { t } = useTranslation() - const router = useRouter() - const [name, setName] = useState("") - - const createEvent = useAxiosRequest( - { - method: "POST", - url: "/api/events/", - data: { name } - }, - (response) => { - router.push(`/events/${response.data.slug}`) - } - ) - - if (createEvent.running) return - if (createEvent.data) return - - return <> - { e.preventDefault(); createEvent.run() }} - noValidate - > - setName(e.target.value)} - required - /> - - - {createEvent.error ? : null} - -} \ No newline at end of file diff --git a/components/ListEvents.tsx b/components/ListEvents.tsx deleted file mode 100644 index 080a225..0000000 --- a/components/ListEvents.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Event } from "@prisma/client" -import { default as Link } from "next/link" - -type ListEventsProps = { - data: Event[] -} - -export function ListEvents(props: ListEventsProps) { - const contents = props.data.map(e => ( -
  • - - {e.name} - -
  • - )) - - return
      {contents}
    -} diff --git a/components/Loading.tsx b/components/Loading.tsx deleted file mode 100644 index 48be918..0000000 --- a/components/Loading.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { faAsterisk } from "@fortawesome/free-solid-svg-icons"; -import { FestaIcon } from "./extensions/FestaIcon"; - -type LoadingProps = { - text: string -} - -export function Loading(props: LoadingProps) { - return ( - - -   - {props.text} - - ) -} \ No newline at end of file diff --git a/components/LogoutLink.tsx b/components/LogoutLink.tsx deleted file mode 100644 index 78f5bde..0000000 --- a/components/LogoutLink.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useTranslation } from "next-i18next" -import { LoginContext } from "./contexts/login" -import { useDefinedContext } from "../utils/definedContext" - -export function LogoutLink() { - const [login, setLogin] = useDefinedContext(LoginContext) - const {t} = useTranslation("common") - - return ( - - ( - setLogin(null)}> - {t("introTelegramLogout")} - - ) - - ) -} \ No newline at end of file diff --git a/components/README.md b/components/README.md new file mode 100644 index 0000000..ec024b9 --- /dev/null +++ b/components/README.md @@ -0,0 +1,5 @@ +# Components + +This directory contains all non-page components used by Festa. + +The structure of the subdirectories is the following: `PAGE/COMPONENTs` diff --git a/components/TelegramLoginButton.tsx b/components/TelegramLoginButton.tsx deleted file mode 100644 index 304f682..0000000 --- a/components/TelegramLoginButton.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { default as OriginalTelegramLoginButton } from 'react-telegram-login' - - -export function TelegramLoginButton(props: any) { - return ( -
    - -
    - ) -} \ No newline at end of file diff --git a/components/WorkInProgress.tsx b/components/WorkInProgress.tsx deleted file mode 100644 index d019fd2..0000000 --- a/components/WorkInProgress.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { faBrush } from "@fortawesome/free-solid-svg-icons" -import { useTranslation } from "next-i18next" -import { FestaIcon } from "./extensions/FestaIcon" - - -export function WorkInProgress() { - const {t} = useTranslation() - - return ( -
    - {t("workInProgress")} -
    - ) -} \ No newline at end of file diff --git a/components/auth/README.md b/components/auth/README.md new file mode 100644 index 0000000..b587362 --- /dev/null +++ b/components/auth/README.md @@ -0,0 +1,3 @@ +# Auth + +This directory contains components related to authentication and authorization of users in the app. diff --git a/components/auth/base.ts b/components/auth/base.ts new file mode 100644 index 0000000..e3d9608 --- /dev/null +++ b/components/auth/base.ts @@ -0,0 +1,18 @@ +import { Token, User } from "@prisma/client" +import { createStateContext } from "../../utils/stateContext" + + +/** + * Data about the user's current login status: + * - `null` if the user is not logged in + * - a {@link Token} with information about the {@link User} if the user is logged in + */ +export type AuthContextContents = Token & { user: User } | null + + +/** + * {@link createStateContext|state context} containing {@link AuthContextContents}. + * + * Please note that the data containing in this context is not validated, and has to be validated by the server on every request. + */ +export const AuthContext = createStateContext() diff --git a/components/auth/requests.tsx b/components/auth/requests.tsx new file mode 100644 index 0000000..186fe21 --- /dev/null +++ b/components/auth/requests.tsx @@ -0,0 +1,74 @@ +import { AxiosInstance, AxiosRequestConfig, AxiosResponse, default as axios } from "axios"; +import { AuthContext, AuthContextContents } from "./base"; +import { ReactNode, useCallback, useContext } from "react"; +import { usePromise } from "../generic/loading/promise"; +import { default as useSWR, SWRConfig } from "swr" + +/** + * Hook which creates an {@link AxiosInstance} based on the current {@link AuthContext}, using the appropriate `Authentication` header the context is non-null. + */ +export function useAxios(): AxiosInstance { + const authContext = useContext(AuthContext) + + let auth: AuthContextContents | undefined = authContext?.[0] + + return axios.create({ + headers: { + Authorization: auth ? `Bearer ${auth.token}` : false, + }, + }) +} + + +/** + * Hook which returns the callback to use as fetcher in {@link useSWR} calls. + * + * Preferably set through {@link SWRConfig}. + */ +export function useAxiosSWRFetcher() { + const axios = useAxios() + + return useCallback( + async (resource: string, init: AxiosRequestConfig) => { + const response = await axios.get(resource, init) + return response.data + }, + [axios] + ) +} + + +export type AxiosSWRFetcherProviderProps = { + children: ReactNode, +} + + +/** + * Component which provides the fetcher to {@link useSWR} via {@link useAxiosSWRFetcher}. + */ +export const AxiosSWRFetcherProvider = ({ children }: AxiosSWRFetcherProviderProps) => { + return ( + + {children} + + ) +} + + +/** + * Hook which uses {@link useAxios} to perform a single HTTP request with the given configuration, tracking the status of the request and any errors that may arise from it. + */ +export function useAxiosRequest(config: AxiosRequestConfig = {}) { + const axios = useAxios() + + const performRequest = useCallback( + async (funcConfig: AxiosRequestConfig = {}): Promise> => { + return await axios.request({ ...config, ...funcConfig }) + }, + [config] + ) + + const promiseHook = usePromise(performRequest) + + return { ...promiseHook, data: promiseHook.result?.data } +} diff --git a/components/auth/storage.ts b/components/auth/storage.ts new file mode 100644 index 0000000..6c9cb7d --- /dev/null +++ b/components/auth/storage.ts @@ -0,0 +1,46 @@ +import { useCallback, useState } from "react"; +import { localStorageSaveJSON, useLocalStorageJSONLoad, useLocalStorageJSONState } from "../generic/storage/json"; +import { AuthContextContents } from "./base"; + + +/** + * Hook holding as state the {@link AuthContextContents}. + */ +export function useStateAuth() { + return useState(null) +} + + +/** + * Hook which combines {@link useState}, {@link useLocalStorageJSONLoad}, and {@link localStorageSaveJSON}. + */ +export function useLocalStorageAuthState(key: string) { + const [state, setStateInner] = useState(undefined); + + const validateAndSetState = useCallback( + (data: any) => { + // Convert expiresAt to a Date, since it is stringified on serialization + data = { ...data, expiresAt: new Date(data.expiresAt) } + + // Refuse to load expired data + if (new Date().getTime() >= data.expiresAt.getTime()) { + return + } + + setStateInner(data) + }, + [setStateInner] + ) + + useLocalStorageJSONLoad(key, validateAndSetState); + + const setState = useCallback( + (value: AuthContextContents) => { + validateAndSetState(value); + localStorageSaveJSON(key, value); + }, + [key, validateAndSetState] + ); + + return [state, setState]; +} diff --git a/components/auth/telegram/loginButton.module.css b/components/auth/telegram/loginButton.module.css new file mode 100644 index 0000000..08f57d7 --- /dev/null +++ b/components/auth/telegram/loginButton.module.css @@ -0,0 +1,3 @@ +.telegramLoginButtonContainer > div { + height: 40px; +} \ No newline at end of file diff --git a/components/auth/telegram/loginButton.tsx b/components/auth/telegram/loginButton.tsx new file mode 100644 index 0000000..6ede399 --- /dev/null +++ b/components/auth/telegram/loginButton.tsx @@ -0,0 +1,19 @@ +import "./react-telegram-login.d.ts" +import { default as OriginalTelegramLoginButton, TelegramLoginButtonProps } from "react-telegram-login" +import style from "./loginButton.module.css" +import { memo, useCallback } from "react" + + +/** + * Wrapper for {@link OriginalTelegramLoginButton}, configuring it for React. + */ +export const TelegramLoginButton = memo((props: TelegramLoginButtonProps) => { + return ( +
    + +
    + ) +}) diff --git a/utils/TelegramLoginDataClass.ts b/components/auth/telegram/processing.ts similarity index 72% rename from utils/TelegramLoginDataClass.ts rename to components/auth/telegram/processing.ts index f16378e..8b28ad9 100644 --- a/utils/TelegramLoginDataClass.ts +++ b/components/auth/telegram/processing.ts @@ -1,31 +1,32 @@ +import { default as TelegramLoginButton, TelegramLoginResponse } from "react-telegram-login" import { default as nodecrypto } from "crypto" -import { TelegramLoginData } from "../types/user" +import { AccountTelegram } from "@prisma/client" /** - * A {@link TelegramLoginData} object extended with various utility methods. + * A {@link TelegramLoginResponse} object extended with various utility methods, and with its fields renamed to follow the camelCase JS convention. */ -export class TelegramLoginDataClass { +export class TelegramLoginObject { id: number firstName: string lastName?: string username?: string photoUrl?: string + lang?: string authDate: Date hash: string - lang?: string /** - * Construct a {@link TelegramLoginDataClass} object from a {@link TelegramLoginData}, validating it in the process. + * Construct a {@link TelegramLoginObject} object from a {@link TelegramLoginData}. * * @param u The {@link TelegramLoginData} to use. */ - constructor(u: TelegramLoginData) { - if(!u.id) throw new Error("Missing `id`") - if(!u.first_name) throw new Error("Missing `first_name`") - if(!u.auth_date) throw new Error("Missing `auth_date`") - if(!u.hash) throw new Error("Missing `hash`") - + constructor(u: TelegramLoginResponse) { + if (!u.id) throw new Error("Missing `id`") + if (!u.first_name) throw new Error("Missing `first_name`") + if (!u.auth_date) throw new Error("Missing `auth_date`") + if (!u.hash) throw new Error("Missing `hash`") + this.id = u.id this.firstName = u.first_name this.lastName = u.last_name @@ -34,18 +35,18 @@ export class TelegramLoginDataClass { this.authDate = new Date(u.auth_date * 1000) // https://stackoverflow.com/a/12372720/4334568 - if(isNaN(this.authDate.getTime())) throw new Error("Invalid `auth_date`") + if (isNaN(this.authDate.getTime())) throw new Error("Invalid `auth_date`") this.hash = u.hash this.lang = u.lang } /** - * Convert this object back into a {@link TelegramLoginData}. + * Convert this object back into a {@link TelegramLoginResponse}. * - * @return The {@link TelegramLoginData} object, ready to be serialized. + * @return The {@link TelegramLoginResponse} object, ready to be serialized. */ - toObject(): TelegramLoginData { + toObject(): TelegramLoginResponse { return { id: this.id, first_name: this.firstName, @@ -61,7 +62,7 @@ export class TelegramLoginDataClass { /** * Convert this object into a partial {@link AccountTelegram} database object. */ - toDatabase() { + toDatabase(): Pick { return { telegramId: this.id, firstName: this.firstName, @@ -89,7 +90,7 @@ export class TelegramLoginDataClass { } /** - * Check if the `auth_date` of the response is recent: it must be in the past, but within `maxMs` from the current date. + * Check if the `authDate` of the response is recent: it must be in the past, but within `maxMs` from the current date. * * @param maxMs The maximum number of milliseconds that may pass after authentication for the response to be considered valid. * @returns `true` if the request was sent within the requested timeframe, `false` otherwise. @@ -107,7 +108,7 @@ export class TelegramLoginDataClass { * @param token The bot token used to validate the signature. * @returns The calculated value of the `hash` parameter. */ - hmac(token: string): string { + hmac(token: string): string { const hash = nodecrypto.createHash("sha256") hash.update(token) const hmac = nodecrypto.createHmac("sha256", hash.digest()) @@ -133,8 +134,8 @@ export class TelegramLoginDataClass { * Get the Telegram "displayed name" of the user represented by this object. */ toTelegramName(): string { - if(this.username) return this.username - else if(this.lastName) return this.firstName + " " + this.lastName + if (this.username) return this.username + else if (this.lastName) return this.firstName + " " + this.lastName else return this.firstName } } diff --git a/components/auth/telegram/react-telegram-login.d.ts b/components/auth/telegram/react-telegram-login.d.ts new file mode 100644 index 0000000..37bae51 --- /dev/null +++ b/components/auth/telegram/react-telegram-login.d.ts @@ -0,0 +1,35 @@ +declare module "react-telegram-login" { + /** + * Serializable Telegram login data including technical information, exactly as returned by Telegram Login. + */ + export type TelegramLoginResponse = { + id: number + first_name: string + last_name?: string + username?: string + photo_url?: string + lang?: string + auth_date: number + hash: string + } + + type TelegramLoginButtonPropsOnAuth = { + dataOnauth: (data: TelegramLoginResponse) => void, + } + + type TelegramLoginButtonPropsAuthUrl = { + dataAuthUrl: string, + } + + export type TelegramLoginButtonProps = (TelegramLoginButtonPropsOnAuth | TelegramLoginButtonPropsAuthUrl) & { + botName: string, + buttonSize: "small" | "medium" | "large", + cornerRadius?: number, + requestAccess: undefined | "write", + usePic?: boolean, + lang?: string, + widgetVersion?: number, + } + + export default TelegramLoginButton = (props: TelegramLoginButtonProps) => JSX.Element +}; \ No newline at end of file diff --git a/components/contexts/login.tsx b/components/contexts/login.tsx deleted file mode 100644 index eff5c65..0000000 --- a/components/contexts/login.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { FestaLoginData } from "../../types/user"; -import { createStateContext } from "../../utils/stateContext"; - - -/** - * Context containing data about the user's current login status: - * - `null` if the user is not logged in - * - an instance of {@link FestaLoginData} if the user is logged in - * - * Please note that the data containing in this context is not validated, and will be validated by the server on every request. - */ -export const LoginContext = createStateContext() diff --git a/components/editable/BaseEditable.tsx b/components/editable/BaseEditable.tsx deleted file mode 100644 index a7432b9..0000000 --- a/components/editable/BaseEditable.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ReactNode } from "react"; -import { useDefinedContext } from "../../utils/definedContext"; -import { EditingContext } from "./EditingContext"; - -type EditableProps = { - editing: JSX.Element, - preview: JSX.Element, -} - - -export function BaseEditable({ editing, preview }: EditableProps) { - const [isEditing,] = useDefinedContext(EditingContext) - - return isEditing ? editing : preview -} diff --git a/components/editable/EditableDateTimeLocal.tsx b/components/editable/EditableDateTimeLocal.tsx deleted file mode 100644 index f67de3a..0000000 --- a/components/editable/EditableDateTimeLocal.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { HTMLProps } from "react"; -import { toDatetimeLocal } from "../../utils/dateFields"; -import { FestaMoment } from "../extensions/FestaMoment"; -import { BaseEditable } from "./BaseEditable"; - - -export type EditableDateTimeLocalProps = Omit, "value" | "max" | "min"> & { value: Date | null, max?: Date, min?: Date } - - -export function EditableDateTimeLocal(props: EditableDateTimeLocalProps) { - return ( - - } - preview={ - - } - /> - ) -} \ No newline at end of file diff --git a/components/editable/EditableEventDuration.tsx b/components/editable/EditableEventDuration.tsx deleted file mode 100644 index ee1a7c1..0000000 --- a/components/editable/EditableEventDuration.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { faCalendar, faChevronRight } from "@fortawesome/free-solid-svg-icons"; -import { useDefinedContext } from "../../utils/definedContext"; -import { FestaIcon } from "../extensions/FestaIcon"; -import { FormFromTo } from "../form/FormFromTo"; -import { EditableDateTimeLocal, EditableDateTimeLocalProps } from "./EditableDateTimeLocal"; -import { EditingContext } from "./EditingContext"; - - -type EditableEventDurationProps = { - startProps: EditableDateTimeLocalProps, - endProps: EditableDateTimeLocalProps, -} - - -export function EditableEventDuration({ startProps, endProps }: EditableEventDurationProps) { - const [editing,] = useDefinedContext(EditingContext) - - return ( - - } - start={ - - } - connector={ - - } - end={ - - } - /> - ) -} \ No newline at end of file diff --git a/components/editable/EditableFilePicker.tsx b/components/editable/EditableFilePicker.tsx deleted file mode 100644 index 0441620..0000000 --- a/components/editable/EditableFilePicker.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { HTMLProps } from "react"; -import { BaseEditable } from "./BaseEditable"; - - -/** - * Controlled input component which displays an `input[type="file"]` in editing mode, and is invisible in preview mode. - * - * Has no value due to how file inputs function in JS and React. - */ -export function EditableFilePicker(props: HTMLProps & { value?: undefined }) { - return ( - - } - preview={<>} - /> - ) -} \ No newline at end of file diff --git a/components/editable/EditableMarkdown.tsx b/components/editable/EditableMarkdown.tsx deleted file mode 100644 index 0096cd0..0000000 --- a/components/editable/EditableMarkdown.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { HTMLProps } from "react"; -import { FestaMarkdown } from "../extensions/FestaMarkdown"; -import { BaseEditable } from "./BaseEditable"; - -/** - * Controlled input component which displays a `textarea` in editing mode, and renders the input in Markdown using {@link FestaMarkdown} in preview mode. - */ -export function EditableMarkdown(props: HTMLProps & { value: string }) { - return ( - - } - preview={ - - } - /> - ) -} \ No newline at end of file diff --git a/components/editable/EditableText.tsx b/components/editable/EditableText.tsx deleted file mode 100644 index 3c6a60d..0000000 --- a/components/editable/EditableText.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { HTMLProps } from "react"; -import { BaseEditable } from "./BaseEditable"; - - -/** - * Controlled input component which displays an `input[type="text"]` in editing mode, and a `span` displaying the input in preview mode. - */ -export function EditableText(props: HTMLProps & { value: string }) { - return ( - - } - preview={ - {props.value} - } - /> - ) -} \ No newline at end of file diff --git a/components/editable/EditingContext.ts b/components/editable/EditingContext.ts deleted file mode 100644 index 2901ba2..0000000 --- a/components/editable/EditingContext.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createStateContext } from "../../utils/stateContext"; - - -/** - * {@link createStateContext State context} representing the editing state of a form. - * - * If `true`, the components should be editable, while if `false`, the components should preview their contents. - */ -export const EditingContext = createStateContext() diff --git a/components/editable/README.md b/components/editable/README.md deleted file mode 100644 index 28a3dc5..0000000 --- a/components/editable/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Editables - -This folder contains controlled input components with two modes: a "editing" mode, which displays a box where the user can input data, and a "preview" mode, which renders the value of the data input by the user. - -For example, [`EditableMarkdown`](EditableMarkdown.tsx) displays a `textarea` in editing mode, and renders the Markdown into a `div` in preview mode. - -The mode of the elements is determined by the current value of the [`EditingContext`](EditingContext.ts) they are in. diff --git a/components/errors/ErrorBlock.tsx b/components/errors/ErrorBlock.tsx deleted file mode 100644 index 6318ecd..0000000 --- a/components/errors/ErrorBlock.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { faCircleExclamation } from "@fortawesome/free-solid-svg-icons"; -import { FestaIcon } from "../extensions/FestaIcon"; - -type ErrorBlockProps = { - error: Error, - text: string -} - -export function ErrorBlock(props: ErrorBlockProps) { - return ( -
    -

    - -   - - {props.text} - -

    -
    -                
    -                    {props.error.name} 
    -                    : 
    -                    {props.error.message}
    -                
    -            
    -
    - ) -} \ No newline at end of file diff --git a/components/errors/ErrorBoundary.tsx b/components/errors/ErrorBoundary.tsx deleted file mode 100644 index ba439e3..0000000 --- a/components/errors/ErrorBoundary.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Component, ErrorInfo, ReactNode } from "react"; -import { ViewNotice } from "../view/ViewNotice"; -import { ErrorBlock } from "./ErrorBlock"; - -type ErrorBoundaryProps = { - text: string, - children: ReactNode, -} - -type ErrorBoundaryState = { - error?: Error, - errorInfo?: ErrorInfo, -} - - -export class ErrorBoundary extends Component { - constructor(props: ErrorBoundaryProps) { - super(props) - this.state = {error: undefined, errorInfo: undefined} - } - - componentDidCatch(error: Error, errorInfo: ErrorInfo) { - this.setState(state => { - return {...state, error, errorInfo} - }) - } - - render() { - if(this.state.error) { - return ( - - } - /> - ) - } - else { - return this.props.children - } - } -} \ No newline at end of file diff --git a/components/errors/ErrorInline.tsx b/components/errors/ErrorInline.tsx deleted file mode 100644 index 0b4fd66..0000000 --- a/components/errors/ErrorInline.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { faCircleExclamation } from "@fortawesome/free-solid-svg-icons"; -import { FestaIcon } from "../extensions/FestaIcon"; - -type ErrorInlineProps = { - error: Error, - text?: string -} - -export function ErrorInline(props: ErrorInlineProps) { - return ( - - -   - {props.text ? - <> - - {props.text} - -   - - : null} - - {JSON.stringify(props.error)} - - - ) -} \ No newline at end of file diff --git a/components/events/README.md b/components/events/README.md new file mode 100644 index 0000000..fc9f8ec --- /dev/null +++ b/components/events/README.md @@ -0,0 +1,3 @@ +# Event + +This directory contains components related to the event details page. diff --git a/components/events/toolbar/toolToggleEditing.tsx b/components/events/toolbar/toolToggleEditing.tsx new file mode 100644 index 0000000..52bb2ba --- /dev/null +++ b/components/events/toolbar/toolToggleEditing.tsx @@ -0,0 +1,36 @@ +import { faBinoculars, faPencil } from "@fortawesome/free-solid-svg-icons" +import { useTranslation } from "next-i18next" +import { useDefinedContext } from "../../../utils/definedContext" +import { EditingContext, EditingMode } from "../../generic/editable/base" +import { FestaIcon } from "../../generic/renderers/fontawesome" +import { Tool } from "../../generic/toolbar/tool" + + +/** + * {@link ToolBar} {@link Tool} which switches between {@link EditingMode}s of the surrounding context. + */ +export function ToolToggleEditing() { + const { t } = useTranslation() + const [editing, setEditing] = useDefinedContext(EditingContext) + + if (editing === EditingMode.EDIT) { + return ( + setEditing(EditingMode.VIEW)} + > + + + ) + } + else { + return ( + setEditing(EditingMode.EDIT)} + > + + + ) + } +} diff --git a/styles/components/views/event.css b/components/events/views/event.module.css similarity index 77% rename from styles/components/views/event.css rename to components/events/views/event.module.css index 36549cd..4bb05ac 100644 --- a/styles/components/views/event.css +++ b/components/events/views/event.module.css @@ -1,6 +1,6 @@ -.view-event { +.viewEvent { display: grid; - grid-template-areas: + grid-template-areas: "x1 ti ti ti x4" "x1 x2 po x3 x4" "x1 x2 de x3 x4" @@ -16,8 +16,8 @@ } @media (max-width: 800px) { - .view-event { - grid-template-areas: + .viewEvent { + grid-template-areas: "ti" "po" "de" @@ -29,19 +29,19 @@ } } -.view-event-title { +.viewEventTitle { grid-area: ti; text-align: center; } -.view-event-postcard { +.viewEventPostcard { grid-area: po; } -.view-event-description { +.viewEventDescription { grid-area: de; } -.view-event-daterange { +.viewEventDaterange { grid-area: dr; } \ No newline at end of file diff --git a/components/events/views/event.tsx b/components/events/views/event.tsx new file mode 100644 index 0000000..d3e6532 --- /dev/null +++ b/components/events/views/event.tsx @@ -0,0 +1,33 @@ +import { ReactNode } from "react" +import style from "./event.module.css" + + +export type ViewEventProps = { + title: ReactNode, + postcard: ReactNode, + description: ReactNode, + daterange: ReactNode, +} + + +/** + * View intended for use in the event details page. + */ +export const ViewEvent = (props: ViewEventProps) => { + return ( +
    +

    + {props.title} +

    +
    + {props.postcard} +
    +
    + {props.description} +
    +
    + {props.daterange} +
    +
    + ) +} \ No newline at end of file diff --git a/components/extensions/FestaIcon.tsx b/components/extensions/FestaIcon.tsx deleted file mode 100644 index 9be10e4..0000000 --- a/components/extensions/FestaIcon.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { FontAwesomeIcon, FontAwesomeIconProps } from "@fortawesome/react-fontawesome"; -import { default as classNames } from "classnames"; - -export function FestaIcon(props: FontAwesomeIconProps) { - const newClassName = classNames(props.className, "icon") - - return ( - - ) -} \ No newline at end of file diff --git a/components/extensions/FestaMarkdown.tsx b/components/extensions/FestaMarkdown.tsx deleted file mode 100644 index 923cd2f..0000000 --- a/components/extensions/FestaMarkdown.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { default as ReactMarkdown } from "react-markdown" - -type FestaMarkdownProps = { - markdown: string, -} - -export function FestaMarkdown({markdown}: FestaMarkdownProps) { - return ( - - {markdown} - - ) -} \ No newline at end of file diff --git a/components/extensions/README.md b/components/extensions/README.md deleted file mode 100644 index 1ad5265..0000000 --- a/components/extensions/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Extensions - -This folder contains various components which add, improve or change features to an HTML element or React component that already exists. diff --git a/components/form/FormFromTo.tsx b/components/form/FormFromTo.tsx deleted file mode 100644 index 312c25c..0000000 --- a/components/form/FormFromTo.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import classNames from "classnames" -import { HTMLProps, memo } from "react" - - -export type FormFromToProps = HTMLProps & { - preview: boolean, -} - -export const FormFromTo = memo((props: FormFromToProps) => { - return ( -
    - ) -}) - - -export type FormFromToPartProps = HTMLProps & { - part: "icon" | "start" | "connector" | "end" -} - -export const FormFromToPart = memo((props: FormFromToPartProps) => { - return ( -
    - ) -}) diff --git a/components/form/FormMonorow.tsx b/components/form/FormMonorow.tsx deleted file mode 100644 index df3633a..0000000 --- a/components/form/FormMonorow.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import classNames from "classnames" -import { HTMLProps, ReactNode } from "react" - -type FormMonorowProps = { - children: ReactNode -} - -export function FormMonorow(props: FormMonorowProps & HTMLProps) { - return ( -
    - {props.children} -
    - ) -} \ No newline at end of file diff --git a/components/generic/README.md b/components/generic/README.md new file mode 100644 index 0000000..af1ca82 --- /dev/null +++ b/components/generic/README.md @@ -0,0 +1,3 @@ +# Generic components + +This directory contains components shared between multiple pages. diff --git a/components/generic/editable/README.md b/components/generic/editable/README.md new file mode 100644 index 0000000..1318b98 --- /dev/null +++ b/components/generic/editable/README.md @@ -0,0 +1,3 @@ +# Generic editables + +Editables are extended `input` elements which display differently based on the `EditingMode` of the `EditingContext` they are contained in. diff --git a/components/generic/editable/base.tsx b/components/generic/editable/base.tsx new file mode 100644 index 0000000..eded58b --- /dev/null +++ b/components/generic/editable/base.tsx @@ -0,0 +1,36 @@ +import { useDefinedContext } from "../../../utils/definedContext"; +import { createStateContext } from "../../../utils/stateContext"; + +/** + * The mode the editing context is currently in. + */ +export enum EditingMode { + /** + * The page is being (pre)viewed. + */ + VIEW = "view", + + /** + * The page is being edited. + */ + EDIT = "edit", +} + +/** + * The context of a previewable `form`. + */ +export const EditingContext = createStateContext() + + +export type EditingModeBranchProps = { + [Mode in EditingMode]: JSX.Element +} + +/** + * Component branching between its props based on the {@link EditingMode} of its innermost surrounding context. + */ +export const EditingModeBranch = (props: EditingModeBranchProps) => { + const [mode] = useDefinedContext(EditingContext) + + return props[mode] +} diff --git a/components/generic/editable/inputs.tsx b/components/generic/editable/inputs.tsx new file mode 100644 index 0000000..242192b --- /dev/null +++ b/components/generic/editable/inputs.tsx @@ -0,0 +1,82 @@ +import { ComponentPropsWithoutRef } from "react" +import { FestaMoment } from "../renderers/datetime" +import { FestaMarkdownRenderer } from "../renderers/markdown" +import { EditingModeBranch } from "./base" + + +type TextInputProps = ComponentPropsWithoutRef<"input"> & { value: string } +type FileInputProps = ComponentPropsWithoutRef<"input"> & { value?: undefined } +type TextAreaProps = ComponentPropsWithoutRef<"textarea"> & { value: string } + + +/** + * Controlled input component which displays an `input[type="text"]` in edit mode, and a `span` displaying the input in view mode. + */ +export const EditableText = (props: TextInputProps) => { + return ( + + } + view={ + {props.value} + } + /> + ) +} + + +/** + * Controlled input component which displays an `input[type="file"]` in edit mode, and is invisible in view mode. + * + * Has no value due to how file inputs function in JS and React. + */ +export const EditableFilePicker = (props: FileInputProps) => { + return ( + + } + view={ + <> + } + /> + ) +} + + +/** + * Controlled input component which displays a `textarea` in edit mode, and renders the input in Markdown using {@link FestaMarkdownRenderer} in view mode. + */ +export const EditableMarkdown = (props: TextAreaProps) => { + return ( + + } + view={ + + } + /> + ) +} + + +/** + * Controlled input component which displays a `input[type="datetime-local"]` in edit mode, and renders the selected datetime using {@link FestaMoment} in view mode. + */ +export const EditableDateTimeLocal = (props: TextInputProps) => { + return ( + + } + view={ + + } + /> + ) +} \ No newline at end of file diff --git a/components/generic/errors/README.md b/components/generic/errors/README.md new file mode 100644 index 0000000..3da9d19 --- /dev/null +++ b/components/generic/errors/README.md @@ -0,0 +1,3 @@ +# Errors + +This directory contains components that handle and display errors occurring in the application. diff --git a/components/generic/errors/boundaries.tsx b/components/generic/errors/boundaries.tsx new file mode 100644 index 0000000..6798ff3 --- /dev/null +++ b/components/generic/errors/boundaries.tsx @@ -0,0 +1,79 @@ +import { Component, ErrorInfo, ReactNode } from "react"; +import { ViewNotice } from "../views/notice"; +import { ErrorBlock } from "./renderers"; + + +export type ErrorBoundaryProps = { + text: string, + children: ReactNode, +} + + +export type ErrorBoundaryState = { + error?: Error, + errorInfo?: ErrorInfo, +} + + +/** + * Error boundary component which displays a {@link ViewNotice} with an {@link ErrorBlock} containing the occurred error inside. + * + * To be used in `pages/_app`. + */ +export class PageErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props) + this.state = { error: undefined, errorInfo: undefined } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + this.setState(state => { + return { ...state, error, errorInfo } + }) + } + + render() { + if (this.state.error) { + return ( + + } + /> + ) + } + else { + return this.props.children + } + } +} + + +/** + * Error boundary component which displays an {@link ErrorBlock} containing the occurred error inside. + * + * To be used in other components. + */ +export class BlockErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props) + this.state = { error: undefined, errorInfo: undefined } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + this.setState(state => { + return { ...state, error, errorInfo } + }) + } + + render() { + if (this.state.error) { + return ( + + ) + } + else { + return this.props.children + } + } +} \ No newline at end of file diff --git a/styles/components/error.css b/components/generic/errors/renderers.module.css similarity index 60% rename from styles/components/error.css rename to components/generic/errors/renderers.module.css index 75c426c..f7fa7e6 100644 --- a/styles/components/error.css +++ b/components/generic/errors/renderers.module.css @@ -1,6 +1,8 @@ -.error-block pre { +.errorBlock > pre { display: inline-block; margin: 0; + max-width: 100%; + text-align: left; -} +} \ No newline at end of file diff --git a/components/generic/errors/renderers.tsx b/components/generic/errors/renderers.tsx new file mode 100644 index 0000000..f46a794 --- /dev/null +++ b/components/generic/errors/renderers.tsx @@ -0,0 +1,112 @@ +import { faCircleExclamation } from "@fortawesome/free-solid-svg-icons"; +import { FestaIcon } from "../renderers/fontawesome"; +import { default as classNames } from "classnames" +import style from "./renderers.module.css" +import { memo } from "react"; +import { AxiosError } from "axios"; + + +export type ErrorTraceProps = { + error: Error, + inline: boolean, +} + + +export const ErrorTrace = memo((props: ErrorTraceProps) => { + if (props.error instanceof AxiosError) { + if (props.error.response) { + if (props.error.response.data) { + const json = JSON.stringify(props.error.response.data, undefined, props.inline ? undefined : 4).replaceAll("\\n", "\n") + + return ( + + API {props.error.response.status} + :  + {json} + + ) + } + + return ( + + HTTP {props.error.response.status} + :  + {props.error.message} + + ) + } + + return ( + + {props.error.code} + :  + {props.error.message} + + ) + } + + return ( + + {props.error.name} + :  + {props.error.message} + + ) +}) + + +export type ErrorInlineProps = { + error: Error, + text?: string +} + +/** + * Component rendering a `span` element containing an error passed to it as props. + * + * May or may not include some text to display to the user. + */ +export const ErrorInline = memo((props: ErrorInlineProps) => { + return ( + + +   + {props.text ? + <> + + {props.text} + +   + + : null} + + + ) +}) + + +export type ErrorBlockProps = { + error: Error, + text: string +} + +/** + * Component rendering a `div` element containing an error passed to it as props. + * + * Must include some text to display to the user. + */ +export const ErrorBlock = memo((props: ErrorBlockProps) => { + return ( +
    +

    + +   + + {props.text} + +

    +
    +                
    +            
    +
    + ) +}) diff --git a/components/generic/layouts/monorow.module.css b/components/generic/layouts/monorow.module.css new file mode 100644 index 0000000..efc9e3c --- /dev/null +++ b/components/generic/layouts/monorow.module.css @@ -0,0 +1,8 @@ +.layoutMonorow { + display: flex; + flex-direction: row; + gap: 4px; + + justify-content: flex-start; + align-items: center; +} \ No newline at end of file diff --git a/components/generic/layouts/monorow.tsx b/components/generic/layouts/monorow.tsx new file mode 100644 index 0000000..ea2fc74 --- /dev/null +++ b/components/generic/layouts/monorow.tsx @@ -0,0 +1,16 @@ +import { default as classNames } from "classnames"; +import { ComponentPropsWithoutRef } from "react"; +import style from "./monorow.module.css" + + +/** + * A layout to display something in a single line, usually a form or a group of inputs. + */ +export function LayoutMonorow(props: ComponentPropsWithoutRef<"div">) { + return ( +
    + ) +} diff --git a/components/generic/loading/promise.tsx b/components/generic/loading/promise.tsx new file mode 100644 index 0000000..91c44f4 --- /dev/null +++ b/components/generic/loading/promise.tsx @@ -0,0 +1,145 @@ +import { memo, Reducer, useCallback, useEffect, useMemo, useReducer } from "react"; + +/** + * The possible states of a {@link Promise}, plus an additional one that represents that it has not been started yet. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise + */ +export enum UsePromiseStatus { + READY, + PENDING, + REJECTED, + FULFILLED, +} + +/** + * Action to start running the {@link Promise} contained in the {@link usePromise} hook, putting it in the {@link UsePromiseStatus.PENDING} state. + */ +type UsePromiseActionRun = { type: "start" } + +/** + * Action to fulfill the {@link Promise} contained in the {@link usePromise} hook, putting it in the {@link UsePromiseStatus.FULFILLED} state. + */ +type UsePromiseActionFulfill = { type: "fulfill", result: D } + +/** + * Action to reject the {@link Promise} contained in the {@link usePromise} hook, putting it in the {@link UsePromiseStatus.REJECTED} state. + */ +type UsePromiseActionReject = { type: "reject", error: E } + +/** + * Actions that can be performed on the {@link useReducer} hook used inside {@link usePromise}. + */ +type UsePromiseAction = UsePromiseActionRun | UsePromiseActionFulfill | UsePromiseActionReject; + +/** + * The internal state of the {@link useReducer} hook used inside {@link usePromise}. + */ +type UsePromiseState = { + status: UsePromiseStatus, + result: D | undefined, + error: E | undefined, +} + +/** + * The initial {@link UsePromiseState} of the {@link usePromise} hook. + */ +const initialUsePromise: UsePromiseState = { + status: UsePromiseStatus.READY, + result: undefined, + error: undefined, +} + +/** + * The reducer used by {@link usePromise}. + */ +function reducerUsePromise(prev: UsePromiseState, action: UsePromiseAction): UsePromiseState { + switch (action.type) { + case "start": + return { ...prev, status: UsePromiseStatus.PENDING } + case "fulfill": + return { status: UsePromiseStatus.FULFILLED, result: action.result, error: undefined } + case "reject": + return { status: UsePromiseStatus.REJECTED, error: action.error, result: undefined } + } +} + + +/** + * Async function that can be ran using {@link usePromise}. + */ +type UsePromiseFunction = (params: P) => Promise + +/** + * Values returned by the {@link usePromise} hook. + */ +export type UsePromise = UsePromiseState & { run: (params: P) => Promise } + +/** + * Hook executing an asyncronous function in a way that can be handled by React components. + */ +export function usePromise(func: UsePromiseFunction): UsePromise { + const [state, dispatch] = useReducer, UsePromiseAction>>(reducerUsePromise, initialUsePromise) + + const run = useCallback( + async (params: P) => { + dispatch({ type: "start" }) + + try { + var result = await func(params) + } + catch (error) { + dispatch({ type: "reject", error: error as E }) + return + } + + dispatch({ type: "fulfill", result }) + return + }, + [] + ) + + return { ...state, run } +} + + +export type PromiseMultiplexerReadyParams = { + run: UsePromise["run"], +} + +export type PromiseMultiplexerPendingParams = { + +} + +export type PromiseMultiplexerFulfilledParams = { + run: UsePromise["run"], + result: D, +} + +export type PromiseMultiplexerRejectedParams = { + run: UsePromise["run"], + error: E, +} + + +export type PromiseMultiplexerConfig = { + hook: UsePromise, + ready: (params: PromiseMultiplexerReadyParams) => JSX.Element, + pending: (params: PromiseMultiplexerPendingParams) => JSX.Element, + fulfilled: (params: PromiseMultiplexerFulfilledParams) => JSX.Element, + rejected: (error: PromiseMultiplexerRejectedParams) => JSX.Element, +} + +/** + * Function which selects and memoizes an output based on the {@link UsePromiseStatus} of a {@link usePromise} hook. + * + * It would be nice for it to be a component, but TSX does not support that, since it's a generic function, and that would make all its types `unknown`. + */ +export function promiseMultiplexer(config: PromiseMultiplexerConfig): JSX.Element { + switch (config.hook.status) { + case UsePromiseStatus.READY: return config.ready({ run: config.hook.run }) + case UsePromiseStatus.PENDING: return config.pending({}) + case UsePromiseStatus.FULFILLED: return config.fulfilled({ run: config.hook.run, result: config.hook.result! }) + case UsePromiseStatus.REJECTED: return config.rejected({ run: config.hook.run, error: config.hook.error! }) + } +} diff --git a/components/generic/loading/swr.tsx b/components/generic/loading/swr.tsx new file mode 100644 index 0000000..b984930 --- /dev/null +++ b/components/generic/loading/swr.tsx @@ -0,0 +1,19 @@ +import { SWRResponse } from "swr"; + + +export type SWRMultiplexerConfig = { + hook: SWRResponse, + loading: () => JSX.Element, + ready: (data: D) => JSX.Element, + error: (error: E) => JSX.Element, +} + +export function swrMultiplexer(config: SWRMultiplexerConfig): JSX.Element { + if (config.hook.error) { + return config.error(config.hook.error) + } + if (config.hook.data) { + return config.ready(config.hook.data) + } + return config.loading() +} diff --git a/components/generic/loading/textInline.tsx b/components/generic/loading/textInline.tsx new file mode 100644 index 0000000..6cd4972 --- /dev/null +++ b/components/generic/loading/textInline.tsx @@ -0,0 +1,21 @@ +import { faAsterisk } from "@fortawesome/free-solid-svg-icons"; +import { memo } from "react"; +import { FestaIcon } from "../renderers/fontawesome"; + + +export type LoadingTextInlineProps = { + text: string +} + +/** + * Component displaying an animated loading message for the user. + */ +export const LoadingTextInline = memo((props: LoadingTextInlineProps) => { + return ( + + +   + {props.text} + + ) +}) diff --git a/components/generic/renderers/README.md b/components/generic/renderers/README.md new file mode 100644 index 0000000..4edff39 --- /dev/null +++ b/components/generic/renderers/README.md @@ -0,0 +1,3 @@ +# Generic renderers + +These components render raw data in HTML format for usage in other components. \ No newline at end of file diff --git a/components/extensions/FestaMoment.tsx b/components/generic/renderers/datetime.tsx similarity index 61% rename from components/extensions/FestaMoment.tsx rename to components/generic/renderers/datetime.tsx index ccf1366..bc9ce16 100644 --- a/components/extensions/FestaMoment.tsx +++ b/components/generic/renderers/datetime.tsx @@ -7,10 +7,18 @@ type FestaMomentProps = { /** * Component that formats a {@link Date} to a machine-readable and human-readable HTML `time[datetime]` element. */ -export function FestaMoment({ date }: FestaMomentProps) { +export const FestaMoment = ({ date }: FestaMomentProps) => { const { t } = useTranslation() - if (!date || Number.isNaN(date.getTime())) { + if (date === null) { + return ( + + {t("dateNull")} + + ) + } + + if (Number.isNaN(date.getTime())) { return ( {t("dateNaN")} @@ -22,8 +30,8 @@ export function FestaMoment({ date }: FestaMomentProps) { const machine = date.toISOString() let human - // If the date is less than 24 hours away, display just the time - if (date.getTime() - now.getTime() < 86_400_000) { + // If the date is less than 20 hours away, display just the time + if (date.getTime() - now.getTime() < (20 /*hours*/ * 60 /*minutes*/ * 60 /*seconds*/ * 1000 /*milliseconds*/)) { human = date.toLocaleTimeString() } // Otherwise, display the full date diff --git a/components/generic/renderers/fontawesome.tsx b/components/generic/renderers/fontawesome.tsx new file mode 100644 index 0000000..fb82abf --- /dev/null +++ b/components/generic/renderers/fontawesome.tsx @@ -0,0 +1,15 @@ +import { FontAwesomeIcon, FontAwesomeIconProps } from "@fortawesome/react-fontawesome"; +import { default as classNames } from "classnames"; +import { memo } from "react"; + +/** + * Component which adds proper styling to {@link FontAwesomeIcon}. + */ +export const FestaIcon = memo((props: FontAwesomeIconProps) => { + return ( + + ) +}) diff --git a/components/generic/renderers/markdown.tsx b/components/generic/renderers/markdown.tsx new file mode 100644 index 0000000..bddcfff --- /dev/null +++ b/components/generic/renderers/markdown.tsx @@ -0,0 +1,25 @@ +import { memo } from "react" +import { default as ReactMarkdown } from "react-markdown" + +type FestaMarkdownRendererProps = { + code: string, +} + +/** + * Component rendering Markdown source with {@link ReactMarkdown}, using some custom settings to better handle user input. + * + * Currently, it converts `h1` and `h2` into `h3`, and disables the rendering of `img` elements. + */ +export const FestaMarkdownRenderer = memo(({ code }: FestaMarkdownRendererProps) => { + return ( + + {code} + + ) +}) diff --git a/components/generic/storage/base.ts b/components/generic/storage/base.ts new file mode 100644 index 0000000..2bdf9a1 --- /dev/null +++ b/components/generic/storage/base.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from "react" + + +/** + * Load a value from the {@link localStorage} in a SSR-compatible way. + */ +export function localStorageLoad(key: string): string | undefined { + // Prevent the hook from running on Node without causing an error, which can't be caught due to the Rules of Hooks. + const thatLocalStorageOverThere = typeof localStorage !== "undefined" ? localStorage : undefined + if (thatLocalStorageOverThere === undefined) return undefined + // Load and return the value. + return thatLocalStorageOverThere.getItem(key) ?? undefined +} + + +/** + * Save a value in the {@link localStorage} in a SSR-compatible way. + * + * If value is `undefined`, the value is instead removed from the storage. + */ +export function localStorageSave(key: string, value: string | undefined) { + // Prevent the hook from running on Node without causing an error, which can't be caught due to the Rules of Hooks. + const thatLocalStorageOverThere = typeof localStorage !== "undefined" ? localStorage : undefined + if (thatLocalStorageOverThere === undefined) return undefined + + if (value === undefined) { + thatLocalStorageOverThere.removeItem(key) + } + else { + thatLocalStorageOverThere.setItem(key, value) + } +} + + +/** + * Hook which runs {@link localStorageLoad} every time key and callback change and passes the result to the callback as an effect. + */ +export function useLocalStorageLoad(key: string, callback: (data: string | undefined) => void) { + useEffect( + () => { + let value = localStorageLoad(key) + + callback(value) + }, + [key, callback] + ) +} + + +/** + * Hook which combines {@link useState}, {@link useLocalStorageLoad}, and {@link localStorageSave}. + */ +export function useLocalStorageState(key: string, placeholder: string) { + const [state, setInnerState] = useState(undefined) + useLocalStorageLoad(key, setInnerState) + + const setState = useCallback( + (value: string) => { + localStorageSave(key, value) + setInnerState(value) + }, + [key, setInnerState] + ) + + return [state, setState] +} + + diff --git a/components/generic/storage/json.ts b/components/generic/storage/json.ts new file mode 100644 index 0000000..4c0ea0d --- /dev/null +++ b/components/generic/storage/json.ts @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useState } from "react"; +import { localStorageSave, localStorageLoad } from "./base"; + + +/** + * Load a value from the {@link localStorage} using {@link localStorageLoad}, then parse it as {@link JSON}. + */ +export function localStorageLoadJSON(key: string): Expected | undefined { + const value = localStorageLoad(key) + + if (value === undefined) { + return undefined + } + else { + return JSON.parse(value) + } +} + + +/** + * Convert a value to {@link JSON}, then save a value in the {@link localStorage} using {@link localStorageSave}. + * + * If value is `undefined`, the value is instead removed from the storage. + */ +export function localStorageSaveJSON(key: string, value: Expected | undefined) { + if (value === undefined) { + localStorageSave(key, undefined) + } + else { + const str = JSON.stringify(value) + localStorageSave(key, str) + } +} + + +/** + * Hook which behaves like {@link useLocalStorageLoad}, but uses {@link localStorageLoadJSON} instead. + */ +export function useLocalStorageJSONLoad(key: string, callback: (data: Expected) => void) { + useEffect( + () => { + try { + // This usage of var is deliberate. + var value = localStorageLoadJSON(key) + } + catch (e) { + console.error("[useLocalStorageJSONLoad] Encountered an error while loading JSON value:", e) + } + + if (value !== undefined) { + callback(value) + } + }, + [key, callback] + ) +} + + +/** + * Hook which combines {@link useState}, {@link useLocalStorageJSONLoad}, and {@link localStorageSaveJSON}. + */ +export function useLocalStorageJSONState(key: string) { + const [state, setStateInner] = useState(undefined); + useLocalStorageJSONLoad(key, setStateInner); + + const setState = useCallback( + (value: Expected) => { + localStorageSaveJSON(key, value); + setStateInner(value); + }, + [key, setStateInner] + ); + + return [state, setState]; +} diff --git a/styles/components/toolbar.css b/components/generic/toolbar/bar.module.css similarity index 56% rename from styles/components/toolbar.css rename to components/generic/toolbar/bar.module.css index b343a38..9b96618 100644 --- a/styles/components/toolbar.css +++ b/components/generic/toolbar/bar.module.css @@ -11,48 +11,34 @@ gap: 2px; } -.toolbar-top { +.toolbarTop { top: 8px; flex-direction: column; } -.toolbar-bottom { +.toolbarBottom { bottom: 8px; flex-direction: column-reverse; } -.toolbar-left { +.toolbarLeft { left: 8px; } -.toolbar-right { +.toolbarRight { right: 8px; } -.toolbar-vadapt { +.toolbarVadapt { top: 8px; flex-direction: column; } @media (max-width: 800px) { - .toolbar-vadapt { + .toolbarVadapt { top: unset; bottom: 8px; flex-direction: column-reverse; } -} - -.toolbar-tool { - width: 50px !important; - height: 50px !important; - font-size: large; -} - -@media (pointer: fine) { - .toolbar-tool { - width: 40px !important; - height: 40px !important; - font-size: medium; - } } \ No newline at end of file diff --git a/components/generic/toolbar/bar.tsx b/components/generic/toolbar/bar.tsx new file mode 100644 index 0000000..dffa345 --- /dev/null +++ b/components/generic/toolbar/bar.tsx @@ -0,0 +1,50 @@ +import { default as classNames } from "classnames" +import { ComponentPropsWithoutRef, memo, ReactNode } from "react" +import style from "./base.module.css" + + +export type ToolBarProps = ComponentPropsWithoutRef<"div"> & { + /** + * The vertical alignment of the toolbar. + * + * - `top` places it on top of the page + * - `bottom` places it at the bottom of the page + * - `vadapt` places it on top on computers and at the bottom on smartphones + */ + vertical: "top" | "bottom" | "vadapt", + + /** + * The horizontal alignment of the toolbar. + * + * - `left` places it on the left of the webpage + * - `right` places it on the right of the webpage + */ + horizontal: "left" | "right", + + /** + * The buttons to be displayed in the toolbar. + */ + children: ReactNode, +} + + +/** + * Toolbar containing many buttons, displayed in a corner of the screen. + */ +export const ToolBar = memo((props: ToolBarProps) => { + return ( +
    + ) +}) diff --git a/components/generic/toolbar/tool.module.css b/components/generic/toolbar/tool.module.css new file mode 100644 index 0000000..9c7f794 --- /dev/null +++ b/components/generic/toolbar/tool.module.css @@ -0,0 +1,13 @@ +.toolbarTool { + width: 50px !important; + height: 50px !important; + font-size: large; +} + +@media (pointer: fine) { + .toolbarTool { + width: 40px !important; + height: 40px !important; + font-size: medium; + } +} \ No newline at end of file diff --git a/components/generic/toolbar/tool.tsx b/components/generic/toolbar/tool.tsx new file mode 100644 index 0000000..39eb8f6 --- /dev/null +++ b/components/generic/toolbar/tool.tsx @@ -0,0 +1,22 @@ +import { default as classNames } from "classnames" +import { ComponentPropsWithoutRef, memo } from "react" +import style from "./tool.module.css" + + +export type ToolProps = ComponentPropsWithoutRef<"button"> + + +/** + * A single tool button displayed in the toolbar. + */ +export const Tool = memo((props: ToolProps) => { + return ( + + + ), + pending: ({ }) => ( +

    + +

    + ), + rejected: ({ error }) => ( +

    + +

    + ), + fulfilled: ({ result }) => { + return ( +

    + +

    + ) + }, + }) +} + + +export const LandingActionEvents = () => { + const { t } = useTranslation() + const apiHook = useSWR("/api/events/mine") + + return swrMultiplexer({ + hook: apiHook, + loading: () => ( +

    + +

    + ), + ready: (data) => (<> + {data.length === 0 ? + + : + + } + + ), + error: (error) => ( +

    + +

    + ) + }) +} \ No newline at end of file diff --git a/components/landing/actions/login.tsx b/components/landing/actions/login.tsx new file mode 100644 index 0000000..4138548 --- /dev/null +++ b/components/landing/actions/login.tsx @@ -0,0 +1,53 @@ +import { useTranslation } from "next-i18next" +import { useAxiosRequest } from "../../auth/requests" +import { TelegramLoginButton } from "../../auth/telegram/loginButton" +import { ErrorBlock } from "../../generic/errors/renderers" +import { promiseMultiplexer } from "../../generic/loading/promise" +import { LoadingTextInline } from "../../generic/loading/textInline" +import { useDefinedContext } from "../../../utils/definedContext" +import { AuthContext } from "../../auth/base" +import { useRouter } from "next/router" + + +export const LandingActionLogin = () => { + if (!process.env.NEXT_PUBLIC_TELEGRAM_USERNAME) { + throw new Error("Server environment variable `NEXT_PUBLIC_TELEGRAM_USERNAME` is not set.") + } + + const { t } = useTranslation() + const [, setAuth] = useDefinedContext(AuthContext) + + return promiseMultiplexer({ + hook: useAxiosRequest({ method: "POST", url: "/api/auth/telegram" }), + ready: ({ run }) => <> +

    + {t("landingLoginTelegramDescription")} +

    + run({ data })} + /> + , + pending: ({ }) => ( +

    + +

    + ), + fulfilled: ({ result }) => { + setAuth(result.data) + + return ( +

    + +

    + ) + }, + rejected: ({ error }) => ( +

    + +

    + ), + }) +} diff --git a/components/postcard/PostcardRenderer.tsx b/components/postcard/PostcardRenderer.tsx deleted file mode 100644 index 145a1b5..0000000 --- a/components/postcard/PostcardRenderer.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { PostcardContext } from "./PostcardContext" -import { useDefinedContext } from "../../utils/definedContext"; -import classNames from "classnames"; - - -export function PostcardRenderer() { - const { image, visibility } = useDefinedContext(PostcardContext) - - console.debug("[PostcardRenderer] Re-rendering with:", image) - - return ( -
    - ) -} \ No newline at end of file diff --git a/components/postcard/README.md b/components/postcard/README.md new file mode 100644 index 0000000..00a9dd2 --- /dev/null +++ b/components/postcard/README.md @@ -0,0 +1,3 @@ +# Postcard + +The postcard is the image rendered as background of the website. \ No newline at end of file diff --git a/components/postcard/PostcardContext.tsx b/components/postcard/base.ts similarity index 70% rename from components/postcard/PostcardContext.tsx rename to components/postcard/base.ts index f01f8ce..70bd414 100644 --- a/components/postcard/PostcardContext.tsx +++ b/components/postcard/base.ts @@ -1,10 +1,11 @@ +import { ImageProps } from "next/image" import { createDefinedContext } from "../../utils/definedContext"; /** - * The string to be used as the [`background-image`](https://developer.mozilla.org/en-US/docs/Web/CSS/background-image) CSS property of the postcard. + * The string to be used as the `src` of the postcard. */ -export type PostcardImage = string; +export type PostcardSource = ImageProps["src"] /** @@ -26,9 +27,9 @@ export enum PostcardVisibility { /** * Contents of the {@link PostcardContext}. */ -type PostcardContextValue = { - image: PostcardImage, - setImage: React.Dispatch>, +export type PostcardContextContents = { + src: PostcardSource, + setSrc: React.Dispatch>, visibility: PostcardVisibility, setVisibility: React.Dispatch>, } @@ -37,4 +38,7 @@ type PostcardContextValue = { /** * Context containing data about the website's current postcard, the blurred background image. */ -export const PostcardContext = createDefinedContext() +export const PostcardContext = createDefinedContext() + + + diff --git a/components/postcard/changer.tsx b/components/postcard/changer.tsx new file mode 100644 index 0000000..6b2d941 --- /dev/null +++ b/components/postcard/changer.tsx @@ -0,0 +1,33 @@ +import { useEffect } from "react" +import { useDefinedContext } from "../../utils/definedContext" +import { PostcardContext, PostcardSource } from "./base" + + +/** + * Use the passed src as {@link PostcardSource} for the wrapping {@link PostcardContext}. + */ +export function usePostcardImage(src: PostcardSource) { + const { setSrc } = useDefinedContext(PostcardContext) + + useEffect( + () => { + setSrc(src) + }, + [src] + ) +} + + +export type PostcardProps = { + src: PostcardSource +} + + +/** + * The same as {@link usePostcardImage}, but as a component rendering `null`. + */ +export function Postcard(props: PostcardProps) { + usePostcardImage(props.src) + + return null +} diff --git a/styles/components/postcard.css b/components/postcard/renderer.module.css similarity index 68% rename from styles/components/postcard.css rename to components/postcard/renderer.module.css index 284be48..50f1969 100644 --- a/styles/components/postcard.css +++ b/components/postcard/renderer.module.css @@ -2,9 +2,8 @@ width: 100vw; height: 100vh; - background-attachment: fixed; - background-size: cover; - background-position: 50% 50%; + object-fit: cover; + object-position: 50% 50%; position: fixed; top: 0; @@ -12,20 +11,22 @@ user-select: none; pointer-events: none; + + z-index: -1; } -.postcard-background { +.postcardBackground { z-index: -1; filter: blur(7px) contrast(50%) brightness(50%); } @media (prefers-color-scheme: light) { - .postcard-background { + .postcardBackground { filter: blur(7px) contrast(25%) brightness(175%); } } -.postcard-foreground { +.postcardForeground { z-index: 1; filter: none; } \ No newline at end of file diff --git a/components/postcard/renderer.tsx b/components/postcard/renderer.tsx new file mode 100644 index 0000000..df47010 --- /dev/null +++ b/components/postcard/renderer.tsx @@ -0,0 +1,25 @@ +import { default as classNames } from "classnames" +import style from "./renderer.module.css" +import Image, { ImageProps } from "next/image" +import { useDefinedContext } from "../../utils/definedContext" +import { PostcardContext, PostcardVisibility } from "./base" + + +export function PostcardRenderer(props: Partial) { + const { src, visibility } = useDefinedContext(PostcardContext) + + return ( + + ) +} \ No newline at end of file diff --git a/components/postcard/storage.ts b/components/postcard/storage.ts new file mode 100644 index 0000000..c1e792e --- /dev/null +++ b/components/postcard/storage.ts @@ -0,0 +1,17 @@ +import { useState } from "react"; +import { PostcardSource, PostcardVisibility } from "./base"; + +/** + * Hook holding as state the {@link PostcardContextContents}. + */ +export function useStatePostcard(defaultPostcard: PostcardSource) { + const [src, setSrc] = useState(defaultPostcard); + const [visibility, setVisibility] = useState(PostcardVisibility.BACKGROUND); + + return { + src, + setSrc, + visibility, + setVisibility, + }; +} diff --git a/components/tools/ToolToggleVisible.tsx b/components/postcard/toolbar/toolToggleVisibility.tsx similarity index 61% rename from components/tools/ToolToggleVisible.tsx rename to components/postcard/toolbar/toolToggleVisibility.tsx index 156321a..955c190 100644 --- a/components/tools/ToolToggleVisible.tsx +++ b/components/postcard/toolbar/toolToggleVisibility.tsx @@ -1,33 +1,36 @@ import { faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons" import { useTranslation } from "next-i18next" -import { useDefinedContext } from "../../utils/definedContext" -import { FestaIcon } from "../extensions/FestaIcon" -import { PostcardContext, PostcardVisibility } from "../postcard/PostcardContext" +import { useDefinedContext } from "../../../utils/definedContext" +import { FestaIcon } from "../../generic/renderers/fontawesome" +import { Tool } from "../../generic/toolbar/tool" +import { PostcardContext, PostcardVisibility } from "../base" -export function ToolToggleVisible() { + +/** + * Toolbar tool which toggles the {@link PostcardVisibility} state of its wrapping context. + */ +export function ToolToggleVisibility() { const { t } = useTranslation() const { visibility, setVisibility } = useDefinedContext(PostcardContext) if (visibility === PostcardVisibility.BACKGROUND) { return ( - + ) } else { return ( - + ) } -} \ No newline at end of file +} diff --git a/components/postcard/usePostcardImage.ts b/components/postcard/usePostcardImage.ts deleted file mode 100644 index c535ee8..0000000 --- a/components/postcard/usePostcardImage.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useEffect } from "react" -import { useDefinedContext } from "../../utils/definedContext" -import { PostcardContext } from "./PostcardContext" - -export function usePostcardImage(image: string) { - const { setImage } = useDefinedContext(PostcardContext) - - useEffect( - () => { - setImage(image) - }, - [image] - ) -} \ No newline at end of file diff --git a/components/postcard/useStatePostcard.ts b/components/postcard/useStatePostcard.ts deleted file mode 100644 index a02df32..0000000 --- a/components/postcard/useStatePostcard.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useState } from "react"; -import { PostcardImage, PostcardVisibility } from "./PostcardContext"; - -export function useStatePostcard() { - const [visibility, setVisibility] = useState(PostcardVisibility.BACKGROUND) - const [image, setImage] = useState("none") - - return { - visibility, - setVisibility, - image, - setImage, - } -} \ No newline at end of file diff --git a/components/tools/ToolBar.tsx b/components/tools/ToolBar.tsx deleted file mode 100644 index 1020a6e..0000000 --- a/components/tools/ToolBar.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { default as classNames } from "classnames" -import { ReactNode } from "react" - -type ToolBarProps = { - vertical: "top" | "bottom" | "vadapt", - horizontal: "left" | "right", - children: ReactNode, -} - -export function ToolBar({vertical, horizontal, children}: ToolBarProps) { - return ( -
    - {children} -
    - ) -} \ No newline at end of file diff --git a/components/tools/ToolToggleEditing.tsx b/components/tools/ToolToggleEditing.tsx deleted file mode 100644 index 4a3a211..0000000 --- a/components/tools/ToolToggleEditing.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { faBinoculars, faPencil } from "@fortawesome/free-solid-svg-icons" -import { useTranslation } from "next-i18next" -import { EditingContext } from "../editable/EditingContext" -import { useDefinedContext } from "../../utils/definedContext" -import { FestaIcon } from "../extensions/FestaIcon" - -export function ToolToggleEditing() { - const {t} = useTranslation() - const [editing, setEditing] = useDefinedContext(EditingContext) - - return ( - - ) -} \ No newline at end of file diff --git a/components/view/ViewContent.tsx b/components/view/ViewContent.tsx deleted file mode 100644 index 77baad4..0000000 --- a/components/view/ViewContent.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ReactNode } from "react" - -type ViewContentProps = { - title: ReactNode - content: ReactNode -} - - -export function ViewContent(props: ViewContentProps) { - return ( -
    -

    - {props.title} -

    -
    - {props.content} -
    -
    - ) -} \ No newline at end of file diff --git a/components/view/ViewEvent.tsx b/components/view/ViewEvent.tsx deleted file mode 100644 index 4723546..0000000 --- a/components/view/ViewEvent.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { ReactNode } from "react" -import { FormFromTo } from "../form/FormFromTo" - -type ViewEventProps = { - title: ReactNode, - postcard: ReactNode, - description: ReactNode, - daterange: ReactNode, -} - - -export function ViewEvent(props: ViewEventProps) { - return ( -
    -

    - {props.title} -

    -
    - {props.postcard} -
    -
    - {props.description} -
    -
    - {props.daterange} -
    -
    - ) -} \ No newline at end of file diff --git a/components/view/ViewLanding.tsx b/components/view/ViewLanding.tsx deleted file mode 100644 index acb01fd..0000000 --- a/components/view/ViewLanding.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { ReactNode } from "react" - -type ViewLandingProps = { - title: ReactNode - subtitle: ReactNode - actions: ReactNode -} - - -export function ViewLanding(props: ViewLandingProps) { - return ( -
    -
    -

    - {props.title} -

    -

    - {props.subtitle} -

    -
    -
    - {props.actions} -
    -
    - ) -} \ No newline at end of file diff --git a/components/view/ViewNotice.tsx b/components/view/ViewNotice.tsx deleted file mode 100644 index d4b61ab..0000000 --- a/components/view/ViewNotice.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { ReactNode } from "react" - -type ViewNoticeProps = { - notice: ReactNode -} - - -export function ViewNotice(props: ViewNoticeProps) { - return ( -
    - {props.notice} -
    - ) -} \ No newline at end of file diff --git a/hooks/swr/useEventDetailsSWR.ts b/hooks/swr/useEventDetailsSWR.ts deleted file mode 100644 index ca55001..0000000 --- a/hooks/swr/useEventDetailsSWR.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Event } from "@prisma/client"; -import { default as useSWR } from "swr"; - -export function useEventDetailsSWR(slug: string) { - return useSWR(`/api/events/${slug}`) -} diff --git a/hooks/swr/useMyEventsSWR.ts b/hooks/swr/useMyEventsSWR.ts deleted file mode 100644 index 8dbd4f3..0000000 --- a/hooks/swr/useMyEventsSWR.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Event } from "@prisma/client"; -import { default as useSWR } from "swr"; - -export function useMyEventsSWR() { - return useSWR("/api/events/mine") -} diff --git a/hooks/useAxios.ts b/hooks/useAxios.ts deleted file mode 100644 index 70396d5..0000000 --- a/hooks/useAxios.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AxiosInstance, AxiosRequestConfig, default as axios } from "axios"; -import { useContext, useMemo } from "react"; -import { LoginContext } from "../components/contexts/login"; -import { FestaLoginData } from "../types/user"; - -export function useAxios(config: AxiosRequestConfig = {}, data?: FestaLoginData | null): AxiosInstance { - const loginContext = useContext(LoginContext) - - let login = data || loginContext?.[0] - - return useMemo( - () => { - const ax = axios.create({ - ...config, - headers: { - ...(config.headers ?? {}), - Authorization: login ? `Bearer ${login.token}` : false, - }, - }) - - return ax - }, - [config, login] - ) -} diff --git a/hooks/useAxiosRequest.ts b/hooks/useAxiosRequest.ts deleted file mode 100644 index 83bbb4c..0000000 --- a/hooks/useAxiosRequest.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { AxiosRequestConfig, AxiosResponse } from "axios"; -import { useCallback, useReducer } from "react"; -import { useAxios } from "./useAxios"; - -type ReducerActionStart = { type: "start" } -type ReducerActionDone = { type: "done", response: AxiosResponse } -type ReducerActionError = { type: "error", error: any } -type ReducerAction = ReducerActionStart | ReducerActionDone | ReducerActionError - -type ReducerState = { - running: boolean, - response: AxiosResponse | undefined, - error: any | undefined, -} - -export function useAxiosRequest(config: AxiosRequestConfig = {}, onSuccess?: (response: AxiosResponse) => void, onError?: (error: any) => void) { - const axios = useAxios() - - const [state, dispatch] = useReducer( - (prev: ReducerState, action: ReducerAction) => { - switch (action.type) { - case "start": - return { - running: true, - response: undefined, - error: undefined, - } - case "done": - return { - running: false, - response: action.response, - error: undefined, - } - case "error": - return { - running: false, - response: action.error.response, - error: action.error - } - } - }, - { - running: false, - response: undefined, - error: undefined, - } - ) - - const run = useCallback( - - async (funcConfig: AxiosRequestConfig = {}) => { - dispatch({ type: "start" }) - - try { - var response: AxiosResponse = await axios.request({ ...config, ...funcConfig }) - } - catch (error) { - dispatch({ type: "error", error }) - onError?.(error) - return - } - - dispatch({ type: "done", response }) - onSuccess?.(response) - }, - [axios] - ) - - return { - running: state.running, - response: state.response, - data: state.response?.data as T | undefined, - error: state.error, - run, - } -} \ No newline at end of file diff --git a/hooks/useFilePickerState.ts b/hooks/useFilePickerState.ts deleted file mode 100644 index 4f343df..0000000 --- a/hooks/useFilePickerState.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ChangeEvent, useCallback, useState } from "react"; - - -type FileState = { - value: string, - file: File | null, -} - - -export function useFilePickerState() { - const [state, setState] = useState({ value: "", file: null }) - - const onChange = (e: ChangeEvent) => { - setState({ - value: e.target.value, - file: e.target.files![0], - }) - } - - return { state, onChange } -} \ No newline at end of file diff --git a/hooks/useStoredLogin.ts b/hooks/useStoredLogin.ts deleted file mode 100644 index 92389aa..0000000 --- a/hooks/useStoredLogin.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect } from "react" -import { FestaLoginData } from "../types/user" - -export function useStoredLogin(setLogin: React.Dispatch>): void { - const thatStorageOverThere = typeof localStorage !== "undefined" ? localStorage : undefined - - useEffect( - () => { - if(thatStorageOverThere === undefined) return - - const raw = localStorage.getItem("login") - if(raw === null) { - console.debug("No stored login data found.") - return - } - - try { - var parsed = JSON.parse(raw) - } - catch(e) { - console.error("Failed to parse stored login data as JSON.") - return - } - - const data = { - ...parsed, - expiresAt: new Date(parsed.expiresAt) - } - - if(new Date().getTime() >= data.expiresAt.getTime()) { - console.debug("Stored login data has expired, clearing...") - thatStorageOverThere.removeItem("login") - return - } - - setLogin(data) - }, - [thatStorageOverThere] - ) -} \ No newline at end of file diff --git a/next.config.js b/next.config.js index 2884d57..7ea3ba4 100644 --- a/next.config.js +++ b/next.config.js @@ -5,16 +5,16 @@ function fixCssLoaderLocalIdent(webpackConfig) { function innerFix(used) { - if (used.loader?.match?.(/[/]css-loader/)) { + if (used.loader?.match?.(/.*[/]css-loader.*/)) { - let modules = used.loader.options?.modules + if (used.options?.modules) { - if (modules) { - let { getLocalIdent, ...modules } = modules + if (used.options.modules.getLocalIdent) { - modules.localIdentName = "[name]-[local]" + used.options.modules.getLocalIdent = (context, localIdentName, localName) => `festa__${localName}` + + } - used.loader.options.modules = modules } } @@ -60,6 +60,7 @@ function webpack(config) { * @type {import('next').NextConfig} */ const nextConfig = { + experimental: { images: { layoutRaw: true } }, reactStrictMode: true, webpack, i18n, diff --git a/package.json b/package.json index e3eec50..d4fbc84 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,9 @@ "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", "@prisma/client": "^3.15.0", + "ajv": "^8.11.0", "axios": "^0.27.2", "classnames": "^2.3.1", - "cors": "^2.8.5", "crypto-random-string": "^5.0.0", "next": "12.1.6", "next-i18next": "^11.0.0", diff --git a/pages/404.tsx b/pages/404.tsx index a15efcb..996dc65 100644 --- a/pages/404.tsx +++ b/pages/404.tsx @@ -1,10 +1,10 @@ -import { NextPageContext } from "next"; +import { NextPage, NextPageContext } from "next"; import { useTranslation } from "next-i18next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import { default as Link } from "next/link"; -import { ErrorBlock } from "../components/errors/ErrorBlock"; -import { usePostcardImage } from "../components/postcard/usePostcardImage"; -import { ViewNotice } from "../components/view/ViewNotice"; +import { ErrorBlock } from "../components/generic/errors/renderers"; +import { ViewNotice } from "../components/generic/views/notice"; +import { Postcard } from "../components/postcard/changer"; import errorPostcard from "../public/postcards/markus-spiske-iar-afB0QQw-unsplash-red.jpg" @@ -17,12 +17,13 @@ export async function getStaticProps(context: NextPageContext) { } -export default function Page404() { +const Page404: NextPage = (props) => { const { t } = useTranslation() - usePostcardImage(`url(${errorPostcard.src})`) - return <> +

    - ← {t("notFoundBackHome")} + + ← {t("notFoundBackHome")} +

    } /> -} \ No newline at end of file +} + +export default Page404 diff --git a/pages/500.tsx b/pages/500.tsx index 37528d9..fb881c5 100644 --- a/pages/500.tsx +++ b/pages/500.tsx @@ -1,9 +1,9 @@ -import { NextPageContext } from "next"; +import { NextPage, NextPageContext } from "next"; import { useTranslation } from "next-i18next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; -import { ErrorBlock } from "../components/errors/ErrorBlock"; -import { usePostcardImage } from "../components/postcard/usePostcardImage"; -import { ViewNotice } from "../components/view/ViewNotice"; +import { ErrorBlock } from "../components/generic/errors/renderers"; +import { ViewNotice } from "../components/generic/views/notice"; +import { Postcard } from "../components/postcard/changer"; import errorPostcard from "../public/postcards/markus-spiske-iar-afB0QQw-unsplash-red.jpg" @@ -16,19 +16,23 @@ export async function getStaticProps(context: NextPageContext) { } -export default function Page500() { +const Page500: NextPage = (props) => { const { t } = useTranslation() - usePostcardImage(`url(${errorPostcard.src})`) - return <> + - } + } /> -} \ No newline at end of file +} + + +export default Page500 diff --git a/pages/_app.tsx b/pages/_app.tsx index 6e5580f..b80ae7e 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,49 +1,36 @@ -import '../styles/globals.css' -import type { AppProps } from 'next/app' -import { LoginContext } from '../components/contexts/login' -import { useState } from 'react' -import { PostcardRenderer } from '../components/postcard/PostcardRenderer' -import { PostcardContext } from '../components/postcard/PostcardContext' +import { AppProps } from 'next/app' import { appWithTranslation, useTranslation } from 'next-i18next' -import { FestaLoginData } from '../types/user' -import { useStoredLogin } from "../hooks/useStoredLogin" import { SWRConfig } from 'swr' -import { AxiosRequestConfig } from 'axios' -import { useAxios } from '../hooks/useAxios' -import { ErrorBoundary } from '../components/errors/ErrorBoundary' +import { AxiosSWRFetcherProvider, useAxiosSWRFetcher } from '../components/auth/requests' +import { useStatePostcard } from '../components/postcard/storage' +import { PageErrorBoundary } from '../components/generic/errors/boundaries' +import { PostcardContext } from '../components/postcard/base' +import { useStateAuth } from '../components/auth/storage' +import { AuthContext } from '../components/auth/base' +import { PostcardRenderer } from '../components/postcard/renderer' +import '../styles/globals.css' import defaultPostcard from "../public/postcards/adi-goldstein-Hli3R6LKibo-unsplash.jpg" -import { useStatePostcard } from '../components/postcard/useStatePostcard' const App = ({ Component, pageProps }: AppProps): JSX.Element => { const { t } = useTranslation() - const postcardState = useStatePostcard() - const [login, setLogin] = useState(null) - useStoredLogin(setLogin) + const postcardState = useStatePostcard(defaultPostcard) + const authState = useStateAuth() - const axios = useAxios({}, login) - - const swrConfig = { - fetcher: async (resource: string, init: AxiosRequestConfig) => { - const response = await axios.get(resource, init) - // To test loading uncomment the following line: - // await new Promise(res => setTimeout(res, 100000)) - return response.data - } - } - - return <> - - - - - - - - - - - + return ( + + + + + + + + + + + + + ) } export default appWithTranslation(App) diff --git a/pages/api/auth/index.ts b/pages/api/auth/index.ts new file mode 100644 index 0000000..061dcc9 --- /dev/null +++ b/pages/api/auth/index.ts @@ -0,0 +1,18 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { festaAPI } from "../../../utils/api"; +import { festaNoConfig } from "../../../utils/api/configurator"; +import { festaBearerAuthRequired, FestaToken } from "../../../utils/api/authenticator"; +import { festaNoBody } from "../../../utils/api/bodyValidator"; +import { festaDebugAuth } from "../../../utils/api/executor"; +import { festaNoQuery } from "../../../utils/api/queryValidator"; + + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await festaAPI(req, res, { + configurator: festaNoConfig, + authenticator: festaBearerAuthRequired, + queryValidator: festaNoQuery, + bodyValidator: festaNoBody, + executor: festaDebugAuth(), + }) +} diff --git a/pages/api/auth/telegram.ts b/pages/api/auth/telegram.ts new file mode 100644 index 0000000..e9a3010 --- /dev/null +++ b/pages/api/auth/telegram.ts @@ -0,0 +1,123 @@ +import { database } from "../../../utils/prismaClient"; +import { NextApiRequest, NextApiResponse } from "next"; +import { TelegramLoginObject } from "../../../components/auth/telegram/processing"; +import { default as cryptoRandomString } from "crypto-random-string" +import { festaAPI } from "../../../utils/api"; +import { festaNoAuth } from "../../../utils/api/authenticator"; +import { festaJsonSchemaBody } from "../../../utils/api/bodyValidator"; +import { TelegramLoginResponse } from "react-telegram-login"; +import { Response } from "../../../utils/api/throwables"; +import { festaNoQuery } from "../../../utils/api/queryValidator"; + + +type Config = { + botToken: string, + hashExpirationMs: number, + tokenExpirationMs: number, +} + + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await festaAPI(req, res, { + + configurator: { + perform: async () => { + const botToken = process.env.TELEGRAM_TOKEN + if (!botToken) { + throw Response.error({ status: 503, message: "`TELEGRAM_TOKEN` was not set up" }) + } + const hashExpirationMs = parseInt(process.env.TELEGRAM_HASH_EXPIRATION_MS!) // TypeScript type is wrong? + if (!hashExpirationMs) { + throw Response.error({ status: 503, message: "`TELEGRAM_HASH_EXPIRATION_MS` was not set up" }) + } + const tokenExpirationMs = parseInt(process.env.FESTA_TOKEN_EXPIRATION_MS!) // TypeScript type is wrong? + if (!tokenExpirationMs) { + throw Response.error({ status: 503, message: "`FESTA_TOKEN_EXPIRATION_MS` was not set up" }) + } + + return { botToken, hashExpirationMs, tokenExpirationMs } + } + }, + + authenticator: festaNoAuth, + + queryValidator: festaNoQuery, + + bodyValidator: festaJsonSchemaBody({ + type: "object", + properties: { + id: { type: "integer" }, + first_name: { type: "string" }, + auth_date: { type: "integer" }, + hash: { type: "string" }, + last_name: { type: "string", nullable: true }, + username: { type: "string", nullable: true }, + photo_url: { type: "string", nullable: true }, + lang: { type: "string", nullable: true }, + }, + required: [ + "id", + "first_name", + "auth_date", + "hash", + ] + }), + + executor: { + methods: ["POST"], + perform: async ({ config, body }) => { + try { + var tlo: TelegramLoginObject = new TelegramLoginObject(body!) + } + catch (e) { + throw Response.error({ status: 422, message: "Telegram Login response validation failed" }) + } + + if (!tlo.isRecent(config.hashExpirationMs)) { + throw Response.error({ status: 408, message: "Telegram login data is not recent" }) + } + + if (!tlo.isValid(config.botToken)) { + throw Response.error({ status: 401, message: "Telegram login data has been tampered" }) + } + + const accountTelegram = await database.accountTelegram.upsert({ + where: { + telegramId: tlo.id + }, + create: { + ...tlo.toDatabase(), + user: { + create: { + displayName: tlo.toTelegramName(), + displayAvatarURL: tlo.photoUrl, + } + } + }, + update: { + ...tlo.toDatabase(), + user: { + update: { + displayName: tlo.toTelegramName(), + displayAvatarURL: tlo.photoUrl, + } + } + } + }) + + const token = await database.token.create({ + data: { + userId: accountTelegram.userId, + token: cryptoRandomString({ length: 16, type: "base64" }), + expiresAt: new Date(tlo.authDate.getTime() + config.tokenExpirationMs) + }, + include: { + user: true, + } + }) + + return token + } + }, + }) +} diff --git a/pages/api/events/[slug].ts b/pages/api/events/[slug].ts index 8cbed7a..143fbfd 100644 --- a/pages/api/events/[slug].ts +++ b/pages/api/events/[slug].ts @@ -1,38 +1,112 @@ import { database } from "../../../utils/prismaClient"; import { NextApiRequest, NextApiResponse } from "next"; -import { ApiResult } from "../../../types/api"; -import { Model, restInPeace } from "../../../utils/restInPeace"; -import { handleInterrupts, Interrupt } from "../../../utils/interrupt"; -import { authorizeUser } from "../../../utils/authorizeUser"; -import { Event } from "@prisma/client"; +import { Event, Prisma } from "@prisma/client"; +import { festaAPI } from "../../../utils/api"; +import { festaNoConfig } from "../../../utils/api/configurator"; +import { festaBearerAuthOptional, FestaToken } from "../../../utils/api/authenticator"; +import { festaJsonSchemaBody } from "../../../utils/api/bodyValidator"; +import { festaRESTSpecific } from "../../../utils/api/executor"; +import { festaJsonSchemaQuery } from "../../../utils/api/queryValidator"; +import { Response } from "../../../utils/api/throwables"; -export default async function handler(req: NextApiRequest, res: NextApiResponse>) { - handleInterrupts(res, async () => { - const user = await authorizeUser(req) +type Config = {} - const canEdit = async (_model: Model, obj?: Event) => { - if(obj && obj.creatorId !== user.id) { - throw new Interrupt(403, {error: "Only the creator can edit an event"}) +type Auth = FestaToken | undefined + +type Query = { + slug: string +} + +type Body = { + name: string, + postcard?: string, + description: string, + startingAt?: string, + endingAt?: string, +} + + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await festaAPI(req, res, { + + configurator: festaNoConfig, + + authenticator: festaBearerAuthOptional, + + queryValidator: festaJsonSchemaQuery({ + type: "object", + properties: { + slug: { type: "string" } + }, + required: [ + "slug", + ] + }), + + bodyValidator: festaJsonSchemaBody({ + type: "object", + properties: { + name: { type: "string" }, + postcard: { type: "string", nullable: true }, + description: { type: "string" }, + startingAt: { type: "string", nullable: true }, + endingAt: { type: "string", nullable: true }, + }, + required: [ + "slug", + "name", + "description", + ] + }), + + executor: festaRESTSpecific, Prisma.EventFindUniqueArgs, Prisma.EventUpdateArgs>({ + delegate: database.event, + + getFindArgs: async ({ query }) => { + return { + where: { + slug: query.slug, + } + } + }, + + beforeUpdate: async ({ auth }, item) => { + if (!auth) { + throw Response.error({ status: 403, message: "Authentication is required to edit events" }) + } + + if (item && item.creatorId !== auth.user.id) { + throw Response.error({ status: 403, message: "Only the creator can edit an event" }) + } + }, + + getUpdateArgs: async ({ body, query }) => { + return { + where: { + slug: query.slug, + }, + data: body!, + } + }, + + beforeDestruction: async ({ auth }, item) => { + if (!auth) { + throw Response.error({ status: 403, message: "Authentication is required to destroy events" }) + } + + if (item && item.creatorId !== auth.user.id) { + throw Response.error({ status: 403, message: "Only the creator can destroy an event" }) + } + }, + + getDestroyArgs: async ({ query }) => { + return { + where: { + slug: query.slug, + } + } } - } - - const which = { - slug: req.query.slug, - } - const update = { - name: req.body.name, - postcard: req.body.postcard ?? null, - description: req.body.description, - startingAt: req.body.startingAt, - endingAt: req.body.endingAt, - } - - await restInPeace(req, res, { - model: database.event, - retrieve: {which}, - update: {which, update, before: canEdit}, - destroy: {which, before: canEdit}, - }) + }), }) -} \ No newline at end of file +} diff --git a/pages/api/events/index.ts b/pages/api/events/index.ts index 86093d0..7f5f673 100644 --- a/pages/api/events/index.ts +++ b/pages/api/events/index.ts @@ -1,34 +1,74 @@ import { database } from "../../../utils/prismaClient"; import { NextApiRequest, NextApiResponse } from "next"; -import { ApiResult } from "../../../types/api"; -import { restInPeace } from "../../../utils/restInPeace"; import { default as cryptoRandomString } from "crypto-random-string"; -import { handleInterrupts, Interrupt } from "../../../utils/interrupt"; -import { authorizeUser } from "../../../utils/authorizeUser"; -import { Event } from "@prisma/client"; +import { Event, Prisma } from "@prisma/client"; +import { festaNoConfig } from "../../../utils/api/configurator"; +import { festaBearerAuthRequired, FestaToken } from "../../../utils/api/authenticator"; +import { festaJsonSchemaBody } from "../../../utils/api/bodyValidator"; +import { festaAPI } from "../../../utils/api"; +import { festaNoQuery } from "../../../utils/api/queryValidator"; +import { festaRESTGeneric } from "../../../utils/api/executor"; +import { Response } from "../../../utils/api/throwables"; -export default async function handler(req: NextApiRequest, res: NextApiResponse>) { - handleInterrupts(res, async () => { - const user = await authorizeUser(req) +type Config = {} - if (req.body.name.length === 0) { - throw new Interrupt(400, { error: "Name is empty" }) - } +type Auth = FestaToken - const create = { - slug: cryptoRandomString({ length: 12, type: "url-safe" }), - creatorId: user.id, - name: req.body.name, - postcard: req.body.postcard ?? null, - description: req.body.description, - startingAt: req.body.startingAt, - endingAt: req.body.endingAt, - } +type Query = {} - await restInPeace(req, res, { - model: database.event, - create: { create }, - }) +type Body = { + name: string, + postcard?: string, + description: string, + startingAt?: string, + endingAt?: string, +} + + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await festaAPI(req, res, { + + configurator: festaNoConfig, + + authenticator: festaBearerAuthRequired, + + queryValidator: festaNoQuery, + + bodyValidator: festaJsonSchemaBody({ + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + postcard: { type: "string", nullable: true }, + description: { type: "string", nullable: false }, + startingAt: { type: "string", nullable: true }, + endingAt: { type: "string", nullable: true }, + }, + required: [ + "name", + ] + }), + + executor: festaRESTGeneric, Prisma.EventFindManyArgs, Prisma.EventCreateArgs>({ + delegate: database.event, + + getListArgs: async ({ }) => { + throw Response.error({ status: 405, message: "Cannot list all events" }) + }, + + getCreateArgs: async ({ auth, body }) => { + return { + data: { + slug: cryptoRandomString({ length: 12, type: "alphanumeric" }), + creatorId: auth.userId, + name: body!.name, + postcard: body!.postcard ?? null, + description: body!.description, + startingAt: body!.startingAt ?? null, + endingAt: body!.endingAt ?? null, + } + } + }, + }), }) -} \ No newline at end of file +} diff --git a/pages/api/events/mine.ts b/pages/api/events/mine.ts index f8936d1..38d1a03 100644 --- a/pages/api/events/mine.ts +++ b/pages/api/events/mine.ts @@ -1,23 +1,49 @@ import { database } from "../../../utils/prismaClient"; import { NextApiRequest, NextApiResponse } from "next"; -import { ApiResult } from "../../../types/api"; -import { restInPeace } from "../../../utils/restInPeace"; -import { handleInterrupts } from "../../../utils/interrupt"; -import { authorizeUser } from "../../../utils/authorizeUser"; -import { Event } from "@prisma/client"; +import { Event, Prisma } from "@prisma/client"; +import { festaNoConfig } from "../../../utils/api/configurator"; +import { festaBearerAuthRequired, FestaToken } from "../../../utils/api/authenticator"; +import { festaAPI } from "../../../utils/api"; +import { festaNoQuery } from "../../../utils/api/queryValidator"; +import { festaJsonSchemaBody, festaNoBody } from "../../../utils/api/bodyValidator"; +import { festaRESTGeneric } from "../../../utils/api/executor"; +import { Response } from "../../../utils/api/throwables"; -export default async function handler(req: NextApiRequest, res: NextApiResponse>) { - handleInterrupts(res, async () => { - const user = await authorizeUser(req) +type Config = {} - const where = { - creatorId: user.id - } - - await restInPeace(req, res, { - model: database.event, - list: {where} - }) +type Auth = FestaToken + +type Query = {} + +type Body = {} + + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await festaAPI(req, res, { + + configurator: festaNoConfig, + + authenticator: festaBearerAuthRequired, + + queryValidator: festaNoQuery, + + bodyValidator: festaNoBody, + + executor: festaRESTGeneric, Prisma.EventFindManyArgs, Prisma.EventCreateArgs>({ + delegate: database.event, + + getListArgs: async ({ auth }) => { + return { + where: { + creatorId: auth.userId, + } + } + }, + + getCreateArgs: async ({ }) => { + throw Response.error({ status: 405, message: "This route cannot be used to create new events" }) + }, + }), }) -} \ No newline at end of file +} diff --git a/pages/api/login/index.ts b/pages/api/login/index.ts deleted file mode 100644 index d11514a..0000000 --- a/pages/api/login/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { NextApiRequest, NextApiResponse } from "next" -import { database } from "../../../utils/prismaClient" -import { TelegramLoginDataClass } from "../../../utils/TelegramLoginDataClass" -import { default as cryptoRandomString } from "crypto-random-string" -import { ApiResult } from "../../../types/api" -import { FestaLoginData } from "../../../types/user" - - -export default async function handler(req: NextApiRequest, res: NextApiResponse>) { - switch (req.method) { - case "OPTIONS": - return res.status(200).send("") - case "POST": - switch(req.query.provider) { - case "telegram": - return await loginTelegram(req, res) - default: - return res.status(400).json({ error: "Unknown login provider" }) - } - default: - return res.status(405).json({ error: "Invalid method" }) - } -} - - -async function loginTelegram(req: NextApiRequest, res: NextApiResponse>) { - const botToken = process.env.TELEGRAM_TOKEN - if (!botToken) { - return res.status(503).json({ error: "`TELEGRAM_TOKEN` was not set up" }) - } - - const hashExpirationMs = parseInt(process.env.TELEGRAM_HASH_EXPIRATION_MS!) - if (!hashExpirationMs) { - return res.status(503).json({ error: "`TELEGRAM_HASH_EXPIRATION_MS` was not set up" }) - } - - const tokenExpirationMs = parseInt(process.env.FESTA_TOKEN_EXPIRATION_MS!) // Wrong typing? - if (!tokenExpirationMs) { - return res.status(503).json({ error: "`FESTA_TOKEN_EXPIRATION_MS` was not set up" }) - } - - try { - var userData: TelegramLoginDataClass = new TelegramLoginDataClass(req.body) - } - catch (_) { - return res.status(422).json({ error: "Malformed data" }) - } - - if (!userData.isRecent(hashExpirationMs)) { - return res.status(408).json({ error: "Telegram login data is not recent" }) - } - - if (!userData.isValid(botToken)) { - return res.status(401).json({ error: "Telegram login data has been tampered" }) - } - - const accountTelegram = await database.accountTelegram.upsert({ - where: { - telegramId: userData.id - }, - create: { - ...userData.toDatabase(), - user: { - create: { - displayName: userData.toTelegramName(), - displayAvatarURL: userData.photoUrl, - } - } - }, - update: { - ...userData.toDatabase(), - user: { - update: {} - } - } - }) - - const token = await database.token.create({ - data: { - userId: accountTelegram.userId, - token: cryptoRandomString({ length: 16, type: "base64" }), - expiresAt: new Date(userData.authDate.getTime() + tokenExpirationMs) - }, - include: { - user: true, - } - }) - - return res.status(200).json(token) -} \ No newline at end of file diff --git a/pages/events/[slug].tsx b/pages/events/[slug].tsx index e235fa8..810e135 100644 --- a/pages/events/[slug].tsx +++ b/pages/events/[slug].tsx @@ -1,108 +1,49 @@ -import { Event, User } from "@prisma/client"; -import { NextPageContext } from "next"; -import { useTranslation } from "next-i18next"; -import { serverSideTranslations } from "next-i18next/serverSideTranslations"; -import { default as Head } from "next/head"; -import { ChangeEvent, useState } from "react"; -import { ToolBar } from "../../components/tools/ToolBar"; -import { EditableMarkdown } from "../../components/editable/EditableMarkdown"; -import { EditableText } from "../../components/editable/EditableText"; -import { ToolToggleEditing } from "../../components/tools/ToolToggleEditing"; -import { EditingContext } from "../../components/editable/EditingContext"; -import { database } from "../../utils/prismaClient"; -import { EditableFilePicker } from "../../components/editable/EditableFilePicker"; -import { ViewEvent } from "../../components/view/ViewEvent"; -import { ToolToggleVisible } from "../../components/tools/ToolToggleVisible"; -import { WorkInProgress } from "../../components/WorkInProgress"; -import { usePostcardImage } from "../../components/postcard/usePostcardImage"; +import { NextPage, NextPageContext } from 'next' +import { useTranslation } from 'next-i18next' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import { default as Head } from 'next/head' import defaultPostcard from "../../public/postcards/adi-goldstein-Hli3R6LKibo-unsplash.jpg" -import { EditableEventDuration } from "../../components/editable/EditableEventDuration"; -import { fromDatetimeLocal } from "../../utils/dateFields"; +import { Postcard } from '../../components/postcard/changer' +import { ViewEvent } from '../../components/events/views/event' +import useSWR from 'swr' +import { Event } from '@prisma/client' export async function getServerSideProps(context: NextPageContext) { - const slug = context.query.slug as string - if (typeof slug === "object") { - return { notFound: true } - } - - const initialEvent = await database.event.findUnique({ - where: { slug }, - include: { creator: true } - }) - if (!initialEvent) { - return { notFound: true } - } - return { props: { - initialEvent, + slug: context.query.slug, ...(await serverSideTranslations(context.locale ?? "it-IT", ["common"])) } } } -type PageEventDetailProps = { - initialEvent: Event & { creator: User } +type PageEventProps = { + slug: string } -export default function PageEventDetail({ initialEvent }: PageEventDetailProps) { +const PageEvent: NextPage = ({ slug }) => { const { t } = useTranslation() - const editState = useState(false) - const [event, setEvent] = useState(initialEvent) - - const displayedPostcard = event.postcard || defaultPostcard.src - usePostcardImage(`url(${displayedPostcard})`) - - console.debug("Event:", event) + const { data, error } = useSWR(`/api/events/${slug}`) return <> - {initialEvent.name} - {t("siteTitle")} + eventName - {t("siteTitle")} + - - - - - - - ) => setEvent({ ...event, name: e.target.value })} - placeholder={t("eventDetailsNamePlaceholder")} - /> - } - postcard={ - ) => setEvent({ ...event, postcard: URL.createObjectURL(e.target.files![0]) })} - placeholder={t("eventDetailsPostcardPlaceholder")} - accept={"image/*"} - /> - } - description={ - ) => setEvent({ ...event, description: e.target.value })} - placeholder={t("eventDetailsDescriptionPlaceholder")} - /> - } - daterange={ - ) => setEvent({ ...event, startingAt: fromDatetimeLocal(e.target.value) }), - }, - endProps: { - value: event.endingAt, - onChange: (e: ChangeEvent) => setEvent({ ...event, endingAt: fromDatetimeLocal(e.target.value) }), - } - }} /> - } - /> - + + {data?.name ?? slug}} + postcard={<>} + description={<>{data?.description}} + daterange={<>} + /> -} \ No newline at end of file +} + + +export default PageEvent diff --git a/pages/index.tsx b/pages/index.tsx index 17801b8..b1cc227 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,14 +1,14 @@ -import { NextPageContext } from 'next' +import { NextPage, NextPageContext } from 'next' import { useTranslation } from 'next-i18next' import { serverSideTranslations } from 'next-i18next/serverSideTranslations' -import { LoginContext } from '../components/contexts/login' -import { useDefinedContext } from '../utils/definedContext' -import { ActionLoginTelegram } from '../components/ActionLoginTelegram' -import { ActionEventList } from '../components/ActionEventList' import { default as Head } from 'next/head' import defaultPostcard from "../public/postcards/adi-goldstein-Hli3R6LKibo-unsplash.jpg" -import { ViewLanding } from '../components/view/ViewLanding' -import { usePostcardImage } from '../components/postcard/usePostcardImage' +import { Postcard } from '../components/postcard/changer' +import { ViewLanding } from '../components/generic/views/landing' +import { LandingActionLogin } from '../components/landing/actions/login' +import { useDefinedContext } from '../utils/definedContext' +import { AuthContext } from '../components/auth/base' +import { LandingActionEvents } from '../components/landing/actions/events' export async function getStaticProps(context: NextPageContext) { @@ -20,30 +20,28 @@ export async function getStaticProps(context: NextPageContext) { } -export default function PageIndex() { +const PageIndex: NextPage = () => { const { t } = useTranslation() - const [login,] = useDefinedContext(LoginContext) - - usePostcardImage(`url(${defaultPostcard.src})`) + const [auth,] = useDefinedContext(AuthContext) return <> {t("siteTitle")} + - : - - ) + actions={auth ? + + : + } /> } + + +export default PageIndex diff --git a/public/locales/it-IT/common.json b/public/locales/it-IT/common.json index 0febac1..6ba7d03 100644 --- a/public/locales/it-IT/common.json +++ b/public/locales/it-IT/common.json @@ -1,9 +1,21 @@ { "siteTitle": "Festa", "siteSubtitle": "Organizza eventi con facilità!", - "formTelegramLoginDescription": "Per iniziare, effettua il login con Telegram.", - "formTelegramLoginWorking": "Un attimo solo, sto effettuando il login...", - "formTelegramLoginError": "Si è verificato il seguente errore durante il login con Telegram:", + "landingLoginTelegramDescription": "Per iniziare, effettua il login con Telegram.", + "landingLoginTelegramPending": "Un attimo solo, sto effettuando il login...", + "landingLoginTelegramRejected": "Si è verificato il seguente errore durante il login con Telegram:", + "landingLoginTelegramFulfilled": "Login riuscito! Caricamento della dashboard in corso...", + "landingEventsLoading": "Caricamento della lista dei tuoi eventi in corso...", + "landingEventsError": "Si è verificato il seguente errore durante il caricamento dei tuoi eventi:", + "landingEventsDescription": "Questi sono gli eventi che hai creato:", + "landingEventsCreateDescription": "Se vuoi crearne uno nuovo, inserisci il nome qui sotto:", + "landingEventsFirstDescription": "Crea il tuo primo evento inserendone il nome qui sotto:", + "landingEventsCreatePlaceholder": "Party Cop", + "landingEventsCreateSubmitLabel": "Crea evento", + "landingEventsCreatePending": "Creazione dell'evento in corso...", + "landingEventsCreateRejected": "Creazione dell'evento fallita:", + "landingEventsCreateFulfilled": "Evento creato con successo! Trasferimento alla pagina dell'evento in corso...", + "logOutPrompt": "Non sei tu?", "eventsSubtitleFirst": "Crea il tuo primo evento!", "eventsInputDescriptionFirst": "Dai un nome al tuo primo evento!", @@ -17,9 +29,6 @@ "notFoundBackHome": "Torna alla home", "internalServerError": "Si è verificato un errore nella gestione della tua richiesta.", "eventListError": "Si è verificato il seguente errore durante il recupero dei tuoi eventi:", - "eventListLoading": "Caricamento della lista degli eventi creati in corso...", - "eventListDescription": "Questi sono gli eventi che hai creato:", - "eventListCreateAnother": "Se vuoi crearne un altro, inseriscine il nome qui sotto:", "eventListCreateFirst": "Inserisci il nome del tuo primo evento qui sotto!", "eventListCreateEventNameLabel": "Nome evento", "eventListCreateSubmitLabel": "Crea", @@ -33,4 +42,4 @@ "eventDetailsPostcardPlaceholder": "Cartolina", "workInProgress": "Questa pagina è ancora in sviluppo e potrebbe contenere errori o testo inaspettato.", "dateNaN": "Non specificata" -} +} \ No newline at end of file diff --git a/styles/components/form-monorow.css b/styles/components/form-monorow.css deleted file mode 100644 index 1d346a9..0000000 --- a/styles/components/form-monorow.css +++ /dev/null @@ -1,22 +0,0 @@ -.form-monorow { - max-width: 600px; - - margin-left: auto; - margin-right: auto; - - display: flex; - flex-direction: row; - gap: 4px; - justify-content: center; - align-items: stretch; -} - -.form-monorow > input { - flex-grow: 1; -} - -.form-monorow > button, .form-monorow > input[type="submit"] { - flex-grow: 0; - width: 40px; - height: 40px; -} diff --git a/styles/components/list-events.css b/styles/components/list-events.css index 5c0d1cf..e69de29 100644 --- a/styles/components/list-events.css +++ b/styles/components/list-events.css @@ -1,12 +0,0 @@ -.list-events { - max-height: 20vh; - padding-left: 20px; - padding-bottom: 12px; - - text-align: left; - - overflow-y: scroll; - - column-count: auto; - column-width: 140px; -} diff --git a/styles/components/telegram-login.css b/styles/components/telegram-login.css deleted file mode 100644 index 0baa5fe..0000000 --- a/styles/components/telegram-login.css +++ /dev/null @@ -1,3 +0,0 @@ -.container-btn-telegram > div { - height: 40px; -} diff --git a/styles/elements.css b/styles/elements.css index 5580279..cf7882c 100644 --- a/styles/elements.css +++ b/styles/elements.css @@ -67,6 +67,10 @@ button:active:not([disabled]), input[type="submit"]:active:not([disabled]) { textarea { border-style: inset; border-radius: 16px 16px 0 16px; - + resize: vertical; } + +pre { + white-space: pre-wrap; +} \ No newline at end of file diff --git a/styles/flex.css b/styles/flex.css new file mode 100644 index 0000000..b696285 --- /dev/null +++ b/styles/flex.css @@ -0,0 +1,26 @@ +.flex { + display: flex; +} + +.flex-row { + flex-direction: row; +} + +.flex-column { + flex-direction: column; +} + +.flex-grow { + flex-grow: 1; + flex-shrink: 0; +} + +.flex-fix { + flex-grow: 0; + flex-shrink: 0; +} + +.flex-shrink { + flex-grow: 0; + flex-shrink: 1; +} \ No newline at end of file diff --git a/styles/globals.css b/styles/globals.css index 43024fc..bd3de3b 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -1,22 +1,7 @@ @import "color-schemes.css"; @import "elements.css"; @import "mood.css"; -/* Ughhh, there's no way to have readable CSS by using modules and Next? */ -@import "components/views/content.css"; -@import "components/views/landing.css"; -@import "components/views/notice.css"; -@import "components/views/event.css"; - -@import "components/icon.css"; -@import "components/list-events.css"; -@import "components/postcard.css"; -@import "components/square.css"; -@import "components/telegram-login.css"; -@import "components/toolbar.css"; -@import "components/form-monorow.css"; -@import "components/error.css"; -@import "components/form-fromto.css"; -@import "components/work-in-progress.css"; +@import "flex.css"; * { diff --git a/types/api.ts b/types/api.ts deleted file mode 100644 index 3141e44..0000000 --- a/types/api.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type ApiError = { - error: string -} - -export type ApiResult = ApiError | T | T[] | "" diff --git a/types/react-telegram-login.d.ts b/types/react-telegram-login.d.ts deleted file mode 100644 index a4f1860..0000000 --- a/types/react-telegram-login.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "react-telegram-login"; \ No newline at end of file diff --git a/types/user.ts b/types/user.ts deleted file mode 100644 index b2c0f6b..0000000 --- a/types/user.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Token, User } from "@prisma/client" - -/** - * Serializable Telegram login data with technical information. - */ -export type TelegramLoginData = { - id: number - first_name: string - last_name?: string - username?: string - photo_url?: string - lang?: string - auth_date: number - hash: string -} - -/** - * Login data for a specific Festa user. - */ -export type FestaLoginData = Token & {user: User} diff --git a/utils/api/authenticator.ts b/utils/api/authenticator.ts new file mode 100644 index 0000000..e49054a --- /dev/null +++ b/utils/api/authenticator.ts @@ -0,0 +1,98 @@ +import { Response as Response } from "./throwables" +import { Token, User } from "@prisma/client" +import { database } from "../prismaClient" + +/** + * Object containing multiple functions related to API authentication. + * + * @see {@link festaAPI} and its parameters {@link FestaAPI}. + */ +export type FestaAuthenticator = { + /** + * The value that the `WWW-Authenticate` header should be set to in a handler using this authenticator. + */ + header: string, + + /** + * Async function which uses the value of the `Authorization` HTTP header to authenticate the user agent performing the API request. + * + * Any thrown {@link Error} should be caught by the handler, and its {@link Error.message} returned by the API. + * + * @param config - The current configuration of the request handler. + * @param header - The value of the `Authorization` HTTP header. + * @throws {ThrowableResponse} + */ + perform: (params: { config: Config, header: string | undefined }) => Promise, +} + + +/** + * {@link FestaAuthenticator} which does not require any authentication. + * + * Never throws. + */ +export const festaNoAuth: FestaAuthenticator = { + header: "", + perform: async ({ }) => { + return {} + }, +} + + +/** + * Structure of the authentication data returned by the Festa API. + */ +export type FestaToken = Token & { user: User } + + +/** + * {@link FestaAuthenticator} which authenticates the user agent based on the value of its Bearer token. + * + * Returns the {@link FestaToken} if successful, or `undefined` if the Authorization header is missing. + * + * Throws an HTTP 401 error if the header is malformed or the token is invalid or expired. + */ +export const festaBearerAuthOptional: FestaAuthenticator = { + header: "Bearer", + perform: async ({ header }) => { + if (!header || header === "false") { + return undefined + } + + const token = header.match(/^Bearer (\S+)$/)?.[1] + + if (!token) { + throw Response.error({ status: 401, message: "Malformed Authorization header" }) + } + + const dbToken = await database.token.findUnique({ where: { token }, include: { user: true } }) + if (!dbToken) { + throw Response.error({ status: 401, message: "Invalid Authorization token" }) + } + + const now = new Date() + if (dbToken.expiresAt.getTime() < now.getTime()) { + throw Response.error({ status: 401, message: "Expired Authorization token" }) + } + + return dbToken + } +} + +/** + * {@link FestaAuthenticator} which authenticates the user agent based on the value of its Bearer token. + * + * Returns the {@link FestaToken} if successful. + * + * Throws an HTTP 401 error if the header is missing or malformed, or the token is invalid or expired. + */ +export const festaBearerAuthRequired: FestaAuthenticator = { + header: "Bearer", + perform: async (params) => { + if (!params.header) { + throw Response.error({ status: 401, message: "Missing Authorization header" }) + } + + return await festaBearerAuthOptional.perform(params) as FestaToken + } +} diff --git a/utils/api/bodyValidator.ts b/utils/api/bodyValidator.ts new file mode 100644 index 0000000..8f09fa5 --- /dev/null +++ b/utils/api/bodyValidator.ts @@ -0,0 +1,78 @@ +import { default as Ajv, JSONSchemaType } from "ajv" +import { Response } from "./throwables" + +/** + * Object containing data related to API request body validation. + * + * @see {@link FestaQueryValidator}, {@link festaAPI} and its parameters {@link FestaAPI}. + */ +export type FestaBodyValidator = { + /** + * The schema that the request body is expected to follow, to be returned in OPTIONS requests. + * + * Can be `null` if the request body won't be validated. + */ + schema: any, + + /** + * Async function which validates the request body, ensuring it follows the {@link FestaBodyValidator.schema}. + * + * Any thrown {@link Error} should be caught by the handler, and its {@link Error.message} returned by the API. + * + * @param config - The current configuration of the request handler. + * @param body - The deserialized contents of the request body. + * @throws {ThrowableResponse} + */ + perform: (params: { config: Config, body: any }) => Promise, +} + + +/** + * {@link FestaBodyValidator} which ignores the body. + * + * Never throws. + */ +export const festaNoBody: FestaBodyValidator = { + schema: null, + perform: async ({ }) => { + return {} + }, +} + + +/** + * Factory of {@link FestaBodyValidator}s using JSON Schema to perform the validation. + * + * _TypeScript note: `schema` parameter validation has been turned off because it was pointlessly annoying. + * + * @param {JSONSchemaType} schema - The {@link JSONSchemaType} to be used in validation. + */ +export function festaJsonSchemaBody(schema: any /* JSONSchemaType */): FestaBodyValidator { + const ajv = new Ajv() + const validate = ajv.compile(schema) + + return { + schema, + perform: async ({ body }) => { + const valid = validate(body) + + if (!valid) { + throw new Response({ + status: 422, + body: { + error: validate.errors!.map(error => error.message).join(" + "), + validation: validate.errors!.map(error => { + const obj: { [_: string]: string } = {} + obj[error.keyword] = error.message! + return obj + }).reduce((a, b) => { + return { ...a, ...b } + }, {}) + } + }) + } + + return body as Body + } + } +} diff --git a/utils/api/configurator.ts b/utils/api/configurator.ts new file mode 100644 index 0000000..efd9d96 --- /dev/null +++ b/utils/api/configurator.ts @@ -0,0 +1,29 @@ +/** + * Object containing a function related to server configuration. + * + * @see {@link festaAPI} and its parameters {@link FestaAPI}. + */ +export type FestaConfigurator = { + /** + * Async function which returns the relevant configuration variables in form of an object. + * + * Any thrown {@link Error} should be caught by the handler, and depending on the current `NODE_ENV`: + * - in `development` the {@link Error.message} should be returned by the API; + * - in `production` a generic error should be sent instead, to prevent attackers from gaining excess information about the server state. + * + * @throws {ThrowableResponse} + */ + perform: () => Promise +} + + +/** + * {@link FestaConfigurator} which returns an empty object. + * + * Never throws. + */ +export const festaNoConfig: FestaConfigurator<{}> = { + perform: async () => { + return {} + } +} diff --git a/utils/api/executor.ts b/utils/api/executor.ts new file mode 100644 index 0000000..d35d298 --- /dev/null +++ b/utils/api/executor.ts @@ -0,0 +1,268 @@ +import { PrismaDelegate } from "../prismaClient" +import { Response } from "./throwables" + + +/** + * Object containing a function related to data processing and request execution. + */ +export type FestaExecutor = { + /** + * Array of HTTP methods that can be handled by the executor. + */ + methods: string[] + + /** + * Async function which processes the request. + * + * Any thrown {@link Error} should be caught by the handler, and its {@link Error.message} returned by the API. + * + * @param config - The current configuration of the request handler. + * @param auth - The authenticated user agent. + * @param body - The validated contents of the request body. Is always undefined in get requests. + * @throws {ThrowableResponse} + */ + perform: (params: { method: string, config: Config, auth: Auth, query: Query, body: Body | undefined }) => Promise +} + + +/** + * {@link FestaExecutor} factory which does nothing and returns 204. + */ +export function festaNoOp(): FestaExecutor { + return { + methods: ["GET", "POST", "PUT", "PATCH", "DELETE"], + perform: async ({ }) => { + return {} + } + } +} + + +/** + * {@link FestaExecutor} factory which returns the `config` object received as parameter. + */ +export function festaDebugConfig(): FestaExecutor { + return { + methods: ["GET"], + perform: async ({ config }) => { + return config + } + } +} + + +/** + * {@link FestaExecutor} factory which returns the `auth` object received as parameter. + */ +export function festaDebugAuth(): FestaExecutor { + return { + methods: ["GET"], + perform: async ({ auth }) => { + return auth + } + } +} + + +/** + * {@link FestaExecutor} factory which echoes back the `query` object received as parameter. + */ +export function festaDebugQuery(): FestaExecutor { + return { + methods: ["GET"], + perform: async ({ query }) => { + return query + } + } +} + + +/** + * {@link FestaExecutor} factory which echoes back the `body` object received as parameter. + */ +export function festaDebugBody(): FestaExecutor { + return { + methods: ["GET"], + perform: async ({ body }) => { + return body ?? null + } + } +} + + +export type FestaRESTParams = { config: Config, auth: Auth, query: Query, body: Body | undefined } + + +/** + * Parameters of {@link festaRESTGeneric}. + */ +export type FestaRESTGeneric = { + /** + * The model to act on. + */ + delegate: Delegate, + + /** + * Function returning the options that should be used to `findMany` items on the delegate. + * + * @throws {ThrowableResponse} If the request should be short-circuited to a specific response. + */ + getListArgs: (params: FestaRESTParams) => Promise, + + /** + * Function called after retrieving the items to list, which can be used to alter what is returned by the API route. + * + * @throws {ThrowableResponse} If the request should be short-circuited to a specific response. + */ + afterList?: (params: FestaRESTParams, items: Item[]) => Promise + + /** + * Function returning the options that should be used to `create` an item on the delegate. + * + * @throws {ThrowableResponse} If the request should be short-circuited to a specific response. + */ + getCreateArgs: (params: FestaRESTParams) => Promise, + + /** + * Function called after creating a new item, which can be used to alter what is returned by the API route. + * + * @throws {ThrowableResponse} If the request should be short-circuited to a specific response. + */ + afterCreation?: (params: FestaRESTParams, item: Item) => Promise +} + + +/** + * {@link FestaExecutor} factory which lists items matched by a query on a {@link PrismaDelegate}. + * + * @param delegate - The delegate to list items from. + * @param listOptions - Function returning the options that should be used by the delegate + */ +export function festaRESTGeneric({ delegate, getListArgs, afterList, getCreateArgs, afterCreation }: FestaRESTGeneric): FestaExecutor { + return { + methods: ["GET", "POST"], + perform: async (params) => { + let response: any + + if (params.method === "POST") { + const items: Item[] = await delegate.create(await getCreateArgs(params)) + response = afterList ? await afterList(params, items) : items + } + else { + const item: Item = await delegate.findMany(await getListArgs(params)) + response = afterCreation ? await afterCreation(params, item) : item + } + + return response + } + } +} + + +export type FestaRESTSpecific = { + /** + * The model to act on. + */ + delegate: Delegate, + + /** + * Function returning the options that should be used to `retrieve`, `update` or `destroy` an item on the delegate. + * + * @throws {ThrowableResponse} If the request should be short-circuited to a specific response. + */ + getFindArgs: (params: FestaRESTParams) => Promise, + + /** + * Function called after finding an item, which can be used for example to forbid access to that item. + * + * @throws {ThrowableResponse} If the request should be short-circuited to a specific response. + */ + afterFind?: (params: FestaRESTParams, item: Item) => Promise + + /** + * Function called after retrieving an item, which can be used to alter what is returned by the API route. + * + * @throws {ThrowableResponse} If the request should be short-circuited to a specific response. + */ + afterRetrieval?: (params: FestaRESTParams, item: Item) => Promise + + /** + * Function returning the options that should be used to `update` an item on the delegate. + * + * @throws {ThrowableResponse} If the request should be short-circuited to a specific response. + */ + getUpdateArgs: (params: FestaRESTParams) => Promise, + + /** + * Function called before updating an existing item, which can be used for example to forbid access to that item. + * + * @throws {ThrowableResponse} If the request should be short-circuited to a specific response. + */ + beforeUpdate?: (params: FestaRESTParams, item: Item) => Promise + + /** + * Function called after updating an existing item, which can be used to alter what is returned by the API route. + * + * @throws {ThrowableResponse} If the request should be short-circuited to a specific response. + */ + afterUpdate?: (params: FestaRESTParams, item: Item) => Promise + + /** + * Function returning the options that should be used to `destroy` an item on the delegate. + * + * @throws {ThrowableResponse} If the request should be short-circuited to a specific response. + */ + getDestroyArgs: (params: FestaRESTParams) => Promise + + /** + * Function called before destroying an existing item, which can be used for example to forbid access to that item. + * + * @throws {ThrowableResponse} If the request should be short-circuited to a specific response. + */ + beforeDestruction?: (params: FestaRESTParams, item: Item) => Promise + + /** + * Function called after destroying an existing item, which can be used to alter what is returned by the API route. + * + * @throws {ThrowableResponse} If the request should be short-circuited to a specific response. + */ + afterDestruction?: (params: FestaRESTParams) => Promise +} + + +/** + * {@link FestaExecutor} factory which operates on a single item matched by a query on a {@link PrismaDelegate}. + * + * Similar to the old `restInPeace` function. + */ +export function festaRESTSpecific({ delegate, getFindArgs, afterFind, afterRetrieval, getUpdateArgs, beforeUpdate, afterUpdate, getDestroyArgs, beforeDestruction, afterDestruction }: FestaRESTSpecific): FestaExecutor { + return { + methods: ["GET", "PATCH", "DELETE"], + perform: async (params) => { + let response: any + + const item = await delegate.findUnique(await getFindArgs(params)) + if (!item) { + throw Response.error({ status: 404, message: "Item not found" }) + } + + afterFind && await afterFind(params, item) + + if (params.method === "DELETE") { + beforeDestruction && await beforeDestruction(params, item) + await delegate.delete(await getDestroyArgs(params)) + response = afterDestruction ? await afterDestruction(params) : null + } + else if (params.method === "PATCH") { + beforeUpdate && await beforeUpdate(params, item) + response = await delegate.update(await getUpdateArgs(params)) + response = afterUpdate ? await afterUpdate(params, item) : response + } + else { + response = afterRetrieval ? await afterRetrieval(params, item) : item + } + + return response + } + } +} \ No newline at end of file diff --git a/utils/api/index.ts b/utils/api/index.ts new file mode 100644 index 0000000..0f94b90 --- /dev/null +++ b/utils/api/index.ts @@ -0,0 +1,86 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { FestaAuthenticator } from "./authenticator"; +import { FestaConfigurator } from "./configurator"; +import { FestaExecutor } from "./executor"; +import { Response as Response } from "./throwables"; +import { FestaBodyValidator } from "./bodyValidator"; +import { FestaQueryValidator } from "./queryValidator"; + + +/** + * Parameters of {@link festaAPI}. + */ +type FestaAPI = { + configurator: FestaConfigurator, + queryValidator: FestaQueryValidator, + authenticator: FestaAuthenticator, + bodyValidator: FestaBodyValidator, + executor: FestaExecutor, +} + + +export async function festaAPI(req: NextApiRequest, res: NextApiResponse, { configurator, authenticator, queryValidator, bodyValidator, executor }: FestaAPI): Promise { + await Response.handle(res, async () => { + + // Set the Access-Control-Allow-Origin header + res.setHeader("Access-Control-Allow-Origin", "*") + + // Set the WWW-Authenticate header + res.setHeader("WWW-Authenticate", authenticator.header) + + // Set the Allow header + res.setHeader("Allow", ["HEAD", "OPTIONS", ...executor.methods].join(", ")) + + // Get configuration + const config = await Response.convertThrownErrors( + async () => await configurator.perform(), + "Server is not configured appropriately to handle this request" + ) + + // Validate the request query + const query = await Response.convertThrownErrors( + async () => await queryValidator.perform({ config, query: req.query }), + "Unexpected error occurred during validation of this request" + ) + + // Head requests cut-off here + if (req.method === "HEAD") { + throw new Response({ status: 204 }) + } + + // Options requests cut-off here + if (req.method === "OPTIONS") { + // If validation is not performed, no body is sent to OPTIONS requests + if (!bodyValidator.schema) { + throw new Response({ status: 204 }) + } + else { + throw new Response({ status: 200, body: bodyValidator.schema }) + } + } + + // Perform user agent authentication + const auth = await Response.convertThrownErrors( + async () => await authenticator.perform({ config, header: req.headers.authorization }), + "Unexpected error occurred during authentication of this request" + ) + + // Get requests shouldn't have a body + let body: Body | undefined = undefined + if (req.method !== "GET") { + // Validate the request body + body = await Response.convertThrownErrors( + async () => await bodyValidator.perform({ config, body: req.body }), + "Unexpected error occurred during validation of this request" + ) + } + + // Act on the data and determine a response + const result = await Response.convertThrownErrors( + async () => await executor.perform({ config, auth, query, body, method: req.method! }), + "Unexpected error occurred during execution of this request" + ) + + throw new Response({ status: 200, body: result }) + }) +} diff --git a/utils/api/queryValidator.ts b/utils/api/queryValidator.ts new file mode 100644 index 0000000..ffcd787 --- /dev/null +++ b/utils/api/queryValidator.ts @@ -0,0 +1,72 @@ +import { default as Ajv, JSONSchemaType } from "ajv" +import { ParsedUrlQuery } from "querystring" +import { Response } from "./throwables" + +/** + * Object containing data related to API request "query" (the `req.query` object, an instance of {@link ParsedUrlQuery}) validation. + * + * Since validation is performed **before** HEAD and OPTIONS requests are answered, there is no schema. + * + * @see {@link FestaBodyValidator}, {@link festaAPI} and its parameters {@link FestaAPI}. + */ +export type FestaQueryValidator = { + /** + * Async function which validates the request query, ensuring it follows the {@link FestaQueryValidator.schema}. + * + * Any thrown {@link Error} should be caught by the handler, and its {@link Error.message} returned by the API. + * + * @param config - The current configuration of the request handler. + * @param query - The deserialized contents of the request query. + * @throws {ThrowableResponse} + */ + perform: (params: { config: Config, query: any }) => Promise, +} + + +/** + * {@link FestaQueryValidator} which ignores the query. + * + * Never throws. + */ +export const festaNoQuery: FestaQueryValidator = { + perform: async ({ }) => { + return {} + }, +} + + +/** + * Factory of {@link FestaQueryValidator}s using JSON Schema to perform the validation. + * + * _TypeScript note: `schema` parameter validation has been turned off because it was pointlessly annoying. + * + * @param {JSONSchemaType} schema - The {@link JSONSchemaType} to be used in validation. + */ +export function festaJsonSchemaQuery(schema: any /* JSONSchemaType */): FestaQueryValidator { + const ajv = new Ajv() + const validate = ajv.compile(schema) + + return { + perform: async ({ query }) => { + const valid = validate(query) + + if (!valid) { + throw new Response({ + status: 404, + body: { + error: validate.errors!.map(error => error.message).join(" + "), + validation: validate.errors!.map(error => { + const obj: { [_: string]: string } = {} + obj[error.keyword] = error.message! + return obj + }).reduce((a, b) => { + return { ...a, ...b } + }, {}) + } + }) + } + + return query as Query + } + } +} diff --git a/utils/api/throwables.ts b/utils/api/throwables.ts new file mode 100644 index 0000000..bba5abd --- /dev/null +++ b/utils/api/throwables.ts @@ -0,0 +1,148 @@ +import { OutgoingHttpHeaders } from "http2" +import { NextApiResponse } from "next" + +/** + * Object representing a HTTP response which can be `throw`n to interrupt the regular flow of the handling of a HTTP request. + */ +export class Response { + /** + * The status code returned in the response. + */ + status: number + + /** + * The JSON-serializable data returned in the response. + * + * If `undefined`, the response will not contain a body. + */ + body?: any + + /** + * Map of headers to add to the response. + */ + headers: OutgoingHttpHeaders + + /** + * Construct a new {@link Response} with the given parameters. + * + * @constructor + */ + constructor({ status, body = undefined, headers = {} }: { status: number, body?: any, headers?: OutgoingHttpHeaders }) { + this.status = status + this.body = body + this.headers = headers + } + + /** + * Construct a new {@link Response} with a body of `{ error }`. + */ + static error({ status = 500, message, headers = {} }: { status?: number, message: string, headers?: OutgoingHttpHeaders }) { + return new this({ + status, + body: { error: message }, + headers, + }) + } + + /** + * Create a new {@link Response} from a {@link Error} object. + * + * @param error - The error to base the response on. + * @param prodMessage - The message to display if the server is running in production mode and cannot display full error messages. + */ + static fromClassError(error: Error, prodMessage: string = "Unexpected server error occurred") { + return this.error({ + message: process.env.NODE_ENV === "development" ? error.message : prodMessage, + }) + } + + /** + * Create a new {@link Response} from any object. + * + * To be used together with the `catch` keyword. + * + * @param obj - The object to base the response on. + * @param defaultMessage - The message to display if the server is running in production mode and cannot display full error messages or if the error message cannot be determined. + */ + static fromUnknownError(obj: unknown, defaultMessage: string = "Unexpected server error occurred") { + if (process.env.NODE_ENV === "development") { + return this.error({ message: defaultMessage }) + } + + switch (typeof obj) { + case "string": + return this.error({ message: obj }) + case "object": + if (obj) { + if (obj instanceof Error) { + return this.fromClassError(obj, defaultMessage) + } + if (Object.hasOwn(obj, "message") && typeof (obj as { message: any }).message === "string") { + const message = (obj as { message: string }).message + return this.error({ message }) + } + } + default: + return this.error({ message: defaultMessage }) + } + } + + /** + * Consume a {@link NextApiResponse} to send this request. + * + * @param res - The res object to use. + */ + consume(res: NextApiResponse): void { + // Set headers + for (const [hk, hv] of Object.entries(this.headers)) { + if (hv === undefined) continue + res.setHeader(hk, hv) + } + // Send status code and headers + res.status(this.status) + // Conclude the request + if (this.body === undefined) { + res.end() + } + else { + res.json(this.body) + } + } + + /** + * Wrap a function to convert all errors happening in it to {@link Response}s, using {@link Response.fromUnknownError}. + * + * @param f - The function to wrap. + * @param defaultMessage - The message to display if the server is running in production mode and cannot display full error messages or if the error message cannot be determined. + * @returns The return value of `f`. + * @throws If an error is thrown inside `f`, the result of {@link Response.fromUnknownError} being called on the error. + */ + static convertThrownErrors(f: () => T, defaultMessage: string): T { + try { + return f() + } + catch (e) { + throw this.fromUnknownError(e, defaultMessage) + } + } + + /** + * Wrap an async function to catch all instances of {@link Response} thrown in it, and make them {@link Response.consume|consume} the passed `res` to send them as a response to the user agent. + * + * @param res - The res object to use. + * @param f - The function to wrap. + */ + static async handle(res: NextApiResponse, f: () => Promise): Promise { + try { + await f() + } + catch (e) { + if (typeof e === "object" && e instanceof Response) { + e.consume(res) + } + else { + throw e + } + } + } +} \ No newline at end of file diff --git a/utils/authorizeUser.ts b/utils/authorizeUser.ts deleted file mode 100644 index ccf146a..0000000 --- a/utils/authorizeUser.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { User } from "@prisma/client" -import { NextApiRequest, NextApiResponse } from "next" -import { database } from "./prismaClient" -import { Interrupt } from "./interrupt" - - -/** - * Find the user who is authenticating in a request. - * - * _For API route usage._ - * - * @param req The request performed by the user. - * @returns The user. - */ -export async function authorizeUser(req: NextApiRequest): Promise { - const authorization = req.headers.authorization - - if (!authorization) { - throw new Interrupt(401, {error: "Missing Authorization header" }) - } - - const token = authorization.match(/^Bearer (\S+)$/)?.[1] - - if(!(token)) { - throw new Interrupt(401, {error: "Invalid Authorization header" }) - } - - const dbToken = await database.token.findUnique({where: {token}, include: {user: true}}) - - if(!(dbToken)) { - throw new Interrupt(401, {error: "No such Authorization token" }) - } - - return dbToken.user -} \ No newline at end of file diff --git a/utils/dateFields.ts b/utils/dateFields.ts deleted file mode 100644 index 1c93351..0000000 --- a/utils/dateFields.ts +++ /dev/null @@ -1,20 +0,0 @@ -export function toDatetimeLocal(date: Date | null | undefined): string | undefined { - if (date === undefined) { - return undefined - } - else if (date === null) { - return "" - } - else { - return date.toISOString().match(/(.+)[Z]$/)![1] - } -} - -export function fromDatetimeLocal(str: string): Date | null { - if (str === "") { - return null - } - else { - return new Date(`${str}Z`) - } -} \ No newline at end of file diff --git a/utils/interrupt.ts b/utils/interrupt.ts deleted file mode 100644 index 54ad241..0000000 --- a/utils/interrupt.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NextApiResponse } from "next" - -/** - * Pseudo-decorator which intercepts thrown {@link Interrupt}s and turns them into HTTP responses. - */ -export async function handleInterrupts(res: NextApiResponse, f: () => Promise) { - try { - return await f() - } - catch(e) { - if(e instanceof Interrupt) { - return res.status(e.status).json(e.response) - } - else { - console.error(e) - } - } -} - -/** - * Error which interrupts the regular flow of a function to return a specific HTTP response. - * - * Caught by {@link interruptHandler}. - */ -export class Interrupt { - status: number - response: R - - constructor(status: number, response: R) { - this.status = status - this.response = response - } -} \ No newline at end of file diff --git a/utils/prismaClient.ts b/utils/prismaClient.ts index 08416d7..7dd2f3a 100644 --- a/utils/prismaClient.ts +++ b/utils/prismaClient.ts @@ -1,3 +1,21 @@ import { PrismaClient } from "@prisma/client"; + export const database = new PrismaClient() + + +// Tentative typing of a Prisma model. +export type PrismaDelegate = { + findUnique: any, + findFirst: any, + findMany: any, + create: any, + createMany: any, + delete: any, + update: any, + deleteMany: any, + updateMany: any, + upsert: any, + count: any, + aggregate: any, +} diff --git a/utils/queryString.ts b/utils/queryString.ts deleted file mode 100644 index ba8b3e5..0000000 --- a/utils/queryString.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ParsedUrlQuery } from "querystring" - -/** - * Ensure that the passed {@link ParsedUrlQuery} object has **one and only one** key with the specified name, and get its value. - * - * @param queryObj The object to read the value from. - * @param key The name of the value to read. - * @returns The resulting string. - */ -export function getSingle(queryObj: ParsedUrlQuery, key: string): string { - const value = queryObj[key] - - switch(typeof value) { - case "undefined": - throw new Error(`No "${key}" parameter found in the query string.`) - case "object": - throw new Error(`Multiple "${key}" parameters specified in the query string.`) - case "string": - return value - } -} \ No newline at end of file diff --git a/utils/restInPeace.ts b/utils/restInPeace.ts deleted file mode 100644 index c4c9041..0000000 --- a/utils/restInPeace.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { NextApiRequest, NextApiResponse } from "next"; -import { ApiError, ApiResult } from "../types/api"; - - -// I don't know what the typing of a Prisma model is. -export type Model = any - - -type RestInPeaceOptions = { - /** - * Prisma delegate to operate on. - */ - model: Model, - - /** - * Options for the "head" operation. - */ - head?: HeadOptions - - /** - * Options for the "options" operation. - */ - options?: OptionsOptions - - /** - * Options for the "list" operation. - */ - list?: ListOptions - - /** - * Options for the "retrieve" operation. - */ - retrieve?: RetrieveOptions - - /** - * Options for the "create" operation. - */ - create?: CreateOptions - - /** - * Options for the "upsert" operation. - */ - upsert?: UpsertOptions - - /** - * Options for the "update" operation. - */ - update?: UpdateOptions - - /** - * Options for the "destroy" operation. - */ - destroy?: DestroyOptions -} - -/** - * Handle an API route in a [REST](https://en.wikipedia.org/wiki/Representational_state_transfer)ful way. - */ -export async function restInPeace(req: NextApiRequest, res: NextApiResponse>, options: RestInPeaceOptions) { - // Handle HEAD by returning an empty body - if (options.head && req.method === "HEAD") { - return await handleHead(res, options.model, options.head) - } - // Same thing for OPTIONS, but beware of weird CORS things! - else if (options.options && req.method === "OPTIONS") { - return await handleOptions(res, options.model, options.options) - } - // GET can be both "list" and "retrieve" - else if (options.list && options.list.where && req.method === "GET") { - return await handleList(res, options.model, options.list) - } - else if(options.retrieve && options.retrieve.which && req.method === "GET") { - return await handleRetrieve(res, options.model, options.retrieve) - } - // POST is always "create" - else if (options.create && req.method === "POST") { - return await handleCreate(res, options.model, options.create) - } - // PUT is always "upsert" - else if (options.upsert && options.upsert.which && req.method === "PUT") { - return await handleUpsert(res, options.model, options.upsert) - } - // PATCH is always "update" - else if (options.update && options.update.which && req.method === "PATCH") { - return await handleUpdate(res, options.model, options.update) - } - // DELETE is always "destroy" - else if (options.destroy && options.destroy.which && req.method === "DELETE") { - return await handleDestroy(res, options.model, options.destroy) - } - - // What kind of weird HTTP methods are you using?! - else { - return res.status(405).json({ error: "Method not allowed" }) - } -} - - -interface OperationOptions { - before?: (model: T, obj?: any) => Promise, - after?: (model: T, obj?: any) => Promise, -} - - -// === HEAD === - - -interface HeadOptions extends OperationOptions { - before?: (model: T) => Promise, - after?: (model: T) => Promise, -} - -/** - * Handle an `HEAD` HTTP request. - */ -async function handleHead(res: NextApiResponse<"">, model: Model, options: HeadOptions) { - await options.before?.(model) - await options.after?.(model) - return res.status(200).send("") -} - - -// === OPTIONS === - - -interface OptionsOptions extends OperationOptions { - before?: (model: T) => Promise, - after?: (model: T) => Promise, -} - -/** - * Handle an `OPTIONS` HTTP request. - */ -async function handleOptions(res: NextApiResponse<"">, model: Model, options: OptionsOptions) { - await options.before?.(model) - await options.after?.(model) - return res.status(200).send("") -} - - -// === LIST === - - -interface ListOptions extends OperationOptions { - /** - * Prisma Where clause used to list objects available in a API route. - */ - where?: object, - - before?: (model: T) => Promise, - after?: (model: T, obj: T[]) => Promise, -} - -/** - * Handle a `GET` HTTP request where a list of items is requested. - */ -async function handleList(res: NextApiResponse>, model: Model, options: ListOptions) { - await options.before?.(model) - const objs = await model.findMany({ where: options.where }) - const mutatedObjs = await options.after?.(model, objs) ?? objs - return res.status(200).json(mutatedObjs) -} - - -// === RETRIEVE === - - -interface RetrieveOptions extends OperationOptions { - /** - * Prisma Where clause used to select the object to display. - * - * See also `findUnique`. - */ - which?: object, - - before?: (model: T) => Promise, - after?: (model: T, obj: T) => Promise, -} - -/** - * Handle a `GET` HTTP request where a single item is requested. - */ -async function handleRetrieve(res: NextApiResponse>, model: Model, options: RetrieveOptions) { - await options.before?.(model) - const obj = await model.findUnique({ where: options.which }) - const mutatedObj = await options.after?.(model, obj) ?? obj - if (!mutatedObj) { - return res.status(404).json({ error: "Not found" }) - } - return res.status(200).json(mutatedObj) -} - - -// === CREATE === - - -interface CreateOptions extends OperationOptions { - /** - * Prisma Create clause used to create the object. - */ - create: object, - - before?: (model: T) => Promise, - after?: (model: T, obj: T) => Promise, -} - -/** - * Handle a `POST` HTTP request where a single item is created. - */ -async function handleCreate(res: NextApiResponse>, model: Model, options: CreateOptions) { - await options.before?.(model) - const obj = await model.create({ data: options.create }) - const mutatedObj = await options.after?.(model, obj) ?? obj - return res.status(200).json(mutatedObj) -} - - -// === UPSERT === - - -interface UpsertOptions extends OperationOptions { - /** - * Prisma Where clause used to select the object to operate on. - * - * See also `findUnique`. - */ - which?: object, - - /** - * Prisma Create clause used to create the object if it doesn't exist. - */ - create: object, - - /** - * Prisma Update clause used to update the object if it exists. - */ - update: object, - - before?: (model: T, obj?: T) => Promise, - after?: (model: T, obj: T) => Promise, -} - -/** - * Handle a `PUT` HTTP request where a single item is either created or updated. - */ -async function handleUpsert(res: NextApiResponse>, model: Model, options: UpsertOptions) { - const initialObj = await model.findUnique({ where: options.which }) - await options.before?.(model, initialObj) - const obj = await model.upsert({ - where: options.which, - create: options.create, - update: options.update, - }) - const mutatedObj = await options.after?.(model, obj) ?? obj - return res.status(200).json(mutatedObj) -} - - -// === UPDATE === - - -interface UpdateOptions extends OperationOptions { - /** - * Prisma Where clause used to select the object to operate on. - * - * See also `findUnique`. - */ - which?: object, - - /** - * Prisma Update clause used to update the object if it exists. - */ - update: object, - - before?: (model: T, obj?: T) => Promise, - after?: (model: T, obj: T) => Promise, -} - -/** - * Handle a `PATCH` HTTP request where a single item is updated. - */ -async function handleUpdate(res: NextApiResponse>, model: Model, options: UpdateOptions) { - const initialObj = await model.findUnique({ where: options.which }) - await options.before?.(model, initialObj) - const obj = await model.update({ - where: options.which, - data: options.update, - }) - const mutatedObj = await options.after?.(model, obj) ?? obj - return res.status(200).json(mutatedObj) -} - - -// === DESTROY === - - -interface DestroyOptions extends OperationOptions { - /** - * Prisma Where clause used to select the object to operate on. - * - * See also `findUnique`. - */ - which?: object, - - before?: (model: T, obj?: T) => Promise, - after?: (model: T) => Promise, -} - -/** - * Handle a `DELETE` HTTP request where a single item is destroyed. - */ -async function handleDestroy(res: NextApiResponse>, model: Model, options: DestroyOptions) { - const initialObj = await model.findUnique({ where: options.which }) - await options.before?.(model, initialObj) - await model.delete({ - where: options.which, - }) - await options.after?.(model) - return res.status(204).send("") -} \ No newline at end of file diff --git a/utils/types/helpers.ts b/utils/types/helpers.ts new file mode 100644 index 0000000..0c529eb --- /dev/null +++ b/utils/types/helpers.ts @@ -0,0 +1 @@ +export type Dict = { [key: string]: T } diff --git a/yarn.lock b/yarn.lock index 286bf60..070fde9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -340,6 +340,16 @@ ajv@^6.10.0, ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^8.11.0: + version "8.11.0" + resolved "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz#977e91dd96ca669f54a11e23e378e33b884a565f" + integrity sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" @@ -529,14 +539,6 @@ core-js@^3: resolved "https://registry.npmjs.org/core-js/-/core-js-3.22.8.tgz#23f860b1fe60797cc4f704d76c93fea8a2f60631" integrity sha512-UoGQ/cfzGYIuiq6Z7vWL1HfkE9U9IZ4Ub+0XSiJTCzvbZzgPA69oDF2f+lgJ6dFFLEdjW5O6svvoKzXX23xFkA== -cors@^2.8.5: - version "2.8.5" - resolved "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" - integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== - dependencies: - object-assign "^4" - vary "^1" - cross-env@^7.0.3: version "7.0.3" resolved "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" @@ -1363,6 +1365,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" @@ -1786,7 +1793,7 @@ next@12.1.6: "@next/swc-win32-ia32-msvc" "12.1.6" "@next/swc-win32-x64-msvc" "12.1.6" -object-assign@^4, object-assign@^4.1.1: +object-assign@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== @@ -2085,6 +2092,11 @@ remark-rehype@^10.0.0: mdast-util-to-hast "^12.1.0" unified "^10.0.0" +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -2437,11 +2449,6 @@ v8-compile-cache@^2.0.3: resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== -vary@^1: - version "1.1.2" - resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= - vfile-message@^3.0.0: version "3.1.2" resolved "https://registry.npmjs.org/vfile-message/-/vfile-message-3.1.2.tgz#a2908f64d9e557315ec9d7ea3a910f658ac05f7d"