aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--.vscode/tasks.json24
-rw-r--r--README.md31
-rw-r--r--package.json32
-rwxr-xr-xrun_server.sh23
-rw-r--r--src/server/database/database.go74
-rw-r--r--src/server/database/image.go31
-rw-r--r--src/server/database/post.go28
-rw-r--r--src/server/database/posts.go63
-rw-r--r--src/server/database/user.go55
-rw-r--r--src/server/go.mod11
-rw-r--r--src/server/go.sum32
-rw-r--r--src/server/handlers/image.go41
-rw-r--r--src/server/handlers/login.go55
-rw-r--r--src/server/handlers/logout.go20
-rw-r--r--src/server/handlers/post.go121
-rw-r--r--src/server/handlers/posts.go60
-rw-r--r--src/server/handlers/refresh.go32
-rw-r--r--src/server/handlers/signup.go75
-rw-r--r--src/server/helper/clean.go37
-rw-r--r--src/server/helper/crypto.go46
-rw-r--r--src/server/helper/error.go36
-rw-r--r--src/server/helper/jwt.go66
-rw-r--r--src/server/helper/valid.go70
-rw-r--r--src/server/main.go42
-rw-r--r--src/server/spa.go37
-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
-rw-r--r--webpack.config.js59
72 files changed, 3518 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..46470df
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/dist
+/node_modules
+/server
+package-lock.json
+react_go.db \ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..df5f984
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,24 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "shell",
+ "group": {
+ "kind": "build",
+ "isDefault": true
+ },
+ "command": "./run_server.sh",
+ "label": "./run_server.sh",
+ "detail": "webpack --mode=production",
+ "problemMatcher": []
+ },
+ {
+ "type": "npm",
+ "script": "build_release",
+ "group": "build",
+ "problemMatcher": [],
+ "label": "npm: build_release",
+ "detail": "webpack --mode=production"
+ }
+ ]
+} \ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f744995
--- /dev/null
+++ b/README.md
@@ -0,0 +1,31 @@
+# react-go-forum
+
+A self-contained mobile-first forum written in React and Go.
+
+## Dependencies
+
+- [npm](https://npmjs.com) : npm is a package manager for the JavaScript programming language.
+- [Go](https://go.dev) : Go is a statically typed, compiled programming language.
+
+## Building from Source
+
+First, clone the repository using Git.
+```console
+ $ git clone https://salvestromartin.com:443/nJ3ahxac/react-go-forum.git
+```
+
+Then compile from source via npm after moving into the project directory.
+
+```console
+ $ cd ./react-go-forum
+ $ npm update
+```
+
+## Usage
+
+A helper script has been provided which compiles the React and Go code and runs
+the server.
+
+```console
+ $ ./run_server.sh
+```
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..b6be086
--- /dev/null
+++ b/package.json
@@ -0,0 +1,32 @@
+{
+ "private": true,
+ "scripts": {
+ "build": "webpack --mode=development",
+ "build_release": "webpack --mode=production"
+ },
+ "devDependencies": {
+ "@babel/plugin-transform-modules-amd": "^7.16.5",
+ "@babel/preset-env": "^7.16.5",
+ "@babel/preset-react": "^7.16.5",
+ "babel-loader": "^8.2.3",
+ "css-loader": "^6.7.1",
+ "html-webpack-plugin": "^4.5.2",
+ "style-loader": "^3.3.1",
+ "webpack": "^5.65.0",
+ "webpack-cli": "^4.9.1"
+ },
+ "dependencies": {
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+ "@fontsource/heebo": "^4.5.14",
+ "@fortawesome/free-solid-svg-icons": "^6.2.1",
+ "@fortawesome/react-fontawesome": "^0.2.0",
+ "base64-arraybuffer": "^1.0.2",
+ "dayjs": "^1.11.7",
+ "jwt-decode": "^3.1.2",
+ "prop-types": "^15.8.1",
+ "react": "^17.0.2",
+ "react-dom": "^17.0.2",
+ "react-router-dom": "^6.2.1",
+ "react-use-cookie": "^1.4.0"
+ }
+}
diff --git a/run_server.sh b/run_server.sh
new file mode 100755
index 0000000..cc74379
--- /dev/null
+++ b/run_server.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+
+SERVER_DIR=$"./src/server/"
+SERVER_NAME=$"server"
+SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}")
+
+cd "${SCRIPT_DIR}" || exit
+if ! (npm run build) ; then
+ printf "\x1b[31mnpm build failed! stopping\n\x1b[0m" >&2
+ exit 1
+fi
+
+if ! (cd "${SERVER_DIR}" && go build && mv "${SERVER_NAME}" ../../); then
+ printf "\x1b[31mgo build failed! stopping\n\x1b[0m" >&2
+ exit 1
+fi
+
+if ! (sudo setcap 'cap_net_bind_service=+ep' "${SERVER_NAME}"); then
+ # We don't return here because this might not be necessary.
+ printf "\x1b[31mfailed to provide setcap privileges to binary\x1b[0m" >&2
+fi
+
+./${SERVER_NAME}
diff --git a/src/server/database/database.go b/src/server/database/database.go
new file mode 100644
index 0000000..0b5bb4f
--- /dev/null
+++ b/src/server/database/database.go
@@ -0,0 +1,74 @@
+package database
+
+import (
+ "database/sql"
+ "log"
+
+ // We're using sqlite3 because we want to make deploying the project easy.
+ // (ie, no need to set up a postgres server for a little forum application).
+ _ "github.com/mattn/go-sqlite3"
+)
+
+var db *sql.DB
+
+// Initialises the tables in the database (if they do not exist).
+func setup_db(db *sql.DB) error {
+ // FIXME executing multiple queries with one statement is definitely slower
+ // than a single query with multiple statments. IDK if our sqlite3 driver
+ // supports this, but a rewrite is needed if it does.
+ statements := [...]string{
+ ("CREATE TABLE IF NOT EXISTS Users(" +
+ "uid INTEGER PRIMARY KEY AUTOINCREMENT," +
+ "email TEXT NOT NULL UNIQUE," +
+ "username TEXT," +
+ "password_hash TEXT NOT NULL," +
+ "password_salt TEXT NOT NULL);"),
+ ("CREATE TABLE IF NOT EXISTS Posts(" +
+ "uid INTEGER PRIMARY KEY AUTOINCREMENT," +
+ "author INTEGER NOT NULL," +
+ "parent INTEGER," +
+ "time INTEGER NOT NULL," +
+ "subreact TEXT NOT NULL," +
+ "title TEXT NOT NULL," +
+ "contents TEXT NOT NULL," +
+ "thumbnail BLOB NOT NULL," +
+ "image BLOB NOT NULL," +
+
+ "FOREIGN KEY (author) REFERENCES Users (uid)," +
+ "FOREIGN KEY (parent) REFERENCES Posts (uid));"),
+ }
+
+ for _, statement := range statements {
+ _, err := db.Exec(statement)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// Call to initialise the database global var.
+func Init(path string) {
+ var err error
+ db, err = sql.Open("sqlite3", path)
+ if err != nil {
+ log.Fatal(err)
+ }
+ err = db.Ping()
+ if err != nil {
+ log.Fatal(err)
+ }
+ err = setup_db(db)
+ if err != nil {
+ log.Fatal(err)
+ }
+}
+
+func GetDb() *sql.DB {
+ return db
+}
+
+// Call to close connection to database.
+func Close() {
+ db.Close()
+}
diff --git a/src/server/database/image.go b/src/server/database/image.go
new file mode 100644
index 0000000..d85063f
--- /dev/null
+++ b/src/server/database/image.go
@@ -0,0 +1,31 @@
+package database
+
+import (
+ "database/sql"
+)
+
+func getThumbnailOrImage(thumbnail bool) string {
+ if thumbnail {
+ return "Posts.thumbnail "
+ }
+ return "Posts.image "
+}
+
+func GetImage(uid int, thumbnail bool) ([]byte, error) {
+ var db = GetDb()
+
+ var image []byte
+ row := db.QueryRow("SELECT "+getThumbnailOrImage(thumbnail)+
+ "FROM Posts "+
+ "WHERE Posts.uid = ?;", uid)
+ if err := row.Scan(&image); err != nil {
+
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+
+ return nil, err
+ }
+
+ return image, nil
+}
diff --git a/src/server/database/post.go b/src/server/database/post.go
new file mode 100644
index 0000000..d73344a
--- /dev/null
+++ b/src/server/database/post.go
@@ -0,0 +1,28 @@
+package database
+
+import (
+ "time"
+)
+
+// Writes a post to the database, returns the uid of the new post.
+func WritePost(author int,
+ parent *int,
+ title string,
+ contents string,
+ subreact string,
+ image []byte,
+ thumbnail []byte) (int, error) {
+
+ var db = GetDb()
+
+ var uid int
+ row := db.QueryRow("INSERT INTO Posts "+
+ "(author, parent, time, subreact, title, contents, thumbnail, image) "+
+ "VALUES(?, ?, ?, ?, ?, ?, ?, ?) RETURNING Posts.uid;",
+ author, parent, time.Now().Unix(), subreact, title, contents, thumbnail, image)
+ if err := row.Scan(&uid); err != nil {
+ return 0, err
+ }
+
+ return uid, nil
+}
diff --git a/src/server/database/posts.go b/src/server/database/posts.go
new file mode 100644
index 0000000..033f5ae
--- /dev/null
+++ b/src/server/database/posts.go
@@ -0,0 +1,63 @@
+package database
+
+import (
+ "database/sql"
+)
+
+type Post struct {
+ Uid int `json:"uid"`
+ Author string `json:"author"`
+ TimeUpdated int `json:"time_updated"`
+ Subreact string `json:"subreact"`
+ Title string `json:"title"`
+ Contents string `json:"contents"`
+}
+
+func GetPosts(subreact string, page int, amount int) ([]Post, error) {
+ var db = GetDb()
+
+ // This gets posts without parents (threads) and its latest updated time,
+ // which could be the post itself.
+ const query = "SELECT Parent.uid, Parent.author, COALESCE(Latest.updated_time, Parent.time) AS bump_time, Parent.subreact, " +
+ "Parent.title, Parent.contents " +
+ "FROM Posts as Parent " +
+ " LEFT OUTER JOIN ( " +
+ " SELECT Posts.parent, Posts.uid, MAX(Posts.time) AS updated_time " +
+ " FROM Posts " +
+ " WHERE Posts.parent IS NOT NULL " +
+ " ORDER BY Posts.time " +
+ " ) AS LATEST " +
+ " ON Latest.parent = Parent.uid " +
+ "WHERE Parent.subreact = (CASE WHEN (? = \"\") then Parent.subreact ELSE ? END) " +
+ "ORDER BY bump_time DESC " +
+ "LIMIT ? " +
+ "OFFSET ?;"
+
+ rows, err := db.Query(query, subreact, subreact, amount, page*amount)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ posts := make([]Post, 0)
+ for rows.Next() {
+ var post Post
+ if err = rows.Scan(
+ &post.Uid,
+ &post.Author,
+ &post.TimeUpdated,
+ &post.Subreact,
+ &post.Title,
+ &post.Contents); err != nil {
+
+ if err == sql.ErrNoRows {
+ return posts, nil
+ }
+
+ return nil, err
+ }
+ posts = append(posts, post)
+ }
+
+ return posts, nil
+}
diff --git a/src/server/database/user.go b/src/server/database/user.go
new file mode 100644
index 0000000..14530ae
--- /dev/null
+++ b/src/server/database/user.go
@@ -0,0 +1,55 @@
+package database
+
+import (
+ "database/sql"
+)
+
+type User struct {
+ Uid int
+ Email string
+ Username *string
+ Password_hash string
+ Password_salt string
+}
+
+// Gets a user struct from an email, returns nil-nil if the user did not exist.
+func MaybeGetUser(email string) (*User, error) {
+ var db = GetDb()
+
+ row := db.QueryRow(
+ "SELECT Users.uid, Users.email, Users.username, Users.password_hash, Users.password_salt "+
+ "FROM Users "+
+ "WHERE Users.email = ?;", email)
+
+ var user User
+ if err := row.Scan(
+ &user.Uid,
+ &user.Email,
+ &user.Username,
+ &user.Password_hash,
+ &user.Password_salt); err != nil {
+
+ if err != sql.ErrNoRows {
+ return nil, err
+ }
+
+ return nil, nil
+ }
+
+ return &user, nil
+}
+
+// Writes a new user into the database, returns the user's new uid if no error occurred.
+func WriteNewUser(email string, password_hash string, password_salt string) (int, error) {
+ var db = GetDb()
+
+ var uid int
+ row := db.QueryRow("INSERT INTO Users "+
+ "(email, password_hash, password_salt) "+
+ "VALUES(?, ?, ?) RETURNING Users.uid;", email, password_hash, password_salt)
+ if err := row.Scan(&uid); err != nil {
+ return 0, err
+ }
+
+ return uid, nil
+}
diff --git a/src/server/go.mod b/src/server/go.mod
new file mode 100644
index 0000000..14610ea
--- /dev/null
+++ b/src/server/go.mod
@@ -0,0 +1,11 @@
+module server
+
+go 1.18
+
+require github.com/mattn/go-sqlite3 v1.14.16
+
+require (
+ github.com/golang-jwt/jwt/v4 v4.4.3 // indirect
+ golang.org/x/crypto v0.4.0 // indirect
+ golang.org/x/image v0.4.0 // indirect
+)
diff --git a/src/server/go.sum b/src/server/go.sum
new file mode 100644
index 0000000..78079a8
--- /dev/null
+++ b/src/server/go.sum
@@ -0,0 +1,32 @@
+github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
+github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
+github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
+golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
+golang.org/x/image v0.4.0 h1:x1RWAiZIvERqkltrFjtQP1ycmiR5pmhjtCfVOtdURuQ=
+golang.org/x/image v0.4.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/src/server/handlers/image.go b/src/server/handlers/image.go
new file mode 100644
index 0000000..99c9c18
--- /dev/null
+++ b/src/server/handlers/image.go
@@ -0,0 +1,41 @@
+package handlers
+
+import (
+ "net/http"
+ "regexp"
+ "server/database"
+ "server/helper"
+ "strconv"
+)
+
+func Image(writer http.ResponseWriter, request *http.Request) {
+ if request.Method != "GET" {
+ helper.WriteErrorJson("expected GET method", writer, http.StatusBadRequest)
+ return
+ }
+
+ re := regexp.MustCompile(`^/image/([0-9]+).png$`)
+ submatches := re.FindStringSubmatch(request.URL.Path)
+ if len(submatches) != 2 {
+ http.Error(writer, "invalid URL", http.StatusBadRequest)
+ return
+ }
+
+ uid, err := strconv.Atoi(submatches[1])
+ if err != nil {
+ helper.WriteInternalError(err, writer)
+ return
+ }
+
+ image_blob, err := database.GetImage(uid, request.URL.Query().Get("thumbnail") == "1")
+ if err != nil {
+ helper.WriteInternalError(err, writer)
+ return
+ }
+ if image_blob == nil {
+ http.Error(writer, "resource not found", http.StatusNotFound)
+ return
+ }
+
+ writer.Write(image_blob)
+}
diff --git a/src/server/handlers/login.go b/src/server/handlers/login.go
new file mode 100644
index 0000000..745e64a
--- /dev/null
+++ b/src/server/handlers/login.go
@@ -0,0 +1,55 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "server/database"
+ "server/helper"
+)
+
+type loginRequest struct {
+ Email string
+ Password string
+}
+
+func Login(writer http.ResponseWriter, request *http.Request) {
+ if request.Method != "POST" {
+ helper.WriteErrorJson("expected POST method", writer, http.StatusBadRequest)
+ return
+ }
+
+ var login_request loginRequest
+ err := json.NewDecoder(request.Body).Decode(&login_request)
+ if err != nil {
+ helper.WriteErrorJson(err.Error(), writer, http.StatusBadRequest)
+ return
+ }
+
+ user, err := database.MaybeGetUser(login_request.Email)
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+ if user == nil {
+ helper.WriteErrorJson("incorrect email or password", writer, http.StatusForbidden)
+ return
+ }
+
+ hash, err := helper.GenerateHash(login_request.Password, user.Password_salt)
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+ if hash != user.Password_hash {
+ helper.WriteErrorJson("incorrect email or password", writer, http.StatusForbidden)
+ return
+ }
+
+ // Login is successful, issue a valid jwt.
+ err = helper.IssueToken(user.Uid, writer)
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+}
diff --git a/src/server/handlers/logout.go b/src/server/handlers/logout.go
new file mode 100644
index 0000000..d3c8b9b
--- /dev/null
+++ b/src/server/handlers/logout.go
@@ -0,0 +1,20 @@
+package handlers
+
+import (
+ "net/http"
+ "server/helper"
+ "time"
+)
+
+func Logout(writer http.ResponseWriter, request *http.Request) {
+ if request.Method != "POST" {
+ helper.WriteErrorJson("expected POST method", writer, http.StatusBadRequest)
+ return
+ }
+
+ http.SetCookie(writer, &http.Cookie{
+ Name: "token",
+ Expires: time.Time{}, // zero value for time
+ Path: "/",
+ })
+}
diff --git a/src/server/handlers/post.go b/src/server/handlers/post.go
new file mode 100644
index 0000000..2c014ca
--- /dev/null
+++ b/src/server/handlers/post.go
@@ -0,0 +1,121 @@
+package handlers
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ "image/png"
+ "strconv"
+
+ "golang.org/x/image/draw"
+
+ "net/http"
+ "server/database"
+ "server/helper"
+)
+
+const thumbnail_size = 128
+
+func makeThumbnail(source image.Image) ([]byte, error) {
+ dest := image.NewRGBA(image.Rect(0, 0, thumbnail_size, thumbnail_size))
+ draw.NearestNeighbor.Scale(dest, dest.Rect, source, source.Bounds(), draw.Over, nil)
+
+ var buffer bytes.Buffer
+ err := png.Encode(&buffer, dest)
+ if err != nil {
+ return nil, err
+ }
+ return buffer.Bytes(), nil
+}
+
+type postRequest struct {
+ Title string
+ Contents string
+ Subreact string
+ File string
+}
+
+type postResponse struct {
+ Uid int `json:"uid"`
+}
+
+func Post(writer http.ResponseWriter, request *http.Request) {
+ if request.Method != "POST" {
+ helper.WriteErrorJson("expected GET method", writer, http.StatusBadRequest)
+ return
+ }
+
+ claims := helper.GetValidClaims(writer, request)
+ if claims == nil {
+ return
+ }
+ user_uid, err := strconv.Atoi(claims.Subject) // TODO UID
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+
+ var post_request postRequest
+ err = json.NewDecoder(request.Body).Decode(&post_request)
+ if err != nil {
+ helper.WriteErrorJson(err.Error(), writer, http.StatusBadRequest)
+ return
+ }
+
+ post_request.Title = helper.CleanTitle(post_request.Title)
+ if !helper.IsValidRange(post_request.Title, "title", 8, 128, writer) {
+ return
+ }
+
+ post_request.Contents = helper.CleanContents(post_request.Contents)
+ if !helper.IsValidRange(post_request.Contents, "contents", 8, 2048, writer) {
+ return
+ }
+
+ if !helper.IsValidSubreact(post_request.Subreact, writer) {
+ return
+ }
+
+ image_blob, err := base64.StdEncoding.DecodeString(post_request.File)
+ if err != nil {
+ helper.WriteErrorJson("failed to decode image from base64", writer, http.StatusBadRequest)
+ return
+ }
+ image, format, err := image.Decode(bytes.NewReader(image_blob))
+ if err != nil {
+ helper.WriteErrorJson("failed to decode image", writer, http.StatusBadRequest)
+ return
+ }
+ if !helper.IsImageSupported(image, format, writer) {
+ return
+ }
+
+ thumbnail_blob, err := makeThumbnail(image)
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+
+ image_uid, err := database.WritePost(user_uid,
+ nil,
+ post_request.Title,
+ post_request.Contents,
+ post_request.Subreact,
+ image_blob,
+ thumbnail_blob)
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+
+ resp, err := json.Marshal(postResponse{Uid: image_uid})
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+
+ writer.Write(resp)
+}
diff --git a/src/server/handlers/posts.go b/src/server/handlers/posts.go
new file mode 100644
index 0000000..00144db
--- /dev/null
+++ b/src/server/handlers/posts.go
@@ -0,0 +1,60 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+
+ "server/database"
+ "server/helper"
+)
+
+type postsResponse struct {
+ Posts []database.Post `json:"posts"`
+}
+
+func Posts(writer http.ResponseWriter, request *http.Request) {
+ if request.Method != "GET" {
+ helper.WriteErrorJson("expected GET method", writer, http.StatusBadRequest)
+ return
+ }
+
+ page, err := strconv.Atoi(request.URL.Query().Get("page"))
+ if err != nil {
+ helper.WriteErrorJson("expected page parameter", writer, http.StatusBadRequest)
+ return
+ }
+ if page < 0 {
+ helper.WriteErrorJson("expected page parameter >= 0", writer, http.StatusBadRequest)
+ return
+ }
+
+ amount, err := strconv.Atoi(request.URL.Query().Get("amount"))
+ if err != nil {
+ helper.WriteErrorJson("expected amount parameter", writer, http.StatusBadRequest)
+ return
+ }
+ if amount <= 0 {
+ helper.WriteErrorJson("expected amount parameter > 0", writer, http.StatusBadRequest)
+ return
+ }
+
+ subreact := request.URL.Query().Get("subreact")
+ if !helper.IsValidSubreact(subreact, writer) {
+ return
+ }
+
+ posts, err := database.GetPosts(subreact, page, amount)
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+
+ resp, err := json.Marshal(postsResponse{Posts: posts})
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+
+ writer.Write(resp)
+}
diff --git a/src/server/handlers/refresh.go b/src/server/handlers/refresh.go
new file mode 100644
index 0000000..bc60aa7
--- /dev/null
+++ b/src/server/handlers/refresh.go
@@ -0,0 +1,32 @@
+package handlers
+
+import (
+ "net/http"
+ "strconv"
+
+ "server/helper"
+)
+
+// Extends the current token's lifetime (by replacing it with a newer one).
+func Refresh(writer http.ResponseWriter, request *http.Request) {
+ if request.Method != "POST" {
+ helper.WriteErrorJson("expected POST method", writer, http.StatusBadRequest)
+ return
+ }
+
+ claims := helper.GetValidClaims(writer, request)
+ if claims == nil {
+ return
+ }
+
+ uid, err := strconv.Atoi(claims.Subject)
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+ err = helper.IssueToken(uid, writer)
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+}
diff --git a/src/server/handlers/signup.go b/src/server/handlers/signup.go
new file mode 100644
index 0000000..e92b869
--- /dev/null
+++ b/src/server/handlers/signup.go
@@ -0,0 +1,75 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "server/database"
+ "server/helper"
+)
+
+type signupRequest struct {
+ Email string
+ Password string
+}
+
+func Signup(writer http.ResponseWriter, request *http.Request) {
+ if request.Method != "POST" {
+ helper.WriteErrorJson("expected POST method", writer, http.StatusBadRequest)
+ return
+ }
+
+ var signup_request signupRequest
+ err := json.NewDecoder(request.Body).Decode(&signup_request)
+ if err != nil {
+ helper.WriteErrorJson(err.Error(), writer, http.StatusBadRequest)
+ return
+ }
+
+ if len(signup_request.Email) < 3 || len(signup_request.Email) > 254 {
+ helper.WriteErrorJson("invalid email address", writer, http.StatusBadRequest)
+ return
+ }
+ if len(signup_request.Password) < 8 {
+ helper.WriteErrorJson("password too short", writer, http.StatusBadRequest)
+ return
+ }
+ if len(signup_request.Password) > 64 {
+ helper.WriteErrorJson("password too long", writer, http.StatusBadRequest)
+ return
+ }
+
+ user, err := database.MaybeGetUser(signup_request.Email)
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+ if user != nil {
+ helper.WriteErrorJson("a user with that email already exists", writer, http.StatusForbidden)
+ return
+ }
+
+ salt, err := helper.GenerateSalt()
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+
+ hash, err := helper.GenerateHash(signup_request.Password, salt)
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+
+ uid, err := database.WriteNewUser(signup_request.Email, hash, salt)
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+
+ err = helper.IssueToken(uid, writer)
+ if err != nil {
+ helper.WriteInternalErrorJson(err, writer)
+ return
+ }
+}
diff --git a/src/server/helper/clean.go b/src/server/helper/clean.go
new file mode 100644
index 0000000..35937de
--- /dev/null
+++ b/src/server/helper/clean.go
@@ -0,0 +1,37 @@
+package helper
+
+import (
+ "regexp"
+)
+
+func removeTrailingWhitespace(str string) string {
+ re := regexp.MustCompile(`(^\s+)|(\s+$)`)
+ return re.ReplaceAllString(str, "")
+}
+
+func removeDuplicateWhitespace(str string) string {
+ re := regexp.MustCompile(`\s{2,}`)
+ return re.ReplaceAllString(str, " ")
+}
+
+func removeNewlines(str string) string {
+ re := regexp.MustCompile(`\n+`)
+ return re.ReplaceAllString(str, "")
+}
+
+func removeDuplicateNewlines(str string) string {
+ re := regexp.MustCompile(`\n{2,}`)
+ return re.ReplaceAllString(str, "\n")
+}
+
+func CleanTitle(title string) string {
+ title = removeDuplicateWhitespace(title)
+ title = removeNewlines(title)
+ return removeTrailingWhitespace(title)
+}
+
+func CleanContents(contents string) string {
+ contents = removeDuplicateWhitespace(contents)
+ contents = removeDuplicateNewlines(contents)
+ return removeTrailingWhitespace(contents)
+}
diff --git a/src/server/helper/crypto.go b/src/server/helper/crypto.go
new file mode 100644
index 0000000..d676fba
--- /dev/null
+++ b/src/server/helper/crypto.go
@@ -0,0 +1,46 @@
+package helper
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+
+ "golang.org/x/crypto/scrypt"
+)
+
+// Recommended https://pkg.go.dev/golang.org/x/crypto/scrypt
+const SCRYPT_N = 32768
+const SCRYPT_R = 8
+const SCRYPT_P = 1
+const SCRYPT_L = 64
+
+const SALT_LENGTH = SCRYPT_L
+
+func getEncoding() *base64.Encoding {
+ return base64.StdEncoding.WithPadding(base64.NoPadding)
+}
+
+// Generates truly random bytes.
+func generateRandomBytes(len int) ([]byte, error) {
+ salt := make([]byte, len)
+ _, err := rand.Read(salt)
+ if err != nil {
+ return nil, err
+ }
+ return salt, nil
+}
+
+func GenerateSalt() (string, error) {
+ salt, err := generateRandomBytes(SALT_LENGTH)
+ if err != nil {
+ return "", err
+ }
+ return getEncoding().EncodeToString(salt), nil
+}
+
+func GenerateHash(password string, salt string) (string, error) {
+ hash, err := scrypt.Key([]byte(password), []byte(salt), SCRYPT_N, SCRYPT_R, SCRYPT_P, SCRYPT_L)
+ if err != nil {
+ return "", err
+ }
+ return getEncoding().EncodeToString(hash), nil
+}
diff --git a/src/server/helper/error.go b/src/server/helper/error.go
new file mode 100644
index 0000000..8c76304
--- /dev/null
+++ b/src/server/helper/error.go
@@ -0,0 +1,36 @@
+package helper
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+)
+
+type jsonError struct {
+ Message string `json:"error"`
+}
+
+func WriteInternalError(err error, writer http.ResponseWriter) {
+ log.Printf("internal server error: %s\n", err)
+ http.Error(writer, "internal server error", http.StatusInternalServerError)
+}
+
+func WriteErrorJson(message string, writer http.ResponseWriter, code int) {
+ json_error := &jsonError{
+ Message: message,
+ }
+
+ marshal, err := json.Marshal(json_error)
+ if err != nil {
+ http.Error(writer, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ http.Error(writer, string(marshal), code)
+}
+
+// Logs the error and responds with a generic server error.
+func WriteInternalErrorJson(err error, writer http.ResponseWriter) {
+ log.Printf("internal server error: %s\n", err)
+ WriteErrorJson("internal server error", writer, http.StatusInternalServerError)
+}
diff --git a/src/server/helper/jwt.go b/src/server/helper/jwt.go
new file mode 100644
index 0000000..ae127bd
--- /dev/null
+++ b/src/server/helper/jwt.go
@@ -0,0 +1,66 @@
+package helper
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/golang-jwt/jwt/v4"
+)
+
+// WARNING this key should be secret and constant between deployments.
+// If you use this software in the wild, at the very least change this value!
+// https://www.sohamkamani.com/golang/jwt-authentication/
+var jwt_key = []byte("iph7noo1ohQuam5sou5wa2aeChixo7")
+
+func IssueToken(uid int, writer http.ResponseWriter) error {
+ expiration := time.Now().Add(60 * time.Minute)
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
+ Subject: strconv.Itoa(uid),
+ ExpiresAt: jwt.NewNumericDate(expiration),
+ Issuer: "react-go-forum",
+ })
+ token_string, err := token.SignedString(jwt_key)
+ if err != nil {
+ return err
+ }
+
+ http.SetCookie(writer, &http.Cookie{
+ Name: "token",
+ Value: token_string,
+ Expires: expiration,
+ SameSite: http.SameSiteStrictMode,
+ Path: "/",
+ })
+ return nil
+}
+
+// Returns a non-nil RegisteredClaims if valid, nil otherwise. Handles responding to bad tokens.
+func GetValidClaims(writer http.ResponseWriter, request *http.Request) *jwt.RegisteredClaims {
+ cookie, err := request.Cookie("token")
+ if err != nil {
+ if err == http.ErrNoCookie {
+ WriteErrorJson("access denied", writer, http.StatusUnauthorized)
+ return nil
+ }
+
+ WriteInternalErrorJson(err, writer)
+ return nil
+ }
+
+ claims := &jwt.RegisteredClaims{}
+ token, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (interface{}, error) {
+ return jwt_key, nil
+ })
+ if err != nil {
+ WriteInternalErrorJson(err, writer)
+ return nil
+ }
+
+ if !token.Valid {
+ WriteErrorJson("access denied", writer, http.StatusUnauthorized)
+ return nil
+ }
+
+ return claims
+}
diff --git a/src/server/helper/valid.go b/src/server/helper/valid.go
new file mode 100644
index 0000000..9363747
--- /dev/null
+++ b/src/server/helper/valid.go
@@ -0,0 +1,70 @@
+package helper
+
+import (
+ "image"
+ "net/http"
+)
+
+// Checks if the subreact exists, responds with a bad status when false.
+func IsValidSubreact(subreact string, writer http.ResponseWriter) bool {
+ switch subreact {
+ case "":
+ fallthrough
+ case "t":
+ fallthrough
+ case "g":
+ fallthrough
+ case "k":
+ fallthrough
+ case "p":
+ fallthrough
+ case "a":
+ fallthrough
+ case "pr":
+ fallthrough
+ case "m":
+ break
+ default:
+ WriteErrorJson("invalid subreact", writer, http.StatusBadRequest)
+ return false
+ }
+ return true
+}
+
+// Range checks the length of the argument, responds with a bad status when false.
+func IsValidRange(str string, name string, min int, max int, writer http.ResponseWriter) bool {
+ if len(str) < min {
+ WriteErrorJson(name+" too short", writer, http.StatusBadRequest)
+ return false
+ }
+ if len(str) > max {
+ WriteErrorJson(name+" too long", writer, http.StatusBadRequest)
+ return false
+ }
+ return true
+}
+
+// Checks that the image has supported properties, responds with a bad status when false.
+func IsImageSupported(image image.Image, format string, writer http.ResponseWriter) bool {
+ if bounds := image.Bounds(); bounds.Dx() > 4096 || bounds.Dy() > 4096 {
+ WriteErrorJson("image dimensions too large", writer, http.StatusBadRequest)
+ return false
+ } else if bounds.Dx() < 256 || bounds.Dy() < 256 {
+ WriteErrorJson("image dimensions too small", writer, http.StatusBadRequest)
+ return false
+ }
+
+ switch format {
+ case "png":
+ fallthrough
+ case "jpeg":
+ fallthrough
+ case "gif":
+ break
+ default:
+ WriteErrorJson("image format no recognised", writer, http.StatusBadRequest)
+ return false
+ }
+
+ return true
+}
diff --git a/src/server/main.go b/src/server/main.go
new file mode 100644
index 0000000..126edeb
--- /dev/null
+++ b/src/server/main.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+ "log"
+ "net/http"
+ "time"
+
+ "server/database"
+ "server/handlers"
+)
+
+func main() {
+ const database_path string = "./react_go.db"
+ const address string = "0.0.0.0"
+ const port string = "80"
+
+ database.Init(database_path)
+ defer database.Close()
+ log.Printf("database initialised at %s\n", database_path)
+
+ mux := http.NewServeMux()
+ spa := spaHandler{staticPath: "./dist"}
+ mux.Handle("/", spa)
+ mux.HandleFunc("/api/login", handlers.Login)
+ mux.HandleFunc("/api/logout", handlers.Logout)
+ mux.HandleFunc("/api/signup", handlers.Signup)
+ mux.HandleFunc("/api/refresh", handlers.Refresh)
+ mux.HandleFunc("/api/posts", handlers.Posts)
+ mux.HandleFunc("/api/post", handlers.Post)
+ mux.HandleFunc("/image/", handlers.Image)
+
+ server := &http.Server{
+ Addr: address + ":" + port,
+ Handler: mux,
+ /* This is good practice: https://github.com/gorilla/mux */
+ WriteTimeout: 15 * time.Second,
+ ReadTimeout: 15 * time.Second,
+ }
+
+ log.Printf("server listening on %s:%s\n", address, port)
+ log.Fatal(server.ListenAndServe())
+}
diff --git a/src/server/spa.go b/src/server/spa.go
new file mode 100644
index 0000000..1dd1e96
--- /dev/null
+++ b/src/server/spa.go
@@ -0,0 +1,37 @@
+package main
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+)
+
+// https://github.com/gorilla/mux#serving-single-page-applications
+type spaHandler struct {
+ staticPath string
+}
+
+func (h spaHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
+ path, err := filepath.Abs(request.URL.Path)
+ if err != nil {
+ http.Error(writer, err.Error(), http.StatusBadRequest)
+ }
+
+ path = filepath.Join(h.staticPath, path)
+ if _, err = os.Stat(path); os.IsNotExist(err) {
+
+ serve_path := filepath.Base(path)
+ if len(filepath.Ext(path)) == 0 {
+ serve_path = "index.html"
+ }
+ serve_path = filepath.Join(h.staticPath, serve_path)
+
+ http.ServeFile(writer, request, serve_path)
+ return
+ } else if err != nil {
+ http.Error(writer, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ http.FileServer(http.Dir(h.staticPath)).ServeHTTP(writer, request)
+}
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
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 0000000..ab9a777
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,59 @@
+const path = require("path");
+const HtmlWebpackPlugin = require("html-webpack-plugin");
+
+module.exports = {
+ entry: path.resolve(__dirname, "src/web/"),
+ plugins: [
+ new HtmlWebpackPlugin({
+ template: path.join(__dirname, "src/web/", "index.html"),
+ favicon: path.join(__dirname, "src/web/assets/", "favicon.ico")
+ }),
+ ],
+ module: {
+ rules: [
+ {
+ test: /\.?jsx$/,
+ use: {
+ loader: "babel-loader",
+ options: {
+ presets: ["@babel/preset-env", "@babel/preset-react"]
+ }
+ },
+ },
+ {
+ test: /\.(jpg|png|svg|gif)$/,
+ type: "asset/resource",
+ },
+ {
+ test: /\.css$/i,
+ use: [
+ "style-loader",
+ {
+ loader: "css-loader",
+ options: {
+ importLoaders: 1,
+ modules: true,
+ },
+ },
+ ],
+ },
+ {
+ test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
+ generator: {
+ filename: "[name][ext]"
+ }
+ }
+ ]
+ },
+ resolve: {
+ modules: [path.resolve(__dirname, "src/web/"), "node_modules"],
+ alias: {
+ client: path.resolve(__dirname, "web/")
+ },
+ extensions: [".js", ".jsx"]
+ },
+ output: {
+ path: path.resolve(__dirname, "dist"),
+ assetModuleFilename: "[hash][ext][query]"
+ },
+};