aboutsummaryrefslogtreecommitdiff
path: root/src/web
diff options
context:
space:
mode:
authorNicolas James <Eele1Ephe7uZahRie@tutanota.com>2025-02-13 18:04:18 +1100
committerNicolas James <Eele1Ephe7uZahRie@tutanota.com>2025-02-13 18:04:18 +1100
commit93dfe2be64e8658839bcfe5356adf35f8cde7075 (patch)
treec60b1e20d569b74dbde85123e1b2bf3590c66244 /src/web
initial commit
Diffstat (limited to 'src/web')
-rw-r--r--src/web/assets/favicon.icobin0 -> 766 bytes
-rw-r--r--src/web/assets/images/logo.pngbin0 -> 718 bytes
-rw-r--r--src/web/assets/src/favicon.xcfbin0 -> 1697 bytes
-rw-r--r--src/web/assets/svg/burger.svg1
-rw-r--r--src/web/assets/svg/half_circle.svg64
-rw-r--r--src/web/components/app/App.css19
-rw-r--r--src/web/components/app/App.jsx29
-rw-r--r--src/web/components/button/Button.css15
-rw-r--r--src/web/components/button/Button.jsx10
-rw-r--r--src/web/components/card/Card.css5
-rw-r--r--src/web/components/card/Card.jsx10
-rw-r--r--src/web/components/comments/Comments.css30
-rw-r--r--src/web/components/comments/Comments.jsx44
-rw-r--r--src/web/components/darken/Darken.css14
-rw-r--r--src/web/components/darken/Darken.jsx13
-rw-r--r--src/web/components/forum/Forum.css21
-rw-r--r--src/web/components/forum/Forum.jsx14
-rw-r--r--src/web/components/input/Input.css31
-rw-r--r--src/web/components/input/Input.jsx38
-rw-r--r--src/web/components/loading/Loading.css13
-rw-r--r--src/web/components/loading/Loading.jsx13
-rw-r--r--src/web/components/login/Login.css118
-rw-r--r--src/web/components/login/Login.jsx218
-rw-r--r--src/web/components/nav_bar/NavBar.css304
-rw-r--r--src/web/components/nav_bar/NavBar.jsx174
-rw-r--r--src/web/components/not_found/NotFound.css0
-rw-r--r--src/web/components/not_found/NotFound.jsx10
-rw-r--r--src/web/components/post/Post.css91
-rw-r--r--src/web/components/post/Post.jsx63
-rw-r--r--src/web/components/posts/Posts.css84
-rw-r--r--src/web/components/posts/Posts.jsx163
-rw-r--r--src/web/components/settings/Settings.css0
-rw-r--r--src/web/components/settings/Settings.jsx9
-rw-r--r--src/web/components/submission/Submission.css146
-rw-r--r--src/web/components/submission/Submission.jsx221
-rw-r--r--src/web/contexts/StateContext.jsx17
-rw-r--r--src/web/contexts/UserContext.jsx16
-rw-r--r--src/web/helpers/Auth.jsx66
-rw-r--r--src/web/helpers/Input.jsx31
-rw-r--r--src/web/helpers/Location.jsx29
-rw-r--r--src/web/index.css60
-rw-r--r--src/web/index.html16
-rw-r--r--src/web/index.jsx24
-rw-r--r--src/web/reset.css47
-rw-r--r--src/web/styles/animations.css21
45 files changed, 2312 insertions, 0 deletions
diff --git a/src/web/assets/favicon.ico b/src/web/assets/favicon.ico
new file mode 100644
index 0000000..4dda993
--- /dev/null
+++ b/src/web/assets/favicon.ico
Binary files differ
diff --git a/src/web/assets/images/logo.png b/src/web/assets/images/logo.png
new file mode 100644
index 0000000..c4a8c86
--- /dev/null
+++ b/src/web/assets/images/logo.png
Binary files differ
diff --git a/src/web/assets/src/favicon.xcf b/src/web/assets/src/favicon.xcf
new file mode 100644
index 0000000..a62432f
--- /dev/null
+++ b/src/web/assets/src/favicon.xcf
Binary files differ
diff --git a/src/web/assets/svg/burger.svg b/src/web/assets/svg/burger.svg
new file mode 100644
index 0000000..233bf21
--- /dev/null
+++ b/src/web/assets/svg/burger.svg
@@ -0,0 +1 @@
+<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg height="32px" id="Layer_1" style="enable-background:new 0 0 32 32;" version="1.1" viewBox="0 0 32 32" width="32px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M4,10h24c1.104,0,2-0.896,2-2s-0.896-2-2-2H4C2.896,6,2,6.896,2,8S2.896,10,4,10z M28,14H4c-1.104,0-2,0.896-2,2 s0.896,2,2,2h24c1.104,0,2-0.896,2-2S29.104,14,28,14z M28,22H4c-1.104,0-2,0.896-2,2s0.896,2,2,2h24c1.104,0,2-0.896,2-2 S29.104,22,28,22z"/></svg> \ No newline at end of file
diff --git a/src/web/assets/svg/half_circle.svg b/src/web/assets/svg/half_circle.svg
new file mode 100644
index 0000000..a31b646
--- /dev/null
+++ b/src/web/assets/svg/half_circle.svg
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.0"
+ width="120.00001"
+ height="120"
+ id="svg3257"
+ sodipodi:docname="half_circle.svg"
+ inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview42"
+ pagecolor="#505050"
+ bordercolor="#ffffff"
+ borderopacity="1"
+ inkscape:showpageshadow="0"
+ inkscape:pageopacity="0"
+ inkscape:pagecheckerboard="1"
+ inkscape:deskcolor="#505050"
+ showgrid="true"
+ inkscape:zoom="5.6568543"
+ inkscape:cx="53.740115"
+ inkscape:cy="50.027805"
+ inkscape:window-width="1920"
+ inkscape:window-height="1023"
+ inkscape:window-x="0"
+ inkscape:window-y="0"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg3257">
+ <sodipodi:guide
+ position="0,120"
+ orientation="0,120.00001"
+ id="guide410"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="120.00001,120"
+ orientation="120,0"
+ id="guide412"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="120.00001,0"
+ orientation="0,-120.00001"
+ id="guide414"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="0,0"
+ orientation="-120,0"
+ id="guide416"
+ inkscape:locked="false" />
+ <inkscape:grid
+ type="xygrid"
+ id="grid484" />
+ </sodipodi:namedview>
+ <defs
+ id="defs3259" />
+ <path
+ d="m 102.10529,60 c 0,23.242101 -18.863157,42.10528 -42.105278,42.10528 C 36.757922,102.10528 17.89475,83.242101 17.89475,60"
+ id="path3158"
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:15.7895;stroke-miterlimit:4;stroke-opacity:1" />
+</svg>
diff --git a/src/web/components/app/App.css b/src/web/components/app/App.css
new file mode 100644
index 0000000..e211974
--- /dev/null
+++ b/src/web/components/app/App.css
@@ -0,0 +1,19 @@
+
+.content {
+ /* sticky footer */
+ flex-grow: 1;
+ flex-shrink: 0;
+
+ display: flex;
+ flex-direction: column;
+ align-items: left;
+
+ padding: 0;
+ background-color: var(--black-10);
+}
+
+@media (min-width: 40em) {
+ .content {
+ padding: 0em 0em 0em var(--menu-width);
+ }
+} \ No newline at end of file
diff --git a/src/web/components/app/App.jsx b/src/web/components/app/App.jsx
new file mode 100644
index 0000000..ffd2cc7
--- /dev/null
+++ b/src/web/components/app/App.jsx
@@ -0,0 +1,29 @@
+import React from "react";
+import {Route, Routes} from "react-router-dom";
+
+import NavBar from "components/nav_bar/NavBar.jsx";
+import Forum from "components/forum/Forum.jsx";
+import NotFound from "components/not_found/NotFound.jsx";
+import Settings from "components/settings/Settings.jsx";
+import {UserContextProvider} from "contexts/UserContext.jsx";
+import {StateContextProvider} from "contexts/StateContext.jsx";
+
+import styles from "./App.css";
+
+export default function App() {
+ return (
+ <UserContextProvider>
+ <StateContextProvider>
+ <NavBar />
+ <main className={styles.content}>
+ <Routes>
+ <Route path="/*" element={<Forum />} />
+ <Route path="/settings/*" element={<Settings />} />
+
+ <Route path="*" element={<NotFound />} />
+ </Routes>
+ </main>
+ </StateContextProvider>
+ </UserContextProvider>
+ );
+}
diff --git a/src/web/components/button/Button.css b/src/web/components/button/Button.css
new file mode 100644
index 0000000..76c5450
--- /dev/null
+++ b/src/web/components/button/Button.css
@@ -0,0 +1,15 @@
+.button {
+ padding: 0.75em 1em;
+
+ color: var(--black-98);
+ background-color: var(--brand-80);
+ border: 2px solid var(--brand-80);
+ border-radius: 2em;
+ font-family: inherit;
+ letter-spacing: inherit;
+ cursor: pointer;
+}
+.button:hover {
+ color: white;
+ background-color: var(--brand-90);
+} \ No newline at end of file
diff --git a/src/web/components/button/Button.jsx b/src/web/components/button/Button.jsx
new file mode 100644
index 0000000..8d7de02
--- /dev/null
+++ b/src/web/components/button/Button.jsx
@@ -0,0 +1,10 @@
+import React from "react";
+import styles from "./Button.css";
+
+export default function Button({className, children, ...others}) {
+ return (
+ <button className={styles.button + (className != null ? " " + className : "")} {...others} >
+ {children}
+ </button>
+ )
+} \ No newline at end of file
diff --git a/src/web/components/card/Card.css b/src/web/components/card/Card.css
new file mode 100644
index 0000000..79d38ac
--- /dev/null
+++ b/src/web/components/card/Card.css
@@ -0,0 +1,5 @@
+.card {
+ box-shadow: 0 0 0.2em 0.01em var(--black-10);
+ border-radius: 1.0em;
+ background-color: var(--black-10);
+} \ No newline at end of file
diff --git a/src/web/components/card/Card.jsx b/src/web/components/card/Card.jsx
new file mode 100644
index 0000000..36c4f1d
--- /dev/null
+++ b/src/web/components/card/Card.jsx
@@ -0,0 +1,10 @@
+import React from "react";
+import styles from "./Card.css";
+
+export default function Card({children, className, ...others}) {
+ return (
+ <div className={styles.card + (className ? " " + className : "")} {...others}>
+ {children}
+ </div>
+ );
+} \ No newline at end of file
diff --git a/src/web/components/comments/Comments.css b/src/web/components/comments/Comments.css
new file mode 100644
index 0000000..402e4a2
--- /dev/null
+++ b/src/web/components/comments/Comments.css
@@ -0,0 +1,30 @@
+.container {
+ width: 100%;
+ position: relative;
+
+ /*TODO*/
+ display: none;
+
+ justify-content: center;
+ padding: 1em 1em 0em 0em;
+ overflow: auto;
+
+ color: var(--black-98);
+}
+
+.card {
+ padding: 1em;
+}
+
+.originalPost {
+
+}
+.originalPost > h1 {
+ font-size: 1.8em;
+}
+
+@media (min-width: 80em) {
+ .container {
+ display: flex;
+ }
+} \ No newline at end of file
diff --git a/src/web/components/comments/Comments.jsx b/src/web/components/comments/Comments.jsx
new file mode 100644
index 0000000..66e0f06
--- /dev/null
+++ b/src/web/components/comments/Comments.jsx
@@ -0,0 +1,44 @@
+import React, {Fragment} from "react";
+
+import Card from "components/card/Card.jsx";
+
+import styles from "./Comments.css";
+
+function OtherComments() {
+ return (
+ <Fragment />
+ );
+}
+
+function OriginalComment() {
+ return (
+ <div className={styles.originalPost}>
+ <h1>Galahs in Australia</h1>
+ <p>
+ The galah is very common as a companion parrot or avicultural specimen around the world, although generally
+ absent from Australian aviaries, although permits are available to take a limited number of galahs from
+ the wild per year for avicultural purposes. When tame, it can be an affectionate and friendly bird that
+ can learn to talk, as well as mimic other sounds heard in its environment. While it is a noisy bird that may
+ be unsuitable for apartment living, it is comparatively quieter than other cockatoo species. Like most
+ parrots, the galah requires plenty of exercise and play time out of its cage as well as several hours of daily
+ social interaction with humans or other birds in order to thrive in captivity. It may also be prone to obesity
+ if not provided with a suitable, nutritionally-balanced diet. The World Parrot Trust recommends that captive
+ galahs should be kept in an aviary with a minimum length of 7 metres.
+ The breeding requirements include the use upright or tilted logs with a hollow some twenty to thirty
+ centimetres in diameter. Sand and finer grades of wood material are used to construct their nest, the
+ availability of eucalypt leaves for the nest lining is also suggested for captive breeding.
+ </p>
+ </div>
+ );
+}
+
+export default function Comments() {
+ return (
+ <div className={styles.container}>
+ <Card className={styles.card}>
+ <OriginalComment />
+ <OtherComments />
+ </Card>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/src/web/components/darken/Darken.css b/src/web/components/darken/Darken.css
new file mode 100644
index 0000000..bd3b151
--- /dev/null
+++ b/src/web/components/darken/Darken.css
@@ -0,0 +1,14 @@
+.darken {
+ visibility: hidden;
+
+ position: fixed;
+ inset: 0;
+
+ background-color: rgba(0, 0, 0, 0.0);
+ cursor: pointer;
+ transition: background-color var(--transition-time) ease-in-out;
+}
+.darkenActive {
+ visibility: initial;
+ background-color: rgba(0, 0, 0, 0.5);
+} \ No newline at end of file
diff --git a/src/web/components/darken/Darken.jsx b/src/web/components/darken/Darken.jsx
new file mode 100644
index 0000000..7f46365
--- /dev/null
+++ b/src/web/components/darken/Darken.jsx
@@ -0,0 +1,13 @@
+import React from "react";
+import styles from "./Darken.css";
+
+export default function Darken({active, className, ...others}) {
+
+ return (
+ <div
+ className={styles.darken
+ + (active ? " " + styles.darkenActive : "")
+ + (className ? " " + className : "")}
+ {...others} />
+ );
+} \ No newline at end of file
diff --git a/src/web/components/forum/Forum.css b/src/web/components/forum/Forum.css
new file mode 100644
index 0000000..4ba6dea
--- /dev/null
+++ b/src/web/components/forum/Forum.css
@@ -0,0 +1,21 @@
+.container {
+ width: 100%;
+
+ /*
+ We're using dvh, which is a newish feature that excludes the size of mobile
+ added menus. If we don't use this and instead use 100vh, we have to scroll
+ before we see some elements at the bottom of the page.
+ */
+ height: calc(100vh - var(--nav-bar-height));
+ height: calc(100dvh - var(--nav-bar-height));
+
+ display: flex;
+
+ background-color: var(--black-15);
+}
+
+@media(min-width: 40em) {
+ .container {
+ border-radius: 1em 0 0 0;
+ }
+}
diff --git a/src/web/components/forum/Forum.jsx b/src/web/components/forum/Forum.jsx
new file mode 100644
index 0000000..eed0fe9
--- /dev/null
+++ b/src/web/components/forum/Forum.jsx
@@ -0,0 +1,14 @@
+import React from "react";
+
+import Posts from "components/posts/Posts.jsx";
+import Comments from "components/comments/Comments.jsx";
+import styles from "./Forum.css";
+
+export default function Forum() {
+ return (
+ <div className={styles.container}>
+ <Posts />
+ <Comments />
+ </div>
+ );
+} \ No newline at end of file
diff --git a/src/web/components/input/Input.css b/src/web/components/input/Input.css
new file mode 100644
index 0000000..a7da62d
--- /dev/null
+++ b/src/web/components/input/Input.css
@@ -0,0 +1,31 @@
+.container {
+ display: flex;
+ flex-direction: column;
+}
+
+.row {
+ width: 100%;
+
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+}
+
+.input {
+ padding: 0.2rem 1rem;
+ margin: 0.5rem 0 1rem;
+
+ background-color: transparent;
+ border-radius: 2em;
+ border: 2px solid var(--brand-80);
+ color: var(--black-98);
+ font-size: inherit;
+ font-family: inherit;
+ letter-spacing: inherit;
+ resize: none;
+}
+
+.label {
+ font-size: 1.35em;
+ font-style: italic;
+}
diff --git a/src/web/components/input/Input.jsx b/src/web/components/input/Input.jsx
new file mode 100644
index 0000000..2c7bc6e
--- /dev/null
+++ b/src/web/components/input/Input.jsx
@@ -0,0 +1,38 @@
+import React from "react";
+
+import styles from "./Input.css";
+import animations from "styles/animations.css";
+
+function InputField({className, type, id, value, onChange, inputType}) {
+ // idk how to do this less uglily
+ switch (inputType) {
+ case "textarea":
+ return <textarea className={className} type={type} id={id} value={value} onChange={onChange} />;
+ default:
+ break;
+ }
+ return <input className={className} type={type} id={id} value={value} onChange={onChange} />;
+}
+
+export default function Input({className, type, field, title, setField, validateField, inputType}) {
+ return (
+ <div className={styles.container + (className ? " " + className : "")}>
+ <div className={styles.row}>
+ <label htmlFor={title} className={styles.label}>{title}</label>
+ <h3 className={(field?.animating ? animations.shake : "")}
+ onAnimationEnd={() => {setField({...field, animating: false});}}>
+ {field?.erroring ? validateField(field) : null}
+ </h3>
+ </div>
+ <InputField className={styles.input}
+ inputType={inputType}
+ type={type}
+ id={title}
+ value={field?.target?.value ?? ""}
+ onChange={(event) => {setField({...field,
+ erroring: true,
+ target: event.target});}}
+ field={field} />
+ </div>
+ );
+} \ No newline at end of file
diff --git a/src/web/components/loading/Loading.css b/src/web/components/loading/Loading.css
new file mode 100644
index 0000000..3889953
--- /dev/null
+++ b/src/web/components/loading/Loading.css
@@ -0,0 +1,13 @@
+.loading {
+ width: 1em;
+ height: 1em;
+ animation: rotate 1s infinite linear;
+}
+@keyframes rotate {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+} \ No newline at end of file
diff --git a/src/web/components/loading/Loading.jsx b/src/web/components/loading/Loading.jsx
new file mode 100644
index 0000000..df2caf4
--- /dev/null
+++ b/src/web/components/loading/Loading.jsx
@@ -0,0 +1,13 @@
+import React from "react";
+
+import styles from "./Loading.css";
+
+import half_circle from "assets/svg/half_circle.svg";
+
+export default function Loading({className, ...others}) {
+ return (
+ <img src={half_circle}
+ className={styles.loading + (className ? " " + className : "")}
+ {...others} />
+ );
+} \ No newline at end of file
diff --git a/src/web/components/login/Login.css b/src/web/components/login/Login.css
new file mode 100644
index 0000000..1be8f84
--- /dev/null
+++ b/src/web/components/login/Login.css
@@ -0,0 +1,118 @@
+.screenCenter {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: var(--login-z-index);
+
+ position: fixed;
+ inset: 0;
+ visibility: hidden;
+}
+
+.darken {
+ z-index: calc(var(--login-z-index) - 1);
+}
+.login {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ flex: 0 1 100%;
+
+ position: relative;
+ padding: 4em 2em 2em;
+ margin: 0em 3em;
+ max-width: 25em;
+
+ color: var(--black-98);
+ visibility: visible;
+ border-radius: 2em;
+
+ translate: 100vw;
+ transition: translate var(--transition-time) ease-in-out;
+}
+.loginActive {
+ translate: initial;
+ visibility: initial;
+}
+
+.formContainer {
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+}
+
+.login .formContainer h1 {
+ font-size: 2em;
+ font-weight: bolder;
+ text-align: center;
+ color: white;
+ margin-bottom: 2rem;
+}
+.login .formContainer h2 {
+ font-size: 1.5em;
+ font-weight: 500;
+ margin-bottom: 0.5rem;
+}
+.login .formContainer p {
+ text-align: center;
+ line-height: 1.25em;
+}
+
+.promptContainer {
+ min-height: 5em;
+ display: flex;
+ justify-content: space-between;
+}
+
+.login .formContainer button {
+ height: 2.25rem;
+ font-size: 1.25em;
+ margin-bottom: 1.25rem;
+ width: 100%;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ border-radius: 2.5rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.loading {
+ font-size: 1.65rem;
+ filter: invert(95%);
+}
+
+.formContainer a {
+ display: block;
+ text-align: center;
+ text-decoration: underline;
+ cursor: pointer;
+ min-height: 3em;
+ color: inherit;
+}
+
+.promptContainer input {
+ margin: 0 0.25em 0 0;
+}
+
+.cardError {
+ display: none;
+ position: absolute;
+ inset: 0 0 auto 0;
+ min-height: 2em;
+ margin: 1em;
+
+ justify-content: center;
+ align-items: center;
+
+ color: white;
+ border-radius: 2em;
+ font-weight: normal;
+ font-style: italic;
+ text-transform: uppercase;
+}
+.cardErrorActive {
+ display: flex;
+} \ No newline at end of file
diff --git a/src/web/components/login/Login.jsx b/src/web/components/login/Login.jsx
new file mode 100644
index 0000000..7944aa3
--- /dev/null
+++ b/src/web/components/login/Login.jsx
@@ -0,0 +1,218 @@
+import React, {Fragment, useState, useContext} from "react";
+import {Link} from "react-router-dom";
+
+import Card from "components/card/Card.jsx";
+import Button from "components/button/Button.jsx";
+import Darken from "components/darken/Darken.jsx";
+import Loading from "components/loading/Loading.jsx";
+import Input from "components/input/Input.jsx";
+import {UserContext} from "contexts/UserContext.jsx";
+import {maybeAuthFromCookie} from "helpers/Auth.jsx";
+import {getFieldError, correctFieldAnimate} from "helpers/Input.jsx";
+
+import styles from "./Login.css";
+
+const validateEmail = (email) => getFieldError(email?.target?.value, [3, 254]);
+const validatePassword = (password) => getFieldError(password?.target?.value, [8, 64]);
+
+function RequestButton({email, password, apiPath, promise, setPromise, setEmail,
+ setPassword, setLoginActive, setError, children}) {
+
+ const userState = useContext(UserContext);
+
+ const onClick = () => {
+ if (promise != null) {
+ return;
+ }
+ setError(null);
+
+ // We don't want to send a worthless request, we should animate the bad values.
+ const emailErrors = validateEmail(email);
+ const passwordErrors = validatePassword(password);
+ if (emailErrors != null || passwordErrors != null) {
+ correctFieldAnimate(email, setEmail, emailErrors);
+ correctFieldAnimate(password, setPassword, passwordErrors);
+ return;
+ }
+
+ const init = {
+ method: "POST",
+ headers: {
+ 'Content-Type': "application/json"
+ },
+ body: JSON.stringify({
+ "email": email.target.value,
+ "password": password.target.value
+ }),
+ referrer: "same-origin",
+ };
+ setPromise(fetch(apiPath, init)
+ .then((response) => {
+ if (response.ok) {
+ if (!maybeAuthFromCookie(userState)) {
+ throw new Error("rejected authentication token");
+ }
+
+ setLoginActive(false);
+ 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);
+ })
+ );
+ };
+
+ // I'm not worried about this object becoming unmounted mid promise because
+ // our website design removes this possibility.
+ return (
+ <Button className={(promise != null ? styles.buttonLoading : null)}
+ onClick={onClick}>
+
+ {children}
+ </Button>
+ );
+}
+
+function CardError({active, text}) {
+ return (
+ <div className={styles.cardError + (active ? " " + styles.cardErrorActive : "")}>
+ <p>
+ {text}
+ </p>
+ </div>
+ );
+}
+
+function FormContents(props) {
+ const {setLoginActive,
+ isRegister,
+ setIsRegister,
+ title,
+ prompt,
+ linkContents,
+ apiPath} = props;
+
+ const [error, setError] = useState(null);
+ // Email and password .value's are null here to represent the state where the user
+ // has not typed in anything yet. This is to only validate after at least some
+ // input has been received.
+ const [email, setEmail] = useState(null);
+ const [password, setPassword] = useState(null);
+ const [promise, setPromise] = useState(null);
+
+ return (
+ <div className={styles.formContainer}>
+ <CardError active={error != null} text={error} />
+
+ <h1>{title}</h1>
+
+ <Input type="text"
+ title="Email"
+ field={email}
+ setField={setEmail}
+ validateField={validateEmail} />
+
+ <Input type="text"
+ title="Password"
+ field={password}
+ setField={setPassword}
+ validateField={validatePassword} />
+
+ <div className={styles.promptContainer}>
+ {prompt}
+ </div>
+
+ <RequestButton email={email}
+ password={password}
+ promise={promise}
+ setEmail={setEmail}
+ setPassword={setPassword}
+ setLoginActive={setLoginActive}
+ setPromise={setPromise}
+ setError={setError}
+ apiPath={apiPath}>
+
+ {promise == null ? title : <Loading className={styles.loading} />}
+ </RequestButton>
+
+ <a onClick={() => {
+ if (promise != null) { // disallow mid transaction change
+ return;
+ }
+ setIsRegister(!isRegister);
+ setError(null);
+ setEmail({...email, value: null, animating: false});
+ setPassword({...password, value: null, animating: false});}}>
+
+ {linkContents}
+ </a>
+ </div>
+ );
+};
+
+function RegisterPrompt() {
+ return (
+ <p>
+ By registering, you agree to both our privacy policy and to
+ waive all rights of basic privacy and security.
+ </p>
+ );
+}
+
+function LoginPrompt(props) {
+ const {setLoginActive} = props;
+
+ return (
+ <Fragment>
+ <div>
+ <input type="checkbox" id="save_login" /><label htmlFor="save_login">Save Login</label>
+ </div>
+ <Link to="settings/recover" onClick={() => setLoginActive(false)}>
+ Can't log in?
+ </Link>
+ </Fragment>
+ );
+}
+
+function Prompt(props) {
+ const {setLoginActive} = props;
+ const [isRegister, setIsRegister] = useState(true);
+ return (
+ <FormContents setLoginActive={setLoginActive}
+ isRegister={isRegister}
+ setIsRegister={setIsRegister}
+ title={isRegister ? "Sign Up" : "Login"}
+ prompt={isRegister ? <RegisterPrompt /> : <LoginPrompt setLoginActive={setLoginActive} />}
+ linkContents={isRegister ? "Already have an account?" : "Don't have an account?"}
+ apiPath={isRegister ? "/api/signup" : "/api/login"} />
+ );
+}
+
+// A popup which asks for login/register info.
+export default function Login(props) {
+ const {active, setActive} = props;
+
+ return (
+ <Fragment>
+ <Darken active={active} onClick={() => setActive(false)} className={styles.darken} />
+ <div className={styles.screenCenter}>
+ <Card className={styles.login + (active ? " " + styles.loginActive : "")}>
+ <Prompt setLoginActive={setActive} />
+ </Card>
+ </div>
+ </Fragment>
+ );
+} \ No newline at end of file
diff --git a/src/web/components/nav_bar/NavBar.css b/src/web/components/nav_bar/NavBar.css
new file mode 100644
index 0000000..d9e315e
--- /dev/null
+++ b/src/web/components/nav_bar/NavBar.css
@@ -0,0 +1,304 @@
+:root {
+ --nav-bar-color: var(--black-10);
+ --nav-bar-font-color: var(--black-98);
+ --nav-bar-height: 4rem;
+ --nav-bar-shadow: 0 0.05em 0.16em var(--nav-bar-color);
+}
+
+.navBar {
+ /* sticky footer */
+ flex-shrink: 0;
+
+ min-width: 100%;
+ height: var(--nav-bar-height);
+
+ z-index: var(--nav-bar-z-index);
+ position: sticky;
+ top: 0;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ background-color: var(--nav-bar-color);
+ box-shadow: var(--nav-bar-shadow);
+ font-weight: bolder;
+}
+
+.container {
+ display: flex;
+ align-items: center;
+ min-height: var(--nav-bar-height);
+}
+.bannerContainer > a {
+ display: inherit;
+ justify-content: inherit;
+ align-items: inherit;
+ min-height: var(--nav-bar-height);
+ text-decoration: none;
+}
+
+.bannerContainer {
+ display: flex;
+ align-items: center;
+ min-height: var(--nav-bar-height);
+}
+.logo {
+ margin-left: 0.5em;
+ height: calc(var(--nav-bar-height) - 0.5em);
+ width: calc(var(--nav-bar-height) - 0.5em);
+}
+
+.bannerContainer h1 {
+ font-size: 1.5em;
+ margin: 0;
+
+ color: var(--nav-bar-font-color);
+ text-decoration: none;
+ text-transform: none;
+ font-weight: bold;
+}
+
+.burgerButton {
+ height: var(--nav-bar-height);
+ width: var(--nav-bar-height);
+ margin-left: 0.5em;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ border: 0;
+ background-color: transparent;
+ cursor: pointer;
+ background: url(assets/svg/burger.svg) center center no-repeat;
+ background-size: 3em;
+ filter: invert(95%);
+}
+
+:root {
+ --menu-width: calc(100vw * 0.7);
+}
+
+.menu {
+ position: fixed;
+ inset: 0 calc(100vw - var(--menu-width)) 0 0;
+ translate: calc(var(--menu-width) * -1);
+ z-index: var(--menu-z-index);
+
+ display: flex;
+ flex-direction: column;
+
+ background-color: var(--nav-bar-color);
+
+ transition: translate var(--transition-time) ease-in;
+}
+.menuActive {
+ translate: initial;
+}
+
+.darken {
+ z-index: calc(var(--menu-z-index) - 1);
+}
+
+.itemListContainer {
+ display: inherit;
+ flex-direction: inherit;
+ overflow: scroll;
+ height: 100%;
+}
+.itemList {
+ position: static;
+ display: block;
+ margin: 0;
+ overflow: visible;
+ padding: 0 0.5em;
+}
+.itemList + .itemList {
+ border-top: solid hsl(0, 0%, 35%) 1px;
+}
+.itemList > p {
+ margin-bottom: 1em;
+ height: var(--nav-bar-height);
+ padding-top: 1.5em;
+
+ text-align: center;
+ color: var(--nav-bar-font-color);
+ font-style: italic;
+ font-weight: 100;
+}
+
+.titleContainer {
+ display: flex;
+ align-items: end;
+ margin: 1em 0 0.5em 0.5em;
+}
+.titleContainer h3 {
+ color: var(--nav-bar-font-color);
+ font-size: 1.25em;
+ text-transform: uppercase;
+}
+
+.item {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: 0 1.0rem;
+ min-height: var(--nav-bar-height);
+
+ background-color: transparent;
+ border-radius: 1.5rem;
+ text-decoration: none;
+ color: var(--nav-bar-font-color);
+ font-size: 1.75em;
+ text-transform: uppercase;
+}
+.item:hover {
+ background-color: hsl(0, 0%, 25%);
+}
+.itemActive {
+ background-color: var(--brand-80);
+}
+.itemActive:hover {
+ color: white;
+ background-color: var(--brand-90);
+}
+.itemIcon {
+ width: 1em;
+}
+.item h4 {
+ margin-left: 2rem;
+ font-size: 0.675em;
+ text-transform: capitalize;
+ font-weight: 100;
+}
+.itemActive h4 {
+ font-weight: 800;
+}
+
+.menu span {
+ font-size: 0.8em;
+ width: 100%;
+ margin-top: auto;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ color: var(--nav-bar-font-color);
+}
+
+.loginStatus {
+ font-size: 0.8em;
+
+ padding: 0.75em 1em;
+
+ margin: 0em 1.5em 0em 0.7em;
+ font-weight: 600;
+}
+
+.accountContainer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 1em;
+ width: 12em;
+
+ color: var(--black-98);
+ background-color: hsl(0, 0%, 15%);
+ padding: 0.5em 1em;
+ border-radius: 1em;
+}
+.accountIcon {
+ height: 1.75em;
+ color: hsl(186, 90%, 50%);
+}
+.accountContainer h2 {
+ margin: 0 0.4em 0 0.25em;
+}
+.accountArrow {
+ height: 0.8em;
+ rotate: -90deg;
+}
+.accountInfoItemList {
+ display: none;
+ padding-top: 0.5em;
+
+ position: absolute;
+ right: 1em;
+ top: 3em;
+ width: 12em;
+
+ flex-direction: column;
+ background-color: inherit;
+ border-radius: 1em;
+ border-top-right-radius: 0em;
+ border-top-left-radius: 0em;
+}
+.accountInfoItem {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ color: var(--black-98);
+ text-decoration: none;
+ padding: 1em 0;
+}
+.accountInfoItem + .accountInfoItem {
+ border-top: 1px hsl(0, 0%, 50%) solid;
+}
+.accountInfoItem h3 {
+ font-weight: 200;
+}
+@media (max-width: 40em) {
+ .accountContainerActive {
+ background-color: hsl(0, 0%, 25%);
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+ }
+ .accountArrowActive {
+ rotate: 90deg;
+ }
+ .accountInfoItemListActive {
+ display: flex;
+ }
+}
+
+@media (min-width: 40em) {
+ :root {
+ --menu-width: 15em;
+ }
+ .darken {
+ display: none;
+ }
+ .burgerButton {
+ display: none;
+ }
+ .menu {
+ translate: initial;
+ transition: none;
+
+ }
+ .itemListContainer {
+ font-size: 0.8em;
+ box-shadow: var(--nav-bar-shadow);
+ }
+
+ .accountInfoListActive {
+ display: unset;
+ }
+ .accountContainer:hover {
+ background-color: hsl(0, 0%, 25%);
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+ }
+ .accountContainer:hover .accountInfoItemList {
+ display: flex;
+ }
+ .accountContainer:hover .accountArrow {
+ rotate: 90deg;
+ }
+ .accountInfoItem:hover h3 {
+ color: white;
+ font-weight: bolder;
+ }
+} \ No newline at end of file
diff --git a/src/web/components/nav_bar/NavBar.jsx b/src/web/components/nav_bar/NavBar.jsx
new file mode 100644
index 0000000..3c22851
--- /dev/null
+++ b/src/web/components/nav_bar/NavBar.jsx
@@ -0,0 +1,174 @@
+import React, {useState, Fragment, useContext, useEffect} from "react";
+import {Link, useLocation} from "react-router-dom";
+
+import Button from "components/button/Button.jsx";
+import Login from "components/login/Login.jsx";
+import Darken from "components/darken/Darken.jsx";
+import {UserContext} from "contexts/UserContext.jsx";
+import {StateContext} from "contexts/StateContext.jsx";
+import {refreshToken, logout} from "helpers/Auth.jsx";
+import {getSubreactFromLocation, getInfoFromSubreact} from "helpers/Location.jsx";
+
+import styles from "./NavBar.css"
+import logo from "assets/images/logo.png"
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {faUser, faPlay} from "@fortawesome/free-solid-svg-icons";
+
+function AccountInfoItem({title, onClick, path, className}) {
+ return (
+ <Link className={styles.accountInfoItem + (className ? " " + className : "")}
+ onClick={onClick}
+ to={path}>
+
+ <h3>{title}</h3>
+ </Link>
+ );
+}
+
+function AccountInfo({user, setUser}) {
+ // At this stage the user is logged in and we keep them logged in by periodically
+ // refreshing our token.
+ useEffect(() => {
+ const interval = setInterval(() => {
+ refreshToken(user, setUser);
+ }, 58 * 60 * 1000); // refresh every 58 minutes, expires every sixty.
+ return () => clearInterval(interval);
+ }, []);
+ const [active, setActive] = useState(false);
+
+ return (
+ <div className={styles.accountContainer + (active ? " " + styles.accountContainerActive : "")}
+ onClick={() => {setActive(!active);}}>
+ <FontAwesomeIcon icon={faUser} className={styles.accountIcon} />
+ <h2>{user.username ?? "anonymous"}</h2>
+ <FontAwesomeIcon icon={faPlay} className={styles.accountArrow + (active ? " " + styles.accountArrowActive : "")} />
+ <div className={styles.accountInfoItemList + (active ? " " + styles.accountInfoItemListActive : "")}>
+ <AccountInfoItem title="Profile" path="/settings" />
+ <AccountInfoItem title="Settings" path="/settings" />
+ <AccountInfoItem title="Logout" path="/" onClick={() => {logout(setUser);}} />
+ </div>
+ </div>
+ );
+}
+
+function LoginStatus({loginActive, setLoginActive}) {
+ const [user, setUser] = useContext(UserContext);
+
+ // Not logged in, request a login.
+ if (user == null) {
+ return (
+ <Button className={styles.loginStatus}
+ onClick={() => {setLoginActive(!loginActive);}}>
+
+ <h2>Not Logged In</h2>
+ </Button>
+ );
+ }
+
+ return <AccountInfo user={user} setUser={setUser} />;
+}
+
+function Account() {
+ const [state, setState] = useContext(StateContext);
+ const {loginActive} = state;
+ const setLoginActive = (isActive) => {setState({...state, loginActive: isActive});};
+
+ return (
+ <Fragment>
+ <Login active={loginActive} setActive={setLoginActive} />
+ <LoginStatus loginActive={loginActive} setLoginActive={setLoginActive} />
+ </Fragment>
+ );
+}
+
+function Banner({navBarActive, setNavBarActive, shouldToggleNavBar}) {
+
+ return (
+ <div className={styles.bannerContainer}>
+ <button className={styles.burgerButton}
+ onClick={() => setNavBarActive(!navBarActive)} />
+
+ <Link to="/"
+ onClick={shouldToggleNavBar ? () => setNavBarActive(!navBarActive) : null}>
+
+ <img className={styles.logo} src={logo} />
+ <h1>GoReact</h1>
+ </Link>
+ </div>
+ );
+}
+
+function Item({path, setNavBarActive}) {
+ const location = useLocation();
+ const subreact = path.replace("/", ""); // "a", "m" etc
+ const {icon, name} = getInfoFromSubreact(subreact);
+
+ return (
+ <Link to={path}
+ className={styles.item + (getSubreactFromLocation(location) === subreact ? " " + styles.itemActive : "")}
+ onClick={() => setNavBarActive(false)}>
+ <FontAwesomeIcon icon={icon} className={styles.itemIcon} />
+ <h4>{name}</h4>
+ </Link>
+ );
+}
+
+function ItemList({children, title}) {
+
+ return (
+ <div className={styles.itemList}>
+ <div className={styles.titleContainer}>
+ <h3>{title}</h3>
+ </div>
+ {children && React.Children.count(children) > 0 ? children : <p>There's nothing here...</p>}
+ </div>
+ );
+}
+
+function Menu({navBarActive, setNavBarActive}) {
+
+ return (
+ <div className={styles.container}>
+ <nav className={styles.menu + (navBarActive ? " " + styles.menuActive : "")}>
+ <Banner navBarActive={navBarActive} setNavBarActive={setNavBarActive} shouldToggleNavBar />
+ <div className={styles.itemListContainer}>
+ <ItemList title="Subreacts">
+ <Item path="/" setNavBarActive={setNavBarActive} />
+ <Item path="/t" setNavBarActive={setNavBarActive} />
+ <Item path="/g" setNavBarActive={setNavBarActive} />
+ <Item path="/k" setNavBarActive={setNavBarActive} />
+ <Item path="/p" setNavBarActive={setNavBarActive} />
+ <Item path="/a" setNavBarActive={setNavBarActive} />
+ <Item path="/pr" setNavBarActive={setNavBarActive} />
+ <Item path="/m" setNavBarActive={setNavBarActive} />
+ </ItemList>
+
+ {/*TODO*/}
+ <ItemList title="Watched Threads">
+ </ItemList>
+ <ItemList title="Your Threads">
+ </ItemList>
+ <ItemList title="Recent Threads">
+ </ItemList>
+
+ <span>
+ <p>GoReact 2022-2023</p>
+ </span>
+ </div>
+ </nav>
+ <Account />
+ </div>
+ );
+}
+
+export default function NavBar() {
+ const [active, setActive] = useState(false);
+
+ return (
+ <header className={styles.navBar}>
+ <Darken active={active} onClick={() => setActive(false)} className={styles.darken} />
+ <Banner setNavBarActive={setActive} />
+ <Menu navBarActive={active} setNavBarActive={setActive} />
+ </header>
+ );
+} \ No newline at end of file
diff --git a/src/web/components/not_found/NotFound.css b/src/web/components/not_found/NotFound.css
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/web/components/not_found/NotFound.css
diff --git a/src/web/components/not_found/NotFound.jsx b/src/web/components/not_found/NotFound.jsx
new file mode 100644
index 0000000..5546d1e
--- /dev/null
+++ b/src/web/components/not_found/NotFound.jsx
@@ -0,0 +1,10 @@
+import React, {Fragment} from "react";
+import styles from "./NotFound.css";
+
+export default function NotFound() {
+ return (
+ <Fragment>
+ <p>Not found route!</p>
+ </Fragment>
+ );
+}
diff --git a/src/web/components/post/Post.css b/src/web/components/post/Post.css
new file mode 100644
index 0000000..8721640
--- /dev/null
+++ b/src/web/components/post/Post.css
@@ -0,0 +1,91 @@
+
+
+.postCard {
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+
+ font-size: 2em;
+ margin-bottom: 0.75rem;
+ padding: 1rem 1rem;
+
+ background-color: var(--black-10);
+ border-radius: 0.5em;
+ color: white;
+ box-shadow: none;
+
+ cursor: pointer;
+}
+.postCard:hover {
+ background-color: hsl(0, 0%, 25%);
+}
+
+.postCard > img {
+ flex-shrink: 0;
+
+ border-radius: 50%;
+ height: 2.5em;
+ width: 2.5em;
+}
+
+.rowContainer {
+ display: flex;
+ flex-direction: column;
+ flex-basis: 100%;
+ justify-content: space-between;
+
+ height: 2.5em;
+ padding-left: 0.5em;
+ min-width: 0;
+
+ color: var(--black-95);
+}
+
+.rowContainer > h4 {
+ font-size: 0.4em;
+
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.titleContainer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.titleContainer h3 {
+ font-size: 0.5em;
+ font-weight: bold;
+ color: var(--black-98);
+
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+}
+.titleContainer h4 {
+ font-size: 0.5em;
+ font-style: italic;
+ white-space: nowrap;
+}
+
+.placeholder {
+ text-decoration: line-through hsl(0, 0%, 60%) 1em;
+}
+.placeholder img {
+ background-color: hsl(0, 0%, 50%);
+}
+
+.postIconsContainer {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+
+ font-size: 0.5em;
+ color: var(--brand-50);
+}
+.postIconsContainer > div {
+ margin-left: 0.25em;
+}
diff --git a/src/web/components/post/Post.jsx b/src/web/components/post/Post.jsx
new file mode 100644
index 0000000..3780a38
--- /dev/null
+++ b/src/web/components/post/Post.jsx
@@ -0,0 +1,63 @@
+import React from "react";
+import {useLocation, useNavigate} from "react-router-dom";
+import dayjs from "dayjs";
+import * as relativeTime from "dayjs/plugin/relativeTime";
+
+import Card from "components/card/Card.jsx";
+import styles from "./Post.css";
+import {getInfoFromSubreact} from "helpers/Location.jsx";
+
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+
+dayjs.extend(relativeTime);
+
+function TopRow({title, timeUpdated}) {
+ return (
+ <div className={styles.titleContainer}>
+ <h3>
+ {title}
+ </h3>
+ <h4>
+ {dayjs.unix(timeUpdated).fromNow()}
+ </h4>
+ </div>
+ );
+}
+
+function MiddleRow({contents}) {
+ return (
+ <h4>
+ {contents}
+ </h4>
+ );
+}
+
+function BottomRow({subreact}) {
+ const {icon, name} = getInfoFromSubreact(subreact);
+
+ return (
+ <div className={styles.postIconsContainer}>
+ <FontAwesomeIcon icon={icon} className={styles.subreactIcon} />
+ <div>
+ {name}
+ </div>
+ </div>
+ );
+}
+
+export default function Post({title, contents, timeUpdated, placeholder, subreact, uid}) {
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ return (
+ <Card className={styles.postCard + (placeholder ? " " + styles.placeholder : "")}
+ onClick={() => {navigate("/" + subreact + "/" + uid);}}>
+ <img src={placeholder ? null : "/image/" + uid + ".png?thumbnail=1"} />
+ <div className={styles.rowContainer}>
+ <TopRow title={title} timeUpdated={timeUpdated} />
+ <MiddleRow contents={contents} />
+ <BottomRow subreact={subreact} />
+ </div>
+ </Card>
+ );
+} \ No newline at end of file
diff --git a/src/web/components/posts/Posts.css b/src/web/components/posts/Posts.css
new file mode 100644
index 0000000..1bee23a
--- /dev/null
+++ b/src/web/components/posts/Posts.css
@@ -0,0 +1,84 @@
+.container {
+ position: relative;
+
+ display: flex;
+ flex-direction: column;
+ justify-content: stretch;
+
+ width: 100%;
+}
+.containerPadding {
+ padding: 1rem 1rem 0 1rem;
+ overflow: scroll;
+}
+.postContainer {
+ flex-basis: 100%;
+ transition: opacity var(--transition-time) ease-in-out;
+}
+.postContainerActive {
+ cursor: pointer;
+ opacity: 50%;
+}
+.blockEvents {
+ pointer-events: none;
+}
+
+.container > h1 {
+ font-size: 1.8em;
+
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+
+ color: white;
+ font-weight: bolder;
+ text-align: center;
+}
+.container > h2 {
+ color: var(--black-95);
+ text-align: center;
+}
+.container > h3 {
+ margin-top: 1em;
+ padding: 1.5em 2em;
+
+ color: var(--brand-100);
+ opacity: 0.5;
+ background-color: var(--black-5);
+ border-radius: 1em;
+}
+.container > button {
+ align-self: center;
+
+ margin-top: 1.5em;
+
+ text-transform: uppercase;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.containerFade {
+ animation: containerFade var(--transition-time) linear;
+}
+@keyframes containerFade {
+ 0% {
+ opacity: 0%;
+ }
+ 100% {
+ opacity: 100%;
+ }
+}
+
+.containerLoading {
+ filter: blur(1px);
+ pointer-events: none;
+ animation: containerLoading 0.6s infinite;
+ mask-image: linear-gradient(to bottom, black 0%, transparent 22em);
+}
+@keyframes containerLoading {
+ 0%, 100% {
+ opacity: 100%;
+ }
+ 50% {
+ opacity: 80%;
+ }
+}
diff --git a/src/web/components/posts/Posts.jsx b/src/web/components/posts/Posts.jsx
new file mode 100644
index 0000000..3944f67
--- /dev/null
+++ b/src/web/components/posts/Posts.jsx
@@ -0,0 +1,163 @@
+import React, {useState, useEffect, useRef} from "react";
+import {Route, Routes, useLocation, useNavigate} from "react-router-dom";
+
+
+import Button from "components/button/Button.jsx";
+import Submission from "components/submission/Submission.jsx";
+import Post from "components/post/Post.jsx";
+import {getSubreactFromLocation} from "helpers/Location.jsx";
+
+import styles from "./Posts.css";
+
+async function makePostsRequest(location, setPosts, setError, mounted) {
+ const init = {
+ method: "GET",
+ referrer: "same-origin",
+ }
+ const url = "/api/posts?" + new URLSearchParams({
+ page: 0,
+ amount: 25,
+ subreact: getSubreactFromLocation(location),
+ });
+ return fetch(url, init)
+ .then((response) => {
+ return response.json()
+ .catch(() => {
+ throw new Error("unexpected response from server")
+ })
+ })
+ .then((json) => {
+ if ("error" in json) {
+ throw new Error(json.error);
+ }
+ if (!mounted) {
+ return;
+ }
+ setPosts(json.posts);
+ })
+ .catch((error) => {
+ console.log(error);
+ if (!mounted) {
+ return;
+ }
+ setError(error.message);
+ });
+}
+
+function PostsError({navigate, error}) {
+ return (
+ <div className={styles.container + " " + styles.containerFade}>
+ <h1>Something's wrong!</h1>
+ <h2>Sorry, but an unexpected issue has forced us to stop early. Here are the technical details:</h2>
+ <h3>Error: {error}</h3>
+ <Button onClick={() => {navigate(0);}}>
+ Try again
+ </Button>
+ </div>
+ );
+}
+
+function PostsLoading() {
+ return (
+ <div className={styles.container + " " + styles.containerLoading}>
+ {[...Array(3)].map((_, i) =>
+ <Post placeholder key={i} />
+ )}
+ </div>
+ );
+}
+
+function PostsEmpty({navigate}) {
+ return (
+ <div className={styles.container + " " + styles.containerFade}>
+ <h1>There's nothing here?!?</h1>
+ <h2>Try changing subreacts.</h2>
+ <Button onClick={() => {navigate("/");}}>
+ Take me home
+ </Button>
+ </div>
+ );
+}
+
+function PostsStates({error, posts, navigate}) {
+ if (error !== null) { // Error message.
+ return (
+ <PostsError navigate={navigate} error={error} />
+ );
+ }
+
+ if (posts == null) {
+ return (
+ <PostsLoading />
+ )
+ }
+
+
+ if (posts.length == 0) {
+ return (
+ <PostsEmpty navigate={navigate} />
+ );
+ }
+
+ return (
+ posts.map(({uid, title, contents, time_updated, thumbnail, subreact}, i) =>
+ <Post key={i}
+ uid={uid}
+ title={title}
+ contents={contents}
+ timeUpdated={time_updated}
+ image={thumbnail}
+ subreact={subreact} />)
+ );
+}
+
+export default function Posts() {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const locationRef = useRef(location);
+
+ const [posts, setPosts] = useState(null);
+ const [error, setError] = useState(null);
+ const [submissionActive, setSubmissionActive] = useState(false);
+
+
+ // We make a request if we're at the homepage or if we're changing subreacts.
+ const shouldMakeRequest = () => {
+ if (posts === null) {
+ return true;
+ }
+ const [previous, current] =
+ [locationRef.current, location].map((loc) => getSubreactFromLocation(loc));
+ return previous != current;
+ };
+ // This is a bit ugly, we're caching off our previous location so that we know
+ // if we should update or not.
+ useEffect(() => {
+ let mounted = true;
+
+ if (shouldMakeRequest()) {
+ setPosts(null);
+ makePostsRequest(location, setPosts, setError, mounted);
+ }
+
+ locationRef.current = location;
+ return () => {
+ setError(null);
+ mounted = false;
+ }
+ }, [location]);
+
+ return (
+ <div className={styles.container}>
+ <div className={styles.containerPadding}>
+ <div className={styles.postContainer + (submissionActive ? " " + styles.postContainerActive : "")}
+ onClick={(submissionActive ? () => {setSubmissionActive(false);} : null)}>
+ <div className={(submissionActive ? styles.blockEvents : "")}>
+ <PostsStates error={error} posts={posts} navigate={navigate}/>
+ </div>
+ </div>
+ </div>
+ <Submission active={submissionActive} setActive={setSubmissionActive} />
+ </div>
+ );
+}
diff --git a/src/web/components/settings/Settings.css b/src/web/components/settings/Settings.css
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/web/components/settings/Settings.css
diff --git a/src/web/components/settings/Settings.jsx b/src/web/components/settings/Settings.jsx
new file mode 100644
index 0000000..e2068a1
--- /dev/null
+++ b/src/web/components/settings/Settings.jsx
@@ -0,0 +1,9 @@
+import React from "react";
+import styles from "./Settings.css";
+
+export default function Settings() {
+
+ return (
+ <p>settings route!</p>
+ );
+} \ No newline at end of file
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
diff --git a/src/web/contexts/StateContext.jsx b/src/web/contexts/StateContext.jsx
new file mode 100644
index 0000000..91579a4
--- /dev/null
+++ b/src/web/contexts/StateContext.jsx
@@ -0,0 +1,17 @@
+import React, {createContext, useState} from "react";
+
+// Our StateContext are some variables which we want shared between components.
+const StateContext = createContext(null);
+function StateContextProvider({children}) {
+ const state = useState({
+ loginActive: false,
+ });
+
+ return (
+ <StateContext.Provider value={state}>
+ {children}
+ </StateContext.Provider>
+ );
+}
+
+export {StateContext, StateContextProvider}; \ No newline at end of file
diff --git a/src/web/contexts/UserContext.jsx b/src/web/contexts/UserContext.jsx
new file mode 100644
index 0000000..82e4f05
--- /dev/null
+++ b/src/web/contexts/UserContext.jsx
@@ -0,0 +1,16 @@
+import React, {createContext, useState} from "react";
+
+import {maybeGetUser} from "helpers/Auth.jsx";
+
+const UserContext = createContext(null);
+function UserContextProvider({children}) {
+ const state = useState(maybeGetUser());
+
+ return (
+ <UserContext.Provider value={state}>
+ {children}
+ </UserContext.Provider>
+ );
+}
+
+export {UserContext, UserContextProvider}; \ No newline at end of file
diff --git a/src/web/helpers/Auth.jsx b/src/web/helpers/Auth.jsx
new file mode 100644
index 0000000..bbbe577
--- /dev/null
+++ b/src/web/helpers/Auth.jsx
@@ -0,0 +1,66 @@
+import {getCookie, setCookie} from "react-use-cookie";
+import jwtDecode from "jwt-decode";
+
+export async function refreshToken({user, setUser}) {
+ const init = {
+ method: "POST",
+ referrer: "same-origin",
+ };
+ return fetch("/api/refresh", init)
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error("server rejected refresh");
+ }
+ const user = maybeGetUser();
+ if (user == null) {
+ throw new Error("bad token response");
+ }
+ setUser(user);
+ })
+ .catch((error) => {
+ console.log(error);
+ setUser(null);
+ })
+}
+
+export function maybeGetUser() {
+ const token = getCookie("token");
+ if (token == null || token == "") {
+ return null;
+ }
+
+ const jwt = jwtDecode(token);
+ if (jwt.iss !== "react-go-forum") {
+ return null;
+ }
+
+ const expiry_date = (() => {
+ var time = new Date(Date.UTC, 0, 1);
+ time.setUTCSeconds(jwt.exp);
+ return time;
+ })();
+ if (expiry_date <= new Date(Date.now())) {
+ return null;
+ }
+
+ return {
+ id: jwt.sub,
+ expiry: expiry_date,
+ }
+}
+
+export function maybeAuthFromCookie(userState) {
+ const user = maybeGetUser();
+ if (user == null) {
+ return false;
+ }
+
+ const [_, setUser] = userState;
+ setUser(user);
+ return true;
+}
+
+export function logout(setUser) {
+ setCookie("token", "", {path: "/"});
+ setUser(null);
+} \ No newline at end of file
diff --git a/src/web/helpers/Input.jsx b/src/web/helpers/Input.jsx
new file mode 100644
index 0000000..55f6016
--- /dev/null
+++ b/src/web/helpers/Input.jsx
@@ -0,0 +1,31 @@
+export function getFieldError(field, minmax) {
+ if (field == null || field.length == 0) {
+ return "Required"
+ }
+ const [min, max] = minmax;
+ if (field.length < min) {
+ return "Too short";
+ }
+ if (field.length > max) {
+ return "Too long";
+ }
+ return null;
+}
+
+// Removes consecutive spaces.
+export function cleanField(field) {
+ return field.replace(/\s{2,}/g, " ").replace(/\n{2,}/g, "\n");
+}
+
+export function correctFieldAnimate(field, setField, fieldErrors) {
+ if (fieldErrors == null) {
+ return;
+ }
+ setField({...field, animating: true, erroring: true});
+}
+
+// Requires lower case extension.
+export function isSupportedExtension(ext) {
+ const extensions = ["png", "jpeg", "jpg"];
+ return extensions.some((e) => e == ext);
+} \ No newline at end of file
diff --git a/src/web/helpers/Location.jsx b/src/web/helpers/Location.jsx
new file mode 100644
index 0000000..cae0b9e
--- /dev/null
+++ b/src/web/helpers/Location.jsx
@@ -0,0 +1,29 @@
+import {faHouse,
+ faBolt,
+ faCode,
+ faCamera,
+ faCar,
+ faWrench,
+ faGamepad,
+ faQuestion} from "@fortawesome/free-solid-svg-icons";
+
+
+export function getSubreactFromLocation(location) {
+ const paths = location.pathname.split("/");
+ return paths.length >= 1 ? paths[1] : "";
+}
+
+export function getInfoFromSubreact(subreact) {
+ switch (subreact) {
+ case "": return {icon: faHouse, name: "home"}
+ case "t": return {icon: faBolt, name: "technology"}
+ case "g": return {icon: faGamepad, name: "gaming"}
+ case "k": return {icon: faCode, name: "programming"}
+ case "p": return {icon: faCamera, name: "photography"}
+ case "a": return {icon: faCar, name: "automobiles"}
+ case "pr": return {icon: faWrench, name: "projects"}
+ case "m": return {icon: faQuestion, name: "miscellaneous"}
+ default: break;
+ }
+ return {};
+} \ No newline at end of file
diff --git a/src/web/index.css b/src/web/index.css
new file mode 100644
index 0000000..ff68a04
--- /dev/null
+++ b/src/web/index.css
@@ -0,0 +1,60 @@
+:root {
+ --nav-bar-z-index: 1;
+ --menu-z-index: 2;
+ --login-z-index: 3;
+}
+
+:root {
+ --transition-time: 0.15s;
+
+ --black-100: hsl(0, 0%, 100%);
+ --black-98: hsl(0, 0%, 98%);
+ --black-95: hsl(0, 0%, 95%);
+ --black-90: hsl(0, 0%, 90%);
+ --black-80: hsl(0, 0%, 80%);
+ --black-20: hsl(0, 0%, 20%);
+ --black-15: hsl(0, 0%, 15%);
+ --black-10: hsl(0, 0%, 10%);
+ --black-5: hsl(0, 0%, 5%);
+ --black-0: hsl(0, 0%, 0%);
+
+ --brand-100: hsl(188, 100%, 50%);
+ --brand-90: hsl(188, 90%, 50%);
+ --brand-80: hsl(188, 80%, 50%);
+ --brand-70: hsl(188, 70%, 50%);
+ --brand-60: hsl(188, 60%, 50%);
+ --brand-50: hsl(188, 50%, 50%);
+ --brand-40: hsl(188, 40%, 50%);
+ --brand-30: hsl(188, 30%, 50%);
+ --brand-20: hsl(188, 20%, 50%);
+ --brand-10: hsl(188, 10%, 50%);
+}
+
+:root {
+ font-size: calc(1vw + 0.6em);
+ font-family: "Heebo", sans-serif;
+ letter-spacing: 0.04em;
+ overscroll-behavior: none;
+
+ /*Disable copying, TODO turn this back on for appropriate fields.*/
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ -o-user-select: none;
+ user-select: none;
+}
+@media (min-width: 50em) {
+ :root {
+ font-size: 1.125em;
+ }
+}
+
+:root {
+ box-sizing: border-box;
+}
+*,
+*::before,
+*::after {
+ box-sizing: inherit;
+}
diff --git a/src/web/index.html b/src/web/index.html
new file mode 100644
index 0000000..faaf03c
--- /dev/null
+++ b/src/web/index.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>GoReact</title>
+ </head>
+
+ <body style="margin:0;">
+ <noscript>
+ <p style="text-align: center;"><strong>JavaScript is required for this site to function.</strong></p>
+ </noscript>
+ <div id="root" style="display: flex; flex-direction: column; min-height: 100%">
+ </div>
+ </body>
+</html>
diff --git a/src/web/index.jsx b/src/web/index.jsx
new file mode 100644
index 0000000..188b386
--- /dev/null
+++ b/src/web/index.jsx
@@ -0,0 +1,24 @@
+import React from "react";
+import {render} from "react-dom";
+import {BrowserRouter, Routes, Route} from "react-router-dom";
+
+import App from "./components/app/App.jsx";
+import NotFound from "./components/not_found/NotFound.jsx";
+
+import "./reset.css";
+import "./index.css";
+import "@fontsource/heebo";
+
+function BrowserRoutes() {
+ return (
+ <BrowserRouter>
+ <Routes>
+ <Route path="/*" element={<App />} />
+ <Route path="*" element={<NotFound />} />
+ </Routes>
+ </BrowserRouter>
+ );
+}
+
+const root = document.getElementById("root");
+render(<BrowserRoutes />, root);
diff --git a/src/web/reset.css b/src/web/reset.css
new file mode 100644
index 0000000..5f5af49
--- /dev/null
+++ b/src/web/reset.css
@@ -0,0 +1,47 @@
+/* http://meyerweb.com/eric/tools/css/reset/
+ v2.0 | 20110126
+ License: none (public domain)
+*/
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite, code,
+del, dfn, em, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+b, u, i, center,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, embed,
+figure, figcaption, footer, header, hgroup,
+menu, nav, output, ruby, section, summary,
+time, mark, audio, video {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-size: 100%;
+ font: inherit;
+ vertical-align: baseline;
+}
+/* HTML5 display-role reset for older browsers */
+article, aside, details, figcaption, figure,
+footer, header, hgroup, menu, nav, section {
+ display: block;
+}
+body {
+ line-height: 1;
+}
+ol, ul {
+ list-style: none;
+}
+blockquote, q {
+ quotes: none;
+}
+blockquote:before, blockquote:after,
+q:before, q:after {
+ content: '';
+ content: none;
+}
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+} \ No newline at end of file
diff --git a/src/web/styles/animations.css b/src/web/styles/animations.css
new file mode 100644
index 0000000..bdd21b7
--- /dev/null
+++ b/src/web/styles/animations.css
@@ -0,0 +1,21 @@
+.shake { /* idea from css in depth book */
+ animation: shake 0.3s linear;
+}
+@keyframes shake {
+ 0%,
+ 100% {
+ transform: translateX(0);
+ }
+ 25% {
+ transform: translateX(-0.4em);
+ }
+ 50% {
+ transform: translateX(0.4em);
+ }
+ 75% {
+ transform: translateX(-0.3em);
+ }
+ 90% {
+ transform: translateX(-0.15em);
+ }
+} \ No newline at end of file