diff options
| author | Nicolas James <Eele1Ephe7uZahRie@tutanota.com> | 2025-02-13 18:04:18 +1100 |
|---|---|---|
| committer | Nicolas James <Eele1Ephe7uZahRie@tutanota.com> | 2025-02-13 18:04:18 +1100 |
| commit | 93dfe2be64e8658839bcfe5356adf35f8cde7075 (patch) | |
| tree | c60b1e20d569b74dbde85123e1b2bf3590c66244 /src/web/components/submission | |
initial commit
Diffstat (limited to 'src/web/components/submission')
| -rw-r--r-- | src/web/components/submission/Submission.css | 146 | ||||
| -rw-r--r-- | src/web/components/submission/Submission.jsx | 221 |
2 files changed, 367 insertions, 0 deletions
diff --git a/src/web/components/submission/Submission.css b/src/web/components/submission/Submission.css new file mode 100644 index 0000000..a170132 --- /dev/null +++ b/src/web/components/submission/Submission.css @@ -0,0 +1,146 @@ +.container { + position: absolute; + inset: 0; + + width: 100%; + height: 100%; + padding: 0em 1em; + + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: flex-end; + + visibility: hidden; + overflow: hidden; +} + +.card { + --submission-height: 37em; + + position: relative; + + width: 100%; + padding: 1em; + height: var(--submission-height); + + color: var(--black-98); + background-color: var(--black-10); + border-radius: 0; + border-top: 0.25em solid var(--brand-80); + box-shadow: unset; + + visibility: initial; + translate: 0 var(--submission-height); + + transition: translate var(--transition-time) ease-in-out; +} +.cardActive { + translate: 0 0; +} + +.tabContainer { + font-size: 5em; + + position: absolute; + top: -0.5em; + right: 0em; + + width: var(--tab-size); + height: 0.5em; + + overflow: hidden; +} +.tab { + --tab-size: 1em; + width: var(--tab-size); + height: var(--tab-size); + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + padding-bottom: calc(0.5 * var(--tab-size)); + + border-radius: 1em; + background-color: var(--brand-80); + cursor: pointer; +} +.tab:hover { + color: white; +} +.tabIcon { + position: absolute; + + width: calc(0.4 * var(--tab-size)); + height: calc(0.4 * var(--tab-size)); + + background-color: transparent; + opacity: 100%; + + transition: opacity var(--transition-time) linear; +} +.tabIconHidden { + opacity: 0%; +} + +.card h1 { + font-size: 1.8em; + font-weight: bolder; + text-align: center; +} + +form { + display: flex; + flex-direction: column; +} +.contentsInput textarea { + min-height: 15em; + border-radius: 1em; +} +.fileInput input { + border: unset; + margin-bottom: 0; +} + +.submissionButton { + margin: 0.5rem auto 0; + padding: 0.25em 2em; + min-height: 2.3em; + min-width: 8em; + + display: flex; + justify-content: center; + align-items: center; + + font-size: 1.25em; +} + +.error { + margin-top: 0.5em; + min-height: 2em; + + display: flex; + justify-content: center; + align-items: center; + + visibility: hidden; +} +.errorActive{ + visibility: visible; +} +.error p { + padding: 0.5em 1em; + + color: var(--brand-80); + background-color: var(--black-15); + border-radius: 1em; + font-style: italic; + font-weight: 300; + text-transform: uppercase; +} + +.loading { + filter: invert(100%); +}
\ No newline at end of file diff --git a/src/web/components/submission/Submission.jsx b/src/web/components/submission/Submission.jsx new file mode 100644 index 0000000..9fc080e --- /dev/null +++ b/src/web/components/submission/Submission.jsx @@ -0,0 +1,221 @@ +import React, {useEffect, useState, useContext} from "react"; +import {useLocation} from "react-router-dom"; +import {encode} from "base64-arraybuffer"; + +import {getSubreactFromLocation} from "helpers/Location.jsx"; +import {UserContext} from "contexts/UserContext.jsx"; +import {StateContext} from "contexts/StateContext.jsx"; + +import Card from "components/card/Card.jsx"; +import Button from "components/button/Button.jsx"; +import Input from "components/input/Input.jsx"; +import Loading from "components/loading/Loading.jsx"; + +import styles from "./Submission.css"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faMinus, faPlus} from "@fortawesome/free-solid-svg-icons"; +import {getFieldError, correctFieldAnimate, isSupportedExtension} from "helpers/Input.jsx"; + +const maxFileSize = 4 * 1000 * 1000; // 4MB max size + +const validateTitle = (title) => getFieldError(title?.target?.value, [3, 128]); +const validateContents = (contents) => getFieldError(contents?.target?.value, [5, 3000]); +function validateFile(file) { + const files = file?.target?.files; + if (files == null || files.length == 0) { + return "Required"; + } + const f = files.item(0); + if (f.size > maxFileSize) { // 4MB + return "File too large, exceeded 4MB"; + } + const ext = f.name.split(".")?.pop()?.toLowerCase(); + if (!isSupportedExtension(ext)) { + return "Unsupported image extension"; + } + return null; +} + +function ErrorMessage({message}) { + return ( + <div className={styles.error + (message != null ? " " + styles.errorActive : "")}> + <p> + {message} + </p> + </div> + ); +} + +function RequestButton({promise, setPromise, title, setTitle, contents, setContents, + file, setFile, setError, className, children, setSubmissionActive, subreact}) { + const onClick = (event) => { + event.preventDefault(); + if (promise != null) { + return; + } + setError(null); + + const titleErrors = validateTitle(title); + const contentsErrors = validateContents(contents); + const fileErrors = validateFile(file); + if (titleErrors != null || contentsErrors != null || fileErrors != null) { + correctFieldAnimate(title, setTitle, titleErrors); + correctFieldAnimate(contents, setContents, contentsErrors); + correctFieldAnimate(file, setFile, fileErrors); + return; + } + + setPromise(file.target.files.item(0).arrayBuffer() + .then((buffer) => { + const init = { + method: "POST", + headers: { + 'Content-Type': "application/json" + }, + body: JSON.stringify({ + "title": title.target.value, + "contents": contents.target.value, + "subreact": subreact, + "file": encode(buffer), + }), + referrer: "same-origin", + }; + return fetch("/api/post", init); + }) + .then((response) => { + if (response.ok) { + setSubmissionActive(false); + setContents(null); + setTitle(null); + setFile(null); + return; + } + + return response.json() + .then((json) => { + if ("error" in json) { + throw new Error(json.error); + } + throw new Error("unexpected data received"); + }); + }) + .catch((error) => { + console.log(error); + setError(error.message); + }) + .finally(() => { + setPromise(null); + })); + } + + return ( + <Button className={className} onClick={onClick}> + {promise == null ? children : <Loading className={styles.loading} />} + </Button> + ); +} + +function TabIcon({icon, submissionActive, isDefault}) { + return ( + <FontAwesomeIcon icon={icon} + className={styles.tabIcon + + (!isDefault == submissionActive ? " " + styles.tabIconHidden : "")} /> + ); +} + +function Tab({submissionActive, setSubmissionActive, user, setLoginActive}) { + const [animate, setAnimate] = useState(false); + + const onClick = () => { + if (user === null) { + setLoginActive(true); + return; + } + setAnimate(true); + setSubmissionActive(!submissionActive); + }; + + return ( + <div className={styles.tabContainer}> + <div className={styles.tab + (animate ? " " + styles.fade : "")} onClick={onClick}> + <TabIcon icon={faMinus} submissionActive={submissionActive} isDefault /> + <TabIcon icon={faPlus} submissionActive={submissionActive} /> + </div> + </div> + ); +} + +function SubmissionForm({setSubmissionActive, setError, subreact}) { + const [promise, setPromise] = useState(null); + const [title, setTitle] = useState(null); + const [contents, setContents] = useState(null); + const [file, setFile] = useState(null); + + return ( + <form> + <Input type="text" + id="title" + title="Title" + field={title} + setField={setTitle} + validateField={validateTitle} /> + + <Input className={styles.contentsInput} + inputType="textarea" + type="text" + title="Contents" + field={contents} + setField={setContents} + validateField={validateContents} /> + + <Input className={styles.fileInput} + inputType="file" + type="file" + title="Upload image" + field={file} + setField={setFile} + validateField={validateFile}/> + + <RequestButton className={styles.submissionButton} + setError={setError} setSubmissionActive={setSubmissionActive} + promise={promise} setPromise={setPromise} + title={title} setTitle={setTitle} + contents={contents} setContents={setContents} + file={file} setFile={setFile} subreact={subreact}> + + Submit + </RequestButton> + </form> + ); +} + +export default function Submission({active, setActive}) { + const [error, setError] = useState(null); + + const location = useLocation(); + const subreact = getSubreactFromLocation(location); + + const [user] = useContext(UserContext); + + const [state, setState] = useContext(StateContext); + const setLoginActive = (active) => {setState({...state, loginActive: active})}; + + // If our subreact is the front page, disable our ability to post. Posting + // should be done to the main subreact. + useEffect(() => { + if (subreact === "") { + setActive(false); + } + }, [location]); + + return ( + <div className={styles.container}> + <Card className={styles.card + (active ? " " + styles.cardActive : "")}> + <Tab submissionActive={active} setSubmissionActive={setActive} user={user} setLoginActive={setLoginActive} /> + <h1>Make a new submission</h1> + <ErrorMessage message={error} /> + <SubmissionForm setSubmissionActive={setActive} setError={setError} subreact={subreact}/> + </Card> + </div> + ); +}
\ No newline at end of file |
