aboutsummaryrefslogtreecommitdiff
path: root/src/web/components/submission
diff options
context:
space:
mode:
Diffstat (limited to 'src/web/components/submission')
-rw-r--r--src/web/components/submission/Submission.css146
-rw-r--r--src/web/components/submission/Submission.jsx221
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