1
Fork 0
mirror of https://github.com/Steffo99/festa.git synced 2024-10-16 06:57:26 +00:00

Refactor huge chunk of code

This commit is contained in:
Steffo 2022-06-11 05:08:49 +02:00
parent bbe4f33827
commit 990e7e1d7e
Signed by: steffo
GPG key ID: 6965406171929D01
135 changed files with 2908 additions and 1945 deletions

8
.vscode/launch.json vendored
View file

@ -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",

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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")}
/>
)
}

View file

@ -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}
</>
}

View file

@ -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>
}

View file

@ -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/>
&nbsp;
{props.text}
</span>
)
}

View file

@ -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
View 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`

View file

@ -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>
)
}

View file

@ -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>
)
}

View 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
View 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>()

View 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 }
}

View 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];
}

View file

@ -0,0 +1,3 @@
.telegramLoginButtonContainer > div {
height: 40px;
}

View 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>
)
})

View file

@ -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
}
}

View 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
};

View file

@ -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>()

View file

@ -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
}

View file

@ -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} />
}
/>
)
}

View file

@ -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}
/>
}
/>
)
}

View file

@ -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={<></>}
/>
)
}

View file

@ -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} />
}
/>
)
}

View file

@ -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>
}
/>
)
}

View file

@ -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>()

View file

@ -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.

View file

@ -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} />
&nbsp;
<span>
{props.text}
</span>
</p>
<pre>
<code>
<b>{props.error.name}</b>
:&nbsp;
{props.error.message}
</code>
</pre>
</div>
)
}

View file

@ -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
}
}
}

View file

@ -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} />
&nbsp;
{props.text ?
<>
<span>
{props.text}
</span>
&nbsp;
</>
: null}
<code lang="json">
{JSON.stringify(props.error)}
</code>
</span>
)
}

View file

@ -0,0 +1,3 @@
# Event
This directory contains components related to the event details page.

View 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>
)
}
}

View file

@ -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;
}

View 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>
)
}

View file

@ -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}
/>
)
}

View file

@ -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>
)
}

View file

@ -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.

View file

@ -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,
)}
/>
)
})

View file

@ -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>
)
}

View file

@ -0,0 +1,3 @@
# Generic components
This directory contains components shared between multiple pages.

View 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.

View 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]
}

View 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)} />
}
/>
)
}

View file

@ -0,0 +1,3 @@
# Errors
This directory contains components that handle and display errors occurring in the application.

View 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
}
}
}

View file

@ -1,6 +1,8 @@
.error-block pre {
.errorBlock > pre {
display: inline-block;
margin: 0;
max-width: 100%;
text-align: left;
}
}

View 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>
:&nbsp;
{json}
</code>
)
}
return (
<code>
<b>HTTP {props.error.response.status}</b>
:&nbsp;
{props.error.message}
</code>
)
}
return (
<code>
<b>{props.error.code}</b>
:&nbsp;
{props.error.message}
</code>
)
}
return (
<code>
<b>{props.error.name}</b>
:&nbsp;
{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} />
&nbsp;
{props.text ?
<>
<span>
{props.text}
</span>
&nbsp;
</>
: 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} />
&nbsp;
<span>
{props.text}
</span>
</p>
<pre>
<ErrorTrace error={props.error} inline={false} />
</pre>
</div>
)
})

View file

@ -0,0 +1,8 @@
.layoutMonorow {
display: flex;
flex-direction: row;
gap: 4px;
justify-content: flex-start;
align-items: center;
}

View 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)}
/>
)
}

View 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! })
}
}

View 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()
}

View 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 />
&nbsp;
{props.text}
</span>
)
})

View file

@ -0,0 +1,3 @@
# Generic renderers
These components render raw data in HTML format for usage in other components.

View file

@ -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

View 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)}
/>
)
})

View 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>
)
})

View 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]
}

View 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];
}

View file

@ -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;
}
}

View 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,
)}
/>
)
})

View 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;
}
}

View 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,
)}
/>
)
})

View file

@ -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;
}

View 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>
)
})

View file

@ -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;

View 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>
)
})

View 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>
)
})

View file

@ -1,4 +1,4 @@
.work-in-progress {
.wipBanner {
color: var(--warning);
/* TODO: Make this based on --warning. */

View 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>
)
}

View file

@ -0,0 +1,3 @@
# Landing
This directory contains components related to the landing page.

View 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;
}

View 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>
)
})
}

View 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>
),
})
}

View file

@ -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,
}}
/>
)
}

View file

@ -0,0 +1,3 @@
# Postcard
The postcard is the image rendered as background of the website.

View file

@ -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>()

View 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
}

View file

@ -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;
}

View 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,
)}
/>
)
}

View 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,
};
}

View file

@ -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>
)
}
}
}

View file

@ -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]
)
}

View file

@ -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,
}
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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}`)
}

View file

@ -1,6 +0,0 @@
import { Event } from "@prisma/client";
import { default as useSWR } from "swr";
export function useMyEventsSWR() {
return useSWR<Event[]>("/api/events/mine")
}

View file

@ -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]
)
}

View file

@ -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,
}
}

View file

@ -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 }
}

View file

@ -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]
)
}

View file

@ -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,

View file

@ -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",

View file

@ -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