mirror of
https://github.com/Steffo99/festa.git
synced 2024-12-22 14:44:21 +00:00
Refactor huge chunk of code
This commit is contained in:
parent
bbe4f33827
commit
990e7e1d7e
135 changed files with 2908 additions and 1945 deletions
8
.vscode/launch.json
vendored
8
.vscode/launch.json
vendored
|
@ -40,7 +40,7 @@
|
||||||
"name": "Web page",
|
"name": "Web page",
|
||||||
"type": "firefox",
|
"type": "firefox",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"url": "http://local.steffo.eu",
|
"url": "http://nitro.home.steffo.eu",
|
||||||
"pathMappings": [
|
"pathMappings": [
|
||||||
{
|
{
|
||||||
"url": "webpack://_n_e",
|
"url": "webpack://_n_e",
|
||||||
|
@ -55,7 +55,11 @@
|
||||||
"compounds": [
|
"compounds": [
|
||||||
{
|
{
|
||||||
"name": "Everything!",
|
"name": "Everything!",
|
||||||
"configurations": ["Web server", "Web page", "Prisma Studio"],
|
"configurations": [
|
||||||
|
"Web server",
|
||||||
|
"Web page",
|
||||||
|
"Prisma Studio"
|
||||||
|
],
|
||||||
"stopAll": true,
|
"stopAll": true,
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"group": "Full",
|
"group": "Full",
|
||||||
|
|
|
@ -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<HTMLFormElement>) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { data, error } = useMyEventsSWR()
|
|
||||||
|
|
||||||
const newClassName = classNames(props.className, {
|
|
||||||
"negative": error,
|
|
||||||
})
|
|
||||||
|
|
||||||
let contents: JSX.Element
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
contents = <>
|
|
||||||
<p>
|
|
||||||
{t("eventListError")}
|
|
||||||
</p>
|
|
||||||
<code>
|
|
||||||
{JSON.stringify(error)}
|
|
||||||
</code>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
else if (!data) {
|
|
||||||
contents = <>
|
|
||||||
<p>
|
|
||||||
<Loading text={t("eventListLoading")} />
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if (data.length === 0) {
|
|
||||||
contents = <>
|
|
||||||
<p>
|
|
||||||
{t("eventListCreateFirst")}
|
|
||||||
</p>
|
|
||||||
<EventCreate />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
contents = <>
|
|
||||||
<p>
|
|
||||||
{t("eventListDescription")}
|
|
||||||
</p>
|
|
||||||
<ListEvents data={data} />
|
|
||||||
<p>
|
|
||||||
{t("eventListCreateAnother")}
|
|
||||||
</p>
|
|
||||||
<EventCreate />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={newClassName}>
|
|
||||||
{contents}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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<HTMLFormElement>) {
|
|
||||||
const { t } = useTranslation("common")
|
|
||||||
const [_, setLogin] = useDefinedContext(LoginContext)
|
|
||||||
const [working, setWorking] = useState<boolean>(false)
|
|
||||||
const [error, setError] = useState<ApiError | null | undefined>(null)
|
|
||||||
|
|
||||||
const onLogin = useCallback(
|
|
||||||
async (data: TelegramLoginData) => {
|
|
||||||
setError(null)
|
|
||||||
setWorking(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
var response = await axios.post<ApiResult<FestaLoginData>>("/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 = (
|
|
||||||
<div>
|
|
||||||
<code>
|
|
||||||
{JSON.stringify(error)}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else if (working) {
|
|
||||||
message = t("formTelegramLoginWorking")
|
|
||||||
contents = <></>
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
message = t("formTelegramLoginDescription")
|
|
||||||
contents = (
|
|
||||||
<TelegramLoginButton
|
|
||||||
dataOnauth={onLogin}
|
|
||||||
botName={process.env.NEXT_PUBLIC_TELEGRAM_USERNAME}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={newClassName}>
|
|
||||||
<p>
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
{contents}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
import { default as Image, ImageProps } from "next/image";
|
|
||||||
import { default as classNames } from "classnames"
|
|
||||||
|
|
||||||
export function Avatar(props: ImageProps) {
|
|
||||||
return (
|
|
||||||
<Image
|
|
||||||
alt=""
|
|
||||||
{...props}
|
|
||||||
className={classNames(props.className, "avatar")}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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<string>("")
|
|
||||||
|
|
||||||
const createEvent = useAxiosRequest<Event>(
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
url: "/api/events/",
|
|
||||||
data: { name }
|
|
||||||
},
|
|
||||||
(response) => {
|
|
||||||
router.push(`/events/${response.data.slug}`)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (createEvent.running) return <Loading text={t("eventListCreateRunning")} />
|
|
||||||
if (createEvent.data) return <Loading text={t("eventListCreateRedirecting")} />
|
|
||||||
|
|
||||||
return <>
|
|
||||||
<FormMonorow
|
|
||||||
onSubmit={e => { e.preventDefault(); createEvent.run() }}
|
|
||||||
noValidate
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={t("eventListCreateEventNameLabel")}
|
|
||||||
value={name}
|
|
||||||
onChange={e => setName(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
aria-label={t("eventListCreateSubmitLabel")}
|
|
||||||
className="positive"
|
|
||||||
onClick={e => createEvent.run()}
|
|
||||||
disabled={!name}
|
|
||||||
>
|
|
||||||
<FestaIcon icon={faPlus}/>
|
|
||||||
</button>
|
|
||||||
</FormMonorow>
|
|
||||||
{createEvent.error ? <ErrorBlock error={createEvent.error} text={t("eventListCreateError")} /> : 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 => (
|
|
||||||
<li key={e.slug}>
|
|
||||||
<Link href={`/events/${e.slug}`}>
|
|
||||||
{e.name}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
|
|
||||||
return <ul className="list-events">{contents}</ul>
|
|
||||||
}
|
|
|
@ -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 (
|
|
||||||
<span>
|
|
||||||
<FestaIcon icon={faAsterisk} spin/>
|
|
||||||
|
|
||||||
{props.text}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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 (
|
|
||||||
<small>
|
|
||||||
(
|
|
||||||
<a href="javascript:void(0)" onClick={() => setLogin(null)}>
|
|
||||||
{t("introTelegramLogout")}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
</small>
|
|
||||||
)
|
|
||||||
}
|
|
5
components/README.md
Normal file
5
components/README.md
Normal file
|
@ -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`
|
|
@ -1,13 +0,0 @@
|
||||||
import { default as OriginalTelegramLoginButton } from 'react-telegram-login'
|
|
||||||
|
|
||||||
|
|
||||||
export function TelegramLoginButton(props: any) {
|
|
||||||
return (
|
|
||||||
<div className="container-btn-telegram">
|
|
||||||
<OriginalTelegramLoginButton
|
|
||||||
usePic={false}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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 (
|
|
||||||
<div className="work-in-progress">
|
|
||||||
<FestaIcon icon={faBrush}/> {t("workInProgress")}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
3
components/auth/README.md
Normal file
3
components/auth/README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Auth
|
||||||
|
|
||||||
|
This directory contains components related to authentication and authorization of users in the app.
|
18
components/auth/base.ts
Normal file
18
components/auth/base.ts
Normal file
|
@ -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<AuthContextContents>()
|
74
components/auth/requests.tsx
Normal file
74
components/auth/requests.tsx
Normal file
|
@ -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<any>) => {
|
||||||
|
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 (
|
||||||
|
<SWRConfig value={{ fetcher: useAxiosSWRFetcher() }}>
|
||||||
|
{children}
|
||||||
|
</SWRConfig>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Output = any, Input = any>(config: AxiosRequestConfig<Input> = {}) {
|
||||||
|
const axios = useAxios()
|
||||||
|
|
||||||
|
const performRequest = useCallback(
|
||||||
|
async (funcConfig: AxiosRequestConfig<Input> = {}): Promise<AxiosResponse<Output, Input>> => {
|
||||||
|
return await axios.request({ ...config, ...funcConfig })
|
||||||
|
},
|
||||||
|
[config]
|
||||||
|
)
|
||||||
|
|
||||||
|
const promiseHook = usePromise(performRequest)
|
||||||
|
|
||||||
|
return { ...promiseHook, data: promiseHook.result?.data }
|
||||||
|
}
|
46
components/auth/storage.ts
Normal file
46
components/auth/storage.ts
Normal file
|
@ -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<AuthContextContents>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook which combines {@link useState}, {@link useLocalStorageJSONLoad}, and {@link localStorageSaveJSON}.
|
||||||
|
*/
|
||||||
|
export function useLocalStorageAuthState(key: string) {
|
||||||
|
const [state, setStateInner] = useState<AuthContextContents | undefined>(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];
|
||||||
|
}
|
3
components/auth/telegram/loginButton.module.css
Normal file
3
components/auth/telegram/loginButton.module.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.telegramLoginButtonContainer > div {
|
||||||
|
height: 40px;
|
||||||
|
}
|
19
components/auth/telegram/loginButton.tsx
Normal file
19
components/auth/telegram/loginButton.tsx
Normal file
|
@ -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 (
|
||||||
|
<div className={style.telegramLoginButtonContainer}>
|
||||||
|
<OriginalTelegramLoginButton
|
||||||
|
usePic={false}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
|
@ -1,26 +1,27 @@
|
||||||
|
import { default as TelegramLoginButton, TelegramLoginResponse } from "react-telegram-login"
|
||||||
import { default as nodecrypto } from "crypto"
|
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
|
id: number
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName?: string
|
lastName?: string
|
||||||
username?: string
|
username?: string
|
||||||
photoUrl?: string
|
photoUrl?: string
|
||||||
|
lang?: string
|
||||||
authDate: Date
|
authDate: Date
|
||||||
hash: string
|
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.
|
* @param u The {@link TelegramLoginData} to use.
|
||||||
*/
|
*/
|
||||||
constructor(u: TelegramLoginData) {
|
constructor(u: TelegramLoginResponse) {
|
||||||
if (!u.id) throw new Error("Missing `id`")
|
if (!u.id) throw new Error("Missing `id`")
|
||||||
if (!u.first_name) throw new Error("Missing `first_name`")
|
if (!u.first_name) throw new Error("Missing `first_name`")
|
||||||
if (!u.auth_date) throw new Error("Missing `auth_date`")
|
if (!u.auth_date) throw new Error("Missing `auth_date`")
|
||||||
|
@ -41,11 +42,11 @@ export class TelegramLoginDataClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
first_name: this.firstName,
|
first_name: this.firstName,
|
||||||
|
@ -61,7 +62,7 @@ export class TelegramLoginDataClass {
|
||||||
/**
|
/**
|
||||||
* Convert this object into a partial {@link AccountTelegram} database object.
|
* Convert this object into a partial {@link AccountTelegram} database object.
|
||||||
*/
|
*/
|
||||||
toDatabase() {
|
toDatabase(): Pick<AccountTelegram, "telegramId" | "firstName" | "lastName" | "username" | "photoUrl" | "lang"> {
|
||||||
return {
|
return {
|
||||||
telegramId: this.id,
|
telegramId: this.id,
|
||||||
firstName: this.firstName,
|
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.
|
* @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.
|
* @returns `true` if the request was sent within the requested timeframe, `false` otherwise.
|
35
components/auth/telegram/react-telegram-login.d.ts
vendored
Normal file
35
components/auth/telegram/react-telegram-login.d.ts
vendored
Normal file
|
@ -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
|
||||||
|
};
|
|
@ -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<FestaLoginData | 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
|
|
||||||
}
|
|
|
@ -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<HTMLProps<HTMLInputElement>, "value" | "max" | "min"> & { value: Date | null, max?: Date, min?: Date }
|
|
||||||
|
|
||||||
|
|
||||||
export function EditableDateTimeLocal(props: EditableDateTimeLocalProps) {
|
|
||||||
return (
|
|
||||||
<BaseEditable
|
|
||||||
editing={
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
{...props}
|
|
||||||
value={props.value ? toDatetimeLocal(props.value) : undefined}
|
|
||||||
min={props.min ? toDatetimeLocal(props.min) : undefined}
|
|
||||||
max={props.max ? toDatetimeLocal(props.max) : undefined}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
preview={
|
|
||||||
<FestaMoment date={props.value} />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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 (
|
|
||||||
<FormFromTo
|
|
||||||
preview={!editing}
|
|
||||||
icon={
|
|
||||||
<FestaIcon icon={faCalendar} />
|
|
||||||
}
|
|
||||||
start={
|
|
||||||
<EditableDateTimeLocal
|
|
||||||
max={endProps.value ?? undefined}
|
|
||||||
{...startProps}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
connector={
|
|
||||||
<FestaIcon
|
|
||||||
icon={faChevronRight}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
end={
|
|
||||||
<EditableDateTimeLocal
|
|
||||||
min={startProps.value ?? undefined}
|
|
||||||
{...endProps}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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<HTMLInputElement> & { value?: undefined }) {
|
|
||||||
return (
|
|
||||||
<BaseEditable
|
|
||||||
editing={
|
|
||||||
<input type="file" {...props} />
|
|
||||||
}
|
|
||||||
preview={<></>}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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<HTMLTextAreaElement> & { value: string }) {
|
|
||||||
return (
|
|
||||||
<BaseEditable
|
|
||||||
editing={
|
|
||||||
<textarea {...props} />
|
|
||||||
}
|
|
||||||
preview={
|
|
||||||
<FestaMarkdown markdown={props.value} />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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<HTMLInputElement> & { value: string }) {
|
|
||||||
return (
|
|
||||||
<BaseEditable
|
|
||||||
editing={
|
|
||||||
<input type="text" {...props} />
|
|
||||||
}
|
|
||||||
preview={
|
|
||||||
<span>{props.value}</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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<boolean>()
|
|
|
@ -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.
|
|
|
@ -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 (
|
|
||||||
<div className="error error-block negative">
|
|
||||||
<p>
|
|
||||||
<FestaIcon icon={faCircleExclamation} />
|
|
||||||
|
|
||||||
<span>
|
|
||||||
{props.text}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<pre>
|
|
||||||
<code>
|
|
||||||
<b>{props.error.name}</b>
|
|
||||||
:
|
|
||||||
{props.error.message}
|
|
||||||
</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
||||||
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 (
|
|
||||||
<ViewNotice
|
|
||||||
notice={
|
|
||||||
<ErrorBlock text={this.props.text} error={this.state.error}/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return this.props.children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 (
|
|
||||||
<span className="error error-inline negative">
|
|
||||||
<FestaIcon icon={faCircleExclamation} />
|
|
||||||
|
|
||||||
{props.text ?
|
|
||||||
<>
|
|
||||||
<span>
|
|
||||||
{props.text}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
</>
|
|
||||||
: null}
|
|
||||||
<code lang="json">
|
|
||||||
{JSON.stringify(props.error)}
|
|
||||||
</code>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
3
components/events/README.md
Normal file
3
components/events/README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Event
|
||||||
|
|
||||||
|
This directory contains components related to the event details page.
|
36
components/events/toolbar/toolToggleEditing.tsx
Normal file
36
components/events/toolbar/toolToggleEditing.tsx
Normal file
|
@ -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 (
|
||||||
|
<Tool
|
||||||
|
aria-label={t("toggleEditingView")}
|
||||||
|
onClick={() => setEditing(EditingMode.VIEW)}
|
||||||
|
>
|
||||||
|
<FestaIcon icon={faBinoculars} />
|
||||||
|
</Tool>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return (
|
||||||
|
<Tool
|
||||||
|
aria-label={t("toggleEditingEdit")}
|
||||||
|
onClick={() => setEditing(EditingMode.EDIT)}
|
||||||
|
>
|
||||||
|
<FestaIcon icon={faPencil} />
|
||||||
|
</Tool>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
.view-event {
|
.viewEvent {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"x1 ti ti ti x4"
|
"x1 ti ti ti x4"
|
||||||
|
@ -16,7 +16,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.view-event {
|
.viewEvent {
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"ti"
|
"ti"
|
||||||
"po"
|
"po"
|
||||||
|
@ -29,19 +29,19 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-event-title {
|
.viewEventTitle {
|
||||||
grid-area: ti;
|
grid-area: ti;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-event-postcard {
|
.viewEventPostcard {
|
||||||
grid-area: po;
|
grid-area: po;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-event-description {
|
.viewEventDescription {
|
||||||
grid-area: de;
|
grid-area: de;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-event-daterange {
|
.viewEventDaterange {
|
||||||
grid-area: dr;
|
grid-area: dr;
|
||||||
}
|
}
|
33
components/events/views/event.tsx
Normal file
33
components/events/views/event.tsx
Normal file
|
@ -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 (
|
||||||
|
<main className={style.viewEvent}>
|
||||||
|
<h1 className={style.viewEventTitle}>
|
||||||
|
{props.title}
|
||||||
|
</h1>
|
||||||
|
<div className={style.viewEventPostcard}>
|
||||||
|
{props.postcard}
|
||||||
|
</div>
|
||||||
|
<div className={style.viewEventDescription}>
|
||||||
|
{props.description}
|
||||||
|
</div>
|
||||||
|
<div className={style.viewEventDaterange}>
|
||||||
|
{props.daterange}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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 (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
{...props}
|
|
||||||
className={newClassName}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
import { default as ReactMarkdown } from "react-markdown"
|
|
||||||
|
|
||||||
type FestaMarkdownProps = {
|
|
||||||
markdown: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FestaMarkdown({markdown}: FestaMarkdownProps) {
|
|
||||||
return (
|
|
||||||
<ReactMarkdown
|
|
||||||
components={{
|
|
||||||
h1: "h3",
|
|
||||||
h2: "h3",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{markdown}
|
|
||||||
</ReactMarkdown>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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.
|
|
|
@ -1,37 +0,0 @@
|
||||||
import classNames from "classnames"
|
|
||||||
import { HTMLProps, memo } from "react"
|
|
||||||
|
|
||||||
|
|
||||||
export type FormFromToProps = HTMLProps<HTMLDivElement> & {
|
|
||||||
preview: boolean,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormFromTo = memo((props: FormFromToProps) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...props}
|
|
||||||
className={classNames(
|
|
||||||
"form-fromto",
|
|
||||||
props.preview ? "form-fromto-preview" : null,
|
|
||||||
props.className
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
export type FormFromToPartProps = HTMLProps<HTMLDivElement> & {
|
|
||||||
part: "icon" | "start" | "connector" | "end"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormFromToPart = memo((props: FormFromToPartProps) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...props}
|
|
||||||
className={classNames(
|
|
||||||
`form-fromto-${props.part}`,
|
|
||||||
props.className,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
|
@ -1,14 +0,0 @@
|
||||||
import classNames from "classnames"
|
|
||||||
import { HTMLProps, ReactNode } from "react"
|
|
||||||
|
|
||||||
type FormMonorowProps = {
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormMonorow(props: FormMonorowProps & HTMLProps<HTMLFormElement>) {
|
|
||||||
return (
|
|
||||||
<form {...props} className={classNames("form-monorow", props.className)}>
|
|
||||||
{props.children}
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
3
components/generic/README.md
Normal file
3
components/generic/README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Generic components
|
||||||
|
|
||||||
|
This directory contains components shared between multiple pages.
|
3
components/generic/editable/README.md
Normal file
3
components/generic/editable/README.md
Normal file
|
@ -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.
|
36
components/generic/editable/base.tsx
Normal file
36
components/generic/editable/base.tsx
Normal file
|
@ -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<EditingMode>()
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
82
components/generic/editable/inputs.tsx
Normal file
82
components/generic/editable/inputs.tsx
Normal file
|
@ -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 (
|
||||||
|
<EditingModeBranch
|
||||||
|
edit={
|
||||||
|
<input type="text" {...props} />
|
||||||
|
}
|
||||||
|
view={
|
||||||
|
<span>{props.value}</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<EditingModeBranch
|
||||||
|
edit={
|
||||||
|
<input type="file" {...props} />
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<EditingModeBranch
|
||||||
|
edit={
|
||||||
|
<textarea {...props} />
|
||||||
|
}
|
||||||
|
view={
|
||||||
|
<FestaMarkdownRenderer code={props.value} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<EditingModeBranch
|
||||||
|
edit={
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
view={
|
||||||
|
<FestaMoment date={new Date(props.value)} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
3
components/generic/errors/README.md
Normal file
3
components/generic/errors/README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Errors
|
||||||
|
|
||||||
|
This directory contains components that handle and display errors occurring in the application.
|
79
components/generic/errors/boundaries.tsx
Normal file
79
components/generic/errors/boundaries.tsx
Normal file
|
@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
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 (
|
||||||
|
<ViewNotice
|
||||||
|
notice={
|
||||||
|
<ErrorBlock text={this.props.text} error={this.state.error} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
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 (
|
||||||
|
<ErrorBlock text={this.props.text} error={this.state.error} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
.error-block pre {
|
.errorBlock > pre {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
112
components/generic/errors/renderers.tsx
Normal file
112
components/generic/errors/renderers.tsx
Normal file
|
@ -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 (
|
||||||
|
<code>
|
||||||
|
<b>API {props.error.response.status}</b>
|
||||||
|
:
|
||||||
|
{json}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<code>
|
||||||
|
<b>HTTP {props.error.response.status}</b>
|
||||||
|
:
|
||||||
|
{props.error.message}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<code>
|
||||||
|
<b>{props.error.code}</b>
|
||||||
|
:
|
||||||
|
{props.error.message}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<code>
|
||||||
|
<b>{props.error.name}</b>
|
||||||
|
:
|
||||||
|
{props.error.message}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<span className={classNames("negative", style.error, style.errorInline)}>
|
||||||
|
<FestaIcon icon={faCircleExclamation} />
|
||||||
|
|
||||||
|
{props.text ?
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
{props.text}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</>
|
||||||
|
: null}
|
||||||
|
<ErrorTrace error={props.error} inline={true} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={classNames("negative", style.error, style.errorBlock)}>
|
||||||
|
<p>
|
||||||
|
<FestaIcon icon={faCircleExclamation} />
|
||||||
|
|
||||||
|
<span>
|
||||||
|
{props.text}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<pre>
|
||||||
|
<ErrorTrace error={props.error} inline={false} />
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
8
components/generic/layouts/monorow.module.css
Normal file
8
components/generic/layouts/monorow.module.css
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
.layoutMonorow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
}
|
16
components/generic/layouts/monorow.tsx
Normal file
16
components/generic/layouts/monorow.tsx
Normal file
|
@ -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 (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={classNames(style.layoutMonorow, props.className)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
145
components/generic/loading/promise.tsx
Normal file
145
components/generic/loading/promise.tsx
Normal file
|
@ -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<D> = { 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<E = Error> = { type: "reject", error: E }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions that can be performed on the {@link useReducer} hook used inside {@link usePromise}.
|
||||||
|
*/
|
||||||
|
type UsePromiseAction<D, E = Error> = UsePromiseActionRun | UsePromiseActionFulfill<D> | UsePromiseActionReject<E>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The internal state of the {@link useReducer} hook used inside {@link usePromise}.
|
||||||
|
*/
|
||||||
|
type UsePromiseState<D, E = Error> = {
|
||||||
|
status: UsePromiseStatus,
|
||||||
|
result: D | undefined,
|
||||||
|
error: E | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The initial {@link UsePromiseState} of the {@link usePromise} hook.
|
||||||
|
*/
|
||||||
|
const initialUsePromise: UsePromiseState<any, any> = {
|
||||||
|
status: UsePromiseStatus.READY,
|
||||||
|
result: undefined,
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reducer used by {@link usePromise}.
|
||||||
|
*/
|
||||||
|
function reducerUsePromise<D, E = Error>(prev: UsePromiseState<D, E>, action: UsePromiseAction<D, E>): UsePromiseState<D, E> {
|
||||||
|
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<D, P> = (params: P) => Promise<D>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values returned by the {@link usePromise} hook.
|
||||||
|
*/
|
||||||
|
export type UsePromise<D, P, E = Error> = UsePromiseState<D, E> & { run: (params: P) => Promise<void> }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook executing an asyncronous function in a way that can be handled by React components.
|
||||||
|
*/
|
||||||
|
export function usePromise<D, P, E = Error>(func: UsePromiseFunction<D, P>): UsePromise<D, P, E> {
|
||||||
|
const [state, dispatch] = useReducer<Reducer<UsePromiseState<D, E>, UsePromiseAction<D, E>>>(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<D, P, E = Error> = {
|
||||||
|
run: UsePromise<D, P, E>["run"],
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromiseMultiplexerPendingParams<D, P, E = Error> = {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromiseMultiplexerFulfilledParams<D, P, E = Error> = {
|
||||||
|
run: UsePromise<D, P, E>["run"],
|
||||||
|
result: D,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromiseMultiplexerRejectedParams<D, P, E = Error> = {
|
||||||
|
run: UsePromise<D, P, E>["run"],
|
||||||
|
error: E,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type PromiseMultiplexerConfig<D, P, E = Error> = {
|
||||||
|
hook: UsePromise<D, P, E>,
|
||||||
|
ready: (params: PromiseMultiplexerReadyParams<D, P, E>) => JSX.Element,
|
||||||
|
pending: (params: PromiseMultiplexerPendingParams<D, P, E>) => JSX.Element,
|
||||||
|
fulfilled: (params: PromiseMultiplexerFulfilledParams<D, P, E>) => JSX.Element,
|
||||||
|
rejected: (error: PromiseMultiplexerRejectedParams<D, P, E>) => 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<D, P, E = Error>(config: PromiseMultiplexerConfig<D, P, E>): 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! })
|
||||||
|
}
|
||||||
|
}
|
19
components/generic/loading/swr.tsx
Normal file
19
components/generic/loading/swr.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { SWRResponse } from "swr";
|
||||||
|
|
||||||
|
|
||||||
|
export type SWRMultiplexerConfig<D, E = Error> = {
|
||||||
|
hook: SWRResponse<D, E>,
|
||||||
|
loading: () => JSX.Element,
|
||||||
|
ready: (data: D) => JSX.Element,
|
||||||
|
error: (error: E) => JSX.Element,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function swrMultiplexer<D, E = Error>(config: SWRMultiplexerConfig<D, E>): 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()
|
||||||
|
}
|
21
components/generic/loading/textInline.tsx
Normal file
21
components/generic/loading/textInline.tsx
Normal file
|
@ -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 (
|
||||||
|
<span>
|
||||||
|
<FestaIcon icon={faAsterisk} spin />
|
||||||
|
|
||||||
|
{props.text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
3
components/generic/renderers/README.md
Normal file
3
components/generic/renderers/README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Generic renderers
|
||||||
|
|
||||||
|
These components render raw data in HTML format for usage in other components.
|
|
@ -7,10 +7,18 @@ type FestaMomentProps = {
|
||||||
/**
|
/**
|
||||||
* Component that formats a {@link Date} to a machine-readable and human-readable HTML `time[datetime]` element.
|
* 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()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
if (!date || Number.isNaN(date.getTime())) {
|
if (date === null) {
|
||||||
|
return (
|
||||||
|
<span className="disabled">
|
||||||
|
{t("dateNull")}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
return (
|
return (
|
||||||
<span className="disabled">
|
<span className="disabled">
|
||||||
{t("dateNaN")}
|
{t("dateNaN")}
|
||||||
|
@ -22,8 +30,8 @@ export function FestaMoment({ date }: FestaMomentProps) {
|
||||||
const machine = date.toISOString()
|
const machine = date.toISOString()
|
||||||
|
|
||||||
let human
|
let human
|
||||||
// If the date is less than 24 hours away, display just the time
|
// If the date is less than 20 hours away, display just the time
|
||||||
if (date.getTime() - now.getTime() < 86_400_000) {
|
if (date.getTime() - now.getTime() < (20 /*hours*/ * 60 /*minutes*/ * 60 /*seconds*/ * 1000 /*milliseconds*/)) {
|
||||||
human = date.toLocaleTimeString()
|
human = date.toLocaleTimeString()
|
||||||
}
|
}
|
||||||
// Otherwise, display the full date
|
// Otherwise, display the full date
|
15
components/generic/renderers/fontawesome.tsx
Normal file
15
components/generic/renderers/fontawesome.tsx
Normal file
|
@ -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 (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
{...props}
|
||||||
|
className={classNames("icon", props.className)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
25
components/generic/renderers/markdown.tsx
Normal file
25
components/generic/renderers/markdown.tsx
Normal file
|
@ -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 (
|
||||||
|
<ReactMarkdown
|
||||||
|
components={{
|
||||||
|
h1: "h3", // h1 is reserved for the site name
|
||||||
|
h2: "h3", // h2 is reserved for the page name
|
||||||
|
img: undefined, // images reveal the IP of the user to third parties!
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</ReactMarkdown>
|
||||||
|
)
|
||||||
|
})
|
68
components/generic/storage/base.ts
Normal file
68
components/generic/storage/base.ts
Normal file
|
@ -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<string | undefined>(undefined)
|
||||||
|
useLocalStorageLoad(key, setInnerState)
|
||||||
|
|
||||||
|
const setState = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
localStorageSave(key, value)
|
||||||
|
setInnerState(value)
|
||||||
|
},
|
||||||
|
[key, setInnerState]
|
||||||
|
)
|
||||||
|
|
||||||
|
return [state, setState]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
75
components/generic/storage/json.ts
Normal file
75
components/generic/storage/json.ts
Normal file
|
@ -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<Expected>(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<Expected>(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<Expected>(key: string, callback: (data: Expected) => void) {
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
try {
|
||||||
|
// This usage of var is deliberate.
|
||||||
|
var value = localStorageLoadJSON<Expected>(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<Expected>(key: string) {
|
||||||
|
const [state, setStateInner] = useState<Expected | undefined>(undefined);
|
||||||
|
useLocalStorageJSONLoad(key, setStateInner);
|
||||||
|
|
||||||
|
const setState = useCallback(
|
||||||
|
(value: Expected) => {
|
||||||
|
localStorageSaveJSON(key, value);
|
||||||
|
setStateInner(value);
|
||||||
|
},
|
||||||
|
[key, setStateInner]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [state, setState];
|
||||||
|
}
|
|
@ -11,48 +11,34 @@
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-top {
|
.toolbarTop {
|
||||||
top: 8px;
|
top: 8px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-bottom {
|
.toolbarBottom {
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-left {
|
.toolbarLeft {
|
||||||
left: 8px;
|
left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-right {
|
.toolbarRight {
|
||||||
right: 8px;
|
right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-vadapt {
|
.toolbarVadapt {
|
||||||
top: 8px;
|
top: 8px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.toolbar-vadapt {
|
.toolbarVadapt {
|
||||||
top: unset;
|
top: unset;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
|
|
||||||
flex-direction: column-reverse;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
50
components/generic/toolbar/bar.tsx
Normal file
50
components/generic/toolbar/bar.tsx
Normal file
|
@ -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 (
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
{...props}
|
||||||
|
className={classNames(
|
||||||
|
"toolbar",
|
||||||
|
props.vertical === "top" ? style.toolbarTop : null,
|
||||||
|
props.vertical === "bottom" ? style.toolbarBottom : null,
|
||||||
|
props.vertical === "vadapt" ? style.toolbarVadapt : null,
|
||||||
|
props.horizontal === "left" ? style.toolbarLeft : null,
|
||||||
|
props.horizontal === "right" ? style.toolbarRight : null,
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
13
components/generic/toolbar/tool.module.css
Normal file
13
components/generic/toolbar/tool.module.css
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
22
components/generic/toolbar/tool.tsx
Normal file
22
components/generic/toolbar/tool.tsx
Normal file
|
@ -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 (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
className={classNames(
|
||||||
|
style.toolbarTool,
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
|
@ -1,4 +1,4 @@
|
||||||
.view-content {
|
.viewContent {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"title"
|
"title"
|
||||||
|
@ -13,11 +13,11 @@
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-content-title {
|
.viewContentTitle {
|
||||||
grid-area: title;
|
grid-area: title;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-content-content {
|
.viewContentContent {
|
||||||
grid-area: content;
|
grid-area: content;
|
||||||
}
|
}
|
24
components/generic/views/content.tsx
Normal file
24
components/generic/views/content.tsx
Normal file
|
@ -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 (
|
||||||
|
<main className={style.viewContent}>
|
||||||
|
<h2 className={style.viewContentTitle}>
|
||||||
|
{props.title}
|
||||||
|
</h2>
|
||||||
|
<div className={style.viewContentContent}>
|
||||||
|
{props.content}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
})
|
|
@ -1,4 +1,4 @@
|
||||||
.view-landing {
|
.viewLanding {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"titles"
|
"titles"
|
||||||
|
@ -13,30 +13,30 @@
|
||||||
gap: 32px;
|
gap: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-landing-titles {
|
.viewLandingTitles {
|
||||||
grid-area: titles;
|
grid-area: titles;
|
||||||
text-shadow: 2px 2px 4px var(--background);
|
text-shadow: 2px 2px 4px var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-landing-titles-title {
|
.viewLandingTitlesTitle {
|
||||||
font-size: 10rem;
|
font-size: 10rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-landing-titles-subtitle {
|
.viewLandingTitlesSubtitle {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) or (max-height: 600px) {
|
@media (max-width: 800px) or (max-height: 600px) {
|
||||||
.view-landing-titles-title {
|
.viewLandingTitlesTitle {
|
||||||
font-size: 5rem;
|
font-size: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-landing-titles-subtitle {
|
.viewLandingTitlesSubtitle {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-landing-actions {
|
.viewLandingActions {
|
||||||
grid-area: actions;
|
grid-area: actions;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
justify-self: center;
|
justify-self: center;
|
33
components/generic/views/landing.tsx
Normal file
33
components/generic/views/landing.tsx
Normal file
|
@ -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 (
|
||||||
|
<main className={style.viewLanding}>
|
||||||
|
<hgroup className={style.viewLandingTitles}>
|
||||||
|
<h1 className={style.viewLandingTitlesTitle}>
|
||||||
|
{props.title}
|
||||||
|
</h1>
|
||||||
|
<h2 className={style.viewLandingTitlesSubtitle}>
|
||||||
|
{props.subtitle}
|
||||||
|
</h2>
|
||||||
|
</hgroup>
|
||||||
|
<div className={style.viewLandingActions}>
|
||||||
|
{props.actions}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
})
|
21
components/generic/views/notice.tsx
Normal file
21
components/generic/views/notice.tsx
Normal file
|
@ -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 (
|
||||||
|
<main className={style.viewNotice}>
|
||||||
|
{props.notice}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
})
|
|
@ -1,4 +1,4 @@
|
||||||
.work-in-progress {
|
.wipBanner {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
|
|
||||||
/* TODO: Make this based on --warning. */
|
/* TODO: Make this based on --warning. */
|
18
components/generic/wip/banner.tsx
Normal file
18
components/generic/wip/banner.tsx
Normal file
|
@ -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 (
|
||||||
|
<div className={style.wipBanner}>
|
||||||
|
<FestaIcon icon={faBrush} /> {t("workInProgress")}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
3
components/landing/README.md
Normal file
3
components/landing/README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Landing
|
||||||
|
|
||||||
|
This directory contains components related to the landing page.
|
28
components/landing/actions/events.module.css
Normal file
28
components/landing/actions/events.module.css
Normal file
|
@ -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;
|
||||||
|
}
|
147
components/landing/actions/events.tsx
Normal file
147
components/landing/actions/events.tsx
Normal file
|
@ -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 (
|
||||||
|
<p>
|
||||||
|
<label htmlFor="festa-landing-action-events-form-create-name">
|
||||||
|
{t("landingEventsFirstDescription")}
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displayed if the user has one or more events created on Festa.
|
||||||
|
*/
|
||||||
|
const LandingActionEventsList = ({ data }: { data: Event[] }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
{t("landingEventsDescription")}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<ul className={style.landingActionEventsList}>
|
||||||
|
{data.map(e => (
|
||||||
|
<li key={e.slug}>
|
||||||
|
<Link href={`/events/${e.slug}`}>
|
||||||
|
{e.name}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label htmlFor="festa-landing-action-events-form-create-name">
|
||||||
|
{t("landingEventsCreateDescription")}
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-line form to create a new event on Festa.
|
||||||
|
*/
|
||||||
|
const LandingActionEventsFormCreate = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const router = useRouter()
|
||||||
|
const [name, setName] = useState<string>("")
|
||||||
|
|
||||||
|
const createHook = useAxiosRequest<Event, Partial<Event>>({ method: "POST", url: "/api/events/" })
|
||||||
|
|
||||||
|
return promiseMultiplexer({
|
||||||
|
hook: createHook,
|
||||||
|
ready: ({ run }) => (
|
||||||
|
<form className={style.landingActionEventsFormCreate}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("landingEventsCreatePlaceholder")}
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
id="festa-landing-action-events-form-create-name"
|
||||||
|
className={style.landingActionEventsFormCreateName}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-label={t("landingEventsCreateSubmitLabel")}
|
||||||
|
disabled={!name}
|
||||||
|
className={classNames(style.landingActionEventsFormCreateSubmit, "positive")}
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
run({ data: { name } })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FestaIcon icon={faPlus} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
),
|
||||||
|
pending: ({ }) => (
|
||||||
|
<p>
|
||||||
|
<LoadingTextInline text={t("landingEventsCreatePending")} />
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
rejected: ({ error }) => (
|
||||||
|
<p>
|
||||||
|
<ErrorBlock text={t("landingEventsCreateRejected")} error={error} />
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
fulfilled: ({ result }) => {
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
<LoadingTextInline text={t("landingEventsCreateFulfilled", name)} />
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const LandingActionEvents = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const apiHook = useSWR<Event[], Error>("/api/events/mine")
|
||||||
|
|
||||||
|
return swrMultiplexer({
|
||||||
|
hook: apiHook,
|
||||||
|
loading: () => (
|
||||||
|
<p>
|
||||||
|
<LoadingTextInline text={t("landingEventsLoading")} />
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
ready: (data) => (<>
|
||||||
|
{data.length === 0 ?
|
||||||
|
<LandingActionEventsFirst />
|
||||||
|
:
|
||||||
|
<LandingActionEventsList data={data} />
|
||||||
|
}
|
||||||
|
<LandingActionEventsFormCreate />
|
||||||
|
</>),
|
||||||
|
error: (error) => (
|
||||||
|
<p>
|
||||||
|
<ErrorBlock text={t("landingEventsError")} error={error} />
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
53
components/landing/actions/login.tsx
Normal file
53
components/landing/actions/login.tsx
Normal file
|
@ -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 }) => <>
|
||||||
|
<p>
|
||||||
|
{t("landingLoginTelegramDescription")}
|
||||||
|
</p>
|
||||||
|
<TelegramLoginButton
|
||||||
|
botName={process.env.NEXT_PUBLIC_TELEGRAM_USERNAME!}
|
||||||
|
buttonSize="large"
|
||||||
|
requestAccess={undefined}
|
||||||
|
dataOnauth={(data) => run({ data })}
|
||||||
|
/>
|
||||||
|
</>,
|
||||||
|
pending: ({ }) => (
|
||||||
|
<p>
|
||||||
|
<LoadingTextInline text={t("landingLoginTelegramPending")} />
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
fulfilled: ({ result }) => {
|
||||||
|
setAuth(result.data)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
<LoadingTextInline text={t("landingLoginTelegramFulfilled")} />
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
rejected: ({ error }) => (
|
||||||
|
<p>
|
||||||
|
<ErrorBlock text={t("landingLoginTelegramRejected")} error={error} />
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
|
@ -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 (
|
|
||||||
<div
|
|
||||||
className={classNames("postcard", `postcard-${visibility}`)}
|
|
||||||
style={{
|
|
||||||
backgroundImage: image,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
3
components/postcard/README.md
Normal file
3
components/postcard/README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Postcard
|
||||||
|
|
||||||
|
The postcard is the image rendered as background of the website.
|
|
@ -1,10 +1,11 @@
|
||||||
|
import { ImageProps } from "next/image"
|
||||||
import { createDefinedContext } from "../../utils/definedContext";
|
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}.
|
* Contents of the {@link PostcardContext}.
|
||||||
*/
|
*/
|
||||||
type PostcardContextValue = {
|
export type PostcardContextContents = {
|
||||||
image: PostcardImage,
|
src: PostcardSource,
|
||||||
setImage: React.Dispatch<React.SetStateAction<PostcardImage>>,
|
setSrc: React.Dispatch<React.SetStateAction<PostcardSource>>,
|
||||||
visibility: PostcardVisibility,
|
visibility: PostcardVisibility,
|
||||||
setVisibility: React.Dispatch<React.SetStateAction<PostcardVisibility>>,
|
setVisibility: React.Dispatch<React.SetStateAction<PostcardVisibility>>,
|
||||||
}
|
}
|
||||||
|
@ -37,4 +38,7 @@ type PostcardContextValue = {
|
||||||
/**
|
/**
|
||||||
* Context containing data about the website's current postcard, the blurred background image.
|
* Context containing data about the website's current postcard, the blurred background image.
|
||||||
*/
|
*/
|
||||||
export const PostcardContext = createDefinedContext<PostcardContextValue>()
|
export const PostcardContext = createDefinedContext<PostcardContextContents>()
|
||||||
|
|
||||||
|
|
||||||
|
|
33
components/postcard/changer.tsx
Normal file
33
components/postcard/changer.tsx
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -2,9 +2,8 @@
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
||||||
background-attachment: fixed;
|
object-fit: cover;
|
||||||
background-size: cover;
|
object-position: 50% 50%;
|
||||||
background-position: 50% 50%;
|
|
||||||
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
@ -12,20 +11,22 @@
|
||||||
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.postcard-background {
|
.postcardBackground {
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
filter: blur(7px) contrast(50%) brightness(50%);
|
filter: blur(7px) contrast(50%) brightness(50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
.postcard-background {
|
.postcardBackground {
|
||||||
filter: blur(7px) contrast(25%) brightness(175%);
|
filter: blur(7px) contrast(25%) brightness(175%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.postcard-foreground {
|
.postcardForeground {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
filter: none;
|
filter: none;
|
||||||
}
|
}
|
25
components/postcard/renderer.tsx
Normal file
25
components/postcard/renderer.tsx
Normal file
|
@ -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<ImageProps>) {
|
||||||
|
const { src, visibility } = useDefinedContext(PostcardContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
width={"100vw"}
|
||||||
|
height={"100vh"}
|
||||||
|
layout="raw"
|
||||||
|
{...props}
|
||||||
|
className={classNames(
|
||||||
|
style.postcard,
|
||||||
|
visibility === PostcardVisibility.BACKGROUND ? style.postcardBackground : null,
|
||||||
|
visibility === PostcardVisibility.FOREGROUND ? style.postcardForeground : null,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
17
components/postcard/storage.ts
Normal file
17
components/postcard/storage.ts
Normal file
|
@ -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<PostcardSource>(defaultPostcard);
|
||||||
|
const [visibility, setVisibility] = useState<PostcardVisibility>(PostcardVisibility.BACKGROUND);
|
||||||
|
|
||||||
|
return {
|
||||||
|
src,
|
||||||
|
setSrc,
|
||||||
|
visibility,
|
||||||
|
setVisibility,
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,33 +1,36 @@
|
||||||
import { faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons"
|
import { faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons"
|
||||||
import { useTranslation } from "next-i18next"
|
import { useTranslation } from "next-i18next"
|
||||||
import { useDefinedContext } from "../../utils/definedContext"
|
import { useDefinedContext } from "../../../utils/definedContext"
|
||||||
import { FestaIcon } from "../extensions/FestaIcon"
|
import { FestaIcon } from "../../generic/renderers/fontawesome"
|
||||||
import { PostcardContext, PostcardVisibility } from "../postcard/PostcardContext"
|
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 { t } = useTranslation()
|
||||||
const { visibility, setVisibility } = useDefinedContext(PostcardContext)
|
const { visibility, setVisibility } = useDefinedContext(PostcardContext)
|
||||||
|
|
||||||
if (visibility === PostcardVisibility.BACKGROUND) {
|
if (visibility === PostcardVisibility.BACKGROUND) {
|
||||||
return (
|
return (
|
||||||
<button
|
<Tool
|
||||||
aria-label={t("toggleVisibleShow")}
|
aria-label={t("toggleVisibleShow")}
|
||||||
onClick={() => setVisibility(PostcardVisibility.FOREGROUND)}
|
onClick={() => setVisibility(PostcardVisibility.FOREGROUND)}
|
||||||
className="toolbar-tool"
|
|
||||||
>
|
>
|
||||||
<FestaIcon icon={faEye} />
|
<FestaIcon icon={faEye} />
|
||||||
</button>
|
</Tool>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return (
|
return (
|
||||||
<button
|
<Tool
|
||||||
aria-label={t("toggleVisibleHide")}
|
aria-label={t("toggleVisibleHide")}
|
||||||
onClick={() => setVisibility(PostcardVisibility.BACKGROUND)}
|
onClick={() => setVisibility(PostcardVisibility.BACKGROUND)}
|
||||||
className="toolbar-tool"
|
|
||||||
>
|
>
|
||||||
<FestaIcon icon={faEyeSlash} />
|
<FestaIcon icon={faEyeSlash} />
|
||||||
</button>
|
</Tool>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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]
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { PostcardImage, PostcardVisibility } from "./PostcardContext";
|
|
||||||
|
|
||||||
export function useStatePostcard() {
|
|
||||||
const [visibility, setVisibility] = useState<PostcardVisibility>(PostcardVisibility.BACKGROUND)
|
|
||||||
const [image, setImage] = useState<PostcardImage>("none")
|
|
||||||
|
|
||||||
return {
|
|
||||||
visibility,
|
|
||||||
setVisibility,
|
|
||||||
image,
|
|
||||||
setImage,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 (
|
|
||||||
<div className={classNames("toolbar", `toolbar-${vertical}`, `toolbar-${horizontal}`)} role="toolbar">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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 (
|
|
||||||
<button
|
|
||||||
aria-label={editing ? t("toggleEditingView") : t("toggleEditingEdit")}
|
|
||||||
onClick={() => setEditing(!editing)}
|
|
||||||
className="toolbar-tool"
|
|
||||||
>
|
|
||||||
<FestaIcon icon={editing ? faBinoculars : faPencil}/>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
import { ReactNode } from "react"
|
|
||||||
|
|
||||||
type ViewContentProps = {
|
|
||||||
title: ReactNode
|
|
||||||
content: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function ViewContent(props: ViewContentProps) {
|
|
||||||
return (
|
|
||||||
<main className="view-content">
|
|
||||||
<h1 className="view-content-title">
|
|
||||||
{props.title}
|
|
||||||
</h1>
|
|
||||||
<div className="view-content-content">
|
|
||||||
{props.content}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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 (
|
|
||||||
<main className="view-event">
|
|
||||||
<h1 className="view-event-title">
|
|
||||||
{props.title}
|
|
||||||
</h1>
|
|
||||||
<div className="view-event-postcard">
|
|
||||||
{props.postcard}
|
|
||||||
</div>
|
|
||||||
<div className="view-event-description">
|
|
||||||
{props.description}
|
|
||||||
</div>
|
|
||||||
<div className="view-event-daterange">
|
|
||||||
{props.daterange}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
import { ReactNode } from "react"
|
|
||||||
|
|
||||||
type ViewLandingProps = {
|
|
||||||
title: ReactNode
|
|
||||||
subtitle: ReactNode
|
|
||||||
actions: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function ViewLanding(props: ViewLandingProps) {
|
|
||||||
return (
|
|
||||||
<main className="view-landing">
|
|
||||||
<hgroup className="view-landing-titles">
|
|
||||||
<h1 className="view-landing-titles-title">
|
|
||||||
{props.title}
|
|
||||||
</h1>
|
|
||||||
<h2 className="view-landing-titles-subtitle">
|
|
||||||
{props.subtitle}
|
|
||||||
</h2>
|
|
||||||
</hgroup>
|
|
||||||
<div className="view-landing-actions">
|
|
||||||
{props.actions}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
import { ReactNode } from "react"
|
|
||||||
|
|
||||||
type ViewNoticeProps = {
|
|
||||||
notice: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function ViewNotice(props: ViewNoticeProps) {
|
|
||||||
return (
|
|
||||||
<main className="view-notice">
|
|
||||||
{props.notice}
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
import { Event } from "@prisma/client";
|
|
||||||
import { default as useSWR } from "swr";
|
|
||||||
|
|
||||||
export function useEventDetailsSWR(slug: string) {
|
|
||||||
return useSWR<Event>(`/api/events/${slug}`)
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
import { Event } from "@prisma/client";
|
|
||||||
import { default as useSWR } from "swr";
|
|
||||||
|
|
||||||
export function useMyEventsSWR() {
|
|
||||||
return useSWR<Event[]>("/api/events/mine")
|
|
||||||
}
|
|
|
@ -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<D>(config: AxiosRequestConfig<D> = {}, 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]
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,76 +0,0 @@
|
||||||
import { AxiosRequestConfig, AxiosResponse } from "axios";
|
|
||||||
import { useCallback, useReducer } from "react";
|
|
||||||
import { useAxios } from "./useAxios";
|
|
||||||
|
|
||||||
type ReducerActionStart = { type: "start" }
|
|
||||||
type ReducerActionDone<T, D = any> = { type: "done", response: AxiosResponse<T, D> }
|
|
||||||
type ReducerActionError<T, D = any> = { type: "error", error: any }
|
|
||||||
type ReducerAction<T, D = any> = ReducerActionStart | ReducerActionDone<T, D> | ReducerActionError<T, D>
|
|
||||||
|
|
||||||
type ReducerState<T, D = any> = {
|
|
||||||
running: boolean,
|
|
||||||
response: AxiosResponse<T, D> | undefined,
|
|
||||||
error: any | undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAxiosRequest<T, D = any>(config: AxiosRequestConfig<D> = {}, onSuccess?: (response: AxiosResponse<T>) => void, onError?: (error: any) => void) {
|
|
||||||
const axios = useAxios()
|
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(
|
|
||||||
(prev: ReducerState<T, D>, action: ReducerAction<T, D>) => {
|
|
||||||
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<D> = {}) => {
|
|
||||||
dispatch({ type: "start" })
|
|
||||||
|
|
||||||
try {
|
|
||||||
var response: AxiosResponse<T, D> = 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,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
import { ChangeEvent, useCallback, useState } from "react";
|
|
||||||
|
|
||||||
|
|
||||||
type FileState = {
|
|
||||||
value: string,
|
|
||||||
file: File | null,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function useFilePickerState() {
|
|
||||||
const [state, setState] = useState<FileState>({ value: "", file: null })
|
|
||||||
|
|
||||||
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setState({
|
|
||||||
value: e.target.value,
|
|
||||||
file: e.target.files![0],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return { state, onChange }
|
|
||||||
}
|
|
|
@ -1,40 +0,0 @@
|
||||||
import { useEffect } from "react"
|
|
||||||
import { FestaLoginData } from "../types/user"
|
|
||||||
|
|
||||||
export function useStoredLogin(setLogin: React.Dispatch<React.SetStateAction<FestaLoginData | null>>): 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]
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -5,16 +5,16 @@ function fixCssLoaderLocalIdent(webpackConfig) {
|
||||||
|
|
||||||
function innerFix(used) {
|
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) {
|
if (used.options.modules.getLocalIdent) {
|
||||||
let { getLocalIdent, ...modules } = modules
|
|
||||||
|
|
||||||
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}
|
* @type {import('next').NextConfig}
|
||||||
*/
|
*/
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
experimental: { images: { layoutRaw: true } },
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
webpack,
|
webpack,
|
||||||
i18n,
|
i18n,
|
||||||
|
|
|
@ -17,9 +17,9 @@
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||||
"@fortawesome/react-fontawesome": "^0.1.18",
|
"@fortawesome/react-fontawesome": "^0.1.18",
|
||||||
"@prisma/client": "^3.15.0",
|
"@prisma/client": "^3.15.0",
|
||||||
|
"ajv": "^8.11.0",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"cors": "^2.8.5",
|
|
||||||
"crypto-random-string": "^5.0.0",
|
"crypto-random-string": "^5.0.0",
|
||||||
"next": "12.1.6",
|
"next": "12.1.6",
|
||||||
"next-i18next": "^11.0.0",
|
"next-i18next": "^11.0.0",
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { NextPageContext } from "next";
|
import { NextPage, NextPageContext } from "next";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||||
import { default as Link } from "next/link";
|
import { default as Link } from "next/link";
|
||||||
import { ErrorBlock } from "../components/errors/ErrorBlock";
|
import { ErrorBlock } from "../components/generic/errors/renderers";
|
||||||
import { usePostcardImage } from "../components/postcard/usePostcardImage";
|
import { ViewNotice } from "../components/generic/views/notice";
|
||||||
import { ViewNotice } from "../components/view/ViewNotice";
|
import { Postcard } from "../components/postcard/changer";
|
||||||
import errorPostcard from "../public/postcards/markus-spiske-iar-afB0QQw-unsplash-red.jpg"
|
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()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
usePostcardImage(`url(${errorPostcard.src})`)
|
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
|
<Postcard
|
||||||
|
src={errorPostcard}
|
||||||
|
/>
|
||||||
<ViewNotice
|
<ViewNotice
|
||||||
notice={<>
|
notice={<>
|
||||||
<ErrorBlock
|
<ErrorBlock
|
||||||
|
@ -30,9 +31,13 @@ export default function Page404() {
|
||||||
error={new Error("HTTP 404 (Not found)")}
|
error={new Error("HTTP 404 (Not found)")}
|
||||||
/>
|
/>
|
||||||
<p>
|
<p>
|
||||||
<Link href="/"><a>← {t("notFoundBackHome")}</a></Link>
|
<Link href="/"><a>
|
||||||
|
← {t("notFoundBackHome")}
|
||||||
|
</a></Link>
|
||||||
</p>
|
</p>
|
||||||
</>}
|
</>}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Page404
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue