diff options
Diffstat (limited to 'src')
66 files changed, 3344 insertions, 0 deletions
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 Binary files differnew file mode 100644 index 0000000..4dda993 --- /dev/null +++ b/src/web/assets/favicon.ico diff --git a/src/web/assets/images/logo.png b/src/web/assets/images/logo.png Binary files differnew file mode 100644 index 0000000..c4a8c86 --- /dev/null +++ b/src/web/assets/images/logo.png diff --git a/src/web/assets/src/favicon.xcf b/src/web/assets/src/favicon.xcf Binary files differnew file mode 100644 index 0000000..a62432f --- /dev/null +++ b/src/web/assets/src/favicon.xcf 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 |
