├── .gitignore ├── web ├── assets │ ├── images │ │ ├── 01.png │ │ ├── bg.jpg │ │ ├── favicon.ico │ │ └── github.png │ ├── css │ │ ├── error.css │ │ ├── login.css │ │ ├── navbar.css │ │ ├── app.css │ │ └── post.css │ └── js │ │ └── index.js └── templates │ ├── error.html │ ├── login.html │ ├── register.html │ ├── partials │ ├── footer.html │ ├── header.html │ └── navbar.html │ ├── post-form.html │ ├── post.html │ └── home.html ├── server ├── config │ ├── path_config.go │ ├── session_config.go │ ├── db_config.go │ └── db_setup.go.go ├── controllers │ ├── assets_controller.go │ ├── register_controller.go │ ├── login_controller.go │ ├── comment_controller.go │ └── post_controller.go ├── utils │ ├── strings.go │ ├── flags.go │ └── templates.go ├── models │ ├── user.go │ ├── session.go │ ├── category.go │ ├── comment.go │ └── post.go ├── validators │ ├── login_request_validator.go │ ├── comment_request_validator.go │ ├── react_request_validators.go │ ├── register_validator_requests.go │ └── post_request_validator.go ├── routes │ └── routes.go └── database │ └── sql │ ├── schema.sql │ └── seed.sql ├── go.mod ├── go.sum ├── .dockerignore ├── commands.sh ├── dockerfile ├── cmd └── main.go └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | server/database/database.db -------------------------------------------------------------------------------- /web/assets/images/01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmaach/forum/HEAD/web/assets/images/01.png -------------------------------------------------------------------------------- /web/assets/images/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmaach/forum/HEAD/web/assets/images/bg.jpg -------------------------------------------------------------------------------- /web/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmaach/forum/HEAD/web/assets/images/favicon.ico -------------------------------------------------------------------------------- /web/assets/images/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmaach/forum/HEAD/web/assets/images/github.png -------------------------------------------------------------------------------- /server/config/path_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Define a base path for templates 4 | var BasePath = "../" 5 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module forum 2 | 3 | go 1.22.3 4 | 5 | require ( 6 | github.com/mattn/go-sqlite3 v1.14.24 7 | golang.org/x/crypto v0.29.0 8 | ) 9 | -------------------------------------------------------------------------------- /server/config/session_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | ) 7 | 8 | func GenerateSessionID() (string, error) { 9 | bytes := make([]byte, 16) 10 | if _, err := rand.Read(bytes); err != nil { 11 | return "", err 12 | } 13 | return hex.EncodeToString(bytes), nil 14 | } 15 | -------------------------------------------------------------------------------- /web/templates/error.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 |
3 |
4 |

{{.Data.Code}}

5 |

{{.Data.Message}}

6 | Back Home 7 |
8 |
9 | 10 | {{template "footer.html"}} -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 2 | github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 3 | golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= 4 | golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore the database file if it exists 2 | server/database/database.db 3 | 4 | # Ignore version control system directories 5 | .git 6 | .gitignore 7 | 8 | # Ignore Docker-specific files 9 | Dockerfile 10 | .dockerignore 11 | 12 | # Ignore the commands script as it's not needed inside the container 13 | commands.sh 14 | 15 | # Ignore the README as it's not needed in the image 16 | README.md 17 | -------------------------------------------------------------------------------- /commands.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Stop and remove existing container if it exists 4 | docker stop forum-con 5 | docker rm forum-con 6 | 7 | # Remove unused data 8 | docker system prune -f 9 | 10 | # Remove the old image 11 | docker rmi forum-img 12 | 13 | # Build a new image 14 | docker build --no-cache -f dockerfile -t forum-img . 15 | 16 | # Run a new container 17 | docker run -d -p 8080:8080 --name forum-con forum-img 18 | 19 | docker logs forum-con 20 | -------------------------------------------------------------------------------- /server/config/db_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | ) 7 | 8 | func Connect() (*sql.DB, error) { 9 | db, err := sql.Open("sqlite3", BasePath+"server/database/database.db") 10 | if err != nil { 11 | return nil, fmt.Errorf("failed to connect to database: %v", err) 12 | } 13 | 14 | // Test the database connection 15 | err = db.Ping() 16 | if err != nil { 17 | return nil, fmt.Errorf("failed to ping database: %v", err) 18 | } 19 | return db, nil 20 | } 21 | -------------------------------------------------------------------------------- /web/assets/css/error.css: -------------------------------------------------------------------------------- 1 | /* error page style */ 2 | .error-page { 3 | width: 100%; 4 | height: 87.9vh; 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | } 9 | 10 | .error-card { 11 | text-align: center; 12 | color: var(--color-primary); 13 | display: flex; 14 | flex-direction: column; 15 | gap: 30px; 16 | } 17 | 18 | .error-card h1 { 19 | font-size: 6rem; 20 | } 21 | 22 | .error-card p { 23 | font-size: 2rem; 24 | } 25 | 26 | .error-card a { 27 | font-size: 1.2rem; 28 | color: var(--color-primary); 29 | } -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | # Use official golang image with specific version 2 | FROM golang:1.22.3-alpine 3 | 4 | # Install required dependencies 5 | RUN apk add --no-cache gcc musl-dev 6 | 7 | # Set working directory 8 | WORKDIR /app 9 | 10 | # Copy go mod files 11 | COPY go.mod go.sum ./ 12 | 13 | # Download dependencies 14 | RUN go mod download 15 | 16 | # Copy source code 17 | COPY . . 18 | 19 | # Set environment variable for template base path 20 | ENV BASE_PATH="/app/" 21 | 22 | # Build the application 23 | RUN go build -o forum ./cmd/main.go 24 | 25 | # Expose port 8080 26 | EXPOSE 8080 27 | 28 | # Command to run the application 29 | CMD ["./forum"] -------------------------------------------------------------------------------- /server/controllers/assets_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "forum/server/config" 10 | "forum/server/utils" 11 | ) 12 | 13 | // ServeStaticFiles returns a handler function for serving static files 14 | func ServeStaticFiles(w http.ResponseWriter, r *http.Request) { 15 | // Get clean file path and prevent directory traversal 16 | filePath := filepath.Clean(config.BasePath + "web/assets" + strings.TrimPrefix(r.URL.Path, "/assets")) 17 | 18 | // block access to dirictories 19 | if info, err := os.Stat(filePath); err != nil || info.IsDir() { 20 | utils.RenderError(nil, w, r, http.StatusNotFound, false, "") 21 | return 22 | } 23 | 24 | // Serve the file 25 | http.ServeFile(w, r, filePath) 26 | } 27 | -------------------------------------------------------------------------------- /web/templates/login.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 |
3 |
4 |

Log in

5 | 11 | 12 |

New to 01Forum? Register

13 |
14 |
15 | {{template "footer.html"}} -------------------------------------------------------------------------------- /server/utils/strings.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "unicode" 4 | 5 | // Helper function to check if a string is alphanumeric 6 | func IsAlphanumeric(s string) bool { 7 | for _, char := range s { 8 | if !unicode.IsLetter(char) && !unicode.IsDigit(char) { 9 | return false 10 | } 11 | } 12 | return true 13 | } 14 | 15 | // Helper function to check if a string contains at least one uppercase letter 16 | func ContainsUppercase(s string) bool { 17 | for _, char := range s { 18 | if unicode.IsUpper(char) { 19 | return true 20 | } 21 | } 22 | return false 23 | } 24 | 25 | // Helper function to check if a string contains at least one digit 26 | func ContainsDigit(s string) bool { 27 | for _, char := range s { 28 | if unicode.IsDigit(char) { 29 | return true 30 | } 31 | } 32 | return false 33 | } 34 | -------------------------------------------------------------------------------- /server/utils/flags.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "slices" 7 | 8 | "forum/server/config" 9 | ) 10 | 11 | var ValidFlags = []string{"--migrate", "--seed", "--drop"} 12 | 13 | func HandleFlags(flags []string, db *sql.DB) error { 14 | if len(flags) != 1 { 15 | return fmt.Errorf("expected a single flag, got %d", len(flags)) 16 | } 17 | 18 | flag := flags[0] 19 | if !slices.Contains(ValidFlags, flag) { 20 | return fmt.Errorf("invalid flag: '%s'", flag) 21 | } 22 | 23 | switch flag { 24 | case "--migrate": 25 | return config.CreateTables(db) 26 | case "--seed": 27 | return config.CreateDemoData(db) 28 | case "--drop": 29 | return config.Drop() 30 | } 31 | return nil 32 | } 33 | 34 | func Usage() { 35 | fmt.Println(`Usage: go run main.go [option] 36 | Options: 37 | --migrate Create database tables 38 | --seed Insert demo data into the database 39 | --drop Drop all tables`) 40 | } 41 | -------------------------------------------------------------------------------- /server/models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | 7 | "golang.org/x/crypto/bcrypt" 8 | ) 9 | 10 | func GetUserInfo(db *sql.DB, username string) (int, string, error) { 11 | var user_id int 12 | var hashedPassword string 13 | err := db.QueryRow("SELECT id,password FROM users WHERE username = ?", username).Scan(&user_id, &hashedPassword) 14 | if err != nil { 15 | return 0, "", err 16 | } 17 | return user_id, hashedPassword, nil 18 | } 19 | 20 | func StoreUser(db *sql.DB, email, username, password string) (int64, error) { 21 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 22 | if err != nil { 23 | return -1, err 24 | } 25 | 26 | query := `INSERT INTO users (email,username,password) VALUES (?,?,?)` 27 | result, err := db.Exec(query, email, username, hashedPassword) 28 | if err != nil { 29 | return -1, fmt.Errorf("%v", err) 30 | } 31 | 32 | userID, _ := result.LastInsertId() 33 | 34 | return userID, nil 35 | } 36 | -------------------------------------------------------------------------------- /web/templates/register.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 |
3 |
4 |

Register

5 |
6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 | 14 |

Already registered? Log in

15 |
16 |
17 | {{template "footer.html"}} -------------------------------------------------------------------------------- /web/templates/partials/footer.html: -------------------------------------------------------------------------------- 1 | 2 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /server/validators/login_request_validator.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "html" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | // Validates a login request. 10 | // Returns: 11 | // - int: HTTP status code. 12 | // - string: Error or success message. 13 | // - string: username. 14 | // - string: password. 15 | func LoginRequest(r *http.Request) (int, string, string, string) { 16 | // Check if the method is POST 17 | if r.Method != http.MethodPost { 18 | return http.StatusMethodNotAllowed, "Invalid HTTP method", "", "" 19 | } 20 | 21 | // Parse form data 22 | err := r.ParseForm() 23 | if err != nil { 24 | return http.StatusBadRequest, "Failed to parse form data", "", "" 25 | } 26 | 27 | // Retrieve and sanitize inputs 28 | username := strings.TrimSpace(html.EscapeString(r.FormValue("username"))) 29 | password := strings.TrimSpace(html.EscapeString(r.FormValue("password"))) 30 | 31 | // Validate inputs 32 | if len(username) < 4 { 33 | return http.StatusBadRequest, "Username must be at least 4 characters long", "", "" 34 | } 35 | if len(password) < 6 { 36 | return http.StatusBadRequest, "Password must be at least 6 characters long", "", "" 37 | } 38 | 39 | return http.StatusOK, "success", username, password 40 | } 41 | -------------------------------------------------------------------------------- /server/models/session.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | func StoreSession(db *sql.DB, user_id int, session_id string, expires_at time.Time) error { 11 | query := `INSERT OR REPLACE INTO sessions (user_id,session_id,expires_at) VALUES (?,?,?)` 12 | 13 | _, err := db.Exec(query, user_id, session_id, expires_at) 14 | if err != nil { 15 | return fmt.Errorf("%v", err) 16 | } 17 | 18 | return nil 19 | } 20 | 21 | func ValidSession(r *http.Request, db *sql.DB) (int, string, bool) { 22 | cookie, err := r.Cookie("session_id") 23 | if err != nil || cookie == nil { 24 | return -1, "", false 25 | } 26 | var expiration time.Time 27 | var user_id int 28 | var username string 29 | query := ` 30 | SELECT 31 | s.user_id, 32 | s.expires_at, 33 | u.username 34 | FROM sessions s 35 | INNER JOIN users u ON s.user_id = u.id 36 | WHERE session_id = ? 37 | ` 38 | err = db.QueryRow(query, cookie.Value).Scan(&user_id, &expiration, &username) 39 | if err != nil || expiration.Before(time.Now()) { 40 | return -1, "", false 41 | } 42 | return user_id, username, true 43 | } 44 | 45 | func DeleteUserSession(db *sql.DB, userID int) error { 46 | _, err := db.Exec(`DELETE FROM sessions WHERE user_id = ?;`, userID) 47 | return err 48 | } 49 | -------------------------------------------------------------------------------- /server/validators/comment_request_validator.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // Validates a request to create a comment. 10 | // Returns: 11 | // - int: HTTP status code. 12 | // - string: Error or success message. 13 | // - string: Comment content. 14 | // - int: Post ID. 15 | func CreateCommentRequest(r *http.Request) (int, string, string, int) { 16 | if r.Method != http.MethodPost { 17 | return http.StatusMethodNotAllowed, "Invalid HTTP method", "", 0 18 | } 19 | 20 | // Parse form data 21 | if err := r.ParseForm(); err != nil { 22 | return http.StatusBadRequest, "Failed to parse form data", "", 0 23 | } 24 | 25 | // Validate comment content 26 | content := strings.TrimSpace(r.FormValue("comment")) 27 | if content == "" { 28 | return http.StatusBadRequest, "Comment content cannot be empty", "", 0 29 | } 30 | if len(content) > 1800 { 31 | return http.StatusBadRequest, "Comment content exceeds the maximum length of 1800 characters", "", 0 32 | } 33 | 34 | // Validate Post ID 35 | postIDStr := r.FormValue("postid") 36 | postID, err := strconv.Atoi(postIDStr) 37 | if err != nil || postID <= 0 { 38 | return http.StatusBadRequest, "Post ID must be a valid positive integer", "", 0 39 | } 40 | 41 | return http.StatusOK, "success", content, postID 42 | } 43 | -------------------------------------------------------------------------------- /server/validators/react_request_validators.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // validates a request to react to a post. 10 | // Returns: 11 | // - int: HTTP status code. 12 | // - string: Error or success message. 13 | // - int: target ID. 14 | // - string: reaction type (like/dislike). 15 | func ReactRequest(r *http.Request) (int, string, int, string) { 16 | // Check if the method is POST 17 | if r.Method != http.MethodPost { 18 | return http.StatusMethodNotAllowed, "Invalid HTTP method", 0, "" 19 | } 20 | 21 | if err := r.ParseForm(); err != nil { 22 | return http.StatusBadRequest, "Failed to parse form data", 0, "" 23 | } 24 | 25 | // Validate the reaction type 26 | reactionType := strings.TrimSpace(r.FormValue("reaction")) 27 | if reactionType != "like" && reactionType != "dislike" { 28 | return http.StatusBadRequest, "Invalid reaction type", 0, "" 29 | } 30 | 31 | // Validate the target ID 32 | targetIdStr := r.FormValue("target_id") 33 | targetId, err := strconv.Atoi(targetIdStr) 34 | if err != nil { 35 | return http.StatusBadRequest, "Target ID must be a valid integer", 0, "" 36 | } 37 | 38 | if targetId <= 0 { 39 | return http.StatusBadRequest, "Target ID must be greater than 0", 0, "" 40 | } 41 | 42 | return http.StatusOK, "success", targetId, reactionType 43 | } 44 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | 9 | "forum/server/config" 10 | "forum/server/routes" 11 | "forum/server/utils" 12 | 13 | _ "github.com/mattn/go-sqlite3" 14 | ) 15 | 16 | func main() { 17 | // Check if running in Docker 18 | isDocker := os.Getenv("BASE_PATH") != "" 19 | if isDocker { 20 | config.BasePath = os.Getenv("BASE_PATH") 21 | } 22 | 23 | // Connect to the database 24 | db, err := config.Connect() 25 | if err != nil { 26 | log.Fatal("Database connection error:", err) 27 | } 28 | 29 | // Handle database setup based on environment 30 | if isDocker { 31 | // Create the database schema and demo data 32 | err := config.CreateDemoData(db) 33 | if err != nil { 34 | log.Fatalf("Error creating the database schema and demo data: %v", err) 35 | } 36 | log.Println("Database setup complete.") 37 | } else { 38 | // Handle command-line flags for database setup 39 | if len(os.Args) > 1 { 40 | if err := utils.HandleFlags(os.Args[1:], db); err != nil { 41 | fmt.Println(err) 42 | utils.Usage() 43 | os.Exit(1) 44 | } 45 | return 46 | } 47 | } 48 | 49 | 50 | // Start the HTTP server 51 | server := http.Server{ 52 | Addr: ":8080", 53 | Handler: routes.Routes(db), 54 | } 55 | 56 | log.Println("Server starting on http://localhost:8080") 57 | if err := server.ListenAndServe(); err != nil { 58 | log.Fatal("Server error:", err) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /web/templates/partials/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 |

20 | 01 21 | forum 22 |

23 |
24 | {{ if .IsAuthenticated}} 25 |
26 | {{.UserName}} 27 | 28 | 29 | 30 |
31 | {{else}} 32 | Log in 33 | 34 | 35 | {{end}} 36 |
-------------------------------------------------------------------------------- /web/templates/post-form.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 | {{template "navbar.html" .}} 3 |
4 |
5 |

Create a New Post

6 |
7 | 8 | 9 |
10 |
11 | 12 |
13 |
14 | 20 |
21 |
22 |
23 | 24 | 25 |
26 | 27 | 32 |
33 |
34 | {{template "footer.html"}} -------------------------------------------------------------------------------- /server/models/category.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | type Category struct { 10 | ID int 11 | Label string 12 | PostsCount int 13 | } 14 | 15 | func FetchCategories(db *sql.DB) ([]Category, error) { 16 | var categories []Category 17 | query := ` 18 | SELECT 19 | c.id, 20 | c.label, 21 | ( 22 | SELECT 23 | COUNT(id) 24 | FROM 25 | post_category pc 26 | WHERE 27 | pc.category_id = c.id 28 | ) as posts_count 29 | FROM categories c 30 | ORDER BY posts_count DESC; 31 | ` 32 | rows, err := db.Query(query) 33 | if err != nil { 34 | return nil, err 35 | } 36 | defer rows.Close() 37 | for rows.Next() { 38 | var category Category 39 | rows.Scan(&category.ID, &category.Label, &category.PostsCount) 40 | categories = append(categories, category) 41 | } 42 | return categories, nil 43 | } 44 | 45 | func CheckCategories(db *sql.DB, ids []int) error { 46 | placeholders := strings.Repeat("?,", len(ids)) 47 | placeholders = placeholders[:len(placeholders)-1] 48 | 49 | query := fmt.Sprintf(` 50 | SELECT id 51 | FROM categories 52 | WHERE id IN (%s); 53 | `, placeholders) 54 | 55 | args := make([]interface{}, len(ids)) 56 | for i, id := range ids { 57 | args[i] = id 58 | } 59 | 60 | rows, err := db.Query(query, args...) 61 | if err != nil { 62 | return err 63 | } 64 | defer rows.Close() 65 | var count int 66 | for rows.Next() { 67 | var id int 68 | if err := rows.Scan(&id); err != nil { 69 | return err 70 | } 71 | count++ 72 | } 73 | if count != len(ids) { 74 | return fmt.Errorf("categories does not exists in db") 75 | } 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /server/controllers/register_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | "net/http" 7 | "strings" 8 | 9 | "forum/server/models" 10 | "forum/server/utils" 11 | ) 12 | 13 | func GetRegisterPage(w http.ResponseWriter, r *http.Request, db *sql.DB) { 14 | var valid bool 15 | if _, _, valid = models.ValidSession(r, db); valid { 16 | http.Redirect(w, r, "/", http.StatusFound) 17 | return 18 | } 19 | 20 | if r.Method != http.MethodGet { 21 | utils.RenderError(db, w, r, http.StatusMethodNotAllowed, false, "") 22 | return 23 | } 24 | 25 | err := utils.RenderTemplate(db, w, r, "register", http.StatusOK, nil, false, "") 26 | if err != nil { 27 | log.Println(err) 28 | http.Redirect(w, r, "/500", http.StatusSeeOther) 29 | } 30 | } 31 | 32 | func Signup(w http.ResponseWriter, r *http.Request, db *sql.DB) { 33 | var valid bool 34 | if _, _, valid = models.ValidSession(r, db); valid { 35 | w.WriteHeader(302) 36 | return 37 | } 38 | 39 | if r.Method != http.MethodPost { 40 | w.WriteHeader(405) 41 | return 42 | } 43 | if err := r.ParseForm(); err != nil { 44 | w.WriteHeader(400) 45 | return 46 | } 47 | 48 | email := r.FormValue("email") 49 | username := r.FormValue("username") 50 | password := r.FormValue("password") 51 | passwordConfirmation := r.FormValue("password-confirmation") 52 | 53 | if len(strings.TrimSpace(username)) < 4 || len(strings.TrimSpace(password)) < 6 || email == "" || password != passwordConfirmation { 54 | w.WriteHeader(400) 55 | return 56 | } 57 | 58 | _, err := models.StoreUser(db, email, username, password) 59 | if err != nil { 60 | if err.Error() == "UNIQUE constraint failed: users.username" { 61 | w.WriteHeader(304) 62 | return 63 | } 64 | 65 | w.WriteHeader(500) 66 | return 67 | } 68 | w.Header().Set("Content-Type", "text/html") 69 | w.WriteHeader(200) 70 | } 71 | -------------------------------------------------------------------------------- /server/config/db_setup.go.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | // CreateTables executes all queries from schema.sql 12 | func CreateTables(db *sql.DB) error { 13 | // read file that contains all queries to create tables for database schema 14 | content, err := os.ReadFile(BasePath + "server/database/sql/schema.sql") 15 | if err != nil { 16 | return fmt.Errorf("failed to read schema.sql file: %v", err) 17 | } 18 | 19 | queries := strings.TrimSpace(string(content)) 20 | 21 | // execute all queries to create database schema 22 | _, err = db.Exec(queries) 23 | if err != nil { 24 | log.Printf("failed to create tables %q: %v\n", queries, err) 25 | return err 26 | } 27 | log.Println("Database schema created successfully") 28 | return nil 29 | } 30 | 31 | // CreateFakeData generates and inserts fake data into the database 32 | func CreateDemoData(db *sql.DB) error { 33 | // create database schema before creating demo data 34 | if err := CreateTables(db); err != nil { 35 | return err 36 | } 37 | 38 | // read file that contains all queries to create demo data 39 | content, err := os.ReadFile(BasePath + "server/database/sql/seed.sql") 40 | if err != nil { 41 | return fmt.Errorf("failed to read seed.sql file: %v", err) 42 | } 43 | 44 | queries := strings.TrimSpace(string(content)) 45 | 46 | // execute all queries to create demo data in the database 47 | _, err = db.Exec(queries) 48 | if err != nil { 49 | log.Printf("failed to isert demo data %q: %v\n", queries, err) 50 | return err 51 | } 52 | 53 | log.Println("Demo data created successfully") 54 | return nil 55 | } 56 | 57 | // Drop all tables in the database. 58 | func Drop() error { 59 | err := os.Remove(BasePath + "server/database/database.db") 60 | if err != nil { 61 | log.Printf("failed to drop tables: %v\n", err) 62 | return err 63 | } 64 | 65 | log.Println("Database schema dropped successfully") 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /web/templates/partials/navbar.html: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /server/utils/templates.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "text/template" 9 | 10 | "forum/server/config" 11 | "forum/server/models" 12 | ) 13 | 14 | type GlobalData struct { 15 | IsAuthenticated bool 16 | Data any 17 | UserName string 18 | Categories []models.Category 19 | } 20 | 21 | type Error struct { 22 | Code int 23 | Message string 24 | Details string 25 | } 26 | 27 | // RenderError handles error responses 28 | func RenderError(db *sql.DB, w http.ResponseWriter, r *http.Request, statusCode int, isauth bool, username string) { 29 | typeError := Error{ 30 | Code: statusCode, 31 | Message: http.StatusText(statusCode), 32 | } 33 | if err := RenderTemplate(db, w, r, "error", statusCode, typeError, isauth, username); err != nil { 34 | http.Error(w, "500 | Internal Server Error", http.StatusInternalServerError) 35 | log.Println(err) 36 | } 37 | } 38 | 39 | func ParseTemplates(tmpl string) (*template.Template, error) { 40 | // Parse the template files 41 | t, err := template.ParseFiles( 42 | config.BasePath+"web/templates/partials/header.html", 43 | config.BasePath+"web/templates/partials/footer.html", 44 | config.BasePath+"web/templates/partials/navbar.html", 45 | config.BasePath+"web/templates/"+tmpl+".html", 46 | ) 47 | if err != nil { 48 | return nil, fmt.Errorf("error parsing template files: %w", err) 49 | } 50 | return t, nil 51 | } 52 | 53 | func RenderTemplate(db *sql.DB, w http.ResponseWriter, r *http.Request, tmpl string, statusCode int, data any, isauth bool, username string) error { 54 | t, err := ParseTemplates(tmpl) 55 | if err != nil { 56 | return err 57 | } 58 | categories, err := models.FetchCategories(db) 59 | if err != nil { 60 | categories = nil 61 | } 62 | 63 | globalData := GlobalData{ 64 | IsAuthenticated: isauth, 65 | Data: data, 66 | UserName: username, 67 | Categories: categories, 68 | } 69 | w.WriteHeader(statusCode) 70 | // Execute the template with the provided data 71 | err = t.ExecuteTemplate(w, tmpl+".html", globalData) 72 | if err != nil { 73 | return fmt.Errorf("error executing template: %w", err) 74 | } 75 | 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /server/routes/routes.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "database/sql" 5 | "net/http" 6 | 7 | "forum/server/controllers" 8 | ) 9 | 10 | func Routes(db *sql.DB) http.Handler { 11 | mux := http.NewServeMux() 12 | 13 | // serve static files 14 | mux.HandleFunc("/assets/", controllers.ServeStaticFiles) 15 | 16 | // routes to get pages 17 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 18 | controllers.IndexPosts(w, r, db) 19 | }) 20 | mux.HandleFunc("/category/{id}", func(w http.ResponseWriter, r *http.Request) { 21 | controllers.IndexPostsByCategory(w, r, db) 22 | }) 23 | mux.HandleFunc("/mycreatedposts", func(w http.ResponseWriter, r *http.Request) { 24 | controllers.MyCreatedPosts(w, r, db) 25 | }) 26 | 27 | mux.HandleFunc("/mylikedposts", func(w http.ResponseWriter, r *http.Request) { 28 | controllers.MyLikedPosts(w, r, db) 29 | }) 30 | mux.HandleFunc("/post/{id}", func(w http.ResponseWriter, r *http.Request) { 31 | controllers.ShowPost(w, r, db) 32 | }) 33 | 34 | mux.HandleFunc("/post/addcommentREQ", func(w http.ResponseWriter, r *http.Request) { 35 | controllers.CreateComment(w, r, db) 36 | }) 37 | 38 | mux.HandleFunc("/post/create", func(w http.ResponseWriter, r *http.Request) { 39 | controllers.GetPostCreationForm(w, r, db) 40 | }) 41 | 42 | mux.HandleFunc("/post/createpost", func(w http.ResponseWriter, r *http.Request) { 43 | controllers.CreatePost(w, r, db) 44 | }) 45 | 46 | mux.HandleFunc("/post/postreaction", func(w http.ResponseWriter, r *http.Request) { 47 | controllers.ReactToPost(w, r, db) 48 | }) 49 | 50 | mux.HandleFunc("/post/commentreaction", func(w http.ResponseWriter, r *http.Request) { 51 | controllers.ReactToComment(w, r, db) 52 | }) 53 | 54 | mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) { 55 | controllers.Signin(w, r, db) 56 | }) 57 | 58 | mux.HandleFunc("/signup", func(w http.ResponseWriter, r *http.Request) { 59 | controllers.Signup(w, r, db) 60 | }) 61 | 62 | mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { 63 | controllers.GetLoginPage(w, r, db) 64 | }) 65 | 66 | mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { 67 | controllers.Logout(w, r, db) 68 | }) 69 | 70 | 71 | 72 | 73 | mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { 74 | controllers.GetRegisterPage(w, r, db) 75 | }) 76 | 77 | return mux 78 | } 79 | -------------------------------------------------------------------------------- /server/validators/register_validator_requests.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "html" 5 | "net/http" 6 | "strings" 7 | 8 | "forum/server/utils" 9 | ) 10 | 11 | // Validates a registration request. 12 | // Returns: 13 | // - int: HTTP status code. 14 | // - string: Error or success message. 15 | // - string: email. 16 | // - string: username. 17 | // - string: password. 18 | func RegisterRequest(r *http.Request) (int, string, string, string, string) { 19 | // Check if the method is POST 20 | if r.Method != http.MethodPost { 21 | return http.StatusMethodNotAllowed, "Invalid HTTP method", "", "", "" 22 | } 23 | 24 | // Parse form data 25 | if err := r.ParseForm(); err != nil { 26 | return http.StatusBadRequest, "Failed to parse form data", "", "", "" 27 | } 28 | 29 | // Retrieve and sanitize inputs 30 | email := strings.TrimSpace(html.EscapeString(r.FormValue("email"))) 31 | username := strings.TrimSpace(html.EscapeString(r.FormValue("username"))) 32 | password := strings.TrimSpace(r.FormValue("password")) 33 | passwordConfirmation := strings.TrimSpace(r.FormValue("password-confirmation")) 34 | 35 | // Validate email 36 | if email == "" { 37 | return http.StatusBadRequest, "Email is required", "", "", "" 38 | } 39 | if !strings.Contains(email, "@") || !strings.Contains(email, ".") || len(email) < 5 { 40 | return http.StatusBadRequest, "Invalid email format", "", "", "" 41 | } 42 | 43 | // Validate username 44 | if len(username) < 4 { 45 | return http.StatusBadRequest, "Username must be at least 4 characters long", "", "", "" 46 | } 47 | if strings.Contains(username, " ") { 48 | return http.StatusBadRequest, "Username cannot contain spaces", "", "", "" 49 | } 50 | if !utils.IsAlphanumeric(username) { 51 | return http.StatusBadRequest, "Username must contain only letters and numbers", "", "", "" 52 | } 53 | 54 | // Validate password 55 | if password != passwordConfirmation { 56 | return http.StatusBadRequest, "Passwords do not match", "", "", "" 57 | } 58 | if len(password) < 6 { 59 | return http.StatusBadRequest, "Password must be at least 6 characters long", "", "", "" 60 | } 61 | if !utils.ContainsUppercase(password) { 62 | return http.StatusBadRequest, "Password must contain at least one uppercase letter", "", "", "" 63 | } 64 | if !utils.ContainsDigit(password) { 65 | return http.StatusBadRequest, "Password must contain at least one digit", "", "", "" 66 | } 67 | 68 | return http.StatusOK, "success", email, username, password 69 | } 70 | -------------------------------------------------------------------------------- /server/database/sql/schema.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys = ON; 2 | CREATE TABLE IF NOT EXISTS sessions ( 3 | user_id BIGINT UNIQUE NOT NULL, 4 | session_id TEXT NOT NULL, 5 | expires_at TIMESTAMP NOT NULL, 6 | FOREIGN KEY (user_id) REFERENCES users(id) on DELETE CASCADE 7 | ); 8 | CREATE TABLE IF NOT EXISTS users ( 9 | id INTEGER PRIMARY KEY AUTOINCREMENT, 10 | email TEXT UNIQUE NOT NULL, 11 | username TEXT UNIQUE NOT NULL, 12 | password TEXT NOT NULL, 13 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 14 | ); 15 | CREATE TABLE IF NOT EXISTS post_category ( 16 | id INTEGER PRIMARY KEY AUTOINCREMENT, 17 | post_id BIGINT NOT NULL, 18 | category_id BIGINT NOT NULL, 19 | FOREIGN KEY (post_id) REFERENCES posts(id), 20 | FOREIGN KEY (category_id) REFERENCES categories(id) UNIQUE (post_id, category_id) 21 | ); 22 | CREATE TABLE IF NOT EXISTS categories ( 23 | id INTEGER PRIMARY KEY AUTOINCREMENT, 24 | label TEXT UNIQUE NOT NULL, 25 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 26 | ); 27 | CREATE TABLE IF NOT EXISTS posts ( 28 | id INTEGER PRIMARY KEY AUTOINCREMENT, 29 | user_id BIGINT NOT NULL, 30 | title TEXT NOT NULL, 31 | content TEXT NOT NULL, 32 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 33 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 34 | ); 35 | CREATE TABLE IF NOT EXISTS comments ( 36 | id INTEGER PRIMARY KEY AUTOINCREMENT, 37 | user_id BIGINT NOT NULL, 38 | post_id BIGINT NOT NULL, 39 | content TEXT NOT NULL, 40 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 41 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, 42 | FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE 43 | ); 44 | CREATE TABLE IF NOT EXISTS post_reactions ( 45 | user_id BIGINT NOT NULL, 46 | post_id BIGINT NOT NULL, 47 | reaction TEXT NOT NULL, 48 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 49 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, 50 | FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, 51 | UNIQUE (user_id, post_id), 52 | CHECK (reaction IN ('like', 'dislike')) 53 | ); 54 | CREATE TABLE IF NOT EXISTS comment_reactions ( 55 | user_id BIGINT NOT NULL, 56 | comment_id BIGINT NOT NULL, 57 | reaction TEXT NOT NULL, 58 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 59 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, 60 | FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE, 61 | UNIQUE (user_id, comment_id), 62 | CHECK (reaction IN ('like', 'dislike')) 63 | ); -------------------------------------------------------------------------------- /web/assets/css/login.css: -------------------------------------------------------------------------------- 1 | /* style of login and register pages */ 2 | .login-page, 3 | .register-page { 4 | background-image: url(../images/bg.jpg); 5 | background-repeat: no-repeat; 6 | background-attachment: fixed; 7 | background-size: cover; 8 | width: 100%; 9 | min-height: 87.8vh; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .login-card, 16 | .register-card { 17 | border: solid 1px var(--color-border); 18 | background-color: var(--color-bg); 19 | width: 400px; 20 | height: fit-content; 21 | padding: 40px 20px; 22 | border-radius: 10px; 23 | display: flex; 24 | flex-direction: column; 25 | justify-content: space-between; 26 | gap: 10px; 27 | } 28 | 29 | .login-form, 30 | .register-form { 31 | /* border: blue solid 1px; */ 32 | display: flex; 33 | flex-direction: column; 34 | justify-content: space-between; 35 | align-items: center; 36 | gap: 30px; 37 | margin: 20px 0; 38 | } 39 | 40 | .login-card h1, 41 | .register-card h1 { 42 | /* border: blue solid 1px; */ 43 | text-align: center; 44 | color: var(--color-primary); 45 | } 46 | 47 | .login-input, 48 | .register-input { 49 | width: 90%; 50 | outline: none; 51 | border: none; 52 | border-bottom: 1px solid var(--color-border); 53 | background-color: var(--color-bg-variant); 54 | border-radius: 10px; 55 | padding: 15px 10px; 56 | font-size: 1rem; 57 | font-weight: 500; 58 | transition: var(--transition); 59 | } 60 | 61 | .login-input:hover, 62 | .register-input:hover { 63 | background-color: var(--color-bg-variant-hover); 64 | } 65 | 66 | .login-input:focus, 67 | .register-input:focus { 68 | /* border: none; */ 69 | outline: none; 70 | } 71 | 72 | .login-submit, 73 | .register-submit { 74 | width: fit-content; 75 | border: none; 76 | margin: 0 auto; 77 | cursor: pointer; 78 | background-color: var(--color-primary); 79 | padding: 8px 20px; 80 | border-radius: 20px; 81 | color: var(--color-white); 82 | font-size: 1rem; 83 | font-weight: 700; 84 | transition: var(--transition); 85 | } 86 | 87 | .login-submit:hover, 88 | .register-submit:hover { 89 | background-color: var(--color-primary-hover); 90 | } 91 | 92 | 93 | .login-submit i, 94 | .register-submit i { 95 | margin-left: 10px; 96 | } 97 | 98 | .form-line { 99 | border-top: 1px solid var(--color-border); 100 | width: 80%; 101 | margin: 0 auto; 102 | } 103 | 104 | .form-second-option { 105 | display: inline-block; 106 | margin: 0 auto; 107 | } -------------------------------------------------------------------------------- /server/controllers/login_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | "forum/server/config" 10 | "forum/server/models" 11 | "forum/server/utils" 12 | 13 | "golang.org/x/crypto/bcrypt" 14 | ) 15 | 16 | func GetLoginPage(w http.ResponseWriter, r *http.Request, db *sql.DB) { 17 | var valid bool 18 | 19 | if _, _, valid = models.ValidSession(r, db); valid { 20 | http.Redirect(w, r, "/", http.StatusFound) 21 | return 22 | } 23 | 24 | if r.Method != http.MethodGet { 25 | utils.RenderError(db, w, r, http.StatusMethodNotAllowed, false, "") 26 | return 27 | } 28 | 29 | err := utils.RenderTemplate(db, w, r, "login", http.StatusOK, nil, false, "") 30 | if err != nil { 31 | log.Println(err) 32 | http.Redirect(w, r, "/500", http.StatusSeeOther) 33 | } 34 | } 35 | 36 | func Signin(w http.ResponseWriter, r *http.Request, db *sql.DB) { 37 | var valid bool 38 | 39 | if _, _, valid = models.ValidSession(r, db); valid { 40 | w.WriteHeader(302) 41 | return 42 | } 43 | 44 | if r.Method != http.MethodPost { 45 | w.WriteHeader(405) 46 | return 47 | } 48 | 49 | if err := r.ParseForm(); err != nil { 50 | w.WriteHeader(400) 51 | return 52 | } 53 | 54 | username := r.FormValue("username") 55 | password := r.FormValue("password") 56 | 57 | if len(username) < 4 || len(password) < 6 { 58 | w.WriteHeader(400) 59 | return 60 | } 61 | 62 | // get user information from database 63 | user_id, hashedPassword, err := models.GetUserInfo(db, username) 64 | if err != nil { 65 | if err == sql.ErrNoRows { 66 | w.WriteHeader(404) 67 | return 68 | } 69 | w.WriteHeader(500) 70 | return 71 | } 72 | 73 | // Verify the password 74 | if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)); err != nil { 75 | w.WriteHeader(401) 76 | return 77 | } 78 | 79 | sessionID, err := config.GenerateSessionID() 80 | if err != nil { 81 | http.Error(w, "Failed to create session", http.StatusInternalServerError) 82 | return 83 | } 84 | 85 | err = models.StoreSession(db, user_id, sessionID, time.Now().Add(10*time.Hour)) 86 | if err != nil { 87 | http.Error(w, "Failed to create session", http.StatusInternalServerError) 88 | return 89 | } 90 | 91 | // Set session ID as a cookie 92 | http.SetCookie(w, &http.Cookie{ 93 | Name: "session_id", 94 | Value: sessionID, 95 | Expires: time.Now().Add(10 * time.Hour), 96 | Path: "/", 97 | }) 98 | http.Redirect(w, r, "/", http.StatusFound) 99 | } 100 | 101 | func Logout(w http.ResponseWriter, r *http.Request, db *sql.DB) { 102 | if userID, _, valid := models.ValidSession(r, db); valid { 103 | // Use the new model function 104 | err := models.DeleteUserSession(db, userID) 105 | if err != nil { 106 | http.Error(w, "Error while logging out!", http.StatusInternalServerError) 107 | return 108 | } 109 | 110 | w.Header().Set("Content-Type", "text/html") 111 | http.Redirect(w, r, "/", http.StatusFound) 112 | return 113 | } 114 | 115 | http.Redirect(w, r, "/", http.StatusFound) 116 | } 117 | -------------------------------------------------------------------------------- /server/controllers/comment_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "html" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | 11 | "forum/server/models" 12 | ) 13 | 14 | func CreateComment(w http.ResponseWriter, r *http.Request, db *sql.DB) { 15 | // Validate session 16 | userID, username, valid := models.ValidSession(r, db) 17 | if !valid { 18 | w.WriteHeader(http.StatusUnauthorized) 19 | return 20 | } 21 | 22 | // Validate method 23 | if r.Method != http.MethodPost { 24 | w.WriteHeader(http.StatusMethodNotAllowed) 25 | return 26 | } 27 | 28 | // Parse form data 29 | if err := r.ParseForm(); err != nil { 30 | w.WriteHeader(http.StatusBadRequest) 31 | return 32 | } 33 | 34 | content := html.EscapeString(strings.TrimSpace(r.FormValue("comment"))) 35 | postIDStr := r.FormValue("postid") 36 | postID, err := strconv.Atoi(postIDStr) 37 | if err != nil || content == "" { 38 | w.WriteHeader(http.StatusBadRequest) 39 | return 40 | } 41 | 42 | // Store the comment using the models package 43 | commentID, err := models.StoreComment(db, userID, postID, content) 44 | if err != nil { 45 | w.WriteHeader(http.StatusInternalServerError) 46 | return 47 | } 48 | 49 | // Fetch additional details using the models package 50 | commentsCount, err := models.CountCommentsByPostID(db, postID) 51 | if err != nil { 52 | w.WriteHeader(http.StatusInternalServerError) 53 | return 54 | } 55 | 56 | commentTime, err := models.FetchCommentTimeByID(db, commentID) 57 | if err != nil { 58 | w.WriteHeader(http.StatusInternalServerError) 59 | return 60 | } 61 | 62 | // Return the new comment details as JSON 63 | w.Header().Set("Content-Type", "application/json") 64 | json.NewEncoder(w).Encode(map[string]interface{}{ 65 | "ID": commentID, 66 | "username": username, 67 | "created_at": commentTime, 68 | "content": content, 69 | "likes": 0, 70 | "dislikes": 0, 71 | "commentscount": commentsCount, 72 | }) 73 | } 74 | 75 | func ReactToComment(w http.ResponseWriter, r *http.Request, db *sql.DB) { 76 | if r.Method != http.MethodPost { 77 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 78 | return 79 | } 80 | 81 | var user_id int 82 | var valid bool 83 | 84 | if user_id, _, valid = models.ValidSession(r, db); !valid { 85 | w.WriteHeader(401) 86 | return 87 | } 88 | 89 | if err := r.ParseForm(); err != nil { 90 | w.WriteHeader(400) 91 | return 92 | } 93 | 94 | userReaction := r.FormValue("reaction") 95 | id := r.FormValue("comment_id") 96 | comment_id, err := strconv.Atoi(id) 97 | if err != nil { 98 | w.WriteHeader(400) 99 | return 100 | } 101 | likeCount, dislikeCount, err := models.ReactToComment(db, user_id, comment_id, userReaction) 102 | if err != nil { 103 | w.WriteHeader(500) 104 | return 105 | } 106 | // Return the new count as JSON 107 | w.Header().Set("Content-Type", "application/json") 108 | json.NewEncoder(w).Encode(map[string]int{"commentlikesCount": likeCount, "commentdislikesCount": dislikeCount}) 109 | } 110 | -------------------------------------------------------------------------------- /web/assets/css/navbar.css: -------------------------------------------------------------------------------- 1 | /* Nav style */ 2 | nav { 3 | position: fixed; 4 | background-color: var(--color-bg); 5 | text-decoration: none; 6 | width: 300px; 7 | height: 100vh; 8 | padding: 20px 0 0 10px; 9 | margin-top: 65px; 10 | border-right: 1px solid var(--color-border); 11 | /* z-index: 1; */ 12 | } 13 | 14 | .close-nav { 15 | display: none; 16 | border-radius: 15px; 17 | background-color: transparent; 18 | border: none; 19 | cursor: pointer; 20 | transition: var(--transition); 21 | float: right; 22 | } 23 | 24 | .close-nav i { 25 | color: var(--color-text-light); 26 | font-size: 1.4rem !important; 27 | } 28 | 29 | nav i { 30 | font-size: 1rem; 31 | display: inline-block; 32 | margin: 1px 20px 0 10px; 33 | } 34 | 35 | .nav-list { 36 | list-style: none; 37 | display: flex; 38 | flex-direction: column; 39 | margin-top: 50px; 40 | } 41 | 42 | .nav-list a { 43 | display: inline-block; 44 | width: 70%; 45 | padding: 10px; 46 | border-radius: 10px; 47 | transition: var(--transition); 48 | } 49 | 50 | .nav-list a:hover { 51 | background-color: var(--color-bg-variant); 52 | } 53 | 54 | .nav-list>li>a, 55 | .categories-title { 56 | text-decoration: none; 57 | color: var(--color-text); 58 | font-weight: 400; 59 | font-size: 1.1rem; 60 | display: flex; 61 | align-items: center; 62 | transition: var(--transition); 63 | } 64 | 65 | .categories-title { 66 | padding: 10px; 67 | } 68 | 69 | /* .nav-list>li>a:hover, 70 | .categories-title:hover { 71 | color: var(--color-primary-hover); 72 | } */ 73 | 74 | .categories-list { 75 | display: flex; 76 | flex-direction: column; 77 | margin-left: 3rem; 78 | } 79 | 80 | .categories-list li { 81 | /* border: red solid 1px; */ 82 | font-weight: 400; 83 | font-size: 1rem; 84 | list-style: none; 85 | } 86 | 87 | .categories-list li a { 88 | text-decoration: underline; 89 | color: var(--color-text); 90 | transition: var(--transition); 91 | text-transform: capitalize; 92 | } 93 | 94 | 95 | nav.mobile-nav { 96 | display: none; 97 | width: 80%; 98 | height: fit-content; 99 | max-height: 80vh; 100 | padding: 10px; 101 | margin-left: 7px; 102 | background-color: transparent; 103 | backdrop-filter: blur(16px); 104 | padding: 10px; 105 | margin-top: 60px; 106 | border: 2px solid var(--color-border); 107 | border-radius: 10px; 108 | } 109 | 110 | nav.mobile-nav .nav-list { 111 | margin-top: 10px; 112 | } 113 | 114 | nav.mobile-nav .close-nav { 115 | display: block; 116 | } 117 | 118 | /* ================= medium style ================== */ 119 | @media screen and (max-width: 1024px) { 120 | nav { 121 | width: 200px; 122 | padding: 0; 123 | } 124 | 125 | .categories-list { 126 | margin-left: 1rem; 127 | } 128 | 129 | .categories-list li a { 130 | font-size: 0.9rem; 131 | } 132 | } 133 | 134 | /* ================= small style ================== */ 135 | @media screen and (max-width: 600px) { 136 | nav { 137 | display: none; 138 | } 139 | } -------------------------------------------------------------------------------- /web/templates/post.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 | {{template "navbar.html" .}} 3 |
4 |
5 |
6 |
7 |

{{.Data.Post.Title}}

8 |
9 |

{{.Data.Post.UserName}}

10 | 11 |

{{.Data.Post.CreatedAt}}

12 |
13 |

{{.Data.Post.Content}}

14 |
15 | {{range .Data.Post.Categories}} 16 | 17 | {{end}} 18 |
19 |
20 | 29 | 30 | 31 |
32 |
33 | 34 | 35 | 36 | 37 |
38 |
39 |

Comments:

40 | {{range .Data.Comments}} 41 |
42 |
43 |

{{.UserName}}

44 | 45 |

{{.CreatedAt}}

46 |
47 |
48 |

{{.Content}}

49 |
50 | 56 | 57 |
58 | {{end}} 59 |
60 |
61 |
62 | {{template "footer.html"}} -------------------------------------------------------------------------------- /web/templates/home.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 | {{template "navbar.html" .}} 3 |
4 |
5 |
6 | 9 | 10 | 11 | Create post 12 | 13 |
14 | {{if .Data}} 15 | {{range .Data}} 16 |
17 |
18 | {{.Title}} 19 |
20 |

{{.UserName}}

21 | 22 |

{{.CreatedAt}}

23 |
24 |

{{.Content}}

25 |
26 | {{range .Categories}} 27 | 28 | {{end}} 29 |
30 |
31 | 41 | 42 |
43 | {{end}} 44 | {{else}} 45 |

No posts available to display !

46 | {{end}} 47 |
48 | 55 | 77 |
78 | 79 | {{template "footer.html"}} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Forum Application 2 | 3 | A comprehensive web forum application built using Go that enables user communication through posts, comments, and reactions. 4 | 5 | ## Authors 6 | 7 | - Abdelhamid Bouziani 8 | - Hamza Maach 9 | - Omar Ait Benhammou 10 | - Mehdi Moulabbi 11 | - Youssef Basta 12 | 13 | ## Features 14 | 15 | ### User Authentication 16 | - Username-based registration and login 17 | - Secure session management using cookies 18 | 19 | ### Content Management 20 | - Create and read posts 21 | - Comment on posts 22 | - Multiple category associations for posts 23 | 24 | ### Interaction System 25 | - Like/dislike functionality for posts and comments 26 | - Comprehensive user engagement tools 27 | 28 | ### Content Discovery 29 | - Filter posts by categories 30 | - Filter posts by creation date 31 | 32 | ## Project Structure 33 | 34 | ``` 35 | forum/ 36 | ├── cmd/ 37 | │ └── main.go # Application entry point 38 | ├── server/ 39 | │ ├── config/ # Configuration management 40 | │ ├── database/ # Database interaction logic 41 | │ ├── controllers/ # Request handling and business logic 42 | │ ├── models/ # Data structures and models 43 | │ ├── validators/ # Request validation 44 | │ ├── routes/ # Application routing 45 | │ └── utils/ # Shared utility functions 46 | ├── web/ 47 | │ ├── assets/ # Static resources (CSS, JS, images) 48 | │ └── templates/ # HTML templates 49 | ├── dockerfile # Docker containerization 50 | ├── commands.sh # Docker build and deployment script 51 | ├── go.mod # Go module dependencies 52 | ├── go.sum # Dependency checksum 53 | └── README.md # Project documentation 54 | ``` 55 | 56 | ## Database Schema 57 | 58 | View the detailed database schema [here](https://drawsql.app/teams/zone-01/diagrams/forum-db). 59 | 60 | ### Key Tables 61 | - **Users**: User authentication and profile information 62 | - **Posts**: Forum post content 63 | - **Comments**: Post responses and discussions 64 | - **Categories**: Post classification 65 | - **Categories_Posts**: Post-category relationships 66 | - **Posts_Reactions**: Post interaction tracking 67 | - **Comments_Reactions**: Comment interaction tracking 68 | - **Sessions**: User authentication state management 69 | 70 | ## Technologies 71 | 72 | ### Backend 73 | - Go 1.22+ 74 | - SQLite3 database 75 | - bcrypt for password hashing 76 | 77 | ### Frontend 78 | - HTML5 & CSS3 79 | - JavaScript 80 | - Font Awesome icons 81 | 82 | ### Development & Deployment 83 | - Docker containerization 84 | 85 | ## Getting Started 86 | 87 | ### Prerequisites 88 | - Go 1.22 or higher 89 | - SQLite3 90 | - Docker (optional) 91 | 92 | ### Local Development 93 | 94 | 1. **Clone the Repository** 95 | ```bash 96 | git clone https://github.com/hmaach/forum.git 97 | cd forum 98 | ``` 99 | 100 | 2. **Install Dependencies** 101 | ```bash 102 | go mod download 103 | ``` 104 | 105 | 3. **Database Management** 106 | 107 | - Create database schema: 108 | ```bash 109 | go run . --migrate 110 | ``` 111 | 112 | - Create schema with demo data: 113 | ```bash 114 | go run . --seed 115 | ``` 116 | 117 | - Drop database schema: 118 | ```bash 119 | go run . --drop 120 | ``` 121 | 122 | 4. **Run the Application** 123 | ```bash 124 | cd cmd 125 | go run . 126 | ``` 127 | 128 | Access the forum at `http://localhost:8080` 129 | 130 | ### Docker Deployment 131 | 132 | 1. Make script executable: 133 | ```bash 134 | chmod +x commands.sh 135 | ``` 136 | 137 | 2. Run deployment script: 138 | ```bash 139 | ./commands.sh 140 | ``` 141 | 142 | 3. Access the forum at `http://localhost:8080` 143 | 144 | ## Contributing 145 | 146 | Please read our contributing guidelines before submitting pull requests or issues. 147 | -------------------------------------------------------------------------------- /server/database/sql/seed.sql: -------------------------------------------------------------------------------- 1 | -- Insert Users 2 | INSERT INTO users (email, username, password) VALUES 3 | ('user1@example.com', 'User1', 'password1'), 4 | ('user2@example.com', 'User2', 'password2'), 5 | ('user3@example.com', 'User3', 'password3'), 6 | ('user4@example.com', 'User4', 'password4'), 7 | ('user5@example.com', 'User5', 'password5'), 8 | ('user6@example.com', 'User6', 'password6'), 9 | ('user7@example.com', 'User7', 'password7'), 10 | ('user8@example.com', 'User8', 'password8'), 11 | ('user9@example.com', 'User9', 'password9'), 12 | ('user10@example.com', 'User10', 'password10'), 13 | ('user11@example.com', 'User11', 'password11'), 14 | ('user12@example.com', 'User12', 'password12'), 15 | ('user13@example.com', 'User13', 'password13'), 16 | ('user14@example.com', 'User14', 'password14'), 17 | ('user15@example.com', 'User15', 'password15'), 18 | ('user16@example.com', 'User16', 'password16'), 19 | ('user17@example.com', 'User17', 'password17'), 20 | ('user18@example.com', 'User18', 'password18'), 21 | ('user19@example.com', 'User19', 'password19'), 22 | ('user20@example.com', 'User20', 'password20'); 23 | 24 | -- Insert Categories 25 | INSERT INTO categories (label) VALUES 26 | ('Technology'), 27 | ('Health'), 28 | ('Travel'), 29 | ('Education'), 30 | ('Entertainment'); 31 | -- Insert Posts 32 | INSERT INTO posts (user_id, title, content) VALUES 33 | (1, 'Post 1 Title', 'Content of post 1'), 34 | (2, 'Post 2 Title', 'Content of post 2'), 35 | (3, 'Post 3 Title', 'Content of post 3'), 36 | (4, 'Post 4 Title', 'Content of post 4'), 37 | (5, 'Post 5 Title', 'Content of post 5'), 38 | (6, 'Post 6 Title', 'Content of post 6'), 39 | (7, 'Post 7 Title', 'Content of post 7'), 40 | (8, 'Post 8 Title', 'Content of post 8'), 41 | (9, 'Post 9 Title', 'Content of post 9'), 42 | (10, 'Post 10 Title', 'Content of post 10'), 43 | (11, 'Post 11 Title', 'Content of post 11'), 44 | (12, 'Post 12 Title', 'Content of post 12'), 45 | (13, 'Post 13 Title', 'Content of post 13'), 46 | (14, 'Post 14 Title', 'Content of post 14'), 47 | (15, 'Post 15 Title', 'Content of post 15'), 48 | (16, 'Post 16 Title', 'Content of post 16'), 49 | (17, 'Post 17 Title', 'Content of post 17'), 50 | (18, 'Post 18 Title', 'Content of post 18'), 51 | (19, 'Post 19 Title', 'Content of post 19'), 52 | (20, 'Post 20 Title', 'Content of post 20'), 53 | (1, 'Post 21 Title', 'Content of post 21'), 54 | (2, 'Post 22 Title', 'Content of post 22'), 55 | (3, 'Post 23 Title', 'Content of post 23'), 56 | (4, 'Post 24 Title', 'Content of post 24'), 57 | (5, 'Post 25 Title', 'Content of post 25'), 58 | (6, 'Post 26 Title', 'Content of post 26'), 59 | (7, 'Post 27 Title', 'Content of post 27'), 60 | (8, 'Post 28 Title', 'Content of post 28'), 61 | (9, 'Post 29 Title', 'Content of post 29'), 62 | (10, 'Post 30 Title', 'Content of post 30'), 63 | (11, 'Post 31 Title', 'Content of post 31'), 64 | (12, 'Post 32 Title', 'Content of post 32'), 65 | (13, 'Post 33 Title', 'Content of post 33'), 66 | (14, 'Post 34 Title', 'Content of post 34'), 67 | (15, 'Post 35 Title', 'Content of post 35'); 68 | 69 | -- Link Posts with Categories 70 | INSERT INTO post_category (post_id, category_id) VALUES 71 | (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), 72 | (6, 1), (7, 2), (8, 3), (9, 4), (10, 5), 73 | (11, 1), (12, 2), (13, 3), (14, 4), (15, 5), 74 | (16, 1), (17, 2), (18, 3), (19, 4), (20, 5), 75 | (21, 1), (22, 2), (23, 3), (24, 4), (25, 5), 76 | (26, 1), (27, 2), (28, 3), (29, 4), (30, 5), 77 | (31, 1), (32, 2), (33, 3), (34, 4), (35, 5); 78 | 79 | -- Insert Comments 80 | INSERT INTO comments (user_id, post_id, content) VALUES 81 | (1, 1, 'Comment 1 on Post 1'), 82 | (2, 2, 'Comment 2 on Post 2'), 83 | (3, 3, 'Comment 3 on Post 3'), 84 | (4, 4, 'Comment 4 on Post 4'), 85 | (5, 5, 'Comment 5 on Post 5'), 86 | (6, 6, 'Comment 6 on Post 6'), 87 | (7, 7, 'Comment 7 on Post 7'), 88 | (8, 8, 'Comment 8 on Post 8'), 89 | (9, 9, 'Comment 9 on Post 9'), 90 | (10, 10, 'Comment 10 on Post 10'), 91 | (11, 11, 'Comment 11 on Post 11'), 92 | (12, 12, 'Comment 12 on Post 12'), 93 | (13, 13, 'Comment 13 on Post 13'), 94 | (14, 14, 'Comment 14 on Post 14'), 95 | (1, 15, 'Comment 15 on Post 15'); 96 | 97 | -- Insert Post Reactions (Likes and Dislikes) 98 | INSERT INTO post_reactions (user_id, post_id, reaction) VALUES 99 | (1, 1, 'like'), (2, 1, 'dislike'), (3, 2, 'like'), (4, 2, 'dislike'), 100 | (5, 3, 'like'), (6, 4, 'dislike'), (7, 5, 'like'), (8, 6, 'dislike'), 101 | (9, 7, 'like'), (10, 8, 'dislike'), (11, 9, 'like'), (12, 10, 'dislike'), 102 | (13, 11, 'like'), (14, 12, 'dislike'), (15, 13, 'like'), (16, 14, 'dislike'); 103 | 104 | -- Insert Comment Reactions (Likes and Dislikes) 105 | INSERT INTO comment_reactions (user_id, comment_id, reaction) VALUES 106 | (1, 1, 'like'), (2, 2, 'dislike'), (3, 3, 'like'), (4, 4, 'dislike'), 107 | (5, 5, 'like'), (6, 6, 'dislike'), (7, 7, 'like'), (8, 8, 'dislike'), 108 | (9, 9, 'like'), (10, 10, 'dislike'), (11, 11, 'like'), (12, 12, 'dislike'), 109 | (13, 13, 'like'), (14, 14, 'dislike'), (15, 15, 'like'); 110 | -------------------------------------------------------------------------------- /server/models/comment.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | ) 7 | 8 | type Comment struct { 9 | ID int 10 | UserID int 11 | PostID int 12 | UserName string 13 | Content string 14 | Likes int 15 | Dislikes int 16 | CreatedAt string 17 | } 18 | 19 | func FetchCommentsByPostID(postID int, db *sql.DB) ([]Comment, error) { 20 | var comments []Comment 21 | query := ` 22 | SELECT 23 | c.id, 24 | c.user_id, 25 | u.username, 26 | c.content, 27 | strftime('%m/%d/%Y %I:%M %p', c.created_at) AS formatted_created_at, 28 | ( 29 | SELECT 30 | COUNT(*) 31 | FROM 32 | comment_reactions AS cr 33 | WHERE 34 | cr.comment_id = c.id 35 | AND cr.reaction = 'like' 36 | ) AS likes_count, 37 | ( 38 | SELECT 39 | COUNT(*) 40 | FROM 41 | comment_reactions AS cr 42 | WHERE 43 | cr.comment_id = c.id 44 | AND cr.reaction = 'dislike' 45 | ) AS dislikes_count 46 | FROM 47 | comments c 48 | INNER JOIN users u 49 | ON c.user_id = u.id 50 | WHERE 51 | c.post_id = ? 52 | ORDER BY 53 | c.created_at DESC 54 | ` 55 | 56 | rows, err := db.Query(query, postID) 57 | if err != nil { 58 | return nil, err 59 | } 60 | defer rows.Close() 61 | 62 | for rows.Next() { 63 | var comment Comment 64 | err := rows.Scan( 65 | &comment.ID, 66 | &comment.UserID, 67 | &comment.UserName, 68 | &comment.Content, 69 | &comment.CreatedAt, 70 | &comment.Likes, 71 | &comment.Dislikes, 72 | ) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | // Assign the post ID and format the created_at field 78 | comment.PostID = postID 79 | // comment.CreatedAt = utils.FormatTime(comment.CreatedAt) 80 | 81 | // Append the comment to the slice 82 | comments = append(comments, comment) 83 | } 84 | 85 | return comments, nil 86 | } 87 | 88 | func StoreComment(db *sql.DB, user_id, post_id int, content string) (int64, error) { 89 | query := `INSERT INTO comments (user_id,post_id,content) VALUES (?,?,?)` 90 | 91 | result, err := db.Exec(query, user_id, post_id, content) 92 | if err != nil { 93 | return 0, fmt.Errorf("%v", err) 94 | } 95 | 96 | commentID, _ := result.LastInsertId() 97 | 98 | return commentID, nil 99 | } 100 | 101 | func StoreCommentReaction(db *sql.DB, user_id, comment_id int, reaction string) (int64, error) { 102 | query := `INSERT INTO comment_reactions (user_id,comment_id,reaction) VALUES (?,?,?)` 103 | result, err := db.Exec(query, user_id, comment_id, reaction) 104 | if err != nil { 105 | fmt.Println(err) 106 | return 0, fmt.Errorf("error inserting reaction data -> ") 107 | } 108 | creactionID, _ := result.LastInsertId() 109 | 110 | return creactionID, nil 111 | } 112 | 113 | // Count comments by post ID 114 | func CountCommentsByPostID(db *sql.DB, postID int) (int, error) { 115 | var count int 116 | query := "SELECT COUNT(*) FROM comments WHERE post_id = ?" 117 | err := db.QueryRow(query, postID).Scan(&count) 118 | if err != nil { 119 | return 0, fmt.Errorf("error counting comments: %v", err) 120 | } 121 | return count, nil 122 | } 123 | 124 | // Fetch the creation time of a comment by its ID 125 | func FetchCommentTimeByID(db *sql.DB, commentID int64) (string, error) { 126 | var commentTime string 127 | query := "SELECT strftime('%m/%d/%Y %I:%M %p', created_at) AS formatted_created_at FROM comments WHERE id = ?" 128 | err := db.QueryRow(query, commentID).Scan(&commentTime) 129 | if err != nil { 130 | return "", fmt.Errorf("error fetching comment time: %v", err) 131 | } 132 | return commentTime, nil 133 | } 134 | 135 | func ReactToComment(db *sql.DB, user_id, comment_id int, userReaction string) (int, int, error) { 136 | var likeCount, dislikeCount int 137 | var dbreaction string 138 | var err error 139 | 140 | db.QueryRow("SELECT reaction FROM comment_reactions WHERE user_id=? AND comment_id=?", user_id, comment_id).Scan(&dbreaction) 141 | 142 | if dbreaction == "" { 143 | _, err = StoreCommentReaction(db, user_id, comment_id, userReaction) 144 | } else { 145 | if userReaction == dbreaction { 146 | query := "DELETE FROM comment_reactions WHERE user_id = ? AND comment_id = ?" 147 | _, err = db.Exec(query, user_id, comment_id) 148 | 149 | } else { 150 | query := "UPDATE comment_reactions SET reaction = ? WHERE user_id = ? AND comment_id = ?" 151 | _, err = db.Exec(query, userReaction, user_id, comment_id) 152 | } 153 | } 154 | if err != nil { 155 | return 0, 0, err 156 | } 157 | 158 | // Fetch the new count of reactions for this post 159 | err = db.QueryRow("SELECT COUNT(*) FROM comment_reactions WHERE comment_id=? AND reaction=?", comment_id, "like").Scan(&likeCount) 160 | if err != nil { 161 | return 0, 0, fmt.Errorf("error fetching likes count: %v", err) 162 | } 163 | err = db.QueryRow("SELECT COUNT(*) FROM comment_reactions WHERE comment_id=? AND reaction=?", comment_id, "dislike").Scan(&dislikeCount) 164 | if err != nil { 165 | return 0, 0, fmt.Errorf("error fetching likes count: %v", err) 166 | } 167 | return likeCount, dislikeCount, nil 168 | } 169 | -------------------------------------------------------------------------------- /server/validators/post_request_validator.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "html" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // validates a request for posts index. 11 | // returns: 12 | // - int: HTTP status code. 13 | // - string: Error or success message. 14 | // - int: page number. 15 | func IndexPostsRequest(r *http.Request) (int, string, int) { 16 | if r.URL.Path != "/" { 17 | return http.StatusNotFound, "Invalid path", 0 18 | } 19 | 20 | if r.Method != http.MethodGet { 21 | return http.StatusMethodNotAllowed, "Invalid HTTP method", 0 22 | } 23 | 24 | err := r.ParseForm() 25 | if err != nil { 26 | return http.StatusBadRequest, "Failed to parse form data", 0 27 | } 28 | 29 | pageStr := r.FormValue("PageID") 30 | page, err := strconv.Atoi(pageStr) 31 | if err != nil && pageStr != "" { 32 | return http.StatusBadRequest, "PageID must be a valid integer", 0 33 | } 34 | 35 | if page < 0 { 36 | return http.StatusBadRequest, "PageID cannot be negative", 0 37 | } 38 | 39 | return http.StatusOK, "success", page 40 | } 41 | 42 | // validates a request for posts by category. 43 | // Returns: 44 | // - int: HTTP status code. 45 | // - string: Error or success message. 46 | // - int: category ID. 47 | // - int: page number. 48 | func IndexPostsByCategoryRequest(r *http.Request) (int, string, int, int) { 49 | if r.URL.Path != "/" { 50 | return http.StatusNotFound, "Invalid path", 0, 0 51 | } 52 | 53 | if r.Method != http.MethodGet { 54 | return http.StatusMethodNotAllowed, "Invalid HTTP method", 0, 0 55 | } 56 | 57 | err := r.ParseForm() 58 | if err != nil { 59 | return http.StatusBadRequest, "Failed to parse form data", 0, 0 60 | } 61 | 62 | categorieIdStr := r.PathValue("id") 63 | categorieId, err := strconv.Atoi(categorieIdStr) 64 | if err != nil { 65 | return http.StatusBadRequest, "Category ID must be a valid integer", 0, 0 66 | } 67 | 68 | pageStr := r.FormValue("PageID") 69 | page, err := strconv.Atoi(pageStr) 70 | if err != nil && pageStr != "" { 71 | return http.StatusBadRequest, "PageID must be a valid integer", 0, 0 72 | } 73 | 74 | if page <= 0 { 75 | return http.StatusBadRequest, "PageID must be greater than 0", 0, 0 76 | } 77 | 78 | return http.StatusOK, "success", categorieId, page 79 | } 80 | 81 | // validates a request to show a specific post. 82 | // Returns: 83 | // - int: HTTP status code. 84 | // - string: Error or success message. 85 | // - int: post ID. 86 | func ShowPostRequest(r *http.Request) (int, string, int) { 87 | if r.Method != http.MethodGet { 88 | return http.StatusMethodNotAllowed, "Invalid HTTP method", 0 89 | } 90 | 91 | err := r.ParseForm() 92 | if err != nil { 93 | return http.StatusBadRequest, "Failed to parse form data", 0 94 | } 95 | 96 | postIdStr := r.PathValue("id") 97 | postId, err := strconv.Atoi(postIdStr) 98 | if err != nil { 99 | return http.StatusBadRequest, "Post ID must be a valid integer", 0 100 | } 101 | 102 | if postId <= 0 { 103 | return http.StatusBadRequest, "Post ID must be greater than 0", 0 104 | } 105 | 106 | return http.StatusOK, "success", postId 107 | } 108 | 109 | // validates a request to create a new post. 110 | // Returns: 111 | // - int: HTTP status code. 112 | // - string: Error or success message. 113 | // - string: title of the post. 114 | // - string: content of the post. 115 | // - []int: List of category IDs. 116 | func CreatePostRequest(r *http.Request) (int, string, string, string, []int) { 117 | if r.Method != http.MethodPost { 118 | return http.StatusMethodNotAllowed, "Invalid HTTP method", "", "", nil 119 | } 120 | 121 | contentType := r.Header.Get("Content-Type") 122 | if !strings.HasPrefix(contentType, "application/x-www-form-urlencoded") { 123 | return http.StatusUnsupportedMediaType, "Unsupported content type", "", "", nil 124 | } 125 | 126 | err := r.ParseForm() 127 | if err != nil { 128 | return http.StatusBadRequest, "Failed to parse form data", "", "", nil 129 | } 130 | 131 | title := r.FormValue("title") 132 | content := r.FormValue("content") 133 | categories := r.Form["categories"] 134 | 135 | if strings.TrimSpace(title) == "" { 136 | return http.StatusBadRequest, "Title is required", "", "", nil 137 | } 138 | if len(title) > 100 { 139 | return http.StatusBadRequest, "Title must not exceed 100 characters", "", "", nil 140 | } 141 | 142 | if len(categories) == 0 { 143 | return http.StatusBadRequest, "At least one category is required", "", "", nil 144 | } 145 | 146 | convertCategories := make([]int, 0, len(categories)) 147 | for _, cat := range categories { 148 | if cat == "" { 149 | return http.StatusBadRequest, "Category ID cannot be empty", "", "", nil 150 | } 151 | 152 | categoryID, err := strconv.Atoi(cat) 153 | if err != nil { 154 | return http.StatusBadRequest, "Category ID must be a valid integer", "", "", nil 155 | } 156 | 157 | convertCategories = append(convertCategories, categoryID) 158 | } 159 | 160 | if strings.TrimSpace(content) == "" { 161 | return http.StatusBadRequest, "Content is required", "", "", nil 162 | } 163 | if len(content) > 3000 { 164 | return http.StatusBadRequest, "Content must not exceed 3000 characters", "", "", nil 165 | } 166 | 167 | return http.StatusOK, "success", 168 | html.EscapeString(title), 169 | html.EscapeString(content), 170 | convertCategories 171 | } 172 | -------------------------------------------------------------------------------- /web/assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Playwrite+GB+S:ital,wght@0,100..400;1,100..400&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'); 2 | 3 | /* global variabls */ 4 | :root { 5 | --color-bg: #FFFFFF; 6 | --color-bg-variant: #e7eef1; 7 | --color-bg-variant-hover: #dbe4e9; 8 | --color-border: #cccccc; 9 | --color-text: #111111; 10 | --color-text-light: #56656b; 11 | 12 | --color-primary: #2761f0; 13 | --color-primary-hover: #1d4ab9; 14 | --color-white: #fff; 15 | --color-light: rgba(255, 255, 255, 0.774); 16 | 17 | --transition: all 400ms ease; 18 | 19 | --container-width-lg: 75%; 20 | --container-width-md: 86%; 21 | --container-width-sm: 95%; 22 | } 23 | 24 | 25 | 26 | /* Global style */ 27 | 28 | * { 29 | margin: 0; 30 | padding: 0; 31 | } 32 | 33 | body { 34 | background-color: var(--color-bg); 35 | font-family: system-ui, 36 | "Segoe UI", 37 | Roboto, 38 | Helvetica, Arial, 39 | sans-serif, 40 | "Apple Color Emoji", 41 | "Segoe UI Emoji", 42 | "Segoe UI Symbol"; 43 | } 44 | 45 | /* The entire scrollbar */ 46 | ::-webkit-scrollbar { 47 | width: 12px; 48 | /* Width of the scrollbar */ 49 | } 50 | 51 | /* The track (background) of the scrollbar */ 52 | ::-webkit-scrollbar-track { 53 | background-color: #f1f1f1; 54 | /* Background color of the track */ 55 | border-radius: 10px; 56 | /* Rounded corners */ 57 | } 58 | 59 | /* The draggable thumb of the scrollbar */ 60 | ::-webkit-scrollbar-thumb { 61 | background-color: var(--color-border); 62 | /* Color of the scrollbar thumb */ 63 | border-radius: 10px; 64 | /* Rounded corners */ 65 | border: 3px solid #f1f1f1; 66 | /* Space around the thumb */ 67 | } 68 | 69 | /* Hover effect for the thumb */ 70 | ::-webkit-scrollbar-thumb:hover { 71 | background-color: var(--color-primary-hover); 72 | /* Darker color when hovered */ 73 | } 74 | 75 | /* Header style */ 76 | header { 77 | position: fixed; 78 | width: 100%; 79 | height: 65px; 80 | display: flex; 81 | align-items: center; 82 | /* background-color: var(--color-bg); */ 83 | backdrop-filter: blur(2px); 84 | justify-content: space-between; 85 | padding: 0 1rem; 86 | border-bottom: 1px solid var(--color-border); 87 | z-index: 2; 88 | } 89 | 90 | header a { 91 | text-decoration: none; 92 | } 93 | 94 | header:first-child { 95 | padding-left: 1.5rem; 96 | } 97 | 98 | .forum-title { 99 | font-family: "Playwrite GB S", cursive; 100 | font-size: 1.3rem; 101 | color: var(--color-primary); 102 | transition: var(--transition); 103 | } 104 | 105 | .img-01 { 106 | width: 40px; 107 | } 108 | 109 | .login-link { 110 | text-decoration: none; 111 | background-color: var(--color-primary); 112 | margin-right: 2.5rem; 113 | padding: 8px 20px; 114 | border-radius: 20px; 115 | color: var(--color-white); 116 | text-decoration: none; 117 | font-size: 1rem; 118 | font-weight: 700; 119 | transition: var(--transition); 120 | } 121 | 122 | .login-link i { 123 | margin-left: 10px; 124 | } 125 | 126 | .login-link:hover { 127 | background-color: var(--color-primary-hover); 128 | } 129 | 130 | .header-user { 131 | margin-right: 2.5rem; 132 | display: flex; 133 | } 134 | 135 | 136 | .header-username { 137 | border: solid 1px var(--color-border); 138 | border-radius: 10px 0 0 10px; 139 | border-right: none; 140 | padding: 8px 20px; 141 | font-size: 1rem; 142 | color: var(--color-text); 143 | } 144 | 145 | .header-username i { 146 | font-size: 0.9 rem; 147 | margin-right: 8px; 148 | } 149 | 150 | .logout-link { 151 | text-decoration: none; 152 | /* background-color: ; */ 153 | padding: 8px 15px; 154 | color: rgb(187, 0, 0); 155 | border: solid 1px rgb(187, 0, 0); 156 | border-radius: 0 10px 10px 0; 157 | text-decoration: none; 158 | font-size: 1rem; 159 | font-weight: 700; 160 | transition: var(--transition); 161 | } 162 | 163 | .logout-link i { 164 | margin-left: 10px; 165 | } 166 | 167 | .logout-link:hover { 168 | background-color: rgb(187, 0, 0); 169 | color: var(--color-white); 170 | } 171 | 172 | /* container style */ 173 | .container { 174 | display: flex; 175 | flex-direction: column; 176 | padding-top: 65px; 177 | margin-left: 320px; 178 | min-height: 80vh; 179 | } 180 | 181 | /* footer style */ 182 | 183 | .footer-container { 184 | display: flex; 185 | border-top: 3px solid var(--color-border); 186 | background-color: var(--color-bg-variant); 187 | 188 | } 189 | 190 | .hidden-footer { 191 | width: 300px; 192 | } 193 | 194 | footer { 195 | width: 100%; 196 | background-color: var(--color-bg-variant); 197 | color: var(--color-text); 198 | padding: 20px 10px; 199 | text-align: center; 200 | } 201 | 202 | footer div { 203 | margin-bottom: 10px; 204 | } 205 | 206 | footer span { 207 | font-size: 16px; 208 | font-weight: bold; 209 | color: var(--color-primary); 210 | } 211 | 212 | .footer-team { 213 | margin-top: 15px; 214 | } 215 | 216 | .footer-team a { 217 | text-decoration: none; 218 | color: var(--color-primary); 219 | font-size: 14px; 220 | display: inline-flex; 221 | flex-wrap: wrap; 222 | align-items: center; 223 | margin: 0 10px; 224 | transition: var(--transition); 225 | } 226 | 227 | .footer-team a:hover { 228 | color: var(--color-primary-hover); 229 | transform: scale(1.1); 230 | } 231 | 232 | .footer-team img { 233 | width: 16px; 234 | height: 16px; 235 | margin-right: 5px; 236 | } 237 | 238 | 239 | /* ================= medium style ================== */ 240 | @media screen and (max-width: 1024px) { 241 | .container { 242 | margin-left: 200px; 243 | } 244 | 245 | .hidden-footer { 246 | width: 200px; 247 | } 248 | 249 | footer { 250 | width: 75%; 251 | } 252 | } 253 | 254 | /* ================= small style ================== */ 255 | @media screen and (max-width: 600px) { 256 | .container { 257 | margin-left: 0px; 258 | align-items: center; 259 | } 260 | 261 | header { 262 | height: 50px; 263 | } 264 | 265 | .forum-title { 266 | font-size: 1rem; 267 | } 268 | 269 | .img-01 { 270 | width: 30px; 271 | } 272 | 273 | .login-link { 274 | margin-right: 2.3rem; 275 | padding: 5px 15px; 276 | border-radius: 15px; 277 | font-size: 0.8rem; 278 | } 279 | 280 | .login-link i { 281 | margin-left: 8px; 282 | } 283 | 284 | .header-user { 285 | margin-right: 2.5rem; 286 | display: flex; 287 | } 288 | 289 | 290 | .header-username { 291 | padding: 5px 15px; 292 | font-size: 0.9rem; 293 | } 294 | 295 | .header-username i { 296 | font-size: 0.7rem; 297 | } 298 | 299 | .logout-link { 300 | padding: 5px 12px; 301 | font-size: 0.9rem; 302 | } 303 | 304 | .logout-link i { 305 | margin-left: 5px; 306 | } 307 | 308 | .container { 309 | padding-top: 50px; 310 | } 311 | 312 | .hidden-footer { 313 | width: 0; 314 | } 315 | 316 | footer { 317 | width: 100%; 318 | } 319 | 320 | .footer-team a { 321 | font-size: 12px; 322 | margin: 0 10px; 323 | } 324 | 325 | footer div { 326 | margin-bottom: 0px; 327 | font-size: 12px; 328 | 329 | } 330 | 331 | footer span { 332 | font-size: 13px; 333 | } 334 | } -------------------------------------------------------------------------------- /server/controllers/post_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "html" 7 | "log" 8 | "net/http" 9 | "strconv" 10 | "strings" 11 | 12 | "forum/server/models" 13 | "forum/server/utils" 14 | ) 15 | 16 | func IndexPosts(w http.ResponseWriter, r *http.Request, db *sql.DB) { 17 | var valid bool 18 | var username string 19 | _, username, valid = models.ValidSession(r, db) 20 | 21 | if r.URL.Path != "/" || r.Method != http.MethodGet { 22 | utils.RenderError(db, w, r, http.StatusNotFound, valid, username) 23 | return 24 | } 25 | id := r.FormValue("PageID") 26 | page, er := strconv.Atoi(id) 27 | if er != nil && id != "" { 28 | utils.RenderError(db, w, r, http.StatusBadRequest, valid, username) 29 | return 30 | } 31 | page = (page - 1) * 10 32 | if page < 0 { 33 | page = 0 34 | } 35 | posts, statusCode, err := models.FetchPosts(db, page) 36 | if err != nil { 37 | log.Println("Error fetching posts:", err) 38 | utils.RenderError(db, w, r, statusCode, valid, username) 39 | return 40 | } 41 | if posts == nil && page > 0 { 42 | utils.RenderError(db, w, r, 404, valid, username) 43 | return 44 | } 45 | 46 | if err := utils.RenderTemplate(db, w, r, "home", statusCode, posts, valid, username); err != nil { 47 | log.Println("Error rendering template:", err) 48 | utils.RenderError(db, w, r, http.StatusInternalServerError, valid, username) 49 | return 50 | } 51 | } 52 | 53 | func IndexPostsByCategory(w http.ResponseWriter, r *http.Request, db *sql.DB) { 54 | var valid bool 55 | var username string 56 | _, username, valid = models.ValidSession(r, db) 57 | 58 | if r.Method != http.MethodGet { 59 | utils.RenderError(db, w, r, http.StatusMethodNotAllowed, valid, username) 60 | return 61 | } 62 | 63 | id, err := strconv.Atoi(r.PathValue("id")) 64 | if err != nil { 65 | utils.RenderError(db, w, r, http.StatusBadRequest, valid, username) 66 | return 67 | } 68 | 69 | if e := models.CheckCategories(db,[]int{id}); e!= nil { 70 | utils.RenderError(db, w, r, 404, valid, username) 71 | return 72 | } 73 | 74 | pid := r.FormValue("PageID") 75 | page, _ := strconv.Atoi(pid) 76 | page = (page - 1) * 10 77 | if page < 0 { 78 | page = 0 79 | } 80 | 81 | posts, statusCode, err := models.FetchPostsByCategory(db, id, page) 82 | if err != nil { 83 | log.Println("Error fetching posts:", err) 84 | utils.RenderError(db, w, r, statusCode, valid, username) 85 | return 86 | } 87 | 88 | if posts == nil && page > 0 { 89 | utils.RenderError(db, w, r, 404, valid, username) 90 | return 91 | } 92 | 93 | if err := utils.RenderTemplate(db, w, r, "home", statusCode, posts, valid, username); err != nil { 94 | log.Println("Error rendering template:", err) 95 | utils.RenderError(db, w, r, http.StatusInternalServerError, valid, username) 96 | return 97 | } 98 | } 99 | 100 | func ShowPost(w http.ResponseWriter, r *http.Request, db *sql.DB) { 101 | var valid bool 102 | var username string 103 | _, username, valid = models.ValidSession(r, db) 104 | 105 | if r.Method != http.MethodGet { 106 | utils.RenderError(db, w, r, http.StatusMethodNotAllowed, valid, username) 107 | return 108 | } 109 | postID, err := strconv.Atoi(r.PathValue("id")) 110 | if err != nil { 111 | utils.RenderError(db, w, r, http.StatusBadRequest, valid, username) 112 | return 113 | } 114 | post, statusCode, err := models.FetchPost(db, postID) 115 | if err != nil { 116 | log.Println("Error fetching posts from the database:", err) 117 | utils.RenderError(db, w, r, statusCode, valid, username) 118 | return 119 | } 120 | 121 | err = utils.RenderTemplate(db, w, r, "post", statusCode, post, valid, username) 122 | if err != nil { 123 | log.Println(err) 124 | utils.RenderError(db, w, r, http.StatusInternalServerError, valid, username) 125 | } 126 | } 127 | 128 | func GetPostCreationForm(w http.ResponseWriter, r *http.Request, db *sql.DB) { 129 | var valid bool 130 | var username string 131 | 132 | if _, username, valid = models.ValidSession(r, db); !valid { 133 | http.Redirect(w, r, "/login", http.StatusFound) 134 | return 135 | } 136 | 137 | if r.Method != http.MethodGet { 138 | utils.RenderError(db, w, r, http.StatusMethodNotAllowed, valid, username) 139 | return 140 | } 141 | 142 | if err := utils.RenderTemplate(db, w, r, "post-form", http.StatusOK, nil, valid, username); err != nil { 143 | log.Println("Error rendering template:", err) 144 | utils.RenderError(db, w, r, http.StatusInternalServerError, valid, username) 145 | return 146 | } 147 | } 148 | 149 | func CreatePost(w http.ResponseWriter, r *http.Request, db *sql.DB) { 150 | var user_id int 151 | var valid bool 152 | 153 | if user_id, _, valid = models.ValidSession(r, db); !valid { 154 | w.WriteHeader(401) 155 | return 156 | } 157 | 158 | if r.Method != http.MethodPost { 159 | w.WriteHeader(405) 160 | return 161 | } 162 | 163 | if err := r.ParseForm(); err != nil { 164 | w.WriteHeader(400) 165 | return 166 | } 167 | 168 | title := r.FormValue("title") 169 | content := r.FormValue("content") 170 | catids := r.Form["categories"] 171 | 172 | catids = strings.Split(catids[0], ",") 173 | 174 | title = html.EscapeString(title) 175 | content = html.EscapeString(content) 176 | 177 | if catids == nil || strings.TrimSpace(title) == "" || strings.TrimSpace(content) == "" { 178 | w.WriteHeader(400) 179 | return 180 | } 181 | 182 | var catidsInt []int 183 | for i := range catids { 184 | id, e := strconv.Atoi(catids[i]) 185 | if e != nil { 186 | w.WriteHeader(400) 187 | return 188 | } 189 | catidsInt = append(catidsInt, id) 190 | } 191 | 192 | err := models.CheckCategories(db, catidsInt) 193 | if err != nil { 194 | w.WriteHeader(400) 195 | return 196 | } 197 | 198 | pid, err := models.StorePost(db, user_id, title, content) 199 | if err != nil { 200 | w.WriteHeader(400) 201 | return 202 | } 203 | 204 | for i := 0; i < len(catidsInt); i++ { 205 | 206 | _, err = models.StorePostCategory(db, pid, catidsInt[i]) 207 | if err != nil { 208 | w.WriteHeader(400) 209 | return 210 | } 211 | } 212 | 213 | w.Header().Set("Content-Type", "text/html") 214 | w.WriteHeader(200) 215 | } 216 | 217 | func MyCreatedPosts(w http.ResponseWriter, r *http.Request, db *sql.DB) { 218 | var valid bool 219 | var username string 220 | var user_id int 221 | if user_id, username, valid = models.ValidSession(r, db); !valid { 222 | http.Redirect(w, r, "/login", http.StatusFound) 223 | return 224 | } 225 | 226 | if r.Method != http.MethodGet { 227 | utils.RenderError(db, w, r, http.StatusNotFound, valid, username) 228 | return 229 | } 230 | id := r.FormValue("PageID") 231 | page, er := strconv.Atoi(id) 232 | if er != nil && id != "" { 233 | utils.RenderError(db, w, r, http.StatusBadRequest, valid, username) 234 | return 235 | } 236 | page = (page - 1) * 10 237 | if page < 0 { 238 | page = 0 239 | } 240 | posts, statusCode, err := models.FetchCreatedPostsByUser(db, user_id, page) 241 | if err != nil { 242 | log.Println("Error fetching posts:", err) 243 | utils.RenderError(db, w, r, statusCode, valid, username) 244 | return 245 | } 246 | if posts == nil && page > 0 { 247 | utils.RenderError(db, w, r, 404, valid, username) 248 | return 249 | } 250 | 251 | if err := utils.RenderTemplate(db, w, r, "home", statusCode, posts, valid, username); err != nil { 252 | log.Println("Error rendering template:", err) 253 | utils.RenderError(db, w, r, http.StatusInternalServerError, valid, username) 254 | return 255 | } 256 | } 257 | 258 | func MyLikedPosts(w http.ResponseWriter, r *http.Request, db *sql.DB) { 259 | var valid bool 260 | var username string 261 | var user_id int 262 | if user_id, username, valid = models.ValidSession(r, db); !valid { 263 | http.Redirect(w, r, "/login", http.StatusFound) 264 | return 265 | } 266 | 267 | if r.Method != http.MethodGet { 268 | utils.RenderError(db, w, r, http.StatusNotFound, valid, username) 269 | return 270 | } 271 | id := r.FormValue("PageID") 272 | page, er := strconv.Atoi(id) 273 | if er != nil && id != "" { 274 | utils.RenderError(db, w, r, http.StatusBadRequest, valid, username) 275 | return 276 | } 277 | page = (page - 1) * 10 278 | if page < 0 { 279 | page = 0 280 | } 281 | posts, statusCode, err := models.FetchLikedPostsByUser(db, user_id, page) 282 | if err != nil { 283 | log.Println("Error fetching posts:", err) 284 | utils.RenderError(db, w, r, statusCode, valid, username) 285 | return 286 | } 287 | if posts == nil && page > 0 { 288 | utils.RenderError(db, w, r, 404, valid, username) 289 | return 290 | } 291 | 292 | if err := utils.RenderTemplate(db, w, r, "home", statusCode, posts, valid, username); err != nil { 293 | log.Println("Error rendering template:", err) 294 | utils.RenderError(db, w, r, http.StatusInternalServerError, valid, username) 295 | return 296 | } 297 | } 298 | 299 | func ReactToPost(w http.ResponseWriter, r *http.Request, db *sql.DB) { 300 | if r.Method != http.MethodPost { 301 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 302 | return 303 | } 304 | 305 | var user_id int 306 | var valid bool 307 | 308 | if user_id, _, valid = models.ValidSession(r, db); !valid { 309 | w.WriteHeader(401) 310 | return 311 | } 312 | 313 | if err := r.ParseForm(); err != nil { 314 | w.WriteHeader(400) 315 | return 316 | } 317 | 318 | userReaction := r.FormValue("reaction") 319 | id := r.FormValue("post_id") 320 | post_id, err := strconv.Atoi(id) 321 | if err != nil { 322 | w.WriteHeader(400) 323 | return 324 | } 325 | likeCount, dislikeCount, err := models.ReactToPost(db, user_id, post_id, userReaction) 326 | if err != nil { 327 | w.WriteHeader(500) 328 | return 329 | } 330 | 331 | // Return the new count as JSON 332 | w.Header().Set("Content-Type", "application/json") 333 | json.NewEncoder(w).Encode(map[string]int{"likesCount": likeCount, "dislikesCount": dislikeCount}) 334 | } 335 | -------------------------------------------------------------------------------- /server/models/post.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "strings" 8 | ) 9 | 10 | type Post struct { 11 | ID int 12 | UserID int 13 | UserName string 14 | Title string 15 | Content string 16 | CreatedAt string 17 | Likes int 18 | Dislikes int 19 | Comments int 20 | CategoriesStr string 21 | Categories []string 22 | } 23 | 24 | type PostDetail struct { 25 | Post Post 26 | Comments []Comment 27 | } 28 | 29 | func FetchPosts(db *sql.DB, currentPage int) ([]Post, int, error) { 30 | var posts []Post 31 | 32 | // Query to fetch posts 33 | query := `SELECT 34 | p.id, 35 | p.user_id, 36 | u.username, 37 | p.title, 38 | p.content, 39 | strftime('%m/%d/%Y %I:%M %p', p.created_at) AS formatted_created_at, 40 | ( 41 | SELECT 42 | COUNT(*) 43 | FROM 44 | post_reactions AS pr 45 | WHERE 46 | pr.post_id = p.id 47 | AND pr.reaction = 'like' 48 | ) AS likes_count, 49 | ( 50 | SELECT 51 | COUNT(*) 52 | FROM 53 | post_reactions AS pr 54 | WHERE 55 | pr.post_id = p.id 56 | AND pr.reaction = 'dislike' 57 | ) AS dislikes_count, 58 | ( 59 | SELECT 60 | COUNT(*) 61 | FROM 62 | comments c 63 | WHERE 64 | c.post_id = p.id 65 | ) AS comments_count, 66 | ( 67 | SELECT 68 | GROUP_CONCAT(c.label) 69 | FROM 70 | categories c 71 | INNER JOIN post_category pc ON c.id = pc.category_id 72 | WHERE 73 | pc.post_id = p.id 74 | ) AS categories 75 | FROM 76 | posts p 77 | INNER JOIN users u ON p.user_id = u.id 78 | ORDER BY 79 | p.created_at DESC 80 | LIMIT 10 OFFSET ? ; 81 | ` 82 | rows, err := db.Query(query, currentPage) 83 | if err != nil { 84 | log.Println("Error executing query:", err) 85 | return nil, 500, err 86 | } 87 | defer rows.Close() 88 | 89 | // Iterate through the rows 90 | for rows.Next() { 91 | var post Post 92 | // Scan the data into the Post struct 93 | err := rows.Scan(&post.ID, 94 | &post.UserID, 95 | &post.UserName, 96 | &post.Title, 97 | &post.Content, 98 | &post.CreatedAt, 99 | &post.Likes, 100 | &post.Dislikes, 101 | &post.Comments, 102 | &post.CategoriesStr) 103 | if err != nil { 104 | log.Println("Error scanning row:", err) 105 | return nil, 500, err 106 | } 107 | // it came from the database as "technology,sports...", so we need to split it 108 | post.Categories = strings.Split(post.CategoriesStr, ",") 109 | 110 | // Format the created_at field to a more readable format 111 | // post.CreatedAt = utils.FormatTime(post.CreatedAt) 112 | // Append the Post struct to the posts slice 113 | posts = append(posts, post) 114 | } 115 | 116 | // Check for errors during iteration 117 | if err = rows.Err(); err != nil { 118 | log.Println("Error iterating rows:", err) 119 | return nil, 500, err 120 | } 121 | 122 | return posts, 200, nil 123 | } 124 | 125 | func FetchPost(db *sql.DB, postID int) (PostDetail, int, error) { 126 | var post Post 127 | post.ID = postID 128 | 129 | // Query to fetch the post 130 | query := `SELECT 131 | p.user_id, 132 | u.username, 133 | p.title, 134 | p.content, 135 | strftime('%m/%d/%Y %I:%M %p', p.created_at) AS formatted_created_at, 136 | ( 137 | SELECT COUNT(*) 138 | FROM post_reactions AS pr 139 | WHERE pr.post_id = p.id 140 | AND pr.reaction = 'like' 141 | ) AS likes_count, 142 | ( 143 | SELECT COUNT(*) 144 | FROM post_reactions AS pr 145 | WHERE pr.post_id = p.id 146 | AND pr.reaction = 'dislike' 147 | ) AS dislikes_count, 148 | ( 149 | SELECT COUNT(*) 150 | FROM comments c 151 | WHERE c.post_id = p.id 152 | ) AS comments_count, 153 | ( 154 | SELECT GROUP_CONCAT(c.label) 155 | FROM categories c 156 | INNER JOIN post_category pc ON c.id = pc.category_id 157 | WHERE pc.post_id = p.id 158 | ) AS categories 159 | FROM 160 | posts p 161 | INNER JOIN users u ON p.user_id = u.id 162 | WHERE p.id = ?` 163 | 164 | // Use QueryRow for a single result 165 | row := db.QueryRow(query, postID) 166 | 167 | // Scan the data into the Post struct 168 | err := row.Scan( 169 | &post.UserID, 170 | &post.UserName, 171 | &post.Title, 172 | &post.Content, 173 | &post.CreatedAt, 174 | &post.Likes, 175 | &post.Dislikes, 176 | &post.Comments, 177 | &post.CategoriesStr) 178 | if err != nil { 179 | if err == sql.ErrNoRows { 180 | return PostDetail{}, 404, fmt.Errorf("post not found: %w", err) 181 | } 182 | log.Println("Error scanning row:", err) 183 | return PostDetail{}, 500, err 184 | } 185 | 186 | // Process categories 187 | post.Categories = strings.Split(post.CategoriesStr, ",") 188 | 189 | // Format the created_at field 190 | // post.CreatedAt = post.CreatedAt.Format("01/02/2006 03:04 PM") 191 | comments, err := FetchCommentsByPostID(postID, db) 192 | if err != nil { 193 | log.Println("Error fetching comments from the database:", err) 194 | } 195 | 196 | return PostDetail{ 197 | Post: post, 198 | Comments: comments, 199 | }, 200, nil 200 | } 201 | 202 | func FetchPostsByCategory(db *sql.DB, categoryID int, currentpage int) ([]Post, int, error) { 203 | var posts []Post 204 | query := ` 205 | SELECT 206 | p.id, 207 | p.user_id, 208 | u.username, 209 | p.title, 210 | p.content, 211 | strftime('%m/%d/%Y %I:%M %p', p.created_at) AS formatted_created_at, 212 | ( 213 | SELECT 214 | COUNT(*) 215 | FROM 216 | post_reactions AS pr 217 | WHERE 218 | pr.post_id = p.id 219 | AND pr.reaction = 'like' 220 | ) AS likes_count, 221 | ( 222 | SELECT 223 | COUNT(*) 224 | FROM 225 | post_reactions AS pr 226 | WHERE 227 | pr.post_id = p.id 228 | AND pr.reaction = 'dislike' 229 | ) AS dislikes_count, 230 | ( 231 | SELECT 232 | COUNT(*) 233 | FROM 234 | comments c 235 | WHERE 236 | c.post_id = p.id 237 | ) AS comments_count, 238 | ( 239 | SELECT 240 | GROUP_CONCAT(c.label) 241 | FROM 242 | categories c 243 | INNER JOIN post_category pc ON c.id = pc.category_id 244 | WHERE 245 | pc.post_id = p.id 246 | ) AS categories 247 | FROM 248 | posts p 249 | INNER JOIN users u ON p.user_id = u.id 250 | INNER JOIN post_category pc ON p.id = pc.post_id 251 | WHERE pc.category_id = ? 252 | ORDER BY 253 | p.created_at 254 | LIMIT 10 OFFSET ? ; 255 | ` 256 | rows, err := db.Query(query, categoryID, currentpage) 257 | if err != nil { 258 | log.Println("Error executing query:", err) 259 | return nil, 500, err 260 | } 261 | defer rows.Close() 262 | for rows.Next() { 263 | var post Post 264 | err := rows.Scan(&post.ID, 265 | &post.UserID, 266 | &post.UserName, 267 | &post.Title, 268 | &post.Content, 269 | &post.CreatedAt, 270 | &post.Likes, 271 | &post.Dislikes, 272 | &post.Comments, 273 | &post.CategoriesStr) 274 | if err != nil { 275 | log.Println("Error scanning row:", err) 276 | return nil, 500, err 277 | } 278 | 279 | // it came from the database as "technology,sports...", so we need to split it 280 | post.Categories = strings.Split(post.CategoriesStr, ",") 281 | 282 | // post.CreatedAt = utils.FormatTime(post.CreatedAt) 283 | 284 | posts = append(posts, post) 285 | } 286 | 287 | // Check for errors during iteration 288 | if err = rows.Err(); err != nil { 289 | log.Println("Error iterating rows:", err) 290 | return nil, 500, err 291 | } 292 | 293 | return posts, 200, nil 294 | } 295 | 296 | func FetchCreatedPostsByUser(db *sql.DB, user_id int, currentPage int) ([]Post, int, error) { 297 | var posts []Post 298 | 299 | // Query to fetch posts 300 | query := `SELECT 301 | p.id, 302 | p.user_id, 303 | u.username, 304 | p.title, 305 | p.content, 306 | strftime('%m/%d/%Y %I:%M %p', p.created_at) AS formatted_created_at, 307 | ( 308 | SELECT 309 | COUNT(*) 310 | FROM 311 | post_reactions AS pr 312 | WHERE 313 | pr.post_id = p.id 314 | AND pr.reaction = 'like' 315 | ) AS likes_count, 316 | ( 317 | SELECT 318 | COUNT(*) 319 | FROM 320 | post_reactions AS pr 321 | WHERE 322 | pr.post_id = p.id 323 | AND pr.reaction = 'dislike' 324 | ) AS dislikes_count, 325 | ( 326 | SELECT 327 | COUNT(*) 328 | FROM 329 | comments c 330 | WHERE 331 | c.post_id = p.id 332 | ) AS comments_count, 333 | ( 334 | SELECT 335 | GROUP_CONCAT(c.label) 336 | FROM 337 | categories c 338 | INNER JOIN post_category pc ON c.id = pc.category_id 339 | WHERE 340 | pc.post_id = p.id 341 | ) AS categories 342 | FROM 343 | posts p 344 | INNER JOIN users u ON p.user_id = u.id 345 | WHERE p.user_id = ? 346 | ORDER BY 347 | p.created_at DESC 348 | LIMIT 10 OFFSET ? ; 349 | ` 350 | rows, err := db.Query(query, user_id, currentPage) 351 | if err != nil { 352 | log.Println("Error executing query:", err) 353 | return nil, 500, err 354 | } 355 | defer rows.Close() 356 | 357 | // Iterate through the rows 358 | for rows.Next() { 359 | var post Post 360 | // Scan the data into the Post struct 361 | err := rows.Scan(&post.ID, 362 | &post.UserID, 363 | &post.UserName, 364 | &post.Title, 365 | &post.Content, 366 | &post.CreatedAt, 367 | &post.Likes, 368 | &post.Dislikes, 369 | &post.Comments, 370 | &post.CategoriesStr) 371 | if err != nil { 372 | log.Println("Error scanning row:", err) 373 | return nil, 500, err 374 | } 375 | // it came from the database as "technology,sports...", so we need to split it 376 | post.Categories = strings.Split(post.CategoriesStr, ",") 377 | 378 | // Format the created_at field to a more readable format 379 | // post.CreatedAt = utils.FormatTime(post.CreatedAt) 380 | 381 | // Append the Post struct to the posts slice 382 | posts = append(posts, post) 383 | } 384 | 385 | // Check for errors during iteration 386 | if err = rows.Err(); err != nil { 387 | log.Println("Error iterating rows:", err) 388 | return nil, 500, err 389 | } 390 | 391 | return posts, 200, nil 392 | } 393 | 394 | func FetchLikedPostsByUser(db *sql.DB, user_id int, currentPage int) ([]Post, int, error) { 395 | var posts []Post 396 | 397 | // Query to fetch posts 398 | query := `SELECT 399 | p.id, 400 | p.user_id, 401 | u.username, 402 | p.title, 403 | p.content, 404 | strftime('%m/%d/%Y %I:%M %p', p.created_at) AS formatted_created_at, 405 | ( 406 | SELECT 407 | COUNT(*) 408 | FROM 409 | post_reactions AS pr 410 | WHERE 411 | pr.post_id = p.id 412 | AND pr.reaction = 'like' 413 | ) AS likes_count, 414 | ( 415 | SELECT 416 | COUNT(*) 417 | FROM 418 | post_reactions AS pr 419 | WHERE 420 | pr.post_id = p.id 421 | AND pr.reaction = 'dislike' 422 | ) AS dislikes_count, 423 | ( 424 | SELECT 425 | COUNT(*) 426 | FROM 427 | comments c 428 | WHERE 429 | c.post_id = p.id 430 | ) AS comments_count, 431 | ( 432 | SELECT 433 | GROUP_CONCAT(c.label) 434 | FROM 435 | categories c 436 | INNER JOIN post_category pc ON c.id = pc.category_id 437 | WHERE 438 | pc.post_id = p.id 439 | ) AS categories 440 | FROM 441 | posts p 442 | INNER JOIN users u ON p.user_id = u.id 443 | INNER JOIN post_reactions pr ON p.id = pr.post_id 444 | WHERE pr.user_id = ? AND pr.reaction = 'like' 445 | ORDER BY 446 | p.created_at DESC 447 | LIMIT 10 OFFSET ? ; 448 | ` 449 | rows, err := db.Query(query, user_id, currentPage) 450 | if err != nil { 451 | log.Println("Error executing query:", err) 452 | return nil, 500, err 453 | } 454 | defer rows.Close() 455 | 456 | // Iterate through the rows 457 | for rows.Next() { 458 | var post Post 459 | // Scan the data into the Post struct 460 | err := rows.Scan(&post.ID, 461 | &post.UserID, 462 | &post.UserName, 463 | &post.Title, 464 | &post.Content, 465 | &post.CreatedAt, 466 | &post.Likes, 467 | &post.Dislikes, 468 | &post.Comments, 469 | &post.CategoriesStr) 470 | if err != nil { 471 | log.Println("Error scanning row:", err) 472 | return nil, 500, err 473 | } 474 | // it came from the database as "technology,sports...", so we need to split it 475 | post.Categories = strings.Split(post.CategoriesStr, ",") 476 | 477 | // Format the created_at field to a more readable format 478 | // post.CreatedAt = utils.FormatTime(post.CreatedAt) 479 | 480 | // Append the Post struct to the posts slice 481 | posts = append(posts, post) 482 | } 483 | 484 | // Check for errors during iteration 485 | if err = rows.Err(); err != nil { 486 | log.Println("Error iterating rows:", err) 487 | return nil, 500, err 488 | } 489 | 490 | return posts, 200, nil 491 | } 492 | 493 | func StorePost(db *sql.DB, user_id int, title, content string) (int64, error) { 494 | query := `INSERT INTO posts (user_id,title,content) VALUES (?,?,?)` 495 | 496 | result, err := db.Exec(query, user_id, title, content) 497 | if err != nil { 498 | return 0, fmt.Errorf("%v", err) 499 | } 500 | 501 | postID, _ := result.LastInsertId() 502 | 503 | return postID, nil 504 | } 505 | 506 | func StorePostCategory(db *sql.DB, post_id int64, category_id int) (int64, error) { 507 | query := `INSERT INTO post_category (post_id, category_id) VALUES (?,?)` 508 | 509 | result, err := db.Exec(query, post_id, category_id) 510 | if err != nil { 511 | return 0, fmt.Errorf("%v", err) 512 | } 513 | 514 | postcatID, _ := result.LastInsertId() 515 | 516 | return postcatID, nil 517 | } 518 | 519 | func StorePostReaction(db *sql.DB, user_id, post_id int, reaction string) (int64, error) { 520 | query := `INSERT INTO post_reactions (user_id,post_id,reaction) VALUES (?,?,?)` 521 | result, err := db.Exec(query, user_id, post_id, reaction) 522 | if err != nil { 523 | return 0, fmt.Errorf("error inserting reaction data -> ") 524 | } 525 | preactionID, _ := result.LastInsertId() 526 | 527 | return preactionID, nil 528 | } 529 | 530 | func ReactToPost(db *sql.DB, user_id, post_id int, userReaction string) (int, int, error) { 531 | var likeCount, dislikeCount int 532 | var dbreaction string 533 | var err error 534 | db.QueryRow("SELECT reaction FROM post_reactions WHERE user_id=? AND post_id=?", user_id, post_id).Scan(&dbreaction) 535 | 536 | if dbreaction == "" { 537 | _, err = StorePostReaction(db, user_id, post_id, userReaction) 538 | } else { 539 | if userReaction == dbreaction { 540 | query := "DELETE FROM post_reactions WHERE user_id = ? AND post_id = ?" 541 | _, err = db.Exec(query, user_id, post_id) 542 | } else { 543 | query := "UPDATE post_reactions SET reaction = ? WHERE user_id = ? AND post_id = ?" 544 | _, err = db.Exec(query, userReaction, user_id, post_id) 545 | } 546 | } 547 | 548 | if err != nil { 549 | return 0, 0, err 550 | } 551 | 552 | // Fetch the new count of reactions for this post 553 | db.QueryRow("SELECT COUNT(*) FROM post_reactions WHERE post_id=? AND reaction=?", post_id, "like").Scan(&likeCount) 554 | db.QueryRow("SELECT COUNT(*) FROM post_reactions WHERE post_id=? AND reaction=?", post_id, "dislike").Scan(&dislikeCount) 555 | 556 | return likeCount, dislikeCount, nil 557 | } 558 | -------------------------------------------------------------------------------- /web/assets/css/post.css: -------------------------------------------------------------------------------- 1 | /* post style */ 2 | .posts { 3 | display: flex; 4 | flex-direction: column; 5 | width: var(--container-width-lg); 6 | align-items: center; 7 | /* margin-left: 15px; */ 8 | /* border: red solid 1px; */ 9 | margin-left: 50px; 10 | min-height: 73vh; 11 | } 12 | 13 | .posts-header { 14 | width: 100%; 15 | display: flex; 16 | margin-top: 11px; 17 | /* border: red solid 1px; */ 18 | } 19 | 20 | .create-post-link { 21 | text-decoration: none; 22 | border: var(--color-primary) solid 1px; 23 | margin-right: 2.5rem; 24 | padding: 8px 20px; 25 | border-radius: 20px; 26 | color: var(--color-primary); 27 | text-decoration: none; 28 | font-size: 1rem; 29 | font-weight: 700; 30 | transition: var(--transition); 31 | } 32 | 33 | .create-post-link i { 34 | margin-right: 5px; 35 | } 36 | 37 | .create-post-link:hover { 38 | background-color: rgb(219, 219, 219); 39 | } 40 | 41 | .post-detail { 42 | width: 90%; 43 | } 44 | 45 | .post { 46 | display: flex; 47 | flex-wrap: wrap; 48 | flex-direction: column; 49 | /* border: #2761f0 solid 1px; */ 50 | width: 100%; 51 | padding: 20px 20px 0 20px; 52 | /*border-bottom: 1px solid var(--color-border); */ 53 | /* padding: 10px; */ 54 | margin-top: 11px; 55 | border: var(--color-border) solid 1px; 56 | border-radius: 10px; 57 | overflow: hidden; 58 | } 59 | 60 | .post-header { 61 | display: flex; 62 | align-items: center; 63 | gap: 10px; 64 | color: var(--color-text-light); 65 | } 66 | 67 | .post-user { 68 | font-size: 1rem; 69 | font-weight: 800; 70 | } 71 | 72 | .post-header span { 73 | background-color: var(--color-text-light); 74 | border-radius: 50%; 75 | padding: 2px; 76 | } 77 | 78 | .post-time { 79 | font-size: 0.9rem; 80 | font-weight: 500; 81 | } 82 | 83 | .post-body { 84 | color: var(--color-text); 85 | margin: 20px 20px; 86 | font-size: 1rem; 87 | font-weight: 500; 88 | line-height: 1.7; 89 | /* width: 95%; */ 90 | } 91 | 92 | .post-title { 93 | font-size: 1.5rem; 94 | font-weight: 600; 95 | color: black; 96 | } 97 | 98 | .post-content { 99 | font-size: 0.9rem; 100 | font-weight: 500; 101 | word-wrap: break-word; 102 | margin: 15px 0; 103 | } 104 | 105 | #post-content-home { 106 | overflow: hidden; 107 | text-overflow: ellipsis; 108 | display: -webkit-box; 109 | -webkit-line-clamp: 2; 110 | /* number of lines to show */ 111 | line-clamp: 2; 112 | -webkit-box-orient: vertical; 113 | } 114 | 115 | .post-categories { 116 | display: flex; 117 | flex-wrap: wrap; 118 | gap: 10px; 119 | margin-top: 1rem; 120 | } 121 | 122 | .post-category { 123 | font-size: 0.8rem; 124 | padding: 5px 15px; 125 | border: var(--color-border) solid 1px; 126 | border-radius: 30px; 127 | } 128 | 129 | .post-footer { 130 | display: flex; 131 | flex-direction: row; 132 | justify-content: space-between; 133 | align-items: center; 134 | gap: 10px; 135 | max-width: 200px; 136 | /* border: #2761f0 solid 1px; */ 137 | margin: 0 10px 10px 20px; 138 | } 139 | 140 | .post-footer span, 141 | .post-footer button, 142 | .post-footer a { 143 | display: flex; 144 | flex-direction: row; 145 | justify-content: space-between; 146 | align-items: center; 147 | gap: 5px; 148 | background-color: var(--color-bg-variant); 149 | color: var(--color-text); 150 | padding: 4px 8px; 151 | border: var(--color-text-light) solid 1px; 152 | border-radius: 10px; 153 | text-decoration: none; 154 | transition: var(--transition); 155 | font-size: 0.9rem; 156 | } 157 | 158 | .post-footer-hover:hover { 159 | cursor: pointer; 160 | background-color: var(--color-bg-variant-hover); 161 | } 162 | 163 | .no-posts { 164 | color: var(--color-text-light); 165 | margin-top: 3rem; 166 | font-size: 0.9rem; 167 | } 168 | 169 | .nav-button { 170 | display: none; 171 | margin-left: 5px; 172 | margin-right: 1rem; 173 | font-size: 1.3rem; 174 | padding: 0px 8px; 175 | border-radius: 15px; 176 | border: none; 177 | cursor: pointer; 178 | color: var(--color-text); 179 | transition: var(--transition); 180 | } 181 | 182 | .nav-button:hover { 183 | color: var(--color-text-light); 184 | } 185 | 186 | /* create post form */ 187 | 188 | .create-post { 189 | display: flex; 190 | flex-direction: column; 191 | align-items: center; 192 | justify-content: space-between; 193 | margin: 11px 0 11px 50px; 194 | padding: 20px 0; 195 | width: var(--container-width-lg); 196 | gap: 20px; 197 | border: var(--color-border) solid 1px; 198 | border-radius: 10px; 199 | } 200 | 201 | .create-post h1 { 202 | font-size: 1.5rem; 203 | font-weight: 600; 204 | color: var(--color-text-light); 205 | } 206 | 207 | .create-post-fields { 208 | width: var(--container-width-lg); 209 | /* border: rgb(145, 255, 0) solid 1px; */ 210 | 211 | } 212 | 213 | .create-post label { 214 | display: block; 215 | margin-bottom: 5px; 216 | font-size: 0.9rem; 217 | font-weight: 400; 218 | color: var(--color-text-light); 219 | /* border: red solid 1px; */ 220 | width: 100%; 221 | margin-left: 5px; 222 | } 223 | 224 | .create-post-title { 225 | padding: 10px; 226 | border: var(--color-border) solid 1px; 227 | border-radius: 10px; 228 | width: 100%; 229 | } 230 | 231 | .create-post textarea { 232 | padding: 10px; 233 | border: var(--color-border) solid 1px; 234 | border-radius: 10px; 235 | resize: vertical; 236 | height: 150px; 237 | width: 100%; 238 | 239 | } 240 | 241 | .create-post textarea:focus, 242 | .create-post input:focus { 243 | outline: var(--color-primary) solid 1px; 244 | } 245 | 246 | .create-post-categories { 247 | width: 100%; 248 | /* border: rgb(47, 137, 255) solid 1px;; */ 249 | display: flex; 250 | align-items: center; 251 | flex-wrap: wrap; 252 | gap: 10px; 253 | } 254 | 255 | .selected-categories { 256 | display: flex; 257 | align-items: center; 258 | flex-wrap: wrap; 259 | gap: 10px; 260 | /* border: red solid 1px; */ 261 | } 262 | 263 | .create-post-categories select { 264 | padding: 7px; 265 | border: var(--color-border) solid 1px; 266 | border-radius: 10px; 267 | cursor: pointer; 268 | } 269 | 270 | .create-post-categories select:focus { 271 | outline: var(--color-primary) solid 1px; 272 | } 273 | 274 | .selected-category { 275 | display: flex; 276 | align-items: center; 277 | font-size: 0.9rem; 278 | padding: 7px 10px; 279 | color: var(--color-text-light); 280 | border: var(--color-text-light) solid 1px; 281 | border-radius: 10px; 282 | } 283 | 284 | .remove-category { 285 | display: flex; 286 | align-items: center; 287 | font-size: 1.6rem; 288 | color: rgb(209, 0, 0); 289 | margin-left: 7px; 290 | margin-bottom: 2px; 291 | max-height: 13px; 292 | cursor: pointer; 293 | transition: var(--transition); 294 | } 295 | 296 | .remove-category:hover { 297 | opacity: 0.7; 298 | } 299 | 300 | .create-post button { 301 | width: fit-content; 302 | height: fit-content; 303 | border: none; 304 | cursor: pointer; 305 | background-color: var(--color-primary); 306 | padding: 8px 20px; 307 | border-radius: 20px; 308 | color: var(--color-white); 309 | font-size: 1rem; 310 | font-weight: 700; 311 | transition: var(--transition); 312 | } 313 | 314 | .create-post button:hover { 315 | background-color: var(--color-primary-hover); 316 | } 317 | 318 | .create-post i { 319 | margin-left: 7px; 320 | } 321 | 322 | #publish-post-circle { 323 | display: none; 324 | } 325 | 326 | .errorarea { 327 | color: red; 328 | height: 20px; 329 | width: var(--container-width-lg); 330 | text-wrap: wrap; 331 | } 332 | 333 | .max-char{ 334 | font-size: 0.8rem; 335 | opacity: 0.7; 336 | margin-left: 5px; 337 | } 338 | 339 | /* pagination style */ 340 | 341 | .pagination { 342 | display: flex; 343 | justify-content: center; 344 | gap: 15px; 345 | width: var(--container-width-lg); 346 | /* border: red 1px solid; */ 347 | margin: 20px; 348 | } 349 | 350 | .pagination a { 351 | text-decoration: none; 352 | color: var(--color-text); 353 | } 354 | 355 | /* style of post's comments */ 356 | 357 | .comments { 358 | /* border: red solid 1px; */ 359 | display: flex; 360 | flex-direction: column; 361 | gap: 20px; 362 | margin: 20px; 363 | max-width: 70%; 364 | } 365 | 366 | .comment-add { 367 | display: flex; 368 | align-items: center; 369 | justify-content: space-between; 370 | gap: 20px; 371 | max-width: 65%; 372 | margin: 20px; 373 | /* border: red solid 1px; */ 374 | } 375 | 376 | .comment-add textarea { 377 | padding: 10px; 378 | border: var(--color-border) solid 1px; 379 | border-radius: 10px; 380 | resize: none; 381 | width: 70%; 382 | } 383 | 384 | .comment-add textarea:focus { 385 | /* border: var(--color-primary) solid 1px; */ 386 | outline: var(--color-primary) solid 1px; 387 | } 388 | 389 | 390 | .comment-add button { 391 | width: fit-content; 392 | height: fit-content; 393 | border: none; 394 | cursor: pointer; 395 | background-color: var(--color-primary); 396 | padding: 8px 20px; 397 | border-radius: 20px; 398 | color: var(--color-white); 399 | font-size: 1rem; 400 | font-weight: 700; 401 | transition: var(--transition); 402 | } 403 | 404 | .comment-add button:hover { 405 | background-color: var(--color-primary-hover); 406 | } 407 | 408 | .comment { 409 | display: flex; 410 | flex-direction: column; 411 | gap: 10px; 412 | padding: 10px; 413 | border: var(--color-border) solid 1px; 414 | border-radius: 10px; 415 | text-wrap: wrap; 416 | } 417 | 418 | .post-time { 419 | font-size: 0.9rem; 420 | font-weight: 500; 421 | } 422 | 423 | .comment-header { 424 | display: flex; 425 | align-items: center; 426 | gap: 10px; 427 | color: var(--color-text-light); 428 | } 429 | 430 | .comment-user { 431 | font-size: 1rem; 432 | font-weight: 800; 433 | } 434 | 435 | .comment-header span { 436 | background-color: var(--color-text-light); 437 | border-radius: 50%; 438 | padding: 2px; 439 | } 440 | 441 | .comment-time { 442 | font-size: 0.9rem; 443 | font-weight: 500; 444 | } 445 | 446 | .comment-body { 447 | margin: 8px 0 0 20px; 448 | } 449 | 450 | .comment-content { 451 | font-size: 0.9rem; 452 | font-weight: 500; 453 | } 454 | 455 | .comment-footer { 456 | display: flex; 457 | flex-direction: row; 458 | justify-content: space-between; 459 | align-items: center; 460 | margin-top: 5px; 461 | max-width: 120px; 462 | /* border: #2761f0 solid 1px; */ 463 | margin: 10px 0 0; 464 | } 465 | 466 | .comment-like, 467 | .comment-dislike { 468 | display: flex; 469 | flex-direction: row; 470 | justify-content: space-between; 471 | align-items: center; 472 | gap: 5px; 473 | background-color: var(--color-bg-variant); 474 | padding: 4px 8px; 475 | border: var(--color-text-light) solid 1px; 476 | border-radius: 10px; 477 | transition: var(--transition); 478 | } 479 | 480 | .comment-like:hover, 481 | .comment-dislike:hover { 482 | cursor: pointer; 483 | background-color: var(--color-bg-variant-hover); 484 | } 485 | 486 | 487 | /* ================= medium style ================== */ 488 | @media screen and (max-width: 1024px) { 489 | .posts { 490 | width: var(--container-width-md); 491 | margin-left: 30px; 492 | } 493 | 494 | .pagination { 495 | width: var(--container-width-md); 496 | } 497 | 498 | .create-post { 499 | margin: 11px; 500 | padding: 20px 0; 501 | width: var(--container-width-md); 502 | gap: 20px; 503 | } 504 | 505 | /* style of post's comments */ 506 | 507 | .comments { 508 | gap: 10px; 509 | margin: 10px; 510 | padding-bottom: 0; 511 | max-width: 95%; 512 | } 513 | 514 | .comment-add { 515 | gap: 5px; 516 | max-width: 90%; 517 | margin: 10px; 518 | } 519 | } 520 | 521 | /* ================= small style ================== */ 522 | @media screen and (max-width: 600px) { 523 | .posts { 524 | width: var(--container-width-sm); 525 | margin-left: 0; 526 | } 527 | 528 | .post { 529 | padding: 0; 530 | 531 | } 532 | 533 | .pagination { 534 | width: var(--container-width-sm); 535 | } 536 | 537 | .create-post { 538 | width: var(--container-width-sm); 539 | } 540 | 541 | .post-header { 542 | gap: 5px; 543 | font-size: 0.8; 544 | } 545 | 546 | .post-time, 547 | .post-user { 548 | font-size: 0.8rem; 549 | } 550 | 551 | .post-title { 552 | font-size: 1rem; 553 | font-weight: 600; 554 | color: black; 555 | } 556 | 557 | .nav-button { 558 | display: block; 559 | } 560 | 561 | .create-post-link { 562 | padding: 5px 15px; 563 | border-radius: 15px; 564 | font-size: 0.9rem; 565 | } 566 | 567 | .post-content { 568 | font-size: 0.9rem; 569 | margin: 10px 0; 570 | } 571 | 572 | .post-categories { 573 | gap: 7px; 574 | margin-top: 0.7rem; 575 | } 576 | 577 | .post-category { 578 | font-size: 0.7rem; 579 | padding: 2px 8px; 580 | border-radius: 20px; 581 | } 582 | 583 | .post-footer { 584 | gap: 10px; 585 | max-width: 150px; 586 | margin: 0 10px 10px 20px; 587 | } 588 | 589 | .post-footer span, 590 | .post-footer button, 591 | .post-footer a { 592 | gap: 5px; 593 | padding: 4px 8px; 594 | border-radius: 10px; 595 | font-size: 0.8rem; 596 | } 597 | 598 | .create-post { 599 | margin: 11px; 600 | padding: 10px 0; 601 | width: var(--container-width-md); 602 | gap: 20px; 603 | } 604 | 605 | .create-post h1 { 606 | font-size: 1.2rem; 607 | } 608 | 609 | .create-post-fields { 610 | width: var(--container-width-md); 611 | 612 | } 613 | 614 | .create-post label { 615 | font-size: 0.8rem; 616 | } 617 | 618 | .create-post-title { 619 | padding: 7px; 620 | border-radius: 10px; 621 | width: 90%; 622 | } 623 | 624 | .create-post textarea { 625 | padding: 7px; 626 | height: 150px; 627 | width: 90%; 628 | 629 | } 630 | 631 | .create-post textarea:focus, 632 | .create-post input:focus { 633 | outline: var(--color-primary) solid 1px; 634 | } 635 | 636 | .create-post-categories { 637 | width: 90%; 638 | gap: 5px; 639 | } 640 | 641 | .selected-categories { 642 | gap: 5px; 643 | } 644 | 645 | .create-post-categories select { 646 | padding: 3px; 647 | font-size: 0.8rem; 648 | } 649 | 650 | .selected-category { 651 | font-size: 0.8rem; 652 | padding: 4px 10px; 653 | } 654 | 655 | .create-post button { 656 | padding: 5px 15px; 657 | font-size: 0.9rem; 658 | } 659 | 660 | /* style of post's comments */ 661 | 662 | .comments { 663 | gap: 10px; 664 | margin: 10px; 665 | padding-bottom: 0; 666 | max-width: 95%; 667 | } 668 | 669 | .comment-add { 670 | flex-direction: column; 671 | gap: 5px; 672 | max-width: 95%; 673 | margin: 10px; 674 | } 675 | 676 | .comment-add textarea { 677 | padding: 10px; 678 | width: 95%; 679 | } 680 | 681 | .comment-add button { 682 | padding: 8px 15px; 683 | font-size: 0.8rem; 684 | float: right; 685 | } 686 | 687 | .comment { 688 | gap: 10px; 689 | padding: 10px; 690 | } 691 | 692 | .comment-header { 693 | gap: 5px; 694 | } 695 | 696 | .comment-user { 697 | font-size: 0.8rem; 698 | } 699 | 700 | .comment-time { 701 | font-size: 0.8rem; 702 | } 703 | 704 | .comment-body { 705 | margin: 8px 0 0 10px; 706 | } 707 | 708 | .comment-content { 709 | font-size: 0.8rem; 710 | } 711 | 712 | .comment-footer { 713 | margin-top: 5px; 714 | max-width: 100px; 715 | } 716 | } -------------------------------------------------------------------------------- /web/assets/js/index.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('resize', () => { 2 | if (document.body.clientWidth > 600) { 3 | document.querySelector('.mobile-nav').style.display = 'none'; 4 | } 5 | }) 6 | 7 | 8 | function throttle(fn, delay) { 9 | let last = 0; 10 | return function () { 11 | const now = +new Date(); 12 | if (now - last > delay) { 13 | fn.apply(this, arguments); 14 | last = now; 15 | } 16 | }; 17 | } 18 | 19 | const addcomment = throttle(addcomm, 5000) 20 | 21 | function postreaction(postId, reaction) { 22 | document.getElementById("errorlogin" + postId).innerText = `` 23 | const xhr = new XMLHttpRequest(); 24 | xhr.open("POST", "/post/postreaction", true); 25 | xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); 26 | xhr.onreadystatechange = function () { 27 | if (xhr.readyState === 4) { 28 | if (xhr.status === 200) { 29 | const response = JSON.parse(xhr.responseText); 30 | document.getElementById("likescount" + postId).innerHTML = `${response.likesCount}`; 32 | document.getElementById("dislikescount" + postId).innerHTML = `${response.dislikesCount}`; 34 | } else if (xhr.status === 401) { 35 | document.getElementById("errorlogin" + postId).innerText = `You must login first!` 36 | setTimeout(() => { 37 | document.getElementById("errorlogin" + postId).innerText = `` 38 | }, 1000); 39 | } else if (xhr.status === 400) { 40 | document.getElementById("errorlogin" + postId).innerText = `Bad request!` 41 | setTimeout(() => { 42 | document.getElementById("errorlogin" + postId).innerText = `` 43 | }, 1000); 44 | } else if (xhr.status === 500) { 45 | document.getElementById("errorlogin" + postId).innerText = `Try again later!` 46 | setTimeout(() => { 47 | document.getElementById("errorlogin" + postId).innerText = `` 48 | }, 1000); 49 | } 50 | }; 51 | } 52 | xhr.send(`reaction=${reaction}&post_id=${postId}`); 53 | } 54 | function commentreaction(commentid, reaction) { 55 | document.getElementById("commenterrorlogin" + commentid).innerText = `` 56 | const xhr = new XMLHttpRequest(); 57 | xhr.open("POST", "/post/commentreaction", true); 58 | xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); 59 | xhr.onreadystatechange = function () { 60 | if (xhr.readyState === 4) { 61 | if (xhr.status === 200) { 62 | const response = JSON.parse(xhr.responseText); 63 | document.getElementById("commentlikescount" + commentid).innerHTML = `${response.commentlikesCount}`; 65 | document.getElementById("commentdislikescount" + commentid).innerHTML = `${response.commentdislikesCount}`; 67 | } else if (xhr.status === 401) { 68 | document.getElementById("commenterrorlogin" + commentid).innerText = `You must login first!` 69 | setTimeout(() => { 70 | document.getElementById("commenterrorlogin" + commentid).innerText = `` 71 | }, 1000); 72 | 73 | } else if (xhr.status === 400) { 74 | document.getElementById("commenterrorlogin" + commentid).innerText = `bad request!` 75 | setTimeout(() => { 76 | document.getElementById("commenterrorlogin" + commentid).innerText = `` 77 | }, 1000); 78 | } else if (xhr.status === 500) { 79 | document.getElementById("commenterrorlogin" + commentid).innerText = `Try again later!` 80 | setTimeout(() => { 81 | document.getElementById("commenterrorlogin" + commentid).innerText = `` 82 | }, 1000); 83 | } 84 | }; 85 | } 86 | xhr.send(`reaction=${reaction}&comment_id=${commentid}`); 87 | } 88 | 89 | 90 | function addcomm(postId) { 91 | const content = document.getElementById("comment-content"); 92 | const xhr = new XMLHttpRequest(); 93 | xhr.open("POST", "/post/addcommentREQ", true); 94 | xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); 95 | xhr.onreadystatechange = function () { 96 | if (xhr.readyState === 4) { 97 | if (xhr.status === 200) { 98 | const response = JSON.parse(xhr.responseText); 99 | const comment = document.createElement("div") 100 | comment.innerHTML = ` 101 |
102 |
103 |

`+ response.username + `

104 | 105 |

`+ response.created_at + `

106 |
107 |
108 |

`+ response.content + `

109 |
110 | 116 | 117 |
118 | ` 119 | document.getElementsByClassName("comments")[0].prepend(comment) 120 | document.getElementsByClassName("post-comments")[0].innerHTML = `` + response.commentscount 121 | content.value = "" 122 | } else if (xhr.status === 400) { 123 | document.getElementById("errorlogin" + postId).innerText = `Invalid comment!` 124 | setTimeout(() => { 125 | document.getElementById("errorlogin" + postId).innerText = `` 126 | }, 1000); 127 | } else if (xhr.status === 401) { 128 | document.getElementById("errorlogin" + postId).innerText = `You must login first!` 129 | setTimeout(() => { 130 | document.getElementById("errorlogin" + postId).innerText = `` 131 | }, 1000); 132 | } else { 133 | document.getElementById("errorlogin" + postId).innerText = `Cannot add comment now, try again later!` 134 | setTimeout(() => { 135 | document.getElementById("errorlogin" + postId).innerText = `` 136 | }, 1000); 137 | } 138 | }; 139 | } 140 | xhr.send(`postid=${postId}&comment=${encodeURIComponent(content.value)}`); 141 | } 142 | 143 | const select = document.getElementById('categories-select'); 144 | if (select) { 145 | 146 | select.addEventListener('change', (e) => { 147 | // Parse the value as JSON to extract id and label 148 | const selectedValue = JSON.parse(e.target.value); 149 | const { id, label } = selectedValue; 150 | 151 | // create the elemenet for the category 152 | const span = document.createElement('span'); 153 | span.textContent = label; 154 | span.classList.add('selected-category'); 155 | 156 | // Add a remove button to the span 157 | const removeBtn = document.createElement('span'); 158 | removeBtn.textContent = '×'; 159 | removeBtn.classList.add('remove-category'); 160 | removeBtn.addEventListener('click', () => { 161 | span.remove(); 162 | input.remove(); 163 | // Re-enable the corresponding option in the select 164 | Array.from(e.target.options).find(option => { 165 | try { 166 | const optionValue = JSON.parse(option.value); 167 | return optionValue.id === id; 168 | } catch { 169 | return false; 170 | } 171 | }).disabled = false; 172 | }); 173 | 174 | span.appendChild(removeBtn); 175 | 176 | // create hidden input to hold the id of selected category 177 | const input = document.createElement('input') 178 | input.type = 'hidden'; 179 | input.value = id 180 | input.name = 'categories' 181 | 182 | // add the elements (span and hidden input) 183 | // at the first position of the categories container 184 | const categoriesContainer = document.querySelector('.selected-categories'); 185 | categoriesContainer.append(input, span); 186 | 187 | // disable the option selected in the select 188 | e.target.options[e.target.selectedIndex].disabled = true; 189 | 190 | // Reset the select 191 | e.target.selectedIndex = 0; 192 | }); 193 | } 194 | 195 | async function pagination(dir, data) { 196 | const path = window.location.pathname 197 | if (dir === "next" && data) { 198 | const page = +document.querySelector(".currentpage").innerText + 1 199 | window.location.href = path + "?PageID=" + page; 200 | } 201 | 202 | if (dir === "back" && document.querySelector(".currentpage").innerText > "1") { 203 | const page = +document.querySelector(".currentpage").innerText - 1 204 | window.location.href = path + "?PageID=" + page; 205 | } 206 | } 207 | 208 | 209 | 210 | function CreatPost() { 211 | const title = document.querySelector(".create-post-title") 212 | const content = document.querySelector(".content") 213 | const categories = document.querySelector(".selected-categories") 214 | const logerror = document.querySelector(".errorarea") 215 | 216 | if (!title.value || !content.value || categories.childElementCount === 0) { 217 | logerror.innerText = 'Please fill in all fields and select at least one category.'; 218 | setTimeout(() => { 219 | logerror.innerText = ''; 220 | }, 3000); 221 | return; 222 | } 223 | 224 | if (title.value.length > 100) { 225 | logerror.innerText = 'Title is too long. Please keep it under 100 characters.'; 226 | setTimeout(() => { 227 | logerror.innerText = ''; 228 | }, 3000); 229 | return; 230 | } 231 | 232 | if (content.value.length > 3000) { 233 | logerror.innerText = 'Content is too long. Please keep it under 3000 characters.'; 234 | setTimeout(() => { 235 | logerror.innerText = ''; 236 | }, 3000); 237 | return; 238 | } 239 | 240 | 241 | let cateris = new Array() 242 | Array.from(categories.getElementsByTagName('input')).forEach((x) => { 243 | cateris.push(x.value) 244 | }) 245 | const xml = new XMLHttpRequest(); 246 | xml.open("POST", "/post/createpost", true) 247 | xml.setRequestHeader("Content-Type", "application/x-www-form-urlencoded") 248 | 249 | xml.onreadystatechange = function () { 250 | if (xml.readyState === 4) { 251 | if (xml.status === 200) { 252 | const btn = document.getElementById("create-post-btn") 253 | document.getElementById("publish-post-icon").style.display = "none" 254 | document.getElementById("publish-post-circle").style.display = "inline-block" 255 | btn.disabled = true 256 | btn.style.background = "grey" 257 | btn.style.cursor = "not-allowed" 258 | 259 | 260 | logerror.innerText = 'Post created successfully, redirect to home page in 2s ...' 261 | logerror.style.color = "green" 262 | setTimeout(() => { 263 | window.location.href = '/' 264 | }, 2000) 265 | 266 | } else if (xml.status === 401) { 267 | logerror.innerText = 'You are loged out, redirect to login page in 2s...' 268 | setTimeout(() => { 269 | window.location.href = '/login' 270 | }, 2000) 271 | 272 | } else { 273 | logerror.innerText = 'Error: check your entries and try again!' 274 | setTimeout(() => { 275 | logerror.innerText = '' 276 | }, 1500) 277 | } 278 | } 279 | } 280 | 281 | // Get form data 282 | xml.send(`title=${encodeURIComponent(title.value)}&content=${encodeURIComponent(content.value)}&categories=${cateris}`) 283 | } 284 | 285 | 286 | function register() { 287 | const email = document.querySelector("#email") 288 | const username = document.querySelector("#username") 289 | const password = document.querySelector("#password") 290 | const passConfirm = document.querySelector("#password-confirmation") 291 | 292 | const xml = new XMLHttpRequest(); 293 | xml.open("POST", "/signup", true) 294 | xml.setRequestHeader("Content-Type", "application/x-www-form-urlencoded") 295 | 296 | xml.onreadystatechange = function () { 297 | if (xml.readyState === 4) { 298 | const logerror = document.querySelector(".errorarea") 299 | if (xml.status === 200) { 300 | logerror.innerText = `User ${username.value} created successfully, redirect to login page in 2s ...` 301 | logerror.style.color = "green" 302 | setTimeout(() => { 303 | window.location.href = '/login' 304 | }, 2000) 305 | 306 | } else if (xml.status === 302) { 307 | logerror.innerText = 'You are already loged in, redirect to home page in 2s...' 308 | logerror.style.color = "green" 309 | setTimeout(() => { 310 | window.location.href = '/' 311 | }, 2000) 312 | 313 | } else if (xml.status === 400) { 314 | logerror.innerText = 'Error: verify your data and try again!' 315 | logerror.style.color = "red" 316 | setTimeout(() => { 317 | logerror.innerText = '' 318 | }, 1500) 319 | } else if (xml.status === 304) { 320 | logerror.innerText = 'User already exists!' 321 | logerror.style.color = "red" 322 | setTimeout(() => { 323 | logerror.innerText = '' 324 | }, 1500) 325 | } else { 326 | logerror.innerText = 'Cannot create user, try again later!' 327 | logerror.style.color = "red" 328 | setTimeout(() => { 329 | logerror.innerText = '' 330 | }, 1500) 331 | } 332 | } 333 | } 334 | 335 | // Get form data 336 | xml.send(`email=${encodeURIComponent(email.value)}&username=${encodeURIComponent(username.value)}&password=${encodeURIComponent(password.value)}&password-confirmation=${encodeURIComponent(passConfirm.value)}`) 337 | 338 | 339 | } 340 | 341 | 342 | 343 | function login() { 344 | const username = document.querySelector("#username") 345 | const password = document.querySelector("#password") 346 | 347 | const xml = new XMLHttpRequest(); 348 | xml.open("POST", "/signin", true) 349 | xml.setRequestHeader("Content-Type", "application/x-www-form-urlencoded") 350 | 351 | xml.onreadystatechange = function () { 352 | if (xml.readyState === 4) { 353 | const logerror = document.querySelector(".errorarea") 354 | if (xml.status === 200) { 355 | logerror.innerText = `Login in successfully, redirect to home page in 2s ...` 356 | logerror.style.color = "green" 357 | setTimeout(() => { 358 | window.location.href = '/' 359 | }, 2000) 360 | 361 | } else if (xml.status === 302) { 362 | logerror.innerText = 'You are already loged in, redirect to home page in 2s...' 363 | logerror.style.color = "green" 364 | setTimeout(() => { 365 | window.location.href = '/' 366 | }, 2000) 367 | 368 | } else if (xml.status === 400) { 369 | logerror.innerText = 'Error: verify your data and try again!' 370 | logerror.style.color = "red" 371 | setTimeout(() => { 372 | logerror.innerText = '' 373 | }, 1500) 374 | } else if (xml.status === 404) { 375 | logerror.innerText = 'User not found!' 376 | logerror.style.color = "red" 377 | setTimeout(() => { 378 | logerror.innerText = '' 379 | }, 1500) 380 | } else if (xml.status === 401) { 381 | logerror.innerText = 'Invalid username or password!' 382 | logerror.style.color = "red" 383 | setTimeout(() => { 384 | logerror.innerText = '' 385 | }, 1500) 386 | } else { 387 | logerror.innerText = 'Cannot log you in now, try again later!' 388 | logerror.style.color = "red" 389 | setTimeout(() => { 390 | logerror.innerText = '' 391 | }, 1500) 392 | } 393 | } 394 | } 395 | 396 | // Get form data 397 | xml.send(`username=${encodeURIComponent(username.value)}&password=${encodeURIComponent(password.value)}`) 398 | } 399 | 400 | const displayMobileNav = (e) => { 401 | const nav = document.querySelector('.mobile-nav') 402 | nav.style.display = 'block' 403 | } 404 | 405 | const closeMobileNav = (e) => { 406 | const nav = document.querySelector('.mobile-nav') 407 | nav.style.display = 'none' 408 | } 409 | 410 | // const formatTime = (timeStr) => { 411 | // // Parse the input time string 412 | // const date = new Date(timeStr); 413 | 414 | // return date.toLocaleString('default', { 415 | // hour: '2-digit', 416 | // minute: '2-digit', 417 | // day: '2-digit', 418 | // month: '2-digit', 419 | // year: 'numeric', 420 | // }).replace(',', ' ') 421 | // } 422 | 423 | // document.addEventListener("DOMContentLoaded", () => { 424 | // document.querySelectorAll("[data-timestamp]").forEach((element) => { 425 | // const time = element.getAttribute("data-timestamp"); 426 | // if (time) { 427 | // element.textContent = formatTime(time); 428 | // } 429 | // }); 430 | // }); --------------------------------------------------------------------------------