aboutsummaryrefslogtreecommitdiffstats
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/HeaderBar/index.tsx139
-rw-r--r--src/components/HeaderBar/logo.svg3
-rw-r--r--src/components/InputTypes/Checkbox.tsx65
-rw-r--r--src/components/InputTypes/Code.tsx11
-rw-r--r--src/components/InputTypes/Radio.tsx39
-rw-r--r--src/components/InputTypes/Range.tsx117
-rw-r--r--src/components/InputTypes/Select.tsx204
-rw-r--r--src/components/InputTypes/ShortText.tsx12
-rw-r--r--src/components/InputTypes/TextArea.tsx21
-rw-r--r--src/components/InputTypes/index.tsx71
-rw-r--r--src/components/Question.tsx166
-rw-r--r--src/components/ScrollToTop.tsx93
12 files changed, 901 insertions, 40 deletions
diff --git a/src/components/HeaderBar/index.tsx b/src/components/HeaderBar/index.tsx
index dfe3957..5c536ef 100644
--- a/src/components/HeaderBar/index.tsx
+++ b/src/components/HeaderBar/index.tsx
@@ -1,59 +1,118 @@
/** @jsx jsx */
-import { css, jsx } from "@emotion/react";
+import { jsx, css } from "@emotion/react";
import Header1 from "./header_1.svg";
import Header2 from "./header_2.svg";
+import Logo from "./logo.svg";
+
+import { Link } from "react-router-dom";
interface HeaderBarProps {
- title?: string
+ title?: string
+ description?: string
}
const headerImageStyles = css`
-z-index: -1;
-top: 0;
-position: absolute;
-width: 100%;
-transition: height 1s;
+ * {
+ z-index: -1;
+ top: 0;
+ position: absolute;
+ width: 100%;
+ transition: height 1s;
+ }
+`;
+
+const headerTextStyles = css`
+ transition: margin 1s;
+ font-family: "Uni Sans", "Hind", "Arial", sans-serif;
+
+ margin: 0 2rem 10rem 2rem;
+
+ .title {
+ font-size: 3vmax;
+ margin-bottom: 0;
+ }
+
+ .full_size {
+ line-height: 200%;
+ }
+
+ .description {
+ font-size: 1.5vmax;
+ }
+
+ .title, .description {
+ transition: font-size 1s;
+ }
+
+ @media (max-width: 480px) {
+ margin-top: 7rem;
+ text-align: center;
+
+ .title {
+ font-size: 5vmax;
+ }
+
+ .full_size {
+ line-height: 100%;
+ }
+
+ .description {
+ font-size: 2vmax;
+ }
+ }
+`;
+
+const homeButtonStyles = css`
+ svg {
+ transform: scale(0.25);
+ transition: top 300ms, transform 300ms;
+
+ @media (max-width: 480px) {
+ transform: scale(0.15);
+ }
+ }
+
+ * {
+ position: absolute;
+ top: -10rem;
+ right: 1rem;
+
+ z-index: 0;
+ transform-origin: right;
+
+ @media (max-width: 700px) {
+ top: -11.5rem;
+ }
+
+ @media (max-width: 480px) {
+ top: -12.5rem;
+ }
+ }
`;
-function HeaderBar({ title }: HeaderBarProps): JSX.Element {
+function HeaderBar({ title, description }: HeaderBarProps): JSX.Element {
if (!title) {
title = "Python Discord Forms";
}
-
- return <div>
+
+ return (
<div>
- <Header1 css={headerImageStyles}/>
- <Header2 css={headerImageStyles}/>
+ <div css={headerImageStyles}>
+ <Header1/>
+ <Header2/>
+ </div>
+
+ <div css={css`${headerTextStyles}; margin-bottom: 12.5%;`}>
+ <h1 className={description ? "title" : "title full_size"}>{title}</h1>
+ {description ? <h1 className="description">{description}</h1> : null}
+ </div>
+
+ <Link to="/" css={homeButtonStyles}>
+ <Logo/>
+ </Link>
</div>
- <h1 css={css`
- font-size: 4vw;
- margin: 0;
- margin-top: 30px;
- margin-left: 30px;
- margin-bottom: 200px;
- transition-property: font-size, margin-bottom;
- transition-duration: 1s;
- font-family: "Uni Sans", "Hind", "Arial", sans-serif;
-
- @media (max-width: 1000px) {
- margin-top: 15px;
- font-size: 8vw;
- }
-
- @media (max-width: 770px) {
- margin-top: 15px;
- font-size: 6vw;
- margin-bottom: 100px;
- }
- @media (max-width: 450px) {
- text-align: center;
- margin-left: 0;
- }
- `}>
- {title}
- </h1>
- </div>;
+ );
}
export default HeaderBar;
diff --git a/src/components/HeaderBar/logo.svg b/src/components/HeaderBar/logo.svg
new file mode 100644
index 0000000..e7f43fc
--- /dev/null
+++ b/src/components/HeaderBar/logo.svg
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="463.86" height="463.86" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata>
+ <path d="m296.09 80.929-11.506 3.3086c-12.223-1.8051-24.757-2.597-36.895-2.5078-13.5 0.1-26.498 1.1992-37.898 3.1992-3.6998 0.65161-7.0474 1.386-10.107 2.1992h-35.893v18.801h4.5v7.6992h2.7832c-0.63936 3.7142-0.88476 7.7997-0.88476 12.301v4h-15.398l-2.2012 11 17.6 15.014v0.0859h79.201v10h-79.201-29.699c-23 0-43.2 13.8-49.5 40-7.3 30-7.6 48.801 0 80.201 0.49734 2.0782 1.0605 4.0985 1.6836 6.0625l-1.1836 10.438 13.346 11.549c7.032 7.5103 16.371 11.951 28.254 11.951h27.201v-36c0-26 22.6-49 49.5-49h79.199c22 0 39.6-18.102 39.6-40.102v-75.199c0-12.9-6.5819-23.831-16.516-31.273zm76.801 77.6-14.301 7.4004h-20.1v0.0996 10.301 24.699c0 27.2-23.1 50-49.5 50h-79.1c-21.7 0-39.6 18.5-39.6 40.1v75.102c0 21.4 18.7 34 39.6 40.1 25.1 7.3 49.1 8.7 79.1 0 19.9-5.7 39.6-17.3 39.6-40.1v-27.9-2.0996-0.10156h-0.11914l-11.721-10h11.84 39.6c23 0 31.602-16 39.602-40 8.3-24.7 7.9-48.499 0-80.199-3.6226-14.491-9.3525-26.71-18.947-33.699zm-123.4 167.6h57.5v10h-57.5z" fill-opacity=".20554"/><path class="st2" d="m229.99 66.629c-13.5 0.1-26.5 1.2-37.9 3.2-33.5 5.9-39.6 18.2-39.6 41v30.1h79.2v10h-79.2-29.7c-23 0-43.2 13.8-49.5 40-7.3 30-7.6 48.8 0 80.2 5.6 23.4 19.1 40 42.1 40h27.2v-36c0-26 22.6-49 49.5-49h79.1c22 0 39.6-18.1 39.6-40.1v-75.2c0-21.4-18.1-37.4-39.6-41-13.5-2.3-27.6-3.3-41.2-3.2zm-42.8 24.2c8.2 0 14.9 6.8 14.9 15.1 0 8.3-6.7 15-14.9 15s-14.9-6.7-14.9-15c0-8.4 6.7-15.1 14.9-15.1z" fill="#cbd6ff"/><path class="st3" d="m320.79 150.93v35c0 27.2-23.1 50-49.5 50h-79.1c-21.7 0-39.6 18.5-39.6 40.1v75.1c0 21.4 18.7 34 39.6 40.1 25.1 7.3 49.1 8.7 79.1 0 19.9-5.7 39.6-17.3 39.6-40.1v-30.1h-79.1v-10h79.1 39.6c23 0 31.6-16 39.6-40 8.3-24.7 7.9-48.5 0-80.2-5.7-22.8-16.6-40-39.6-40h-29.7zm-44.5 190.2c8.2 0 14.9 6.7 14.9 15s-6.7 15.1-14.9 15.1-14.9-6.8-14.9-15.1 6.7-15 14.9-15z" fill="#fff"/></svg>
diff --git a/src/components/InputTypes/Checkbox.tsx b/src/components/InputTypes/Checkbox.tsx
new file mode 100644
index 0000000..3093caf
--- /dev/null
+++ b/src/components/InputTypes/Checkbox.tsx
@@ -0,0 +1,65 @@
+/** @jsx jsx */
+import { jsx, css } from "@emotion/react";
+import React, { ChangeEvent } from "react";
+import colors from "../../colors";
+import { multiSelectInput, hiddenInput } from "../../commonStyles";
+
+interface CheckboxProps {
+ index: number,
+ option: string,
+ handler: (event: ChangeEvent<HTMLInputElement>) => void
+}
+
+const generalStyles = css`
+ cursor: pointer;
+
+ label {
+ width: 1em;
+ height: 1em;
+ top: 0.3rem;
+
+ border-radius: 25%;
+ cursor: pointer;
+ }
+
+ .unselected {
+ background-color: white;
+ }
+
+ .unselected:focus-within, :hover .unselected {
+ background-color: lightgray;
+ }
+
+ .checkmark {
+ position: absolute;
+ }
+`;
+
+const activeStyles = css`
+ .selected {
+ background-color: ${colors.blurple};
+ }
+
+ .selected .checkmark {
+ width: 0.30rem;
+ height: 0.60rem;
+ left: 0.25em;
+
+ border: solid white;
+ border-width: 0 0.2rem 0.2rem 0;
+
+ transform: rotate(45deg);
+ }
+`;
+
+export default function Checkbox(props: CheckboxProps): JSX.Element {
+ return (
+ <label css={[generalStyles, activeStyles]}>
+ <label className="unselected" css={multiSelectInput}>
+ <input type="checkbox" value={props.option} css={hiddenInput} name={`${("000" + props.index).slice(-4)}. ${props.option}`} onChange={props.handler}/>
+ <span className="checkmark"/>
+ </label>
+ {props.option}<br/>
+ </label>
+ );
+}
diff --git a/src/components/InputTypes/Code.tsx b/src/components/InputTypes/Code.tsx
new file mode 100644
index 0000000..51ca98d
--- /dev/null
+++ b/src/components/InputTypes/Code.tsx
@@ -0,0 +1,11 @@
+/** @jsx jsx */
+import { jsx } from "@emotion/react";
+import React, { ChangeEvent } from "react";
+
+interface CodeProps {
+ handler: (event: ChangeEvent<HTMLInputElement>) => void
+}
+
+export default function Code(props: CodeProps): JSX.Element {
+ return <input type="text" className="text" onChange={props.handler}/>;
+}
diff --git a/src/components/InputTypes/Radio.tsx b/src/components/InputTypes/Radio.tsx
new file mode 100644
index 0000000..3bf13ed
--- /dev/null
+++ b/src/components/InputTypes/Radio.tsx
@@ -0,0 +1,39 @@
+/** @jsx jsx */
+import { jsx, css } from "@emotion/react";
+import React, { ChangeEvent } from "react";
+import colors from "../../colors";
+import { multiSelectInput, hiddenInput } from "../../commonStyles";
+
+interface RadioProps {
+ option: string,
+ question_id: string,
+ handler: (event: ChangeEvent<HTMLInputElement>) => void
+}
+
+const styles = css`
+ div {
+ width: 0.7em;
+ height: 0.75em;
+ top: 0.18rem;
+
+ border-radius: 50%;
+ }
+
+ :hover div, :focus-within div {
+ background-color: lightgray;
+ }
+
+ input:checked+div {
+ background-color: ${colors.blurple};
+ }
+`;
+
+export default function Radio(props: RadioProps): JSX.Element {
+ return (
+ <label css={styles}>
+ <input type="radio" name={props.question_id} onChange={props.handler} css={hiddenInput}/>
+ <div css={multiSelectInput}/>
+ {props.option}<br/>
+ </label>
+ );
+}
diff --git a/src/components/InputTypes/Range.tsx b/src/components/InputTypes/Range.tsx
new file mode 100644
index 0000000..e2f89f4
--- /dev/null
+++ b/src/components/InputTypes/Range.tsx
@@ -0,0 +1,117 @@
+/** @jsx jsx */
+import { jsx, css } from "@emotion/react";
+import React, { ChangeEvent } from "react";
+import colors from "../../colors";
+import { hiddenInput, multiSelectInput } from "../../commonStyles";
+
+interface RangeProps {
+ question_id: string,
+ options: Array<string>,
+ handler: (event: ChangeEvent<HTMLInputElement>) => void
+}
+
+const containerStyles = css`
+ display: flex;
+ justify-content: space-between;
+ position: relative;
+ width: 100%;
+
+ @media (max-width: 800px) {
+ width: 20%;
+ display: block;
+ margin: 0 auto;
+
+ label span {
+ margin-left: 0;
+ transform: translateY(1.6rem) translateX(2rem);
+ }
+ }
+`;
+
+const optionStyles = css`
+ display: inline-block;
+ transform: translateX(-50%);
+ margin: 0 50%;
+
+ white-space: nowrap;
+
+ transition: transform 300ms;
+`;
+
+const selectorStyles = css`
+ cursor: pointer;
+
+ div {
+ width: 1rem;
+ height: 1rem;
+
+ background-color: whitesmoke;
+
+ border-radius: 50%;
+ margin: 0 100% 0 0;
+ }
+
+ :hover div, :focus-within div {
+ background-color: lightgray;
+ }
+
+ input:checked+div {
+ background-color: ${colors.blurple};
+ }
+`;
+
+const sliderContainerStyles = css`
+ display: flex;
+ justify-content: center;
+ width: 100%;
+
+ position: absolute;
+ z-index: -1;
+
+ top: 2.1rem;
+
+ transition: all 300ms;
+
+ @media (max-width: 800px) {
+ width: 0.5rem;
+ height: 80%;
+
+ left: 0.4rem;
+
+ background: whitesmoke;
+ }
+`;
+
+const sliderStyles = css`
+ width: 98%; /* Needs to be slightly smaller than container to work on all devices */
+ height: 0.5rem;
+ background-color: whitesmoke;
+
+ transition: transform 300ms;
+
+ @media (max-width: 800px) {
+ display: none;
+ }
+`;
+
+export default function Range(props: RangeProps): JSX.Element {
+ const range = props.options.map((option, index) => {
+ return (
+ <label css={[selectorStyles, css`width: 1rem`]} key={index}>
+ <span css={optionStyles}>{option}</span>
+ <input type="radio" name={props.question_id} css={hiddenInput} onChange={props.handler}/>
+ <div css={multiSelectInput}/>
+ </label>
+ );
+ });
+
+ return (
+ <div css={containerStyles}>
+ { range }
+
+ <div css={sliderContainerStyles}>
+ <div css={sliderStyles}/>
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/InputTypes/Select.tsx b/src/components/InputTypes/Select.tsx
new file mode 100644
index 0000000..e753357
--- /dev/null
+++ b/src/components/InputTypes/Select.tsx
@@ -0,0 +1,204 @@
+/** @jsx jsx */
+import { jsx, css } from "@emotion/react";
+import React from "react";
+import { hiddenInput } from "../../commonStyles";
+
+interface SelectProps {
+ options: Array<string>,
+ state_dict: Map<string, string | boolean | null>
+}
+
+const containerStyles = css`
+ position: relative;
+ width: min(20rem, 90%);
+
+ color: black;
+ cursor: pointer;
+
+ :focus-within .selected_container {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+
+ border-bottom-color: transparent;
+ }
+`;
+
+const mainWindowStyles = css`
+ display: inline-block;
+ position: relative;
+ background: whitesmoke;
+
+ width: 100%;
+ height: 100%;
+ min-height: 2.5rem;
+
+ margin-bottom: 0;
+
+ overflow: hidden;
+ z-index: 1;
+
+ :hover, :focus-within {
+ background-color: lightgray;
+ }
+
+ .selected_option {
+ position: absolute;
+ height: 100%;
+ width: 100%;
+
+ outline: none;
+ padding-left: 0.75rem;
+ line-height: 250%;
+ }
+
+ border: 0.1rem solid black;
+ border-radius: 8px;
+
+ transition: border-radius 200ms;
+`;
+
+const arrowStyles = css`
+ .arrow {
+ display: inline-block;
+ height: 0.5rem;
+ width: 0.5rem;
+
+ position: relative;
+ float: right;
+ right: 1em;
+ top: 0.7rem;
+
+ border: solid black;
+ border-width: 0 0.2rem 0.2rem 0;
+
+ transform: rotate(45deg);
+ transition: transform 200ms;
+ }
+
+ :focus-within .arrow {
+ transform: translateY(40%) rotate(225deg);
+ }
+`;
+
+const optionContainerStyles = css`
+ .option_container {
+ position: absolute;
+ width: 100%;
+ height: 0;
+
+ top: 2.3rem;
+ padding-top: 0.2rem;
+
+ visibility: hidden;
+ opacity: 0;
+
+ overflow: hidden;
+ background: whitesmoke;
+
+ border: 0.1rem solid black;
+ border-radius: 0 0 8px 8px;
+ border-top: none;
+
+ transition: opacity 200ms, visibility 200ms;
+
+ * {
+ cursor: pointer;
+ }
+ }
+
+ :focus-within .option_container {
+ height: auto;
+ visibility: visible;
+ opacity: 1;
+ }
+
+ .option_container .hidden {
+ display: none;
+ }
+`;
+
+const inputStyles = css`
+ position: absolute;
+ width: 100%;
+ height: 100%;
+
+ z-index: 2;
+
+ margin: 0;
+ border: none;
+ outline: none;
+`;
+
+const optionStyles = css`
+ position: relative;
+
+ :hover, :focus-within {
+ background-color: lightgray;
+ }
+
+ div {
+ padding: 0.75rem;
+ }
+`;
+
+class Select extends React.Component<SelectProps> {
+ handler(selected_option: React.RefObject<HTMLDivElement>, event: React.ChangeEvent<HTMLInputElement>): void {
+ const option_container = event.target.parentElement;
+ if (!option_container || !option_container.parentElement || !selected_option.current) {
+ return;
+ }
+
+ // Update stored value
+ this.props.state_dict.set("value", option_container.textContent);
+
+ // Close the menu
+ selected_option.current.focus();
+ selected_option.current.blur();
+ selected_option.current.textContent = option_container.textContent;
+ }
+
+ handle_click(
+ container: React.RefObject<HTMLDivElement>,
+ selected_option: React.RefObject<HTMLDivElement>,
+ event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>
+ ): void {
+ if (!container.current || !selected_option.current || (event.type === "keydown" && (event as React.KeyboardEvent).code !== "Space")) {
+ return;
+ }
+
+ // Check if menu is open
+ if (container.current.contains(document.activeElement)) {
+ // Close menu
+ selected_option.current.focus();
+ selected_option.current.blur();
+ event.preventDefault();
+ }
+ }
+
+ render(): JSX.Element {
+ const container_ref: React.RefObject<HTMLDivElement> = React.createRef();
+ const selected_option_ref: React.RefObject<HTMLDivElement> = React.createRef();
+
+ const handle_click = (event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => this.handle_click(container_ref, selected_option_ref, event);
+
+ return (
+ <div css={[containerStyles, arrowStyles, optionContainerStyles]} ref={container_ref}>
+ <div className="selected_container" css={mainWindowStyles}>
+ <span className="arrow"/>
+ <div tabIndex={0} className="selected_option" ref={selected_option_ref} onMouseDown={handle_click} onKeyDown={handle_click}>...</div>
+ </div>
+
+ <div className="option_container" tabIndex={-1} css={css`outline: none;`}>
+ { this.props.options.map((option, index) => (
+ <div key={index} css={optionStyles}>
+ <input type="checkbox" css={[hiddenInput, inputStyles]} onChange={event => this.handler.call(this, selected_option_ref, event)}/>
+ <div>{option}</div>
+ </div>
+ )) }
+ </div>
+ </div>
+ );
+ }
+}
+
+export default Select;
diff --git a/src/components/InputTypes/ShortText.tsx b/src/components/InputTypes/ShortText.tsx
new file mode 100644
index 0000000..1e38bcd
--- /dev/null
+++ b/src/components/InputTypes/ShortText.tsx
@@ -0,0 +1,12 @@
+/** @jsx jsx */
+import { jsx } from "@emotion/react";
+import React, { ChangeEvent } from "react";
+import { textInputs } from "../../commonStyles";
+
+interface ShortTextProps {
+ handler: (event: ChangeEvent<HTMLInputElement>) => void
+}
+
+export default function ShortText(props: ShortTextProps): JSX.Element {
+ return <input type="text" css={textInputs} placeholder="Enter Text..." onChange={props.handler}/>;
+}
diff --git a/src/components/InputTypes/TextArea.tsx b/src/components/InputTypes/TextArea.tsx
new file mode 100644
index 0000000..6e46c27
--- /dev/null
+++ b/src/components/InputTypes/TextArea.tsx
@@ -0,0 +1,21 @@
+/** @jsx jsx */
+import { jsx, css } from "@emotion/react";
+import React, { ChangeEvent } from "react";
+import { textInputs } from "../../commonStyles";
+
+interface TextAreaProps {
+ handler: (event: ChangeEvent<HTMLTextAreaElement>) => void
+}
+
+const styles = css`
+ min-height: 20rem;
+ width: 100%;
+ box-sizing: border-box;
+ resize: vertical;
+
+ padding: 1rem;
+`;
+
+export default function TextArea(props: TextAreaProps): JSX.Element {
+ return <textarea css={[textInputs, styles]} placeholder="Enter Text..." onChange={props.handler}/>;
+}
diff --git a/src/components/InputTypes/index.tsx b/src/components/InputTypes/index.tsx
new file mode 100644
index 0000000..f1e0b30
--- /dev/null
+++ b/src/components/InputTypes/index.tsx
@@ -0,0 +1,71 @@
+import Checkbox from "./Checkbox";
+import Code from "./Code";
+import Radio from "./Radio";
+import Range from "./Range";
+import Select from "./Select";
+import ShortText from "./ShortText";
+import TextArea from "./TextArea";
+
+import React, { ChangeEvent } from "react";
+
+import { QuestionType } from "../../api/question";
+import { QuestionProp } from "../Question";
+
+const require_options: Array<QuestionType> = [
+ QuestionType.Radio,
+ QuestionType.Checkbox,
+ QuestionType.Select,
+ QuestionType.Range
+];
+
+export default function create_input({ question, public_state }: QuestionProp, handler: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void): JSX.Element | JSX.Element[] {
+ let result: JSX.Element | JSX.Element[];
+
+ // eslint-disable-next-line
+ // @ts-ignore
+ let options: string[] = question.data["options"];
+
+ // Catch input types that require options but don't have any
+ if ((options === undefined || typeof options !== "object") && require_options.includes(question.type)) {
+ // TODO: Implement some sort of warning here
+ options = [];
+ }
+
+ /* eslint-disable react/react-in-jsx-scope */
+ switch (question.type) {
+ case QuestionType.TextArea:
+ result = <TextArea handler={handler}/>;
+ break;
+
+ case QuestionType.Checkbox:
+ result = options.map((option, index) => <Checkbox index={index} option={option} handler={handler} key={index}/>);
+ break;
+
+ case QuestionType.Radio:
+ result = options.map((option, index) => <Radio option={option} question_id={question.id} handler={handler} key={index}/>);
+ break;
+
+ case QuestionType.Select:
+ result = <Select options={options} state_dict={public_state}/>;
+ break;
+
+ case QuestionType.ShortText:
+ result = <ShortText handler={handler}/>;
+ break;
+
+ case QuestionType.Range:
+ result = <Range question_id={question.id} options={options} handler={handler}/>;
+ break;
+
+ case QuestionType.Code:
+ // TODO: Implement
+ result = <Code handler={handler}/>;
+ break;
+
+ default:
+ result = <TextArea handler={handler}/>;
+ }
+ /* eslint-enable react/react-in-jsx-scope */
+
+ return result;
+}
diff --git a/src/components/Question.tsx b/src/components/Question.tsx
new file mode 100644
index 0000000..735d69b
--- /dev/null
+++ b/src/components/Question.tsx
@@ -0,0 +1,166 @@
+/** @jsx jsx */
+import { jsx, css } from "@emotion/react";
+import React, { ChangeEvent } from "react";
+
+import { Question, QuestionType } from "../api/question";
+import { selectable } from "../commonStyles";
+import create_input from "./InputTypes";
+
+const skip_normal_state: Array<QuestionType> = [
+ QuestionType.Radio,
+ QuestionType.Checkbox,
+ QuestionType.Select,
+ QuestionType.Section,
+ QuestionType.Range
+];
+
+export type QuestionProp = {
+ question: Question,
+ public_state: Map<string, string | boolean | null>,
+}
+
+class RenderedQuestion extends React.Component<QuestionProp> {
+ constructor(props: QuestionProp) {
+ super(props);
+ if (props.question.type === QuestionType.TextArea) {
+ this.handler = this.text_area_handler.bind(this);
+ } else {
+ this.handler = this.normal_handler.bind(this);
+ }
+
+ if (!skip_normal_state.includes(props.question.type)) {
+ this.setPublicState("value", "");
+ }
+ }
+
+ setPublicState(target: string, value: string | boolean | null, callback?:() => void): void {
+ this.setState({[target]: value}, callback);
+ this.props.public_state.set(target, value);
+ }
+
+ // This is here to allow dynamic selection between the general handler, and the textarea handler.
+ handler(_: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void {} // eslint-disable-line
+
+ normal_handler(event: ChangeEvent<HTMLInputElement>): void {
+ let target: string;
+ let value: string | boolean;
+
+ switch (event.target.type) {
+ case "checkbox":
+ target = event.target.name;
+ value = event.target.checked;
+ break;
+
+ case "radio":
+ // This handles radios and ranges, as they are both based on the same fundamental input type
+ target = "value";
+ if (event.target.parentElement) {
+ value = event.target.parentElement.innerText.trimEnd();
+ } else {
+ value = event.target.value;
+ }
+ break;
+
+ default:
+ target = "value";
+ value = event.target.value;
+ }
+
+ this.setPublicState(target, value);
+
+ // Toggle checkbox class
+ if (event.target.type == "checkbox" && event.target.parentElement !== null) {
+ event.target.parentElement.classList.toggle("unselected");
+ event.target.parentElement.classList.toggle("selected");
+ }
+ }
+
+ text_area_handler(event: ChangeEvent<HTMLTextAreaElement>): void {
+ this.setPublicState("value", event.target.value);
+ }
+
+ componentDidMount(): void {
+ // Initialize defaults for complex and nested fields
+ const options: string | string[] = this.props.question.data["options"];
+
+ if (this.props.public_state.size === 0) {
+ switch (this.props.question.type) {
+ case QuestionType.Checkbox:
+ if (typeof options === "string") {
+ return;
+ }
+
+ options.forEach((option, index) => {
+ this.setPublicState(`${("000" + index).slice(-4)}. ${option}`, false);
+ });
+ break;
+
+ case QuestionType.Range:
+ case QuestionType.Radio:
+ case QuestionType.Select:
+ this.setPublicState("value", null);
+ break;
+ }
+ }
+ }
+
+ render(): JSX.Element {
+ const question = this.props.question;
+
+ if (question.type === QuestionType.Section) {
+ const styles = css`
+ h1 {
+ margin-bottom: 0;
+ }
+
+ h3 {
+ margin-top: 0;
+ }
+
+ h1, h3 {
+ text-align: center;
+ padding: 0 2rem;
+ }
+
+ @media (max-width: 500px) {
+ h1, h3 {
+ padding: 0;
+ }
+ }
+ `;
+
+ return <div css={styles}>
+ <h1 css={[selectable, css`line-height: 2.5rem;`]}>{question.name}</h1>
+ { question.data["text"] ? <h3 css={selectable}>{question.data["text"]}</h3> : "" }
+ <hr css={css`color: gray; margin: 3rem 0;`}/>
+ </div>;
+
+ } else {
+ const requiredStarStyles = css`
+ span {
+ display: none;
+ }
+
+ .required {
+ display: inline-block;
+ position: relative;
+
+ color: red;
+
+ top: -0.2rem;
+ margin-left: 0.2rem;
+ }
+ `;
+
+ return <div>
+ <h2 css={[selectable, requiredStarStyles]}>
+ {question.name}<span className={question.required ? "required" : ""}>*</span>
+ </h2>
+ { create_input(this.props, this.handler) }
+ <hr css={css`color: gray; margin: 3rem 0;`}/>
+ </div>;
+ }
+ }
+}
+
+export default RenderedQuestion;
diff --git a/src/components/ScrollToTop.tsx b/src/components/ScrollToTop.tsx
new file mode 100644
index 0000000..231c9e8
--- /dev/null
+++ b/src/components/ScrollToTop.tsx
@@ -0,0 +1,93 @@
+/** @jsx jsx */
+import { jsx, css } from "@emotion/react";
+import React from "react";
+import colors from "../colors";
+
+import smoothscroll from "smoothscroll-polyfill";
+smoothscroll.polyfill();
+
+const styles = css`
+ width: 2.5rem;
+ height: 2.5rem;
+
+ position: fixed;
+ bottom: 3rem;
+ right: 3rem;
+
+ background-color: ${colors.blurple};
+ border-radius: 50%;
+
+ opacity: 0;
+ transition: opacity 300ms;
+
+ :after {
+ display: inline-block;
+ content: "";
+
+ position: fixed;
+ bottom: 3.5rem;
+ right: 3.65rem;
+
+ border: solid whitesmoke;
+ border-width: 0.35rem 0.35rem 0 0;
+ padding: 0.4rem;
+
+ transform: rotate(-45deg);
+ }
+
+ @media (max-width: 800px) {
+ bottom: 1.5rem;
+ right: 1.5rem;
+
+ :after {
+ bottom: 2rem;
+ right: 2.15rem;
+ }
+ }
+`;
+
+let last_ref: React.RefObject<HTMLDivElement>;
+
+class ScrollToTop extends React.Component {
+ constructor(props: Record<string, never>) {
+ super(props);
+ last_ref = React.createRef();
+ }
+
+ handleScroll(): void {
+ if (!last_ref.current) return;
+
+ if (window.pageYOffset > 250) {
+ last_ref.current.style.opacity = "1";
+ last_ref.current.style.cursor = "pointer";
+ } else {
+ last_ref.current.style.opacity = "0";
+ last_ref.current.style.cursor = "default";
+ }
+ }
+
+ componentDidMount(): void {
+ // Register event handler
+ window.addEventListener("scroll", this.handleScroll, {passive: true});
+ }
+
+ componentDidUpdate(): void {
+ // Hide previous iterations, and register handler for current one
+ if (last_ref.current) {
+ last_ref.current.style.opacity = "0";
+ }
+
+ window.addEventListener("scroll", this.handleScroll, {passive: true});
+ }
+
+ componentWillUnmount(): void {
+ // Unregister handler
+ window.removeEventListener("scroll", this.handleScroll);
+ }
+
+ render(): JSX.Element {
+ return <div css={styles} key={Date.now()} ref={last_ref} onClick={() => window.scrollTo({top: 0, behavior: "smooth"})}/>;
+ }
+}
+
+export default ScrollToTop;