mirror of
https://github.com/Steffo99/festa.git
synced 2024-12-22 06:34:22 +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",
|
||||
"type": "firefox",
|
||||
"request": "launch",
|
||||
"url": "http://local.steffo.eu",
|
||||
"url": "http://nitro.home.steffo.eu",
|
||||
"pathMappings": [
|
||||
{
|
||||
"url": "webpack://_n_e",
|
||||
|
@ -55,7 +55,11 @@
|
|||
"compounds": [
|
||||
{
|
||||
"name": "Everything!",
|
||||
"configurations": ["Web server", "Web page", "Prisma Studio"],
|
||||
"configurations": [
|
||||
"Web server",
|
||||
"Web page",
|
||||
"Prisma Studio"
|
||||
],
|
||||
"stopAll": true,
|
||||
"presentation": {
|
||||
"group": "Full",
|
||||
|
|
|
@ -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,31 +1,32 @@
|
|||
import { default as TelegramLoginButton, TelegramLoginResponse } from "react-telegram-login"
|
||||
import { default as nodecrypto } from "crypto"
|
||||
import { TelegramLoginData } from "../types/user"
|
||||
import { AccountTelegram } from "@prisma/client"
|
||||
|
||||
|
||||
/**
|
||||
* A {@link TelegramLoginData} object extended with various utility methods.
|
||||
* A {@link TelegramLoginResponse} object extended with various utility methods, and with its fields renamed to follow the camelCase JS convention.
|
||||
*/
|
||||
export class TelegramLoginDataClass {
|
||||
export class TelegramLoginObject {
|
||||
id: number
|
||||
firstName: string
|
||||
lastName?: string
|
||||
username?: string
|
||||
photoUrl?: string
|
||||
lang?: string
|
||||
authDate: Date
|
||||
hash: string
|
||||
lang?: string
|
||||
|
||||
/**
|
||||
* Construct a {@link TelegramLoginDataClass} object from a {@link TelegramLoginData}, validating it in the process.
|
||||
* Construct a {@link TelegramLoginObject} object from a {@link TelegramLoginData}.
|
||||
*
|
||||
* @param u The {@link TelegramLoginData} to use.
|
||||
*/
|
||||
constructor(u: TelegramLoginData) {
|
||||
if(!u.id) throw new Error("Missing `id`")
|
||||
if(!u.first_name) throw new Error("Missing `first_name`")
|
||||
if(!u.auth_date) throw new Error("Missing `auth_date`")
|
||||
if(!u.hash) throw new Error("Missing `hash`")
|
||||
|
||||
constructor(u: TelegramLoginResponse) {
|
||||
if (!u.id) throw new Error("Missing `id`")
|
||||
if (!u.first_name) throw new Error("Missing `first_name`")
|
||||
if (!u.auth_date) throw new Error("Missing `auth_date`")
|
||||
if (!u.hash) throw new Error("Missing `hash`")
|
||||
|
||||
this.id = u.id
|
||||
this.firstName = u.first_name
|
||||
this.lastName = u.last_name
|
||||
|
@ -34,18 +35,18 @@ export class TelegramLoginDataClass {
|
|||
this.authDate = new Date(u.auth_date * 1000)
|
||||
|
||||
// https://stackoverflow.com/a/12372720/4334568
|
||||
if(isNaN(this.authDate.getTime())) throw new Error("Invalid `auth_date`")
|
||||
if (isNaN(this.authDate.getTime())) throw new Error("Invalid `auth_date`")
|
||||
|
||||
this.hash = u.hash
|
||||
this.lang = u.lang
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert this object back into a {@link TelegramLoginData}.
|
||||
* Convert this object back into a {@link TelegramLoginResponse}.
|
||||
*
|
||||
* @return The {@link TelegramLoginData} object, ready to be serialized.
|
||||
* @return The {@link TelegramLoginResponse} object, ready to be serialized.
|
||||
*/
|
||||
toObject(): TelegramLoginData {
|
||||
toObject(): TelegramLoginResponse {
|
||||
return {
|
||||
id: this.id,
|
||||
first_name: this.firstName,
|
||||
|
@ -61,7 +62,7 @@ export class TelegramLoginDataClass {
|
|||
/**
|
||||
* Convert this object into a partial {@link AccountTelegram} database object.
|
||||
*/
|
||||
toDatabase() {
|
||||
toDatabase(): Pick<AccountTelegram, "telegramId" | "firstName" | "lastName" | "username" | "photoUrl" | "lang"> {
|
||||
return {
|
||||
telegramId: this.id,
|
||||
firstName: this.firstName,
|
||||
|
@ -89,7 +90,7 @@ export class TelegramLoginDataClass {
|
|||
}
|
||||
|
||||
/**
|
||||
* Check if the `auth_date` of the response is recent: it must be in the past, but within `maxMs` from the current date.
|
||||
* Check if the `authDate` of the response is recent: it must be in the past, but within `maxMs` from the current date.
|
||||
*
|
||||
* @param maxMs The maximum number of milliseconds that may pass after authentication for the response to be considered valid.
|
||||
* @returns `true` if the request was sent within the requested timeframe, `false` otherwise.
|
||||
|
@ -107,7 +108,7 @@ export class TelegramLoginDataClass {
|
|||
* @param token The bot token used to validate the signature.
|
||||
* @returns The calculated value of the `hash` parameter.
|
||||
*/
|
||||
hmac(token: string): string {
|
||||
hmac(token: string): string {
|
||||
const hash = nodecrypto.createHash("sha256")
|
||||
hash.update(token)
|
||||
const hmac = nodecrypto.createHmac("sha256", hash.digest())
|
||||
|
@ -133,8 +134,8 @@ export class TelegramLoginDataClass {
|
|||
* Get the Telegram "displayed name" of the user represented by this object.
|
||||
*/
|
||||
toTelegramName(): string {
|
||||
if(this.username) return this.username
|
||||
else if(this.lastName) return this.firstName + " " + this.lastName
|
||||
if (this.username) return this.username
|
||||
else if (this.lastName) return this.firstName + " " + this.lastName
|
||||
else return this.firstName
|
||||
}
|
||||
}
|
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,6 +1,6 @@
|
|||
.view-event {
|
||||
.viewEvent {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
grid-template-areas:
|
||||
"x1 ti ti ti x4"
|
||||
"x1 x2 po x3 x4"
|
||||
"x1 x2 de x3 x4"
|
||||
|
@ -16,8 +16,8 @@
|
|||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.view-event {
|
||||
grid-template-areas:
|
||||
.viewEvent {
|
||||
grid-template-areas:
|
||||
"ti"
|
||||
"po"
|
||||
"de"
|
||||
|
@ -29,19 +29,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
.view-event-title {
|
||||
.viewEventTitle {
|
||||
grid-area: ti;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.view-event-postcard {
|
||||
.viewEventPostcard {
|
||||
grid-area: po;
|
||||
}
|
||||
|
||||
.view-event-description {
|
||||
.viewEventDescription {
|
||||
grid-area: de;
|
||||
}
|
||||
|
||||
.view-event-daterange {
|
||||
.viewEventDaterange {
|
||||
grid-area: dr;
|
||||
}
|
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;
|
||||
margin: 0;
|
||||
|
||||
max-width: 100%;
|
||||
|
||||
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.
|
||||
*/
|
||||
export function FestaMoment({ date }: FestaMomentProps) {
|
||||
export const FestaMoment = ({ date }: FestaMomentProps) => {
|
||||
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 (
|
||||
<span className="disabled">
|
||||
{t("dateNaN")}
|
||||
|
@ -22,8 +30,8 @@ export function FestaMoment({ date }: FestaMomentProps) {
|
|||
const machine = date.toISOString()
|
||||
|
||||
let human
|
||||
// If the date is less than 24 hours away, display just the time
|
||||
if (date.getTime() - now.getTime() < 86_400_000) {
|
||||
// If the date is less than 20 hours away, display just the time
|
||||
if (date.getTime() - now.getTime() < (20 /*hours*/ * 60 /*minutes*/ * 60 /*seconds*/ * 1000 /*milliseconds*/)) {
|
||||
human = date.toLocaleTimeString()
|
||||
}
|
||||
// Otherwise, display the full date
|
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;
|
||||
}
|
||||
|
||||
.toolbar-top {
|
||||
.toolbarTop {
|
||||
top: 8px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar-bottom {
|
||||
.toolbarBottom {
|
||||
bottom: 8px;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
.toolbarLeft {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
.toolbarRight {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.toolbar-vadapt {
|
||||
.toolbarVadapt {
|
||||
top: 8px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.toolbar-vadapt {
|
||||
.toolbarVadapt {
|
||||
top: unset;
|
||||
bottom: 8px;
|
||||
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-tool {
|
||||
width: 50px !important;
|
||||
height: 50px !important;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
@media (pointer: fine) {
|
||||
.toolbar-tool {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
font-size: medium;
|
||||
}
|
||||
}
|
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,6 +1,6 @@
|
|||
.view-content {
|
||||
.viewContent {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
grid-template-areas:
|
||||
"title"
|
||||
"content"
|
||||
;
|
||||
|
@ -13,11 +13,11 @@
|
|||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.view-content-title {
|
||||
.viewContentTitle {
|
||||
grid-area: title;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.view-content-content {
|
||||
.viewContentContent {
|
||||
grid-area: content;
|
||||
}
|
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,6 +1,6 @@
|
|||
.view-landing {
|
||||
.viewLanding {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
grid-template-areas:
|
||||
"titles"
|
||||
"actions"
|
||||
;
|
||||
|
@ -13,30 +13,30 @@
|
|||
gap: 32px;
|
||||
}
|
||||
|
||||
.view-landing-titles {
|
||||
.viewLandingTitles {
|
||||
grid-area: titles;
|
||||
text-shadow: 2px 2px 4px var(--background);
|
||||
}
|
||||
|
||||
.view-landing-titles-title {
|
||||
.viewLandingTitlesTitle {
|
||||
font-size: 10rem;
|
||||
}
|
||||
|
||||
.view-landing-titles-subtitle {
|
||||
.viewLandingTitlesSubtitle {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) or (max-height: 600px) {
|
||||
.view-landing-titles-title {
|
||||
.viewLandingTitlesTitle {
|
||||
font-size: 5rem;
|
||||
}
|
||||
|
||||
.view-landing-titles-subtitle {
|
||||
.viewLandingTitlesSubtitle {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.view-landing-actions {
|
||||
.viewLandingActions {
|
||||
grid-area: actions;
|
||||
align-self: start;
|
||||
justify-self: center;
|
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);
|
||||
|
||||
/* 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";
|
||||
|
||||
|
||||
/**
|
||||
* The string to be used as the [`background-image`](https://developer.mozilla.org/en-US/docs/Web/CSS/background-image) CSS property of the postcard.
|
||||
* The string to be used as the `src` of the postcard.
|
||||
*/
|
||||
export type PostcardImage = string;
|
||||
export type PostcardSource = ImageProps["src"]
|
||||
|
||||
|
||||
/**
|
||||
|
@ -26,9 +27,9 @@ export enum PostcardVisibility {
|
|||
/**
|
||||
* Contents of the {@link PostcardContext}.
|
||||
*/
|
||||
type PostcardContextValue = {
|
||||
image: PostcardImage,
|
||||
setImage: React.Dispatch<React.SetStateAction<PostcardImage>>,
|
||||
export type PostcardContextContents = {
|
||||
src: PostcardSource,
|
||||
setSrc: React.Dispatch<React.SetStateAction<PostcardSource>>,
|
||||
visibility: 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.
|
||||
*/
|
||||
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;
|
||||
height: 100vh;
|
||||
|
||||
background-attachment: fixed;
|
||||
background-size: cover;
|
||||
background-position: 50% 50%;
|
||||
object-fit: cover;
|
||||
object-position: 50% 50%;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
@ -12,20 +11,22 @@
|
|||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.postcard-background {
|
||||
.postcardBackground {
|
||||
z-index: -1;
|
||||
filter: blur(7px) contrast(50%) brightness(50%);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.postcard-background {
|
||||
.postcardBackground {
|
||||
filter: blur(7px) contrast(25%) brightness(175%);
|
||||
}
|
||||
}
|
||||
|
||||
.postcard-foreground {
|
||||
.postcardForeground {
|
||||
z-index: 1;
|
||||
filter: none;
|
||||
}
|
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 { useTranslation } from "next-i18next"
|
||||
import { useDefinedContext } from "../../utils/definedContext"
|
||||
import { FestaIcon } from "../extensions/FestaIcon"
|
||||
import { PostcardContext, PostcardVisibility } from "../postcard/PostcardContext"
|
||||
import { useDefinedContext } from "../../../utils/definedContext"
|
||||
import { FestaIcon } from "../../generic/renderers/fontawesome"
|
||||
import { Tool } from "../../generic/toolbar/tool"
|
||||
import { PostcardContext, PostcardVisibility } from "../base"
|
||||
|
||||
export function ToolToggleVisible() {
|
||||
|
||||
/**
|
||||
* Toolbar tool which toggles the {@link PostcardVisibility} state of its wrapping context.
|
||||
*/
|
||||
export function ToolToggleVisibility() {
|
||||
const { t } = useTranslation()
|
||||
const { visibility, setVisibility } = useDefinedContext(PostcardContext)
|
||||
|
||||
if (visibility === PostcardVisibility.BACKGROUND) {
|
||||
return (
|
||||
<button
|
||||
<Tool
|
||||
aria-label={t("toggleVisibleShow")}
|
||||
onClick={() => setVisibility(PostcardVisibility.FOREGROUND)}
|
||||
className="toolbar-tool"
|
||||
>
|
||||
<FestaIcon icon={faEye} />
|
||||
</button>
|
||||
</Tool>
|
||||
)
|
||||
}
|
||||
else {
|
||||
return (
|
||||
<button
|
||||
<Tool
|
||||
aria-label={t("toggleVisibleHide")}
|
||||
onClick={() => setVisibility(PostcardVisibility.BACKGROUND)}
|
||||
className="toolbar-tool"
|
||||
>
|
||||
<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) {
|
||||
|
||||
if (used.loader?.match?.(/[/]css-loader/)) {
|
||||
if (used.loader?.match?.(/.*[/]css-loader.*/)) {
|
||||
|
||||
let modules = used.loader.options?.modules
|
||||
if (used.options?.modules) {
|
||||
|
||||
if (modules) {
|
||||
let { getLocalIdent, ...modules } = modules
|
||||
if (used.options.modules.getLocalIdent) {
|
||||
|
||||
modules.localIdentName = "[name]-[local]"
|
||||
used.options.modules.getLocalIdent = (context, localIdentName, localName) => `festa__${localName}`
|
||||
|
||||
}
|
||||
|
||||
used.loader.options.modules = modules
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -60,6 +60,7 @@ function webpack(config) {
|
|||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {
|
||||
experimental: { images: { layoutRaw: true } },
|
||||
reactStrictMode: true,
|
||||
webpack,
|
||||
i18n,
|
||||
|
|
|
@ -17,9 +17,9 @@
|
|||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
"@fortawesome/react-fontawesome": "^0.1.18",
|
||||
"@prisma/client": "^3.15.0",
|
||||
"ajv": "^8.11.0",
|
||||
"axios": "^0.27.2",
|
||||
"classnames": "^2.3.1",
|
||||
"cors": "^2.8.5",
|
||||
"crypto-random-string": "^5.0.0",
|
||||
"next": "12.1.6",
|
||||
"next-i18next": "^11.0.0",
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { NextPageContext } from "next";
|
||||
import { NextPage, NextPageContext } from "next";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import { default as Link } from "next/link";
|
||||
import { ErrorBlock } from "../components/errors/ErrorBlock";
|
||||
import { usePostcardImage } from "../components/postcard/usePostcardImage";
|
||||
import { ViewNotice } from "../components/view/ViewNotice";
|
||||
import { ErrorBlock } from "../components/generic/errors/renderers";
|
||||
import { ViewNotice } from "../components/generic/views/notice";
|
||||
import { Postcard } from "../components/postcard/changer";
|
||||
import errorPostcard from "../public/postcards/markus-spiske-iar-afB0QQw-unsplash-red.jpg"
|
||||
|
||||
|
||||
|
@ -17,12 +17,13 @@ export async function getStaticProps(context: NextPageContext) {
|
|||
}
|
||||
|
||||
|
||||
export default function Page404() {
|
||||
const Page404: NextPage = (props) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
usePostcardImage(`url(${errorPostcard.src})`)
|
||||
|
||||
return <>
|
||||
<Postcard
|
||||
src={errorPostcard}
|
||||
/>
|
||||
<ViewNotice
|
||||
notice={<>
|
||||
<ErrorBlock
|
||||
|
@ -30,9 +31,13 @@ export default function Page404() {
|
|||
error={new Error("HTTP 404 (Not found)")}
|
||||
/>
|
||||
<p>
|
||||
<Link href="/"><a>← {t("notFoundBackHome")}</a></Link>
|
||||
<Link href="/"><a>
|
||||
← {t("notFoundBackHome")}
|
||||
</a></Link>
|
||||
</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