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.run()}
- disabled={!name}
- >
-
-
-
- {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
-}
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 (
-
- )
-}
\ 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 (
+
+ )
+})
diff --git a/styles/components/views/content.css b/components/generic/views/content.module.css
similarity index 75%
rename from styles/components/views/content.css
rename to components/generic/views/content.module.css
index 3a368a7..e845ae1 100644
--- a/styles/components/views/content.css
+++ b/components/generic/views/content.module.css
@@ -1,6 +1,6 @@
-.view-content {
+.viewContent {
display: grid;
- grid-template-areas:
+ grid-template-areas:
"title"
"content"
;
@@ -13,11 +13,11 @@
margin: 0 auto;
}
-.view-content-title {
+.viewContentTitle {
grid-area: title;
text-align: center;
}
-.view-content-content {
+.viewContentContent {
grid-area: content;
}
\ No newline at end of file
diff --git a/components/generic/views/content.tsx b/components/generic/views/content.tsx
new file mode 100644
index 0000000..5fffd0f
--- /dev/null
+++ b/components/generic/views/content.tsx
@@ -0,0 +1,24 @@
+import { memo, ReactNode } from "react"
+import style from "./content.module.css"
+
+
+export type ViewContentProps = {
+ title: ReactNode
+ content: ReactNode
+}
+
+/**
+ * A view which displays a title and below it some miscellaneous text content.
+ */
+export const ViewContent = memo((props: ViewContentProps) => {
+ return (
+
+
+ {props.title}
+
+
+ {props.content}
+
+
+ )
+})
diff --git a/styles/components/views/landing.css b/components/generic/views/landing.module.css
similarity index 71%
rename from styles/components/views/landing.css
rename to components/generic/views/landing.module.css
index d78d63a..c5a1c30 100644
--- a/styles/components/views/landing.css
+++ b/components/generic/views/landing.module.css
@@ -1,6 +1,6 @@
-.view-landing {
+.viewLanding {
display: grid;
- grid-template-areas:
+ grid-template-areas:
"titles"
"actions"
;
@@ -13,30 +13,30 @@
gap: 32px;
}
-.view-landing-titles {
+.viewLandingTitles {
grid-area: titles;
text-shadow: 2px 2px 4px var(--background);
}
-.view-landing-titles-title {
+.viewLandingTitlesTitle {
font-size: 10rem;
}
-.view-landing-titles-subtitle {
+.viewLandingTitlesSubtitle {
font-size: 2.5rem;
}
@media (max-width: 800px) or (max-height: 600px) {
- .view-landing-titles-title {
+ .viewLandingTitlesTitle {
font-size: 5rem;
}
- .view-landing-titles-subtitle {
+ .viewLandingTitlesSubtitle {
font-size: 1.5rem;
}
}
-.view-landing-actions {
+.viewLandingActions {
grid-area: actions;
align-self: start;
justify-self: center;
diff --git a/components/generic/views/landing.tsx b/components/generic/views/landing.tsx
new file mode 100644
index 0000000..59e26b1
--- /dev/null
+++ b/components/generic/views/landing.tsx
@@ -0,0 +1,33 @@
+import { memo, ReactNode } from "react"
+import style from "./landing.module.css"
+
+
+export type ViewLandingProps = {
+ title: ReactNode
+ subtitle: ReactNode
+ actions: ReactNode
+}
+
+
+/**
+ * A view which displays a *really* big title and subtitle, with some actions the user can take below.
+ *
+ * Intended for the root / landing page of the app.
+ */
+export const ViewLanding = memo((props: ViewLandingProps) => {
+ return (
+
+
+
+ {props.title}
+
+
+ {props.subtitle}
+
+
+
+ {props.actions}
+
+
+ )
+})
diff --git a/styles/components/views/notice.css b/components/generic/views/notice.module.css
similarity index 100%
rename from styles/components/views/notice.css
rename to components/generic/views/notice.module.css
diff --git a/components/generic/views/notice.tsx b/components/generic/views/notice.tsx
new file mode 100644
index 0000000..f8c5677
--- /dev/null
+++ b/components/generic/views/notice.tsx
@@ -0,0 +1,21 @@
+import { memo, ReactNode } from "react"
+import style from "./notice.module.css"
+
+
+export type ViewNoticeProps = {
+ notice: ReactNode
+}
+
+
+/**
+ * A view which displays its contents centered on the screen.
+ *
+ * Intended for errors or other important alerts.
+ */
+export const ViewNotice = memo((props: ViewNoticeProps) => {
+ return (
+
+ {props.notice}
+
+ )
+})
diff --git a/styles/components/work-in-progress.css b/components/generic/wip/banner.module.css
similarity index 91%
rename from styles/components/work-in-progress.css
rename to components/generic/wip/banner.module.css
index 2f4e5f3..4dba7f4 100644
--- a/styles/components/work-in-progress.css
+++ b/components/generic/wip/banner.module.css
@@ -1,4 +1,4 @@
-.work-in-progress {
+.wipBanner {
color: var(--warning);
/* TODO: Make this based on --warning. */
diff --git a/components/generic/wip/banner.tsx b/components/generic/wip/banner.tsx
new file mode 100644
index 0000000..bff26dd
--- /dev/null
+++ b/components/generic/wip/banner.tsx
@@ -0,0 +1,18 @@
+import { faBrush } from "@fortawesome/free-solid-svg-icons"
+import { useTranslation } from "next-i18next"
+import { FestaIcon } from "../renderers/fontawesome"
+import style from "./banner.module.css"
+
+
+/**
+ * A banner to be displayed on the top of a page explaining that a certain page isn't ready yet for user interaction.
+ */
+export function WIPBanner() {
+ const { t } = useTranslation()
+
+ return (
+
+ {t("workInProgress")}
+
+ )
+}
diff --git a/components/landing/README.md b/components/landing/README.md
new file mode 100644
index 0000000..2e5c755
--- /dev/null
+++ b/components/landing/README.md
@@ -0,0 +1,3 @@
+# Landing
+
+This directory contains components related to the landing page.
\ No newline at end of file
diff --git a/components/landing/actions/events.module.css b/components/landing/actions/events.module.css
new file mode 100644
index 0000000..058f093
--- /dev/null
+++ b/components/landing/actions/events.module.css
@@ -0,0 +1,28 @@
+.landingActionEventsList {
+ max-height: 20vh;
+ padding-left: 20px;
+ padding-bottom: 12px;
+
+ text-align: left;
+
+ overflow-y: scroll;
+
+ column-count: auto;
+ column-width: 140px;
+}
+
+.landingActionEventsFormCreate {
+ display: flex;
+ gap: 4px;
+}
+
+.landingActionEventsFormCreateName {
+ flex-grow: 1;
+}
+
+.landingActionEventsFormCreateSubmit {
+ width: 40px;
+ height: 40px;
+
+ flex-shrink: 0;
+}
\ No newline at end of file
diff --git a/components/landing/actions/events.tsx b/components/landing/actions/events.tsx
new file mode 100644
index 0000000..cdf2f2d
--- /dev/null
+++ b/components/landing/actions/events.tsx
@@ -0,0 +1,147 @@
+import { faPlus } from "@fortawesome/free-solid-svg-icons"
+import { Event } from "@prisma/client"
+import classNames from "classnames"
+import { useTranslation } from "next-i18next"
+import Link from "next/link"
+import { useRouter } from "next/router"
+import { useState } from "react"
+import { default as useSWR } from "swr"
+import { useAxiosRequest } from "../../auth/requests"
+import { ErrorBlock } from "../../generic/errors/renderers"
+import { promiseMultiplexer } from "../../generic/loading/promise"
+import { swrMultiplexer } from "../../generic/loading/swr"
+import { LoadingTextInline } from "../../generic/loading/textInline"
+import { FestaIcon } from "../../generic/renderers/fontawesome"
+import style from "./events.module.css"
+
+
+/**
+ * Displayed if the user has never created an event on Festa.
+ */
+const LandingActionEventsFirst = () => {
+ const { t } = useTranslation()
+
+ return (
+
+
+ {t("landingEventsFirstDescription")}
+
+
+ )
+}
+
+/**
+ * Displayed if the user has one or more events created on Festa.
+ */
+const LandingActionEventsList = ({ data }: { data: Event[] }) => {
+ const { t } = useTranslation()
+
+ return (
+ <>
+
+ {t("landingEventsDescription")}
+
+
+
+ {data.map(e => (
+
+
+ {e.name}
+
+
+ ))}
+
+
+
+
+ {t("landingEventsCreateDescription")}
+
+
+ >
+ )
+}
+
+
+/**
+ * One-line form to create a new event on Festa.
+ */
+const LandingActionEventsFormCreate = () => {
+ const { t } = useTranslation()
+ const router = useRouter()
+ const [name, setName] = useState("")
+
+ const createHook = useAxiosRequest>({ method: "POST", url: "/api/events/" })
+
+ return promiseMultiplexer({
+ hook: createHook,
+ ready: ({ run }) => (
+
+ ),
+ 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 (
- setVisibility(PostcardVisibility.FOREGROUND)}
- className="toolbar-tool"
>
-
+
)
}
else {
return (
- setVisibility(PostcardVisibility.BACKGROUND)}
- className="toolbar-tool"
>
-
+
)
}
-}
\ 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 (
- setEditing(!editing)}
- className="toolbar-tool"
- >
-
-
- )
-}
\ 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"