Compare commits
	
		
			17 Commits 
		
	
	
		
			af91df4986
			...
			bdf81e7c68
		
	
	| Author | SHA1 | Date | 
|---|---|---|
|  | bdf81e7c68 | |
|  | c10535492b | |
|  BodgeMaster | e7567d0f08 | |
|  BodgeMaster | 8b95ec3e38 | |
|  BodgeMaster | 869d974f71 | |
|  BodgeMaster | 0bee74ab5b | |
|  | cb9022d8bd | |
|  | 3b56c7e831 | |
|  | e6f097d35c | |
|  | b1c3f80afb | |
|  | 4eb97f27d8 | |
|  | 6b6ca1d85d | |
|  | 92fd9948eb | |
|  | de1f442082 | |
|  | ba5ed6c182 | |
|  | 484f435ff2 | |
|  | eee9540bdc | 
|  | @ -0,0 +1,5 @@ | |||
| config/config.json | ||||
| config/about_page.htmlbody | ||||
| 
 | ||||
| # nano | ||||
| .swp | ||||
|  | @ -0,0 +1,52 @@ | |||
| # Welcome to ThreadR Rewritten | ||||
| 
 | ||||
| This is the source code for the ThreadR Forum Engine, rewritten in Go. ThreadR is a free and open-source forum engine designed to allow users to host their own forum instances on personal web servers. | ||||
| 
 | ||||
| ## Project Overview | ||||
| 
 | ||||
| ThreadR was originally started as a school project in 2019 with the aim of creating a hybrid between a forum and a social media platform. It was built with PHP and (back then still) MySQL. | ||||
| After we finished school, it was temporarily abandoned. An attempt was made to revive it in 2020, open-sourcing the code and making some things configurable, but not much else happened. | ||||
| Here we are now, with a full rewrite in Go started in 2025. | ||||
| 
 | ||||
| ## Project Setup | ||||
| 
 | ||||
| This is for development only. Currently, ThreadR is not ready for production use. | ||||
| 
 | ||||
| ### Prerequisites | ||||
| 
 | ||||
| - UNIX-like OS | ||||
| - Go (golang) | ||||
| - Mariadb | ||||
| 
 | ||||
| ### Setup Steps | ||||
| 
 | ||||
| 1. Create a mariadb user and database for ThreadR (the names can be changed): | ||||
| ```sql | ||||
| CREATE USER threadr IDENTIFIED BY 'super secure password'; | ||||
| CREATE DATABASE `threadr`; | ||||
| GRANT ALL PRIVILEGES ON `threadr`.* TO 'threadr'; | ||||
| ``` | ||||
| 2. Create a config file: In the `config` subdirectory, `cp config.json.sample config.json` and edit it to suit your needs. | ||||
| 3. Create an about page: Also in the `config` subdirectory, `cp about_page.htmlbody.sample about_page.htmlbody` and edit it to suit your needs. | ||||
| 
 | ||||
| ## Running the Application | ||||
| 
 | ||||
| After configuration, run the following command once to initialize the DB: | ||||
| ``` | ||||
| go run main.go --initialize | ||||
| ``` | ||||
| To start the ThreadR server, run this: | ||||
| ``` | ||||
| go run main.go | ||||
| ``` | ||||
| The server will start on port 8080 by default. | ||||
| 
 | ||||
| ## Contributing | ||||
| 
 | ||||
| We welcome contributions! Please join our Discord server to get in touch: [discord.gg/r3w3zSkEUE](https://discord.gg/r3w3zSkEUE). | ||||
| 
 | ||||
| ## License | ||||
| 
 | ||||
| ThreadR is licensed under the Apache 2.0 License. See [LICENSE.md](./LICENSE.md) for details. | ||||
| 
 | ||||
| **Authors:** BodgeMaster, Jocadbz | ||||
|  | @ -0,0 +1,31 @@ | |||
| <main> | ||||
|   <header> | ||||
|     <h2>About ThreadR</h2> | ||||
|   </header> | ||||
|   <section> | ||||
|     <p> | ||||
|       This is a ThreadR development instance. Beep beep. Boop boop. | ||||
|     </p> | ||||
|     <p> | ||||
|       If you see this message in a production environment (aka. a Forum that is actually being used), kindly tell the admin that they forgot to change the about page. :) | ||||
|     </p> | ||||
|     <h2> | ||||
|       What is ThreadR? | ||||
|     </h2> | ||||
|     <p> | ||||
|       ThreadR is a free and open-source forum engine. That means you can download | ||||
|       it and host an instance of ThreadR on your own web server to run your own forum. | ||||
|       It originated in 2019 as a school project and has died twice since. | ||||
|       Currently, the project is being rewritten in Go with (hopefully) less ugly hacks. | ||||
|     </p> | ||||
|     <h2> | ||||
|       Who dis? | ||||
|     </h2> | ||||
|     <p> | ||||
|       Depends.<br /> | ||||
|       If this site is hosted on a LostCave domain, then it's probably us, the developers. | ||||
|       For now, you can find us on Discord: <a href="https://discord.gg/r3w3zSkEUE">discord.gg/r3w3zSkEUE</a><br /> | ||||
|       If it isn't on a LostCave domain, then this site belongs to some lazy admin who forgot to change the about page. | ||||
|     </p> | ||||
|   </section> | ||||
| </main> | ||||
|  | @ -0,0 +1,8 @@ | |||
| { | ||||
|     "domain_name": "localhost", | ||||
|     "threadr_dir": "/threadr", | ||||
|     "db_username": "threadr_user", | ||||
|     "db_password": "threadr_password", | ||||
|     "db_database": "threadr_db", | ||||
|     "db_svr_host": "localhost:3306" | ||||
| } | ||||
|  | @ -0,0 +1,16 @@ | |||
| module threadr | ||||
| 
 | ||||
| go 1.24.0 | ||||
| 
 | ||||
| require ( | ||||
| 	github.com/go-sql-driver/mysql v1.9.0 | ||||
| 	github.com/gorilla/sessions v1.4.0 | ||||
| 	github.com/gorilla/websocket v1.5.0 | ||||
| ) | ||||
| 
 | ||||
| require ( | ||||
| 	filippo.io/edwards25519 v1.1.0 // indirect | ||||
| 	github.com/gorilla/securecookie v1.1.2 // indirect | ||||
| 	golang.org/x/sys v0.33.0 // indirect | ||||
| 	golang.org/x/term v0.32.0 // indirect | ||||
| ) | ||||
|  | @ -0,0 +1,16 @@ | |||
| filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= | ||||
| filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= | ||||
| github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo= | ||||
| github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw= | ||||
| github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= | ||||
| github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= | ||||
| github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= | ||||
| github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= | ||||
| github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= | ||||
| github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= | ||||
| github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= | ||||
| github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= | ||||
| golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= | ||||
| golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= | ||||
| golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= | ||||
| golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= | ||||
|  | @ -0,0 +1,46 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
|     "io/ioutil" | ||||
|     "log" | ||||
|     "net/http" | ||||
|     "github.com/gorilla/sessions" | ||||
|     "html/template" | ||||
| ) | ||||
| 
 | ||||
| func AboutHandler(app *App) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         session := r.Context().Value("session").(*sessions.Session) | ||||
|         loggedIn := session.Values["user_id"] != nil | ||||
|         cookie, _ := r.Cookie("threadr_cookie_banner") | ||||
| 
 | ||||
|         aboutContent, err := ioutil.ReadFile("config/about_page.htmlbody") | ||||
|         if err != nil { | ||||
|             log.Printf("Error reading about_page.htmlbody: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         data := struct { | ||||
|             PageData | ||||
|             AboutContent template.HTML | ||||
|         }{ | ||||
|             PageData: PageData{ | ||||
|                 Title:            "ThreadR - About", | ||||
|                 Navbar:           "about", | ||||
|                 LoggedIn:         loggedIn, | ||||
|                 ShowCookieBanner: cookie == nil || cookie.Value != "accepted", | ||||
|                 BasePath:         app.Config.ThreadrDir, | ||||
|                 StaticPath:       app.Config.ThreadrDir + "/static", | ||||
|                 CurrentURL:       r.URL.Path, | ||||
|             }, | ||||
|             AboutContent: template.HTML(aboutContent), | ||||
|         } | ||||
| 
 | ||||
|         if err := app.Tmpl.ExecuteTemplate(w, "about", data); err != nil { | ||||
|             log.Printf("Error executing template in AboutHandler: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,22 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
|     "net/http" | ||||
|     "time" | ||||
| ) | ||||
| 
 | ||||
| func AcceptCookieHandler(app *App) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         http.SetCookie(w, &http.Cookie{ | ||||
|             Name:    "threadr_cookie_banner", | ||||
|             Value:   "accepted", | ||||
|             Path:    "/", | ||||
|             Expires: time.Now().Add(30 * 24 * time.Hour), | ||||
|         }) | ||||
|         from := r.URL.Query().Get("from") | ||||
|         if from == "" { | ||||
|             from = app.Config.ThreadrDir + "/" | ||||
|         } | ||||
|         http.Redirect(w, r, from, http.StatusFound) | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,78 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
|     "context" | ||||
|     "database/sql" | ||||
|     "html/template" | ||||
|     "net/http" | ||||
|     "github.com/gorilla/sessions" | ||||
| ) | ||||
| 
 | ||||
| type PageData struct { | ||||
|     Title            string | ||||
|     Navbar           string | ||||
|     LoggedIn         bool | ||||
|     ShowCookieBanner bool | ||||
|     BasePath         string | ||||
|     StaticPath       string | ||||
|     CurrentURL       string | ||||
| } | ||||
| 
 | ||||
| type Config struct { | ||||
|     DomainName   string `json:"domain_name"` | ||||
|     ThreadrDir   string `json:"threadr_dir"` | ||||
|     DBUsername   string `json:"db_username"` | ||||
|     DBPassword   string `json:"db_password"` | ||||
|     DBDatabase   string `json:"db_database"` | ||||
|     DBServerHost string `json:"db_svr_host"` | ||||
| } | ||||
| 
 | ||||
| type App struct { | ||||
|     DB     *sql.DB | ||||
|     Store  *sessions.CookieStore | ||||
|     Config *Config | ||||
|     Tmpl   *template.Template | ||||
| } | ||||
| 
 | ||||
| func (app *App) SessionMW(next http.HandlerFunc) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         session, err := app.Store.Get(r, "session-name") | ||||
|         if err != nil { | ||||
|             session = sessions.NewSession(app.Store, "session-name") | ||||
|             session.Options = &sessions.Options{ | ||||
|                 Path:     "/", | ||||
|                 MaxAge:   86400 * 30, // 30 days
 | ||||
|                 HttpOnly: true, | ||||
|             } | ||||
|         } | ||||
|         if _, ok := session.Values["user_id"].(int); ok { | ||||
|             // Skip IP and User-Agent check for WebSocket connections
 | ||||
|             if r.URL.Query().Get("ws") != "true" { | ||||
|                 if session.Values["user_ip"] != r.RemoteAddr || session.Values["user_agent"] != r.UserAgent() { | ||||
|                     session.Values = make(map[interface{}]interface{}) | ||||
|                     session.Options.MaxAge = -1 | ||||
|                     session.Save(r, w) | ||||
|                     http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound) | ||||
|                     return | ||||
|                 } | ||||
|             } | ||||
|             ctx := context.WithValue(r.Context(), "session", session) | ||||
|             r = r.WithContext(ctx) | ||||
|         } else { | ||||
|             ctx := context.WithValue(r.Context(), "session", session) | ||||
|             r = r.WithContext(ctx) | ||||
|         } | ||||
|         next(w, r) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| func (app *App) RequireLoginMW(next http.HandlerFunc) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         session := r.Context().Value("session").(*sessions.Session) | ||||
|         if _, ok := session.Values["user_id"].(int); !ok { | ||||
|             http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound) | ||||
|             return | ||||
|         } | ||||
|         next(w, r) | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,126 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
|     "log" | ||||
|     "net/http" | ||||
|     "strconv" | ||||
|     "threadr/models" | ||||
|     "github.com/gorilla/sessions" | ||||
| ) | ||||
| 
 | ||||
| func BoardHandler(app *App) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         session := r.Context().Value("session").(*sessions.Session) | ||||
|         loggedIn := session.Values["user_id"] != nil | ||||
|         userID, _ := session.Values["user_id"].(int) | ||||
|         cookie, _ := r.Cookie("threadr_cookie_banner") | ||||
| 
 | ||||
|         boardIDStr := r.URL.Query().Get("id") | ||||
|         boardID, err := strconv.Atoi(boardIDStr) | ||||
|         if err != nil { | ||||
|             http.Error(w, "Invalid board ID", http.StatusBadRequest) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         board, err := models.GetBoardByID(app.DB, boardID) | ||||
|         if err != nil { | ||||
|             log.Printf("Error fetching board: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|         if board == nil { | ||||
|             http.Error(w, "Board not found", http.StatusNotFound) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         if board.Private { | ||||
|             if !loggedIn { | ||||
|                 http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) | ||||
|                 return | ||||
|             } | ||||
|             hasPerm, err := models.HasBoardPermission(app.DB, userID, boardID, models.PermViewBoard) | ||||
|             if err != nil { | ||||
|                 log.Printf("Error checking permission: %v", err) | ||||
|                 http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|                 return | ||||
|             } | ||||
|             if !hasPerm { | ||||
|                 http.Error(w, "You do not have permission to view this board", http.StatusForbidden) | ||||
|                 return | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if r.Method == http.MethodPost && loggedIn { | ||||
|             action := r.URL.Query().Get("action") | ||||
|             if action == "create_thread" { | ||||
|                 title := r.FormValue("title") | ||||
|                 if title == "" { | ||||
|                     http.Error(w, "Thread title is required", http.StatusBadRequest) | ||||
|                     return | ||||
|                 } | ||||
|                 if board.Private { | ||||
|                     hasPerm, err := models.HasBoardPermission(app.DB, userID, boardID, models.PermPostInBoard) | ||||
|                     if err != nil { | ||||
|                         log.Printf("Error checking permission: %v", err) | ||||
|                         http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|                         return | ||||
|                     } | ||||
|                     if !hasPerm { | ||||
|                         http.Error(w, "You do not have permission to post in this board", http.StatusForbidden) | ||||
|                         return | ||||
|                     } | ||||
|                 } | ||||
|                 thread := models.Thread{ | ||||
|                     BoardID:         boardID, | ||||
|                     Title:           title, | ||||
|                     CreatedByUserID: userID, | ||||
|                 } | ||||
|                 err = models.CreateThread(app.DB, thread) | ||||
|                 if err != nil { | ||||
|                     log.Printf("Error creating thread: %v", err) | ||||
|                     http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|                     return | ||||
|                 } | ||||
|                 var threadID int | ||||
|                 err = app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&threadID) | ||||
|                 if err != nil { | ||||
|                     log.Printf("Error getting last insert id: %v", err) | ||||
|                     http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|                     return | ||||
|                 } | ||||
|                 http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+strconv.Itoa(threadID), http.StatusFound) | ||||
|                 return | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         threads, err := models.GetThreadsByBoardID(app.DB, boardID) | ||||
|         if err != nil { | ||||
|             log.Printf("Error fetching threads: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         data := struct { | ||||
|             PageData | ||||
|             Board   models.Board | ||||
|             Threads []models.Thread | ||||
|         }{ | ||||
|             PageData: PageData{ | ||||
|                 Title:            "ThreadR - " + board.Name, | ||||
|                 Navbar:           "boards", | ||||
|                 LoggedIn:         loggedIn, | ||||
|                 ShowCookieBanner: cookie == nil || cookie.Value != "accepted", | ||||
|                 BasePath:         app.Config.ThreadrDir, | ||||
|                 StaticPath:       app.Config.ThreadrDir + "/static", | ||||
|                 CurrentURL:       r.URL.Path, | ||||
|             }, | ||||
|             Board:   *board, | ||||
|             Threads: threads, | ||||
|         } | ||||
|         if err := app.Tmpl.ExecuteTemplate(w, "board", data); err != nil { | ||||
|             log.Printf("Error executing template in BoardHandler: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,106 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
|     "log" | ||||
|     "net/http" | ||||
|     "strconv" | ||||
|     "threadr/models" | ||||
|     "github.com/gorilla/sessions" | ||||
| ) | ||||
| 
 | ||||
| func BoardsHandler(app *App) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         session := r.Context().Value("session").(*sessions.Session) | ||||
|         loggedIn := session.Values["user_id"] != nil | ||||
|         cookie, _ := r.Cookie("threadr_cookie_banner") | ||||
|         userID, _ := session.Values["user_id"].(int) | ||||
|         isAdmin := false | ||||
|          | ||||
|         if loggedIn { | ||||
|             user, err := models.GetUserByID(app.DB, userID) | ||||
|             if err != nil { | ||||
|                 log.Printf("Error fetching user: %v", err) | ||||
|             } else if user != nil { | ||||
|                 isAdmin = models.HasGlobalPermission(user, models.PermCreateBoard) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if r.Method == http.MethodPost && loggedIn && isAdmin { | ||||
|             name := r.FormValue("name") | ||||
|             description := r.FormValue("description") | ||||
|             if name == "" { | ||||
|                 http.Error(w, "Board name is required", http.StatusBadRequest) | ||||
|                 return | ||||
|             } | ||||
|             board := models.Board{ | ||||
|                 Name:          name, | ||||
|                 Description:   description, | ||||
|                 Private:       false, | ||||
|                 PublicVisible: true, | ||||
|             } | ||||
|             query := "INSERT INTO boards (name, description, private, public_visible) VALUES (?, ?, ?, ?)" | ||||
|             result, err := app.DB.Exec(query, board.Name, board.Description, board.Private, board.PublicVisible) | ||||
|             if err != nil { | ||||
|                 log.Printf("Error creating board: %v", err) | ||||
|                 http.Error(w, "Failed to create board", http.StatusInternalServerError) | ||||
|                 return | ||||
|             } | ||||
|             boardID, _ := result.LastInsertId() | ||||
|             http.Redirect(w, r, app.Config.ThreadrDir+"/board/?id="+strconv.FormatInt(boardID, 10), http.StatusFound) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         publicBoards, err := models.GetAllBoards(app.DB, false) | ||||
|         if err != nil { | ||||
|             log.Printf("Error fetching public boards: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|         var privateBoards []models.Board | ||||
|         if loggedIn { | ||||
|             privateBoards, err = models.GetAllBoards(app.DB, true) | ||||
|             if err != nil { | ||||
|                 log.Printf("Error fetching private boards: %v", err) | ||||
|                 http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|                 return | ||||
|             } | ||||
|             var accessiblePrivateBoards []models.Board | ||||
|             for _, board := range privateBoards { | ||||
|                 hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermViewBoard) | ||||
|                 if err != nil { | ||||
|                     log.Printf("Error checking permission: %v", err) | ||||
|                     continue | ||||
|                 } | ||||
|                 if hasPerm { | ||||
|                     accessiblePrivateBoards = append(accessiblePrivateBoards, board) | ||||
|                 } | ||||
|             } | ||||
|             privateBoards = accessiblePrivateBoards | ||||
|         } | ||||
| 
 | ||||
|         data := struct { | ||||
|             PageData | ||||
|             PublicBoards  []models.Board | ||||
|             PrivateBoards []models.Board | ||||
|             IsAdmin       bool | ||||
|         }{ | ||||
|             PageData: PageData{ | ||||
|                 Title:            "ThreadR - Boards", | ||||
|                 Navbar:           "boards", | ||||
|                 LoggedIn:         loggedIn, | ||||
|                 ShowCookieBanner: cookie == nil || cookie.Value != "accepted", | ||||
|                 BasePath:         app.Config.ThreadrDir, | ||||
|                 StaticPath:       app.Config.ThreadrDir + "/static", | ||||
|                 CurrentURL:       r.URL.Path, | ||||
|             }, | ||||
|             PublicBoards:  publicBoards, | ||||
|             PrivateBoards: privateBoards, | ||||
|             IsAdmin:       isAdmin, | ||||
|         } | ||||
|         if err := app.Tmpl.ExecuteTemplate(w, "boards", data); err != nil { | ||||
|             log.Printf("Error executing template in BoardsHandler: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,188 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"log" | ||||
| 	"net/http" | ||||
| 	"sync" | ||||
| 	"threadr/models" | ||||
| 	"github.com/gorilla/sessions" | ||||
| 	"github.com/gorilla/websocket" | ||||
| ) | ||||
| 
 | ||||
| var upgrader = websocket.Upgrader{ | ||||
| 	ReadBufferSize:  1024, | ||||
| 	WriteBufferSize: 1024, | ||||
| 	CheckOrigin: func(r *http.Request) bool { | ||||
| 		return true // Allow all origins for now; restrict in production
 | ||||
| 	}, | ||||
| } | ||||
| 
 | ||||
| // ChatHub manages WebSocket connections and broadcasts messages
 | ||||
| type ChatHub struct { | ||||
| 	clients    map[*websocket.Conn]int // Map of connections to user IDs
 | ||||
| 	broadcast  chan []byte | ||||
| 	register   chan *websocket.Conn | ||||
| 	unregister chan *websocket.Conn | ||||
| 	mutex      sync.Mutex | ||||
| } | ||||
| 
 | ||||
| func NewChatHub() *ChatHub { | ||||
| 	return &ChatHub{ | ||||
| 		clients:    make(map[*websocket.Conn]int), | ||||
| 		broadcast:  make(chan []byte), | ||||
| 		register:   make(chan *websocket.Conn), | ||||
| 		unregister: make(chan *websocket.Conn), | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (h *ChatHub) Run() { | ||||
| 	for { | ||||
| 		select { | ||||
| 		case client := <-h.register: | ||||
| 			h.mutex.Lock() | ||||
| 			h.clients[client] = 0 // UserID set later
 | ||||
| 			h.mutex.Unlock() | ||||
| 		case client := <-h.unregister: | ||||
| 			h.mutex.Lock() | ||||
| 			delete(h.clients, client) | ||||
| 			h.mutex.Unlock() | ||||
| 			client.Close() | ||||
| 		case message := <-h.broadcast: | ||||
| 			h.mutex.Lock() | ||||
| 			for client := range h.clients { | ||||
| 				err := client.WriteMessage(websocket.TextMessage, message) | ||||
| 				if err != nil { | ||||
| 					log.Printf("Error broadcasting message: %v", err) | ||||
| 					client.Close() | ||||
| 					delete(h.clients, client) | ||||
| 				} | ||||
| 			} | ||||
| 			h.mutex.Unlock() | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| var hub = NewChatHub() | ||||
| 
 | ||||
| func init() { | ||||
| 	go hub.Run() | ||||
| } | ||||
| 
 | ||||
| func ChatHandler(app *App) http.HandlerFunc { | ||||
| 	return func(w http.ResponseWriter, r *http.Request) { | ||||
| 		session := r.Context().Value("session").(*sessions.Session) | ||||
| 		userID, ok := session.Values["user_id"].(int) | ||||
| 		if !ok { | ||||
| 			http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) | ||||
| 			return | ||||
| 		} | ||||
| 		cookie, _ := r.Cookie("threadr_cookie_banner") | ||||
| 
 | ||||
| 		if r.URL.Query().Get("ws") == "true" { | ||||
| 			// Handle WebSocket connection
 | ||||
| 			ws, err := upgrader.Upgrade(w, r, nil) | ||||
| 			if err != nil { | ||||
| 				log.Printf("Error upgrading to WebSocket: %v", err) | ||||
| 				return | ||||
| 			} | ||||
| 			hub.register <- ws | ||||
| 			hub.mutex.Lock() | ||||
| 			hub.clients[ws] = userID | ||||
| 			hub.mutex.Unlock() | ||||
| 
 | ||||
| 			defer func() { | ||||
| 				hub.unregister <- ws | ||||
| 			}() | ||||
| 
 | ||||
| 			for { | ||||
| 				_, msg, err := ws.ReadMessage() | ||||
| 				if err != nil { | ||||
| 					log.Printf("Error reading WebSocket message: %v", err) | ||||
| 					break | ||||
| 				} | ||||
| 				var chatMsg struct { | ||||
| 					Type    string `json:"type"` | ||||
| 					Content string `json:"content"` | ||||
| 					ReplyTo int    `json:"replyTo"` | ||||
| 				} | ||||
| 				if err := json.Unmarshal(msg, &chatMsg); err != nil { | ||||
| 					log.Printf("Error unmarshaling message: %v", err) | ||||
| 					continue | ||||
| 				} | ||||
| 
 | ||||
| 				if chatMsg.Type == "message" { | ||||
| 					msgObj := models.ChatMessage{ | ||||
| 						UserID:  userID, | ||||
| 						Content: chatMsg.Content, | ||||
| 						ReplyTo: chatMsg.ReplyTo, | ||||
| 					} | ||||
| 					if err := models.CreateChatMessage(app.DB, msgObj); err != nil { | ||||
| 						log.Printf("Error saving chat message: %v", err) | ||||
| 						continue | ||||
| 					} | ||||
| 					// Fetch the saved message with timestamp and user details
 | ||||
| 					var msgID int | ||||
| 					app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&msgID) | ||||
| 					savedMsg, err := models.GetChatMessageByID(app.DB, msgID) | ||||
| 					if err != nil { | ||||
| 						log.Printf("Error fetching saved message: %v", err) | ||||
| 						continue | ||||
| 					} | ||||
| 					response, _ := json.Marshal(savedMsg) | ||||
| 					hub.broadcast <- response | ||||
| 				} | ||||
| 			} | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		if r.URL.Query().Get("autocomplete") == "true" { | ||||
| 			// Handle autocomplete for mentions
 | ||||
| 			prefix := r.URL.Query().Get("prefix") | ||||
| 			usernames, err := models.GetUsernamesMatching(app.DB, prefix) | ||||
| 			if err != nil { | ||||
| 				log.Printf("Error fetching usernames for autocomplete: %v", err) | ||||
| 				http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
| 				return | ||||
| 			} | ||||
| 			response, _ := json.Marshal(usernames) | ||||
| 			w.Header().Set("Content-Type", "application/json") | ||||
| 			w.Write(response) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		// Render chat page
 | ||||
| 		messages, err := models.GetRecentChatMessages(app.DB, 50) | ||||
| 		if err != nil { | ||||
| 			log.Printf("Error fetching chat messages: %v", err) | ||||
| 			http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		// Reverse messages to show oldest first
 | ||||
| 		for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { | ||||
| 			messages[i], messages[j] = messages[j], messages[i] | ||||
| 		} | ||||
| 
 | ||||
| 		data := struct { | ||||
| 			PageData | ||||
| 			Messages []models.ChatMessage | ||||
| 		}{ | ||||
| 			PageData: PageData{ | ||||
| 				Title:            "ThreadR - Chat", | ||||
| 				Navbar:           "chat", | ||||
| 				LoggedIn:         true, | ||||
| 				ShowCookieBanner: cookie == nil || cookie.Value != "accepted", | ||||
| 				BasePath:         app.Config.ThreadrDir, | ||||
| 				StaticPath:       app.Config.ThreadrDir + "/static", | ||||
| 				CurrentURL:       r.URL.Path, | ||||
| 			}, | ||||
| 			Messages: messages, | ||||
| 		} | ||||
| 		if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil { | ||||
| 			log.Printf("Error executing template in ChatHandler: %v", err) | ||||
| 			http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | @ -0,0 +1,34 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
|     "log" | ||||
|     "net/http" | ||||
|     "path/filepath" | ||||
|     "github.com/gorilla/sessions" | ||||
| ) | ||||
| 
 | ||||
| func HomeHandler(app *App) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         session := r.Context().Value("session").(*sessions.Session) | ||||
|         loggedIn := session.Values["user_id"] != nil | ||||
|         cookie, _ := r.Cookie("threadr_cookie_banner") | ||||
|         data := struct { | ||||
|             PageData | ||||
|         }{ | ||||
|             PageData: PageData{ | ||||
|                 Title:            "ThreadR - Home", | ||||
|                 Navbar:           "home", | ||||
|                 LoggedIn:         loggedIn, | ||||
|                 ShowCookieBanner: cookie == nil || cookie.Value != "accepted", | ||||
|                 BasePath:         app.Config.ThreadrDir, | ||||
|                 StaticPath:       filepath.Join(app.Config.ThreadrDir, "static"), | ||||
|                 CurrentURL:       r.URL.String(), | ||||
|             }, | ||||
|         } | ||||
|         if err := app.Tmpl.ExecuteTemplate(w, "home", data); err != nil { | ||||
|             log.Printf("Error executing template in HomeHandler: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,78 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
|     "log" | ||||
|     "net/http" | ||||
|     "strconv" | ||||
|     "threadr/models" | ||||
|     "github.com/gorilla/sessions" | ||||
| ) | ||||
| 
 | ||||
| func LikeHandler(app *App) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         if r.Method != http.MethodPost { | ||||
|             http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         session := r.Context().Value("session").(*sessions.Session) | ||||
|         userID, ok := session.Values["user_id"].(int) | ||||
|         if !ok { | ||||
|             http.Error(w, "Unauthorized", http.StatusUnauthorized) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         postIDStr := r.FormValue("post_id") | ||||
|         postID, err := strconv.Atoi(postIDStr) | ||||
|         if err != nil { | ||||
|             http.Error(w, "Invalid post ID", http.StatusBadRequest) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         likeType := r.FormValue("type") | ||||
|         if likeType != "like" && likeType != "dislike" { | ||||
|             http.Error(w, "Invalid like type", http.StatusBadRequest) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         existingLike, err := models.GetLikeByPostAndUser(app.DB, postID, userID) | ||||
|         if err != nil { | ||||
|             log.Printf("Error checking existing like: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         if existingLike != nil { | ||||
|             if existingLike.Type == likeType { | ||||
|                 err = models.DeleteLike(app.DB, postID, userID) | ||||
|                 if err != nil { | ||||
|                     log.Printf("Error deleting like: %v", err) | ||||
|                     http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|                     return | ||||
|                 } | ||||
|             } else { | ||||
|                 err = models.UpdateLikeType(app.DB, postID, userID, likeType) | ||||
|                 if err != nil { | ||||
|                     log.Printf("Error updating like: %v", err) | ||||
|                     http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|                     return | ||||
|                 } | ||||
|             } | ||||
|         } else { | ||||
|             like := models.Like{ | ||||
|                 PostID: postID, | ||||
|                 UserID: userID, | ||||
|                 Type:   likeType, | ||||
|             } | ||||
|             err = models.CreateLike(app.DB, like) | ||||
|             if err != nil { | ||||
|                 log.Printf("Error creating like: %v", err) | ||||
|                 http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|                 return | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         w.WriteHeader(http.StatusOK) | ||||
|         w.Write([]byte("OK")) | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,68 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
|     "database/sql" | ||||
|     "log" | ||||
|     "net/http" | ||||
|     "threadr/models" | ||||
|     "github.com/gorilla/sessions" | ||||
| ) | ||||
| 
 | ||||
| func LoginHandler(app *App) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         session := r.Context().Value("session").(*sessions.Session) | ||||
|         if r.Method == http.MethodPost { | ||||
|             username := r.FormValue("username") | ||||
|             password := r.FormValue("password") | ||||
|             user, err := models.GetUserByUsername(app.DB, username) | ||||
|             if err != nil && err != sql.ErrNoRows { | ||||
|                 log.Printf("Error fetching user in LoginHandler: %v", err) | ||||
|                 http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|                 return | ||||
|             } | ||||
|             if user == nil || !models.CheckPassword(password, user.AuthenticationSalt, user.AuthenticationAlgorithm, user.AuthenticationString) { | ||||
|                 http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=invalid", http.StatusFound) | ||||
|                 return | ||||
|             } | ||||
|             session.Values["user_id"] = user.ID | ||||
|             session.Values["user_ip"] = r.RemoteAddr | ||||
|             session.Values["user_agent"] = r.UserAgent() | ||||
|             session.Options = &sessions.Options{ | ||||
|                 Path:     "/", | ||||
|                 MaxAge:   86400 * 30, // 30 days
 | ||||
|                 HttpOnly: true, | ||||
|             } | ||||
|             if err := session.Save(r, w); err != nil { | ||||
|                 log.Printf("Error saving session: %v", err) | ||||
|                 http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|                 return | ||||
|             } | ||||
|             http.Redirect(w, r, app.Config.ThreadrDir+"/userhome/", http.StatusFound) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         data := struct { | ||||
|             PageData | ||||
|             Error string | ||||
|         }{ | ||||
|             PageData: PageData{ | ||||
|                 Title:            "ThreadR - Login", | ||||
|                 Navbar:           "login", | ||||
|                 LoggedIn:         false, | ||||
|                 BasePath:         app.Config.ThreadrDir, | ||||
|                 StaticPath:       app.Config.ThreadrDir + "/static", | ||||
|                 CurrentURL:       r.URL.Path, | ||||
|             }, | ||||
|             Error: "", | ||||
|         } | ||||
|         if r.URL.Query().Get("error") == "invalid" { | ||||
|             data.Error = "Invalid username or password" | ||||
|         } | ||||
| 
 | ||||
|         if err := app.Tmpl.ExecuteTemplate(w, "login", data); err != nil { | ||||
|             log.Printf("Error executing template in LoginHandler: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,21 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
|     "log" | ||||
|     "net/http" | ||||
|     "github.com/gorilla/sessions" | ||||
| ) | ||||
| 
 | ||||
| func LogoutHandler(app *App) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         session := r.Context().Value("session").(*sessions.Session) | ||||
|         session.Values = make(map[interface{}]interface{}) | ||||
|         session.Options.MaxAge = -1 | ||||
|         if err := session.Save(r, w); err != nil { | ||||
|             log.Printf("Error saving session in LogoutHandler: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|         http.Redirect(w, r, app.Config.ThreadrDir+"/", http.StatusFound) | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,98 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
|     "log" | ||||
|     "net/http" | ||||
|     "strconv" | ||||
|     "threadr/models" | ||||
|     "github.com/gorilla/sessions" | ||||
| ) | ||||
| 
 | ||||
| func NewsHandler(app *App) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         session := r.Context().Value("session").(*sessions.Session) | ||||
|         loggedIn := session.Values["user_id"] != nil | ||||
|         cookie, _ := r.Cookie("threadr_cookie_banner") | ||||
|         userID, _ := session.Values["user_id"].(int) | ||||
|         isAdmin := false | ||||
|          | ||||
|         if loggedIn { | ||||
|             user, err := models.GetUserByID(app.DB, userID) | ||||
|             if err != nil { | ||||
|                 log.Printf("Error fetching user: %v", err) | ||||
|             } else if user != nil { | ||||
|                 isAdmin = models.HasGlobalPermission(user, models.PermManageUsers) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if r.Method == http.MethodPost && loggedIn && isAdmin { | ||||
|             if action := r.URL.Query().Get("action"); action == "delete" { | ||||
|                 newsIDStr := r.URL.Query().Get("id") | ||||
|                 newsID, err := strconv.Atoi(newsIDStr) | ||||
|                 if err != nil { | ||||
|                     http.Error(w, "Invalid news ID", http.StatusBadRequest) | ||||
|                     return | ||||
|                 } | ||||
|                 err = models.DeleteNews(app.DB, newsID) | ||||
|                 if err != nil { | ||||
|                     log.Printf("Error deleting news item: %v", err) | ||||
|                     http.Error(w, "Failed to delete news item", http.StatusInternalServerError) | ||||
|                     return | ||||
|                 } | ||||
|                 http.Redirect(w, r, app.Config.ThreadrDir+"/news/", http.StatusFound) | ||||
|                 return | ||||
|             } else { | ||||
|                 title := r.FormValue("title") | ||||
|                 content := r.FormValue("content") | ||||
|                 if title != "" && content != "" { | ||||
|                     news := models.News{ | ||||
|                         Title:    title, | ||||
|                         Content:  content, | ||||
|                         PostedBy: userID, | ||||
|                     } | ||||
|                     err := models.CreateNews(app.DB, news) | ||||
|                     if err != nil { | ||||
|                         log.Printf("Error creating news item: %v", err) | ||||
|                         http.Error(w, "Failed to create news item", http.StatusInternalServerError) | ||||
|                         return | ||||
|                     } | ||||
|                     http.Redirect(w, r, app.Config.ThreadrDir+"/news/", http.StatusFound) | ||||
|                     return | ||||
|                 } else { | ||||
|                     http.Error(w, "Title and content are required", http.StatusBadRequest) | ||||
|                     return | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         newsItems, err := models.GetAllNews(app.DB) | ||||
|         if err != nil { | ||||
|             log.Printf("Error fetching news items: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         data := struct { | ||||
|             PageData | ||||
|             News    []models.News | ||||
|             IsAdmin bool | ||||
|         }{ | ||||
|             PageData: PageData{ | ||||
|                 Title:            "ThreadR - News", | ||||
|                 Navbar:           "news", | ||||
|                 LoggedIn:         loggedIn, | ||||
|                 ShowCookieBanner: cookie == nil || cookie.Value != "accepted", | ||||
|                 BasePath:         app.Config.ThreadrDir, | ||||
|                 StaticPath:       app.Config.ThreadrDir + "/static", | ||||
|                 CurrentURL:       r.URL.Path, | ||||
|             }, | ||||
|             News:    newsItems, | ||||
|             IsAdmin: isAdmin, | ||||
|         } | ||||
|         if err := app.Tmpl.ExecuteTemplate(w, "news", data); err != nil { | ||||
|             log.Printf("Error executing template in NewsHandler: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,55 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
|     "log" | ||||
|     "net/http" | ||||
|     "threadr/models" | ||||
|     "github.com/gorilla/sessions" | ||||
| ) | ||||
| 
 | ||||
| func ProfileHandler(app *App) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         session := r.Context().Value("session").(*sessions.Session) | ||||
|         userID, ok := session.Values["user_id"].(int) | ||||
|         if !ok { | ||||
|             http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) | ||||
|             return | ||||
|         } | ||||
|         user, err := models.GetUserByID(app.DB, userID) | ||||
|         if err != nil { | ||||
|             log.Printf("Error fetching user in ProfileHandler: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|         if user == nil { | ||||
|             http.Error(w, "User not found", http.StatusNotFound) | ||||
|             return | ||||
|         } | ||||
|         displayName := user.DisplayName | ||||
|         if displayName == "" { | ||||
|             displayName = user.Username | ||||
|         } | ||||
|         data := struct { | ||||
|             PageData | ||||
|             User        models.User | ||||
|             DisplayName string | ||||
|         }{ | ||||
|             PageData: PageData{ | ||||
|                 Title:            "ThreadR - Profile", | ||||
|                 Navbar:           "profile", | ||||
|                 LoggedIn:         true, | ||||
|                 ShowCookieBanner: false, | ||||
|                 BasePath:         app.Config.ThreadrDir, | ||||
|                 StaticPath:       app.Config.ThreadrDir + "/static", | ||||
|                 CurrentURL:       r.URL.Path, | ||||
|             }, | ||||
|             User:        *user, | ||||
|             DisplayName: displayName, | ||||
|         } | ||||
|         if err := app.Tmpl.ExecuteTemplate(w, "profile", data); err != nil { | ||||
|             log.Printf("Error executing template in ProfileHandler: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,65 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
|     "log" | ||||
|     "net/http" | ||||
|     "threadr/models" | ||||
|     "github.com/gorilla/sessions" | ||||
| ) | ||||
| 
 | ||||
| func ProfileEditHandler(app *App) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         session := r.Context().Value("session").(*sessions.Session) | ||||
|         userID, ok := session.Values["user_id"].(int) | ||||
|         if !ok { | ||||
|             http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         if r.Method == http.MethodPost { | ||||
|             displayName := r.FormValue("display_name") | ||||
|             pfpURL := r.FormValue("pfp_url") | ||||
|             bio := r.FormValue("bio") | ||||
|             err := models.UpdateUserProfile(app.DB, userID, displayName, pfpURL, bio) | ||||
|             if err != nil { | ||||
|                 log.Printf("Error updating profile: %v", err) | ||||
|                 http.Error(w, "Failed to update profile", http.StatusInternalServerError) | ||||
|                 return | ||||
|             } | ||||
|             http.Redirect(w, r, app.Config.ThreadrDir+"/profile/", http.StatusFound) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         user, err := models.GetUserByID(app.DB, userID) | ||||
|         if err != nil { | ||||
|             log.Printf("Error fetching user: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|         if user == nil { | ||||
|             http.Error(w, "User not found", http.StatusNotFound) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         data := struct { | ||||
|             PageData | ||||
|             User models.User | ||||
|         }{ | ||||
|             PageData: PageData{ | ||||
|                 Title:            "ThreadR - Edit Profile", | ||||
|                 Navbar:           "profile", | ||||
|                 LoggedIn:         true, | ||||
|                 ShowCookieBanner: false, | ||||
|                 BasePath:         app.Config.ThreadrDir, | ||||
|                 StaticPath:       app.Config.ThreadrDir + "/static", | ||||
|                 CurrentURL:       r.URL.Path, | ||||
|             }, | ||||
|             User: *user, | ||||
|         } | ||||
|         if err := app.Tmpl.ExecuteTemplate(w, "profile_edit", data); err != nil { | ||||
|             log.Printf("Error executing template in ProfileEditHandler: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,66 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
|     "log" | ||||
|     "net/http" | ||||
|     "threadr/models" | ||||
|     "github.com/gorilla/sessions" | ||||
| ) | ||||
| 
 | ||||
| func SignupHandler(app *App) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         session := r.Context().Value("session").(*sessions.Session) | ||||
|         cookie, _ := r.Cookie("threadr_cookie_banner") | ||||
|         if r.Method == http.MethodPost { | ||||
|             username := r.FormValue("username") | ||||
|             password := r.FormValue("password") | ||||
|             err := models.CreateUser(app.DB, username, password) | ||||
|             if err != nil { | ||||
|                 log.Printf("Error creating user: %v", err) | ||||
|                 data := struct { | ||||
|                     PageData | ||||
|                     Error string | ||||
|                 }{ | ||||
|                     PageData: PageData{ | ||||
|                         Title:            "ThreadR - Sign Up", | ||||
|                         Navbar:           "signup", | ||||
|                         LoggedIn:         false, | ||||
|                         ShowCookieBanner: cookie == nil || cookie.Value != "accepted", | ||||
|                         BasePath:         app.Config.ThreadrDir, | ||||
|                         StaticPath:       app.Config.ThreadrDir + "/static", | ||||
|                         CurrentURL:       r.URL.Path, | ||||
|                     }, | ||||
|                     Error: "An error occurred during sign up. Please try again.", | ||||
|                 } | ||||
|                 if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil { | ||||
|                     log.Printf("Error executing template in SignupHandler: %v", err) | ||||
|                     http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|                     return | ||||
|                 } | ||||
|                 return | ||||
|             } | ||||
|             http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) | ||||
|             return | ||||
|         } | ||||
|         data := struct { | ||||
|             PageData | ||||
|             Error string | ||||
|         }{ | ||||
|             PageData: PageData{ | ||||
|                 Title:            "ThreadR - Sign Up", | ||||
|                 Navbar:           "signup", | ||||
|                 LoggedIn:         session.Values["user_id"] != nil, | ||||
|                 ShowCookieBanner: cookie == nil || cookie.Value != "accepted", | ||||
|                 BasePath:         app.Config.ThreadrDir, | ||||
|                 StaticPath:       app.Config.ThreadrDir + "/static", | ||||
|                 CurrentURL:       r.URL.Path, | ||||
|             }, | ||||
|             Error: "", | ||||
|         } | ||||
|         if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil { | ||||
|             log.Printf("Error executing template in SignupHandler: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,135 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
|     "log" | ||||
|     "net/http" | ||||
|     "strconv" | ||||
|     "threadr/models" | ||||
|     "github.com/gorilla/sessions" | ||||
| ) | ||||
| 
 | ||||
| func ThreadHandler(app *App) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         session := r.Context().Value("session").(*sessions.Session) | ||||
|         loggedIn := session.Values["user_id"] != nil | ||||
|         userID, _ := session.Values["user_id"].(int) | ||||
|         cookie, _ := r.Cookie("threadr_cookie_banner") | ||||
| 
 | ||||
|         threadIDStr := r.URL.Query().Get("id") | ||||
|         threadID, err := strconv.Atoi(threadIDStr) | ||||
|         if err != nil { | ||||
|             http.Error(w, "Invalid thread ID", http.StatusBadRequest) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         thread, err := models.GetThreadByID(app.DB, threadID) | ||||
|         if err != nil { | ||||
|             log.Printf("Error fetching thread: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|         if thread == nil { | ||||
|             http.Error(w, "Thread not found", http.StatusNotFound) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         board, err := models.GetBoardByID(app.DB, thread.BoardID) | ||||
|         if err != nil { | ||||
|             log.Printf("Error fetching board: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|         if board.Private { | ||||
|             if !loggedIn { | ||||
|                 http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) | ||||
|                 return | ||||
|             } | ||||
|             hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermViewBoard) | ||||
|             if err != nil { | ||||
|                 log.Printf("Error checking permission: %v", err) | ||||
|                 http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|                 return | ||||
|             } | ||||
|             if !hasPerm { | ||||
|                 http.Error(w, "You do not have permission to view this board", http.StatusForbidden) | ||||
|                 return | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if r.Method == http.MethodPost && loggedIn { | ||||
|             action := r.URL.Query().Get("action") | ||||
|             if action == "submit" { | ||||
|                 content := r.FormValue("content") | ||||
|                 replyToStr := r.URL.Query().Get("to") | ||||
|                 replyTo := -1 | ||||
|                 if replyToStr != "" { | ||||
|                     replyTo, err = strconv.Atoi(replyToStr) | ||||
|                     if err != nil { | ||||
|                         http.Error(w, "Invalid reply_to ID", http.StatusBadRequest) | ||||
|                         return | ||||
|                     } | ||||
|                 } | ||||
|                 if content == "" { | ||||
|                     http.Error(w, "Content cannot be empty", http.StatusBadRequest) | ||||
|                     return | ||||
|                 } | ||||
|                 if board.Private { | ||||
|                     hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermPostInBoard) | ||||
|                     if err != nil { | ||||
|                         log.Printf("Error checking permission: %v", err) | ||||
|                         http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|                         return | ||||
|                     } | ||||
|                     if !hasPerm { | ||||
|                         http.Error(w, "You do not have permission to post in this board", http.StatusForbidden) | ||||
|                         return | ||||
|                     } | ||||
|                 } | ||||
|                 post := models.Post{ | ||||
|                     ThreadID: threadID, | ||||
|                     UserID:   userID, | ||||
|                     Content:  content, | ||||
|                     ReplyTo:  replyTo, | ||||
|                 } | ||||
|                 err = models.CreatePost(app.DB, post) | ||||
|                 if err != nil { | ||||
|                     log.Printf("Error creating post: %v", err) | ||||
|                     http.Error(w, "Failed to create post", http.StatusInternalServerError) | ||||
|                     return | ||||
|                 } | ||||
|                 http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+threadIDStr, http.StatusFound) | ||||
|                 return | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         posts, err := models.GetPostsByThreadID(app.DB, threadID) | ||||
|         if err != nil { | ||||
|             log.Printf("Error fetching posts: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         data := struct { | ||||
|             PageData | ||||
|             Thread models.Thread | ||||
|             Posts  []models.Post | ||||
|         }{ | ||||
|             PageData: PageData{ | ||||
|                 Title:            "ThreadR - " + thread.Title, | ||||
|                 Navbar:           "boards", | ||||
|                 LoggedIn:         loggedIn, | ||||
|                 ShowCookieBanner: cookie == nil || cookie.Value != "accepted", | ||||
|                 BasePath:         app.Config.ThreadrDir, | ||||
|                 StaticPath:       app.Config.ThreadrDir + "/static", | ||||
|                 CurrentURL:       r.URL.Path, | ||||
|             }, | ||||
|             Thread: *thread, | ||||
|             Posts:  posts, | ||||
|         } | ||||
|         if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil { | ||||
|             log.Printf("Error executing template in ThreadHandler: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,49 @@ | |||
| package handlers | ||||
| 
 | ||||
| import ( | ||||
|     "log" | ||||
|     "net/http" | ||||
|     "threadr/models" | ||||
|     "github.com/gorilla/sessions" | ||||
| ) | ||||
| 
 | ||||
| func UserHomeHandler(app *App) http.HandlerFunc { | ||||
|     return func(w http.ResponseWriter, r *http.Request) { | ||||
|         session := r.Context().Value("session").(*sessions.Session) | ||||
|         userID, ok := session.Values["user_id"].(int) | ||||
|         if !ok { | ||||
|             http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) | ||||
|             return | ||||
|         } | ||||
|         user, err := models.GetUserByID(app.DB, userID) | ||||
|         if err != nil { | ||||
|             log.Printf("Error fetching user in UserHomeHandler: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|         if user == nil { | ||||
|             http.Error(w, "User not found", http.StatusNotFound) | ||||
|             return | ||||
|         } | ||||
|         data := struct { | ||||
|             PageData | ||||
|             Username string | ||||
|         }{ | ||||
|             PageData: PageData{ | ||||
|                 Title:            "ThreadR - User Home", | ||||
|                 Navbar:           "userhome", | ||||
|                 LoggedIn:         true, | ||||
|                 ShowCookieBanner: false, | ||||
|                 BasePath:         app.Config.ThreadrDir, | ||||
|                 StaticPath:       app.Config.ThreadrDir + "/static", | ||||
|                 CurrentURL:       r.URL.Path, | ||||
|             }, | ||||
|             Username: user.Username, | ||||
|         } | ||||
|         if err := app.Tmpl.ExecuteTemplate(w, "userhome", data); err != nil { | ||||
|             log.Printf("Error executing template in UserHomeHandler: %v", err) | ||||
|             http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||||
|             return | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,366 @@ | |||
| package main | ||||
| 
 | ||||
| import ( | ||||
| 	"bufio" | ||||
| 	"database/sql" | ||||
| 	"encoding/json" | ||||
| 	"flag" | ||||
| 	"fmt" | ||||
| 	"html/template" | ||||
| 	"log" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"strings" | ||||
| 	"syscall" | ||||
| 	"threadr/handlers" | ||||
| 	"threadr/models" | ||||
| 
 | ||||
| 	_ "github.com/go-sql-driver/mysql" | ||||
| 	"github.com/gorilla/sessions" | ||||
| 	"golang.org/x/term" | ||||
| ) | ||||
| 
 | ||||
| func loadConfig(filename string) (*handlers.Config, error) { | ||||
|     file, err := os.Open(filename) | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     defer file.Close() | ||||
|     var config handlers.Config | ||||
|     err = json.NewDecoder(file).Decode(&config) | ||||
|     return &config, err | ||||
| } | ||||
| 
 | ||||
| func createTablesIfNotExist(db *sql.DB) error { | ||||
|     // Create boards table
 | ||||
|     _, err := db.Exec(` | ||||
|         CREATE TABLE boards ( | ||||
|             id INT AUTO_INCREMENT PRIMARY KEY, | ||||
|             name VARCHAR(255) NOT NULL, | ||||
|             description TEXT, | ||||
|             private BOOLEAN DEFAULT FALSE, | ||||
|             public_visible BOOLEAN DEFAULT TRUE, | ||||
|             pinned_threads TEXT, | ||||
|             custom_landing_page TEXT, | ||||
|             color_scheme VARCHAR(255) | ||||
|         )`) | ||||
|     if err != nil { | ||||
|         return fmt.Errorf("error creating boards table: %v", err) | ||||
|     } | ||||
| 
 | ||||
|     // Create users table
 | ||||
|     _, err = db.Exec(` | ||||
|         CREATE TABLE users ( | ||||
|             id INT AUTO_INCREMENT PRIMARY KEY, | ||||
|             username VARCHAR(255) NOT NULL UNIQUE, | ||||
|             display_name VARCHAR(255), | ||||
|             pfp_url VARCHAR(255), | ||||
|             bio TEXT, | ||||
|             authentication_string VARCHAR(128) NOT NULL, | ||||
|             authentication_salt VARCHAR(255) NOT NULL, | ||||
|             authentication_algorithm VARCHAR(50) NOT NULL, | ||||
|             created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||||
|             updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | ||||
|             verified BOOLEAN DEFAULT FALSE, | ||||
|             permissions BIGINT DEFAULT 0 | ||||
|         )`) | ||||
|     if err != nil { | ||||
|         return fmt.Errorf("error creating users table: %v", err) | ||||
|     } | ||||
| 
 | ||||
|     // Create threads table (without type field)
 | ||||
|     _, err = db.Exec(` | ||||
|         CREATE TABLE threads ( | ||||
|             id INT AUTO_INCREMENT PRIMARY KEY, | ||||
|             board_id INT NOT NULL, | ||||
|             title VARCHAR(255) NOT NULL, | ||||
|             created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||||
|             updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | ||||
|             created_by_user_id INT NOT NULL, | ||||
|             accepted_answer_post_id INT, | ||||
|             FOREIGN KEY (board_id) REFERENCES boards(id) | ||||
|         )`) | ||||
|     if err != nil { | ||||
|         return fmt.Errorf("error creating threads table: %v", err) | ||||
|     } | ||||
| 
 | ||||
|     // Create posts table
 | ||||
|     _, err = db.Exec(` | ||||
|         CREATE TABLE posts ( | ||||
|             id INT AUTO_INCREMENT PRIMARY KEY, | ||||
|             thread_id INT NOT NULL, | ||||
|             user_id INT NOT NULL, | ||||
|             post_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||||
|             edit_time TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, | ||||
|             content TEXT, | ||||
|             attachment_hash BIGINT, | ||||
|             attachment_name VARCHAR(255), | ||||
|             title VARCHAR(255), | ||||
|             reply_to INT DEFAULT -1, | ||||
|             FOREIGN KEY (thread_id) REFERENCES threads(id) | ||||
|         )`) | ||||
|     if err != nil { | ||||
|         return fmt.Errorf("error creating posts table: %v", err) | ||||
|     } | ||||
| 
 | ||||
|     // Create likes table
 | ||||
|     _, err = db.Exec(` | ||||
|         CREATE TABLE likes ( | ||||
|             id INT AUTO_INCREMENT PRIMARY KEY, | ||||
|             post_id INT NOT NULL, | ||||
|             user_id INT NOT NULL, | ||||
|             type VARCHAR(20) NOT NULL, | ||||
|             UNIQUE KEY unique_like (post_id, user_id), | ||||
|             FOREIGN KEY (post_id) REFERENCES posts(id) | ||||
|         )`) | ||||
|     if err != nil { | ||||
|         return fmt.Errorf("error creating likes table: %v", err) | ||||
|     } | ||||
| 
 | ||||
|     // Create board_permissions table
 | ||||
|     _, err = db.Exec(` | ||||
|         CREATE TABLE board_permissions ( | ||||
|             user_id INT NOT NULL, | ||||
|             board_id INT NOT NULL, | ||||
|             permissions BIGINT DEFAULT 0, | ||||
|             PRIMARY KEY (user_id, board_id), | ||||
|             FOREIGN KEY (board_id) REFERENCES boards(id) | ||||
|         )`) | ||||
|     if err != nil { | ||||
|         return fmt.Errorf("error creating board_permissions table: %v", err) | ||||
|     } | ||||
| 
 | ||||
|     // Create notifications table
 | ||||
|     _, err = db.Exec(` | ||||
|         CREATE TABLE notifications ( | ||||
|             id INT AUTO_INCREMENT PRIMARY KEY, | ||||
|             user_id INT NOT NULL, | ||||
|             type VARCHAR(50) NOT NULL, | ||||
|             related_id INT NOT NULL, | ||||
|             is_read BOOLEAN DEFAULT FALSE, | ||||
|             created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||||
|         )`) | ||||
|     if err != nil { | ||||
|         return fmt.Errorf("error creating notifications table: %v", err) | ||||
|     } | ||||
| 
 | ||||
|     // Create reactions table
 | ||||
|     _, err = db.Exec(` | ||||
|         CREATE TABLE reactions ( | ||||
|             id INT AUTO_INCREMENT PRIMARY KEY, | ||||
|             post_id INT NOT NULL, | ||||
|             user_id INT NOT NULL, | ||||
|             emoji VARCHAR(10) NOT NULL, | ||||
|             FOREIGN KEY (post_id) REFERENCES posts(id) | ||||
|         )`) | ||||
|     if err != nil { | ||||
|         return fmt.Errorf("error creating reactions table: %v", err) | ||||
|     } | ||||
| 
 | ||||
|     // Create reposts table
 | ||||
|     _, err = db.Exec(` | ||||
|         CREATE TABLE reposts ( | ||||
|             id INT AUTO_INCREMENT PRIMARY KEY, | ||||
|             thread_id INT NOT NULL, | ||||
|             board_id INT NOT NULL, | ||||
|             user_id INT NOT NULL, | ||||
|             created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||||
|             FOREIGN KEY (thread_id) REFERENCES threads(id), | ||||
|             FOREIGN KEY (board_id) REFERENCES boards(id) | ||||
|         )`) | ||||
|     if err != nil { | ||||
|         return fmt.Errorf("error creating reposts table: %v", err) | ||||
|     } | ||||
| 
 | ||||
|     // Create news table
 | ||||
|     _, err = db.Exec(` | ||||
|         CREATE TABLE news ( | ||||
|             id INT AUTO_INCREMENT PRIMARY KEY, | ||||
|             title VARCHAR(255) NOT NULL, | ||||
|             content TEXT NOT NULL, | ||||
|             posted_by INT NOT NULL, | ||||
|             created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||||
|         )`) | ||||
|     if err != nil { | ||||
|         return fmt.Errorf("error creating news table: %v", err) | ||||
|     } | ||||
| 
 | ||||
|     // Create chat_messages table
 | ||||
|     _, err = db.Exec(` | ||||
|         CREATE TABLE chat_messages ( | ||||
|             id INT AUTO_INCREMENT PRIMARY KEY, | ||||
|             user_id INT NOT NULL, | ||||
|             content TEXT NOT NULL, | ||||
|             reply_to INT DEFAULT -1, | ||||
|             timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||||
|         )`) | ||||
|     if err != nil { | ||||
|         return fmt.Errorf("error creating chat_messages table: %v", err) | ||||
|     } | ||||
| 
 | ||||
|     log.Println("Database tables created.") | ||||
|     return nil | ||||
| } | ||||
| 
 | ||||
| func ensureAdminUser(db *sql.DB) error { | ||||
| 	reader := bufio.NewReader(os.Stdin) | ||||
| 
 | ||||
| 	// Get username
 | ||||
| 	fmt.Print("Enter admin username: ") | ||||
| 	username, _ := reader.ReadString('\n') | ||||
| 	username = strings.TrimSpace(username) | ||||
| 
 | ||||
| 	// Check if user already exists
 | ||||
| 	existingUser, err := models.GetUserByUsername(db, username) | ||||
| 	if err != nil && err != sql.ErrNoRows { | ||||
| 		return fmt.Errorf("error checking for admin user: %v", err) | ||||
| 	} | ||||
| 	if existingUser != nil { | ||||
| 		return fmt.Errorf("user '%s' already exists", username) | ||||
| 	} | ||||
| 
 | ||||
| 	// Get password
 | ||||
| 	fmt.Print("Enter admin password: ") | ||||
| 	bytePassword, err := term.ReadPassword(int(syscall.Stdin)) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("error reading password: %v", err) | ||||
| 	} | ||||
| 	password := string(bytePassword) | ||||
| 	fmt.Println() // Newline after password input
 | ||||
| 
 | ||||
| 	// Confirm password
 | ||||
| 	fmt.Print("Confirm admin password: ") | ||||
| 	bytePasswordConfirm, err := term.ReadPassword(int(syscall.Stdin)) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("error reading password confirmation: %v", err) | ||||
| 	} | ||||
| 	passwordConfirm := string(bytePasswordConfirm) | ||||
| 	fmt.Println() | ||||
| 
 | ||||
| 	if password != passwordConfirm { | ||||
| 		return fmt.Errorf("passwords do not match") | ||||
| 	} | ||||
| 
 | ||||
| 	// Create user
 | ||||
| 	log.Printf("Creating admin user: %s", username) | ||||
| 	err = models.CreateUser(db, username, password) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("error creating admin user: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Get the newly created admin user to update permissions
 | ||||
| 	adminUser, err := models.GetUserByUsername(db, username) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("error fetching new admin user: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Set admin permissions (all permissions)
 | ||||
| 	_, err = db.Exec("UPDATE users SET permissions = ? WHERE id = ?", | ||||
| 		models.PermCreateBoard|models.PermManageUsers, adminUser.ID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("error setting admin permissions: %v", err) | ||||
| 	} | ||||
| 	log.Println("Admin user created successfully with full permissions") | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func main() { | ||||
|     // Define command-line flag for initialization
 | ||||
|     initialize := flag.Bool("initialize", false, "Initialize database tables and admin user") | ||||
|     flag.BoolVar(initialize, "i", false, "Short for --initialize") | ||||
|     flag.Parse() | ||||
| 
 | ||||
|     config, err := loadConfig("config/config.json") | ||||
|     if err != nil { | ||||
|         log.Fatal("Error loading config:", err) | ||||
|     } | ||||
| 
 | ||||
|     dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s", config.DBUsername, config.DBPassword, config.DBServerHost, config.DBDatabase) | ||||
|     db, err := sql.Open("mysql", dsn) | ||||
|     if err != nil { | ||||
|         log.Fatal("Error connecting to database:", err) | ||||
|     } | ||||
|     defer db.Close() | ||||
| 
 | ||||
|     // Perform initialization if the flag is set
 | ||||
|     if *initialize { | ||||
|         log.Println("Initializing database...") | ||||
|         err = createTablesIfNotExist(db) | ||||
|         if err != nil { | ||||
|             log.Fatal("Error creating database tables:", err) | ||||
|         } | ||||
| 
 | ||||
|         err = ensureAdminUser(db) | ||||
|         if err != nil { | ||||
|             log.Fatal("Error ensuring admin user:", err) | ||||
|         } | ||||
| 
 | ||||
|         log.Println("Initialization completed successfully. Exiting.") | ||||
|         return | ||||
|     } | ||||
| 
 | ||||
|     // Normal startup (without automatic table creation)
 | ||||
|     log.Println("Starting ThreadR server...") | ||||
| 
 | ||||
|     dir, err := os.Getwd() | ||||
|     if err != nil { | ||||
|         log.Fatal("Error getting working directory:", err) | ||||
|     } | ||||
| 
 | ||||
|     // Parse partial templates
 | ||||
|     tmpl := template.Must(template.ParseFiles( | ||||
|         filepath.Join(dir, "templates/partials/navbar.html"), | ||||
|         filepath.Join(dir, "templates/partials/cookie_banner.html"), | ||||
|     )) | ||||
| 
 | ||||
|     // Parse page-specific templates with unique names
 | ||||
|     tmpl, err = tmpl.ParseFiles( | ||||
|         filepath.Join(dir, "templates/pages/about.html"), | ||||
|         filepath.Join(dir, "templates/pages/board.html"), | ||||
|         filepath.Join(dir, "templates/pages/boards.html"), | ||||
|         filepath.Join(dir, "templates/pages/home.html"), | ||||
|         filepath.Join(dir, "templates/pages/login.html"), | ||||
|         filepath.Join(dir, "templates/pages/news.html"), | ||||
|         filepath.Join(dir, "templates/pages/profile.html"), | ||||
|         filepath.Join(dir, "templates/pages/profile_edit.html"), | ||||
|         filepath.Join(dir, "templates/pages/signup.html"), | ||||
|         filepath.Join(dir, "templates/pages/thread.html"), | ||||
|         filepath.Join(dir, "templates/pages/userhome.html"), | ||||
|         filepath.Join(dir, "templates/pages/chat.html"), | ||||
|     ) | ||||
|     if err != nil { | ||||
|         log.Fatal("Error parsing page templates:", err) | ||||
|     } | ||||
| 
 | ||||
|     store := sessions.NewCookieStore([]byte("secret-key")) // Replace with secure key in production
 | ||||
| 
 | ||||
|     app := &handlers.App{ | ||||
|         DB:     db, | ||||
|         Store:  store, | ||||
|         Config: config, | ||||
|         Tmpl:   tmpl, | ||||
|     } | ||||
| 
 | ||||
|     fs := http.FileServer(http.Dir("static")) | ||||
|     http.Handle(config.ThreadrDir+"/static/", http.StripPrefix(config.ThreadrDir+"/static/", fs)) | ||||
| 
 | ||||
|     http.HandleFunc(config.ThreadrDir+"/", app.SessionMW(handlers.HomeHandler(app))) | ||||
|     http.HandleFunc(config.ThreadrDir+"/login/", app.SessionMW(handlers.LoginHandler(app))) | ||||
|     http.HandleFunc(config.ThreadrDir+"/logout/", app.SessionMW(handlers.LogoutHandler(app))) | ||||
|     http.HandleFunc(config.ThreadrDir+"/userhome/", app.SessionMW(app.RequireLoginMW(handlers.UserHomeHandler(app)))) | ||||
|     http.HandleFunc(config.ThreadrDir+"/boards/", app.SessionMW(handlers.BoardsHandler(app))) | ||||
|     http.HandleFunc(config.ThreadrDir+"/board/", app.SessionMW(handlers.BoardHandler(app))) | ||||
|     http.HandleFunc(config.ThreadrDir+"/thread/", app.SessionMW(handlers.ThreadHandler(app))) | ||||
|     http.HandleFunc(config.ThreadrDir+"/about/", app.SessionMW(handlers.AboutHandler(app))) | ||||
|     http.HandleFunc(config.ThreadrDir+"/profile/", app.SessionMW(app.RequireLoginMW(handlers.ProfileHandler(app)))) | ||||
|     http.HandleFunc(config.ThreadrDir+"/profile/edit/", app.SessionMW(app.RequireLoginMW(handlers.ProfileEditHandler(app)))) | ||||
|     http.HandleFunc(config.ThreadrDir+"/like/", app.SessionMW(app.RequireLoginMW(handlers.LikeHandler(app)))) | ||||
|     http.HandleFunc(config.ThreadrDir+"/news/", app.SessionMW(handlers.NewsHandler(app))) | ||||
|     http.HandleFunc(config.ThreadrDir+"/signup/", app.SessionMW(handlers.SignupHandler(app))) | ||||
|     http.HandleFunc(config.ThreadrDir+"/accept_cookie/", app.SessionMW(handlers.AcceptCookieHandler(app))) | ||||
|     http.HandleFunc(config.ThreadrDir+"/chat/", app.SessionMW(app.RequireLoginMW(handlers.ChatHandler(app)))) | ||||
| 
 | ||||
|     log.Println("Server starting on :8080") | ||||
|     log.Fatal(http.ListenAndServe(":8080", nil)) | ||||
| } | ||||
|  | @ -0,0 +1,101 @@ | |||
| package models | ||||
| 
 | ||||
| import ( | ||||
|     "database/sql" | ||||
|     "encoding/json" | ||||
| ) | ||||
| 
 | ||||
| type Board struct { | ||||
|     ID               int | ||||
|     Name             string | ||||
|     Description      string | ||||
|     Private          bool | ||||
|     PublicVisible    bool | ||||
|     PinnedThreads    []int // Stored as JSON
 | ||||
|     CustomLandingPage string | ||||
|     ColorScheme      string | ||||
| } | ||||
| 
 | ||||
| func GetBoardByID(db *sql.DB, id int) (*Board, error) { | ||||
|     query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE id = ?" | ||||
|     row := db.QueryRow(query, id) | ||||
|     board := &Board{} | ||||
|     var pinnedThreadsJSON sql.NullString | ||||
|     var customLandingPage sql.NullString | ||||
|     var colorScheme sql.NullString | ||||
|     var description sql.NullString | ||||
|     err := row.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme) | ||||
|     if err == sql.ErrNoRows { | ||||
|         return nil, nil | ||||
|     } | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     if description.Valid { | ||||
|         board.Description = description.String | ||||
|     } else { | ||||
|         board.Description = "" | ||||
|     } | ||||
|     if pinnedThreadsJSON.Valid && pinnedThreadsJSON.String != "" { | ||||
|         err = json.Unmarshal([]byte(pinnedThreadsJSON.String), &board.PinnedThreads) | ||||
|         if err != nil { | ||||
|             return nil, err | ||||
|         } | ||||
|     } | ||||
|     if customLandingPage.Valid { | ||||
|         board.CustomLandingPage = customLandingPage.String | ||||
|     } else { | ||||
|         board.CustomLandingPage = "" | ||||
|     } | ||||
|     if colorScheme.Valid { | ||||
|         board.ColorScheme = colorScheme.String | ||||
|     } else { | ||||
|         board.ColorScheme = "" | ||||
|     } | ||||
|     return board, nil | ||||
| } | ||||
| 
 | ||||
| func GetAllBoards(db *sql.DB, private bool) ([]Board, error) { | ||||
|     query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE private = ? ORDER BY id ASC" | ||||
|     rows, err := db.Query(query, private) | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     defer rows.Close() | ||||
| 
 | ||||
|     var boards []Board | ||||
|     for rows.Next() { | ||||
|         board := Board{} | ||||
|         var pinnedThreadsJSON sql.NullString | ||||
|         var customLandingPage sql.NullString | ||||
|         var colorScheme sql.NullString | ||||
|         var description sql.NullString | ||||
|         err := rows.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme) | ||||
|         if err != nil { | ||||
|             return nil, err | ||||
|         } | ||||
|         if description.Valid { | ||||
|             board.Description = description.String | ||||
|         } else { | ||||
|             board.Description = "" | ||||
|         } | ||||
|         if pinnedThreadsJSON.Valid && pinnedThreadsJSON.String != "" { | ||||
|             err = json.Unmarshal([]byte(pinnedThreadsJSON.String), &board.PinnedThreads) | ||||
|             if err != nil { | ||||
|                 return nil, err | ||||
|             } | ||||
|         } | ||||
|         if customLandingPage.Valid { | ||||
|             board.CustomLandingPage = customLandingPage.String | ||||
|         } else { | ||||
|             board.CustomLandingPage = "" | ||||
|         } | ||||
|         if colorScheme.Valid { | ||||
|             board.ColorScheme = colorScheme.String | ||||
|         } else { | ||||
|             board.ColorScheme = "" | ||||
|         } | ||||
|         boards = append(boards, board) | ||||
|     } | ||||
|     return boards, nil | ||||
| } | ||||
|  | @ -0,0 +1,46 @@ | |||
| package models | ||||
| 
 | ||||
| import "database/sql" | ||||
| 
 | ||||
| type BoardPermission struct { | ||||
|     UserID      int | ||||
|     BoardID     int | ||||
|     Permissions int64 | ||||
| } | ||||
| 
 | ||||
| func GetBoardPermission(db *sql.DB, userID, boardID int) (*BoardPermission, error) { | ||||
|     query := "SELECT user_id, board_id, permissions FROM board_permissions WHERE user_id = ? AND board_id = ?" | ||||
|     row := db.QueryRow(query, userID, boardID) | ||||
|     bp := &BoardPermission{} | ||||
|     err := row.Scan(&bp.UserID, &bp.BoardID, &bp.Permissions) | ||||
|     if err == sql.ErrNoRows { | ||||
|         return nil, nil | ||||
|     } | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     return bp, nil | ||||
| } | ||||
| 
 | ||||
| func SetBoardPermission(db *sql.DB, bp BoardPermission) error { | ||||
|     query := "INSERT INTO board_permissions (user_id, board_id, permissions) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE permissions = ?" | ||||
|     _, err := db.Exec(query, bp.UserID, bp.BoardID, bp.Permissions, bp.Permissions) | ||||
|     return err | ||||
| } | ||||
| 
 | ||||
| const ( | ||||
|     PermPostInBoard   int64 = 1 << 0 | ||||
|     PermModerateBoard int64 = 1 << 1 | ||||
|     PermViewBoard     int64 = 1 << 2 | ||||
| ) | ||||
| 
 | ||||
| func HasBoardPermission(db *sql.DB, userID, boardID int, perm int64) (bool, error) { | ||||
|     bp, err := GetBoardPermission(db, userID, boardID) | ||||
|     if err != nil { | ||||
|         return false, err | ||||
|     } | ||||
|     if bp == nil { | ||||
|         return false, nil | ||||
|     } | ||||
|     return bp.Permissions&perm != 0, nil | ||||
| } | ||||
|  | @ -0,0 +1,132 @@ | |||
| package models | ||||
| 
 | ||||
| import ( | ||||
| 	"database/sql" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| type ChatMessage struct { | ||||
| 	ID          int | ||||
| 	UserID      int | ||||
| 	Content     string | ||||
| 	ReplyTo     int // -1 if not a reply
 | ||||
| 	Timestamp   time.Time | ||||
| 	Username    string // For display, fetched from user
 | ||||
| 	PfpURL      string // For display, fetched from user
 | ||||
| 	Mentions    []string // List of mentioned usernames
 | ||||
| } | ||||
| 
 | ||||
| func CreateChatMessage(db *sql.DB, msg ChatMessage) error { | ||||
| 	query := "INSERT INTO chat_messages (user_id, content, reply_to, timestamp) VALUES (?, ?, ?, NOW())" | ||||
| 	_, err := db.Exec(query, msg.UserID, msg.Content, msg.ReplyTo) | ||||
| 	return err | ||||
| } | ||||
| 
 | ||||
| func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) { | ||||
| 	query := ` | ||||
| 		SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_url | ||||
| 		FROM chat_messages cm | ||||
| 		JOIN users u ON cm.user_id = u.id | ||||
| 		ORDER BY cm.timestamp DESC | ||||
| 		LIMIT ?` | ||||
| 	rows, err := db.Query(query, limit) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer rows.Close() | ||||
| 
 | ||||
| 	var messages []ChatMessage | ||||
| 	for rows.Next() { | ||||
| 		var msg ChatMessage | ||||
| 		var timestampStr string | ||||
| 		var pfpURL sql.NullString | ||||
| 		err := rows.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, ×tampStr, &msg.Username, &pfpURL) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		msg.Timestamp, err = time.Parse("2006-01-02 15:04:05", timestampStr) | ||||
| 		if err != nil { | ||||
| 			msg.Timestamp = time.Time{} | ||||
| 		} | ||||
| 		if pfpURL.Valid { | ||||
| 			msg.PfpURL = pfpURL.String | ||||
| 		} | ||||
| 		// Parse mentions from content (simple @username detection)
 | ||||
| 		msg.Mentions = extractMentions(msg.Content) | ||||
| 		messages = append(messages, msg) | ||||
| 	} | ||||
| 	return messages, nil | ||||
| } | ||||
| 
 | ||||
| func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) { | ||||
| 	query := ` | ||||
| 		SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_url | ||||
| 		FROM chat_messages cm | ||||
| 		JOIN users u ON cm.user_id = u.id | ||||
| 		WHERE cm.id = ?` | ||||
| 	row := db.QueryRow(query, id) | ||||
| 	var msg ChatMessage | ||||
| 	var timestampStr string | ||||
| 	var pfpURL sql.NullString | ||||
| 	err := row.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, ×tampStr, &msg.Username, &pfpURL) | ||||
| 	if err == sql.ErrNoRows { | ||||
| 		return nil, nil | ||||
| 	} | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	msg.Timestamp, err = time.Parse("2006-01-02 15:04:05", timestampStr) | ||||
| 	if err != nil { | ||||
| 		msg.Timestamp = time.Time{} | ||||
| 	} | ||||
| 	if pfpURL.Valid { | ||||
| 		msg.PfpURL = pfpURL.String | ||||
| 	} | ||||
| 	msg.Mentions = extractMentions(msg.Content) | ||||
| 	return &msg, nil | ||||
| } | ||||
| 
 | ||||
| func GetUsernamesMatching(db *sql.DB, prefix string) ([]string, error) { | ||||
| 	query := "SELECT username FROM users WHERE username LIKE ? LIMIT 10" | ||||
| 	rows, err := db.Query(query, prefix+"%") | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer rows.Close() | ||||
| 
 | ||||
| 	var usernames []string | ||||
| 	for rows.Next() { | ||||
| 		var username string | ||||
| 		if err := rows.Scan(&username); err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		usernames = append(usernames, username) | ||||
| 	} | ||||
| 	return usernames, nil | ||||
| } | ||||
| 
 | ||||
| // Simple utility to extract mentions from content
 | ||||
| func extractMentions(content string) []string { | ||||
| 	var mentions []string | ||||
| 	var currentMention string | ||||
| 	inMention := false | ||||
| 
 | ||||
| 	for _, char := range content { | ||||
| 		if char == '@' { | ||||
| 			inMention = true | ||||
| 			currentMention = "@" | ||||
| 		} else if inMention && (char == ' ' || char == '\n' || char == '\t') { | ||||
| 			if len(currentMention) > 1 { | ||||
| 				mentions = append(mentions, currentMention) | ||||
| 			} | ||||
| 			inMention = false | ||||
| 			currentMention = "" | ||||
| 		} else if inMention { | ||||
| 			currentMention += string(char) | ||||
| 		} | ||||
| 	} | ||||
| 	if inMention && len(currentMention) > 1 { | ||||
| 		mentions = append(mentions, currentMention) | ||||
| 	} | ||||
| 	return mentions | ||||
| } | ||||
|  | @ -0,0 +1,62 @@ | |||
| package models | ||||
| 
 | ||||
| import "database/sql" | ||||
| 
 | ||||
| type Like struct { | ||||
|     ID     int | ||||
|     PostID int | ||||
|     UserID int | ||||
|     Type   string // "like" or "dislike"
 | ||||
| } | ||||
| 
 | ||||
| func GetLikesByPostID(db *sql.DB, postID int) ([]Like, error) { | ||||
|     query := "SELECT id, post_id, user_id, type FROM likes WHERE post_id = ?" | ||||
|     rows, err := db.Query(query, postID) | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     defer rows.Close() | ||||
| 
 | ||||
|     var likes []Like | ||||
|     for rows.Next() { | ||||
|         like := Like{} | ||||
|         err := rows.Scan(&like.ID, &like.PostID, &like.UserID, &like.Type) | ||||
|         if err != nil { | ||||
|             return nil, err | ||||
|         } | ||||
|         likes = append(likes, like) | ||||
|     } | ||||
|     return likes, nil | ||||
| } | ||||
| 
 | ||||
| func GetLikeByPostAndUser(db *sql.DB, postID, userID int) (*Like, error) { | ||||
|     query := "SELECT id, post_id, user_id, type FROM likes WHERE post_id = ? AND user_id = ?" | ||||
|     row := db.QueryRow(query, postID, userID) | ||||
|     like := &Like{} | ||||
|     err := row.Scan(&like.ID, &like.PostID, &like.UserID, &like.Type) | ||||
|     if err == sql.ErrNoRows { | ||||
|         return nil, nil | ||||
|     } | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     return like, nil | ||||
| } | ||||
| 
 | ||||
| func CreateLike(db *sql.DB, like Like) error { | ||||
|     query := "INSERT INTO likes (post_id, user_id, type) VALUES (?, ?, ?)" | ||||
|     _, err := db.Exec(query, like.PostID, like.UserID, like.Type) | ||||
|     return err | ||||
| } | ||||
| 
 | ||||
| func UpdateLikeType(db *sql.DB, postID, userID int, likeType string) error { | ||||
|     query := "UPDATE likes SET type = ? WHERE post_id = ? AND user_id = ?" | ||||
|     _, err := db.Exec(query, likeType, postID, userID) | ||||
|     return err | ||||
| } | ||||
| 
 | ||||
| func DeleteLike(db *sql.DB, postID, userID int) error { | ||||
|     query := "DELETE FROM likes WHERE post_id = ? AND user_id = ?" | ||||
|     _, err := db.Exec(query, postID, userID) | ||||
|     return err | ||||
| } | ||||
|  | @ -0,0 +1,53 @@ | |||
| package models | ||||
| 
 | ||||
| import ( | ||||
|     "database/sql" | ||||
|     "time" | ||||
| ) | ||||
| 
 | ||||
| type News struct { | ||||
|     ID        int | ||||
|     Title     string | ||||
|     Content   string | ||||
|     PostedBy  int | ||||
|     CreatedAt time.Time | ||||
| } | ||||
| 
 | ||||
| func GetAllNews(db *sql.DB) ([]News, error) { | ||||
|     query := "SELECT id, title, content, posted_by, created_at FROM news ORDER BY created_at DESC" | ||||
|     rows, err := db.Query(query) | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     defer rows.Close() | ||||
| 
 | ||||
|     var newsItems []News | ||||
|     for rows.Next() { | ||||
|         news := News{} | ||||
|         var createdAtStr string | ||||
|         err := rows.Scan(&news.ID, &news.Title, &news.Content, &news.PostedBy, &createdAtStr) | ||||
|         if err != nil { | ||||
|             return nil, err | ||||
|         } | ||||
|         // Parse the timestamp string into time.Time
 | ||||
|         news.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtStr) | ||||
|         if err != nil { | ||||
|             // Fallback to a default time if parsing fails
 | ||||
|             news.CreatedAt = time.Time{} | ||||
|         } | ||||
|         newsItems = append(newsItems, news) | ||||
|     } | ||||
|     return newsItems, nil | ||||
| } | ||||
| 
 | ||||
| func CreateNews(db *sql.DB, news News) error { | ||||
|     query := "INSERT INTO news (title, content, posted_by, created_at) VALUES (?, ?, ?, NOW())" | ||||
|     _, err := db.Exec(query, news.Title, news.Content, news.PostedBy) | ||||
|     return err | ||||
| } | ||||
| 
 | ||||
| func DeleteNews(db *sql.DB, id int) error { | ||||
|     query := "DELETE FROM news WHERE id = ?" | ||||
|     _, err := db.Exec(query, id) | ||||
|     return err | ||||
| } | ||||
|  | @ -0,0 +1,49 @@ | |||
| package models | ||||
| 
 | ||||
| import ( | ||||
|     "database/sql" | ||||
|     "time" | ||||
| ) | ||||
| 
 | ||||
| type Notification struct { | ||||
|     ID        int | ||||
|     UserID    int | ||||
|     Type      string | ||||
|     RelatedID int | ||||
|     CreatedAt time.Time | ||||
|     Read      bool | ||||
| } | ||||
| 
 | ||||
| func GetNotificationsByUserID(db *sql.DB, userID int) ([]Notification, error) { | ||||
|     query := "SELECT id, user_id, type, related_id, read, created_at FROM notifications WHERE user_id = ? ORDER BY created_at DESC" | ||||
|     rows, err := db.Query(query, userID) | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     defer rows.Close() | ||||
| 
 | ||||
|     var notifications []Notification | ||||
|     for rows.Next() { | ||||
|         notification := Notification{} | ||||
|         err := rows.Scan(¬ification.ID, ¬ification.UserID, ¬ification.Type, ¬ification.RelatedID, ¬ification.Read, ¬ification.CreatedAt) | ||||
|         if err != nil { | ||||
|             return nil, err | ||||
|         } | ||||
|         notifications = append(notifications, notification) | ||||
|     } | ||||
|     return notifications, nil | ||||
| } | ||||
| 
 | ||||
| // Stubbed for future implementation
 | ||||
| func CreateNotification(db *sql.DB, notification Notification) error { | ||||
|     query := "INSERT INTO notifications (user_id, type, related_id, read, created_at) VALUES (?, ?, ?, ?, NOW())" | ||||
|     _, err := db.Exec(query, notification.UserID, notification.Type, notification.RelatedID, notification.Read) | ||||
|     return err | ||||
| } | ||||
| 
 | ||||
| // Stubbed for future implementation
 | ||||
| func MarkNotificationAsRead(db *sql.DB, id int) error { | ||||
|     query := "UPDATE notifications SET read = true WHERE id = ?" | ||||
|     _, err := db.Exec(query, id) | ||||
|     return err | ||||
| } | ||||
|  | @ -0,0 +1,61 @@ | |||
| package models | ||||
| 
 | ||||
| import ( | ||||
|     "database/sql" | ||||
|     "time" | ||||
| ) | ||||
| 
 | ||||
| type Post struct { | ||||
|     ID              int | ||||
|     ThreadID        int | ||||
|     UserID          int | ||||
|     PostTime        time.Time | ||||
|     EditTime        *time.Time | ||||
|     Content         string | ||||
|     AttachmentHash  *int64 | ||||
|     AttachmentName  *string | ||||
|     Title           string | ||||
|     ReplyTo         int | ||||
| } | ||||
| 
 | ||||
| func GetPostsByThreadID(db *sql.DB, threadID int) ([]Post, error) { | ||||
|     query := "SELECT id, thread_id, user_id, post_time, edit_time, content, attachment_hash, attachment_name, title, reply_to FROM posts WHERE thread_id = ? ORDER BY post_time ASC" | ||||
|     rows, err := db.Query(query, threadID) | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     defer rows.Close() | ||||
| 
 | ||||
|     var posts []Post | ||||
|     for rows.Next() { | ||||
|         post := Post{} | ||||
|         var postTimeStr string | ||||
|         var editTimeStr sql.NullString | ||||
|         err := rows.Scan(&post.ID, &post.ThreadID, &post.UserID, &postTimeStr, &editTimeStr, &post.Content, &post.AttachmentHash, &post.AttachmentName, &post.Title, &post.ReplyTo) | ||||
|         if err != nil { | ||||
|             return nil, err | ||||
|         } | ||||
|         post.PostTime, err = time.Parse("2006-01-02 15:04:05", postTimeStr) | ||||
|         if err != nil { | ||||
|             post.PostTime = time.Time{} | ||||
|         } | ||||
|         if editTimeStr.Valid { | ||||
|             editTime, err := time.Parse("2006-01-02 15:04:05", editTimeStr.String) | ||||
|             if err != nil { | ||||
|                 post.EditTime = nil | ||||
|             } else { | ||||
|                 post.EditTime = &editTime | ||||
|             } | ||||
|         } else { | ||||
|             post.EditTime = nil | ||||
|         } | ||||
|         posts = append(posts, post) | ||||
|     } | ||||
|     return posts, nil | ||||
| } | ||||
| 
 | ||||
| func CreatePost(db *sql.DB, post Post) error { | ||||
|     query := "INSERT INTO posts (thread_id, user_id, content, title, reply_to, post_time) VALUES (?, ?, ?, ?, ?, NOW())" | ||||
|     _, err := db.Exec(query, post.ThreadID, post.UserID, post.Content, post.Title, post.ReplyTo) | ||||
|     return err | ||||
| } | ||||
|  | @ -0,0 +1,44 @@ | |||
| package models | ||||
| 
 | ||||
| import "database/sql" | ||||
| 
 | ||||
| type Reaction struct { | ||||
|     ID     int | ||||
|     PostID int | ||||
|     UserID int | ||||
|     Emoji  string | ||||
| } | ||||
| 
 | ||||
| func GetReactionsByPostID(db *sql.DB, postID int) ([]Reaction, error) { | ||||
|     query := "SELECT id, post_id, user_id, emoji FROM reactions WHERE post_id = ?" | ||||
|     rows, err := db.Query(query, postID) | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     defer rows.Close() | ||||
| 
 | ||||
|     var reactions []Reaction | ||||
|     for rows.Next() { | ||||
|         reaction := Reaction{} | ||||
|         err := rows.Scan(&reaction.ID, &reaction.PostID, &reaction.UserID, &reaction.Emoji) | ||||
|         if err != nil { | ||||
|             return nil, err | ||||
|         } | ||||
|         reactions = append(reactions, reaction) | ||||
|     } | ||||
|     return reactions, nil | ||||
| } | ||||
| 
 | ||||
| // Stubbed for future implementation
 | ||||
| func CreateReaction(db *sql.DB, reaction Reaction) error { | ||||
|     query := "INSERT INTO reactions (post_id, user_id, emoji) VALUES (?, ?, ?)" | ||||
|     _, err := db.Exec(query, reaction.PostID, reaction.UserID, reaction.Emoji) | ||||
|     return err | ||||
| } | ||||
| 
 | ||||
| // Stubbed for future implementation
 | ||||
| func DeleteReaction(db *sql.DB, postID, userID int, emoji string) error { | ||||
|     query := "DELETE FROM reactions WHERE post_id = ? AND user_id = ? AND emoji = ?" | ||||
|     _, err := db.Exec(query, postID, userID, emoji) | ||||
|     return err | ||||
| } | ||||
|  | @ -0,0 +1,41 @@ | |||
| package models | ||||
| 
 | ||||
| import ( | ||||
|     "database/sql" | ||||
|     "time" | ||||
| ) | ||||
| 
 | ||||
| type Repost struct { | ||||
|     ID        int | ||||
|     ThreadID  int | ||||
|     BoardID   int | ||||
|     UserID    int | ||||
|     CreatedAt time.Time | ||||
| } | ||||
| 
 | ||||
| func GetRepostsByThreadID(db *sql.DB, threadID int) ([]Repost, error) { | ||||
|     query := "SELECT id, thread_id, board_id, user_id, created_at FROM reposts WHERE thread_id = ?" | ||||
|     rows, err := db.Query(query, threadID) | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     defer rows.Close() | ||||
| 
 | ||||
|     var reposts []Repost | ||||
|     for rows.Next() { | ||||
|         repost := Repost{} | ||||
|         err := rows.Scan(&repost.ID, &repost.ThreadID, &repost.BoardID, &repost.UserID, &repost.CreatedAt) | ||||
|         if err != nil { | ||||
|             return nil, err | ||||
|         } | ||||
|         reposts = append(reposts, repost) | ||||
|     } | ||||
|     return reposts, nil | ||||
| } | ||||
| 
 | ||||
| // Stubbed for future implementation
 | ||||
| func CreateRepost(db *sql.DB, repost Repost) error { | ||||
|     query := "INSERT INTO reposts (thread_id, board_id, user_id, created_at) VALUES (?, ?, ?, NOW())" | ||||
|     _, err := db.Exec(query, repost.ThreadID, repost.BoardID, repost.UserID) | ||||
|     return err | ||||
| } | ||||
|  | @ -0,0 +1,76 @@ | |||
| package models | ||||
| 
 | ||||
| import ( | ||||
|     "database/sql" | ||||
|     "time" | ||||
| ) | ||||
| 
 | ||||
| type Thread struct { | ||||
|     ID                  int | ||||
|     BoardID             int | ||||
|     Title               string | ||||
|     CreatedAt           time.Time | ||||
|     UpdatedAt           time.Time | ||||
|     CreatedByUserID     int | ||||
|     AcceptedAnswerPostID *int | ||||
| } | ||||
| 
 | ||||
| func GetThreadByID(db *sql.DB, id int) (*Thread, error) { | ||||
|     query := "SELECT id, board_id, title, created_at, updated_at, created_by_user_id, accepted_answer_post_id FROM threads WHERE id = ?" | ||||
|     row := db.QueryRow(query, id) | ||||
|     thread := &Thread{} | ||||
|     var createdAtStr string | ||||
|     var updatedAtStr string | ||||
|     err := row.Scan(&thread.ID, &thread.BoardID, &thread.Title, &createdAtStr, &updatedAtStr, &thread.CreatedByUserID, &thread.AcceptedAnswerPostID) | ||||
|     if err == sql.ErrNoRows { | ||||
|         return nil, nil | ||||
|     } | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     thread.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtStr) | ||||
|     if err != nil { | ||||
|         thread.CreatedAt = time.Time{} | ||||
|     } | ||||
|     thread.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtStr) | ||||
|     if err != nil { | ||||
|         thread.UpdatedAt = time.Time{} | ||||
|     } | ||||
|     return thread, nil | ||||
| } | ||||
| 
 | ||||
| func GetThreadsByBoardID(db *sql.DB, boardID int) ([]Thread, error) { | ||||
|     query := "SELECT id, board_id, title, created_at, updated_at, created_by_user_id, accepted_answer_post_id FROM threads WHERE board_id = ? ORDER BY updated_at DESC" | ||||
|     rows, err := db.Query(query, boardID) | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     defer rows.Close() | ||||
| 
 | ||||
|     var threads []Thread | ||||
|     for rows.Next() { | ||||
|         thread := Thread{} | ||||
|         var createdAtStr string | ||||
|         var updatedAtStr string | ||||
|         err := rows.Scan(&thread.ID, &thread.BoardID, &thread.Title, &createdAtStr, &updatedAtStr, &thread.CreatedByUserID, &thread.AcceptedAnswerPostID) | ||||
|         if err != nil { | ||||
|             return nil, err | ||||
|         } | ||||
|         thread.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtStr) | ||||
|         if err != nil { | ||||
|             thread.CreatedAt = time.Time{} | ||||
|         } | ||||
|         thread.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtStr) | ||||
|         if err != nil { | ||||
|             thread.UpdatedAt = time.Time{} | ||||
|         } | ||||
|         threads = append(threads, thread) | ||||
|     } | ||||
|     return threads, nil | ||||
| } | ||||
| 
 | ||||
| func CreateThread(db *sql.DB, thread Thread) error { | ||||
|     query := "INSERT INTO threads (board_id, title, created_by_user_id, created_at, updated_at, type) VALUES (?, ?, ?, NOW(), NOW(), 'classic')" | ||||
|     _, err := db.Exec(query, thread.BoardID, thread.Title, thread.CreatedByUserID) | ||||
|     return err | ||||
| } | ||||
|  | @ -0,0 +1,161 @@ | |||
| package models | ||||
| 
 | ||||
| import ( | ||||
|     "crypto/sha256" | ||||
|     "database/sql" | ||||
|     "fmt" | ||||
|     "time" | ||||
| ) | ||||
| 
 | ||||
| type User struct { | ||||
|     ID                     int | ||||
|     Username               string | ||||
|     DisplayName            string | ||||
|     PfpURL                 string | ||||
|     Bio                    string | ||||
|     AuthenticationString   string | ||||
|     AuthenticationSalt     string | ||||
|     AuthenticationAlgorithm string | ||||
|     CreatedAt              time.Time | ||||
|     UpdatedAt              time.Time | ||||
|     Verified               bool | ||||
|     Permissions            int64 | ||||
| } | ||||
| 
 | ||||
| func GetUserByID(db *sql.DB, id int) (*User, error) { | ||||
|     query := "SELECT id, username, display_name, pfp_url, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE id = ?" | ||||
|     row := db.QueryRow(query, id) | ||||
|     user := &User{} | ||||
|     var displayName sql.NullString | ||||
|     var pfpURL sql.NullString | ||||
|     var bio sql.NullString | ||||
|     var createdAtString sql.NullString | ||||
|     var updatedAtString sql.NullString | ||||
|     err := row.Scan(&user.ID, &user.Username, &displayName, &pfpURL, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions) | ||||
|     if err == sql.ErrNoRows { | ||||
|         return nil, nil | ||||
|     } | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     if displayName.Valid { | ||||
|         user.DisplayName = displayName.String | ||||
|     } else { | ||||
|         user.DisplayName = "" | ||||
|     } | ||||
|     if pfpURL.Valid { | ||||
|         user.PfpURL = pfpURL.String | ||||
|     } else { | ||||
|         user.PfpURL = "" | ||||
|     } | ||||
|     if bio.Valid { | ||||
|         user.Bio = bio.String | ||||
|     } else { | ||||
|         user.Bio = "" | ||||
|     } | ||||
|     if createdAtString.Valid { | ||||
|         user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String) | ||||
|         if err != nil { | ||||
|             return nil, fmt.Errorf("error parsing created_at: %v", err) | ||||
|         } | ||||
|     } else { | ||||
|         user.CreatedAt = time.Time{} | ||||
|     } | ||||
|     if updatedAtString.Valid { | ||||
|         user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String) | ||||
|         if err != nil { | ||||
|             return nil, fmt.Errorf("error parsing updated_at: %v", err) | ||||
|         } | ||||
|     } else { | ||||
|         user.UpdatedAt = time.Time{} | ||||
|     } | ||||
|     return user, nil | ||||
| } | ||||
| 
 | ||||
| func GetUserByUsername(db *sql.DB, username string) (*User, error) { | ||||
|     query := "SELECT id, username, display_name, pfp_url, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE username = ?" | ||||
|     row := db.QueryRow(query, username) | ||||
|     user := &User{} | ||||
|     var displayName sql.NullString | ||||
|     var pfpURL sql.NullString | ||||
|     var bio sql.NullString | ||||
|     var createdAtString sql.NullString | ||||
|     var updatedAtString sql.NullString | ||||
|     err := row.Scan(&user.ID, &user.Username, &displayName, &pfpURL, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions) | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     if displayName.Valid { | ||||
|         user.DisplayName = displayName.String | ||||
|     } else { | ||||
|         user.DisplayName = "" | ||||
|     } | ||||
|     if pfpURL.Valid { | ||||
|         user.PfpURL = pfpURL.String | ||||
|     } else { | ||||
|         user.PfpURL = "" | ||||
|     } | ||||
|     if bio.Valid { | ||||
|         user.Bio = bio.String | ||||
|     } else { | ||||
|         user.Bio = "" | ||||
|     } | ||||
|     if createdAtString.Valid { | ||||
|         user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String) | ||||
|         if err != nil { | ||||
|             return nil, fmt.Errorf("error parsing created_at: %v", err) | ||||
|         } | ||||
|     } else { | ||||
|         user.CreatedAt = time.Time{} | ||||
|     } | ||||
|     if updatedAtString.Valid { | ||||
|         user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String) | ||||
|         if err != nil { | ||||
|             return nil, fmt.Errorf("error parsing updated_at: %v", err) | ||||
|         } | ||||
|     } else { | ||||
|         user.UpdatedAt = time.Time{} | ||||
|     } | ||||
|     return user, nil | ||||
| } | ||||
| 
 | ||||
| func CheckPassword(password, salt, algorithm, hash string) bool { | ||||
|     if algorithm != "sha256" { | ||||
|         return false | ||||
|     } | ||||
|     computedHash := HashPassword(password, salt, algorithm) | ||||
|     return computedHash == hash | ||||
| } | ||||
| 
 | ||||
| func HashPassword(password, salt, algorithm string) string { | ||||
|     if algorithm != "sha256" { | ||||
|         return "" | ||||
|     } | ||||
|     data := password + salt | ||||
|     hash := sha256.Sum256([]byte(data)) | ||||
|     return fmt.Sprintf("%x", hash) | ||||
| } | ||||
| 
 | ||||
| func CreateUser(db *sql.DB, username, password string) error { | ||||
|     salt := "random-salt" // Replace with secure random generation
 | ||||
|     algorithm := "sha256" | ||||
|     hash := HashPassword(password, salt, algorithm) | ||||
|     query := "INSERT INTO users (username, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions) VALUES (?, ?, ?, ?, NOW(), NOW(), ?, 0)" | ||||
|     _, err := db.Exec(query, username, hash, salt, algorithm, false) | ||||
|     return err | ||||
| } | ||||
| 
 | ||||
| func UpdateUserProfile(db *sql.DB, userID int, displayName, pfpURL, bio string) error { | ||||
|     query := "UPDATE users SET display_name = ?, pfp_url = ?, bio = ?, updated_at = NOW() WHERE id = ?" | ||||
|     _, err := db.Exec(query, displayName, pfpURL, bio, userID) | ||||
|     return err | ||||
| } | ||||
| 
 | ||||
| const ( | ||||
|     PermCreateBoard int64 = 1 << 0 | ||||
|     PermManageUsers int64 = 1 << 1 | ||||
| ) | ||||
| 
 | ||||
| func HasGlobalPermission(user *User, perm int64) bool { | ||||
|     return user.Permissions&perm != 0 | ||||
| } | ||||
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 93 KiB | 
|  | @ -0,0 +1,272 @@ | |||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||
| 
 | ||||
| <svg | ||||
|    xmlns:dc="http://purl.org/dc/elements/1.1/" | ||||
|    xmlns:cc="http://creativecommons.org/ns#" | ||||
|    xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:xlink="http://www.w3.org/1999/xlink" | ||||
|    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||
|    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||
|    width="261.82941mm" | ||||
|    height="95.154205mm" | ||||
|    viewBox="0 0 261.82941 95.154203" | ||||
|    version="1.1" | ||||
|    id="svg8" | ||||
|    inkscape:version="0.92.3 (2405546, 2018-03-11)" | ||||
|    sodipodi:docname="ThreadR.svg"> | ||||
|   <defs | ||||
|      id="defs2"> | ||||
|     <linearGradient | ||||
|        inkscape:collect="always" | ||||
|        id="linearGradient1092"> | ||||
|       <stop | ||||
|          style="stop-color:#a9dfff;stop-opacity:1;" | ||||
|          offset="0" | ||||
|          id="stop1088" /> | ||||
|       <stop | ||||
|          style="stop-color:#a9dfff;stop-opacity:0;" | ||||
|          offset="1" | ||||
|          id="stop1090" /> | ||||
|     </linearGradient> | ||||
|     <linearGradient | ||||
|        inkscape:collect="always" | ||||
|        id="linearGradient1060"> | ||||
|       <stop | ||||
|          style="stop-color:#766fff;stop-opacity:1;" | ||||
|          offset="0" | ||||
|          id="stop1056" /> | ||||
|       <stop | ||||
|          style="stop-color:#766fff;stop-opacity:0;" | ||||
|          offset="1" | ||||
|          id="stop1058" /> | ||||
|     </linearGradient> | ||||
|     <linearGradient | ||||
|        inkscape:collect="always" | ||||
|        xlink:href="#linearGradient1060" | ||||
|        id="linearGradient1062" | ||||
|        x1="30.859402" | ||||
|        y1="95.286171" | ||||
|        x2="146.35486" | ||||
|        y2="95.286171" | ||||
|        gradientUnits="userSpaceOnUse" | ||||
|        gradientTransform="matrix(2.2724332,0,0,0.98143486,-39.444389,5.9391184)" /> | ||||
|     <linearGradient | ||||
|        inkscape:collect="always" | ||||
|        xlink:href="#linearGradient1092" | ||||
|        id="linearGradient1094" | ||||
|        x1="-144.12688" | ||||
|        y1="82.6875" | ||||
|        x2="-39.544453" | ||||
|        y2="82.6875" | ||||
|        gradientUnits="userSpaceOnUse" | ||||
|        gradientTransform="matrix(2.5099298,0,0,0.96280136,68.591637,19.844646)" /> | ||||
|   </defs> | ||||
|   <sodipodi:namedview | ||||
|      id="base" | ||||
|      pagecolor="#ffffff" | ||||
|      bordercolor="#666666" | ||||
|      borderopacity="1.0" | ||||
|      inkscape:pageopacity="0.0" | ||||
|      inkscape:pageshadow="2" | ||||
|      inkscape:zoom="0.70710678" | ||||
|      inkscape:cx="84.893034" | ||||
|      inkscape:cy="106.28905" | ||||
|      inkscape:document-units="mm" | ||||
|      inkscape:current-layer="layer4" | ||||
|      showgrid="false" | ||||
|      inkscape:measure-start="0,0" | ||||
|      inkscape:measure-end="0,0" | ||||
|      inkscape:window-width="1600" | ||||
|      inkscape:window-height="847" | ||||
|      inkscape:window-x="0" | ||||
|      inkscape:window-y="1" | ||||
|      inkscape:window-maximized="1" /> | ||||
|   <metadata | ||||
|      id="metadata5"> | ||||
|     <rdf:RDF> | ||||
|       <cc:Work | ||||
|          rdf:about=""> | ||||
|         <dc:format>image/svg+xml</dc:format> | ||||
|         <dc:type | ||||
|            rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> | ||||
|         <dc:title /> | ||||
|       </cc:Work> | ||||
|     </rdf:RDF> | ||||
|   </metadata> | ||||
|   <g | ||||
|      inkscape:groupmode="layer" | ||||
|      id="layer5" | ||||
|      inkscape:label="background" | ||||
|      transform="translate(-30.990202,-96.114479)"> | ||||
|     <rect | ||||
|        style="display:inline;opacity:1;fill:#f9f9f9;fill-opacity:1;stroke:none;stroke-width:0.78851396;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect1103" | ||||
|        width="261.82941" | ||||
|        height="95.154205" | ||||
|        x="30.990202" | ||||
|        y="96.114479" /> | ||||
|   </g> | ||||
|   <g | ||||
|      inkscape:groupmode="layer" | ||||
|      id="layer3" | ||||
|      inkscape:label="window" | ||||
|      style="display:inline" | ||||
|      transform="translate(-30.990202,-96.114479)"> | ||||
|     <rect | ||||
|        style="fill:#e6e6e6;stroke:none;stroke-width:0.47835484;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect1021" | ||||
|        width="1.0631751" | ||||
|        height="88.470581" | ||||
|        x="30.999176" | ||||
|        y="102.7981" /> | ||||
|     <path | ||||
|        style="fill:#e6e6e6;stroke:none;stroke-width:0.48693055;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        d="m 291.71793,102.7981 v 88.47058 h 1.10168 V 102.7981 Z" | ||||
|        id="rect1021-9" | ||||
|        inkscape:connector-curvature="0" /> | ||||
|     <rect | ||||
|        style="fill:#e6e6e6;stroke:none;stroke-width:0.53601629;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect909-6-2-9-2" | ||||
|        width="259.65558" | ||||
|        height="5.0737643" | ||||
|        x="32.062351" | ||||
|        y="186.19492" | ||||
|        ry="0" /> | ||||
|     <rect | ||||
|        style="opacity:1;fill:url(#linearGradient1094);fill-opacity:1;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect1023-6" | ||||
|        width="261.82043" | ||||
|        height="6.6836219" | ||||
|        x="-292.81961" | ||||
|        y="96.114479" | ||||
|        transform="scale(-1,1)" /> | ||||
|     <rect | ||||
|        style="display:inline;opacity:1;fill:url(#linearGradient1062);fill-opacity:1;stroke:none;stroke-width:0.53430772;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect1023" | ||||
|        width="261.82043" | ||||
|        height="6.6836276" | ||||
|        x="30.999176" | ||||
|        y="96.114479" /> | ||||
|   </g> | ||||
|   <g | ||||
|      inkscape:label="contents" | ||||
|      inkscape:groupmode="layer" | ||||
|      id="layer1" | ||||
|      style="display:inline" | ||||
|      transform="translate(-30.990202,-96.114479)"> | ||||
|     <path | ||||
|        style="fill:#e6e6e6;stroke:none;stroke-width:0.46272928;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        d="m 33.365806,121.08017 4.989638,-12.31832 c 1.088722,-2.58288 1.758941,-2.58288 5.171142,-2.58288 H 111.3019 c 3.41221,0 4.08244,0 5.17115,2.58288 l 4.98963,12.31833 z" | ||||
|        id="path817" | ||||
|        inkscape:connector-curvature="0" | ||||
|        sodipodi:nodetypes="ccccccc" /> | ||||
|     <rect | ||||
|        style="fill:%COLOR_CYAN%;fill-opacity:1;stroke:none;stroke-width:0.46806985;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect909" | ||||
|        width="255.74254" | ||||
|        height="15.640329" | ||||
|        x="32.062351" | ||||
|        y="121.10081" | ||||
|        ry="0" /> | ||||
|     <rect | ||||
|        style="fill:%COLOR_ORANGE%;fill-opacity:1;stroke:none;stroke-width:0.41052076;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect909-5" | ||||
|        width="255.74254" | ||||
|        height="9.9996996" | ||||
|        x="32.062351" | ||||
|        y="136.74113" | ||||
|        ry="0" /> | ||||
|     <rect | ||||
|        style="fill:%COLOR_PINK%;fill-opacity:1;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect909-6-2-9" | ||||
|        width="255.74255" | ||||
|        height="10.471648" | ||||
|        x="32.062351" | ||||
|        y="175.72327" | ||||
|        ry="0" /> | ||||
|     <rect | ||||
|        style="fill:%COLOR_BEIGE%;fill-opacity:1;stroke:none;stroke-width:0.52205408;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect909-6-2-1" | ||||
|        width="255.74254" | ||||
|        height="28.982441" | ||||
|        x="32.062351" | ||||
|        y="146.74083" | ||||
|        ry="0" /> | ||||
|     <rect | ||||
|        style="fill:#ececec;stroke:none;stroke-width:0.48659384;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect983" | ||||
|        width="3.9130244" | ||||
|        height="65.094109" | ||||
|        x="287.8049" | ||||
|        y="121.10081" /> | ||||
|     <rect | ||||
|        style="fill:#b3b3b3;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect983-7" | ||||
|        width="2.182373" | ||||
|        height="45.629673" | ||||
|        x="288.67023" | ||||
|        y="127.22635" | ||||
|        ry="1.1820796" | ||||
|        rx="1.0911865" /> | ||||
|     <rect | ||||
|        style="fill:#b3b3b3;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect1002" | ||||
|        width="0.46817124" | ||||
|        height="0.48868293" | ||||
|        x="144.64522" | ||||
|        y="120.98614" | ||||
|        rx="1.5645972" | ||||
|        ry="0" /> | ||||
|     <rect | ||||
|        style="fill:#b3b3b3;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect1002-0" | ||||
|        width="0.46817127" | ||||
|        height="0.48868293" | ||||
|        x="141.20036" | ||||
|        y="120.98614" | ||||
|        rx="1.5645972" | ||||
|        ry="0" /> | ||||
|   </g> | ||||
|   <g | ||||
|      inkscape:groupmode="layer" | ||||
|      id="layer4" | ||||
|      inkscape:label="ThreadR" | ||||
|      transform="translate(4.9926758e-8,-4.1053391)"> | ||||
|     <g | ||||
|        aria-label="ThreadR" | ||||
|        style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:98.77777863px;line-height:1.25;font-family:Playball;-inkscape-font-specification:Playball;letter-spacing:0px;word-spacing:0px;fill:%COLOR_BLUE%;fill-opacity:1;stroke:none;stroke-width:0.26458332" | ||||
|        id="text840"> | ||||
|       <path | ||||
|          d="M 66.388955,26.034411 52.461289,25.441745 q -0.987778,0 -6.519334,11.853333 -5.531555,11.754556 -9.581444,22.817667 -4.049889,11.063111 -4.049889,16.002 0,4.840111 3.062111,4.840111 3.160889,0 7.902222,-6.420555 0.493889,-0.592667 0.987778,-0.592667 0.493889,0 0.493889,0.889 0,0.889 -1.382889,3.259667 -1.284111,2.370666 -3.457222,5.136444 -2.173111,2.667 -5.630334,4.741334 -3.457222,2.173111 -7.112,2.173111 -5.432777,0 -5.432777,-7.507111 0,-9.285112 9.383888,-31.60889 6.716889,-15.705666 12.347223,-26.077333 H 42.1884 L 32.014288,24.7503 q -8.494888,0 -14.816666,2.173111 -3.457223,1.185334 -5.531556,3.556 -1.9755555,2.271889 -1.9755555,5.531556 l 0.7902225,4.148667 q 0,0.987778 -0.987778,0.987778 -1.2841112,0 -2.0743334,-2.469445 -0.7902222,-2.568222 -0.7902222,-4.445 0,-4.642556 2.1731111,-7.902222 2.1731115,-3.259667 5.9266665,-4.938889 7.112,-3.160889 15.705667,-3.160889 8.593667,0 19.459222,1.086555 10.865556,0.987778 19.755556,0.987778 8.89,0 11.557,-2.765778 0.296334,0.691445 0.296334,1.382889 0,3.259667 -4.148667,5.235223 -4.148667,1.876777 -10.964334,1.876777 z" | ||||
|          style="fill:%COLOR_BLUE%;fill-opacity:1;stroke-width:0.26458332" | ||||
|          id="path841" /> | ||||
|       <path | ||||
|          d="m 83.240932,73.360961 q -8.938683,15.043151 -15.115823,15.043151 -4.287661,0 -4.287661,-4.941711 0,-5.741106 4.57835,-16.351251 1.017412,-2.688873 1.017412,-3.706284 0,-1.453444 -1.308101,-1.453444 -0.872066,0 -2.979561,1.017411 -2.034822,1.017411 -3.9243,2.979561 -1.889478,1.96215 -3.197578,4.57835 -1.235428,2.543528 -2.034822,4.433006 -0.726723,1.889478 -1.526117,4.651023 -0.799394,2.688872 -1.453445,4.433005 -0.581377,1.671462 -1.235427,3.924301 h -7.049206 q 1.96215,-5.014384 5.595761,-15.98789 3.706284,-11.046178 6.322484,-17.29599 2.6162,-6.322484 5.087056,-10.610145 2.543528,-4.360333 5.741105,-7.267222 3.270251,-2.906889 6.83119,-2.906889 3.633611,0 3.633611,3.415594 0,2.398184 -1.96215,6.322484 -4.214989,8.284634 -14.098412,19.185467 3.9243,-2.834216 6.177139,-3.706283 2.325511,-0.872067 4.433006,-0.872067 4.142317,0 4.142317,3.633611 0,1.453445 -0.508706,2.688873 -0.508705,1.235428 -1.162755,2.834217 -0.581378,1.598789 -1.017411,2.6162 -0.363362,1.017411 -1.017412,2.761544 -0.65405,1.744134 -1.017411,2.979562 -0.944739,3.052233 -0.944739,4.869039 0,1.816805 1.235428,1.816805 4.360334,0 10.900834,-12.644967 0.218017,-0.508705 0.508706,-0.508705 0.363361,0 0.363361,1.380772 0,1.3081 -0.726723,2.688872 z M 74.447593,35.716748 q -2.543528,0 -6.685845,8.066617 -4.069644,8.066617 -7.993944,18.531418 9.956094,-11.700229 13.589706,-19.185468 2.034822,-4.57835 2.034822,-5.959122 0,-1.453445 -0.944739,-1.453445 z" | ||||
|          style="font-size:72.67222595px;fill:%COLOR_BLUE%;fill-opacity:1" | ||||
|          id="path843" /> | ||||
|       <path | ||||
|          d="m 100.06455,58.753844 3.70629,0.07267 h 0.72672 q -1.09009,0.799394 -3.12491,4.723695 -1.962148,3.9243 -2.979559,5.959122 -0.944739,1.96215 -2.180167,5.377745 -1.162756,3.342922 -1.162756,5.450417 0,2.107494 1.453445,2.107494 0.944739,-0.07267 2.6162,-1.3081 1.744137,-1.3081 2.834217,-2.688872 1.16276,-1.453445 2.47086,-3.633611 1.38077,-2.252839 2.32551,-3.851628 1.01741,-1.671461 1.16275,-1.671461 0.50871,0 0.50871,1.380772 0,1.3081 -0.72672,2.543528 -3.12491,5.159728 -5.15973,7.921272 -2.03482,2.761545 -4.941714,5.087056 -2.834217,2.252839 -5.159728,2.252839 -4.142317,0 -4.142317,-6.104467 0,-4.142317 1.598789,-9.374717 1.598789,-5.2324 4.723695,-10.101439 -2.543528,2.034822 -5.087056,2.034822 -0.799394,0 -1.671461,-0.363361 -5.159728,10.174111 -5.595762,10.174111 -0.363361,0 -0.363361,-1.162755 0,-1.235428 0.872067,-3.342923 0.944739,-2.180166 1.96215,-4.142316 1.090084,-1.96215 1.162756,-2.180167 -2.6162,-1.3081 -2.6162,-4.214989 0,-1.453445 0.872066,-2.6162 0.944739,-1.162756 2.543528,-1.162756 1.598789,0 2.6162,1.017411 -0.145344,0.436033 -0.145344,1.3081 0,1.96215 1.380772,3.124906 1.453445,1.090083 3.488267,1.090083 0.581378,0 0.872067,-0.07267 2.543528,-3.633611 5.159726,-3.633611 z" | ||||
|          style="font-size:72.67222595px;fill:%COLOR_BLUE%;fill-opacity:1" | ||||
|          id="path845" /> | ||||
|       <path | ||||
|          d="m 134.07062,69.0733 q 0.43603,0 0.43603,1.453444 0,1.380773 -1.16276,3.342923 -1.16275,1.96215 -2.32551,3.706283 -1.09008,1.744134 -2.97956,4.069645 -1.88948,2.325511 -3.70628,3.706283 -4.57835,3.342923 -9.22938,3.342923 -4.57835,0 -7.41256,-2.325511 -2.83422,-2.398184 -2.83422,-7.267223 0,-7.412567 5.45042,-13.735051 5.45041,-6.322483 12.71764,-6.322483 3.27025,0 5.2324,1.744133 1.96215,1.671461 1.96215,4.069645 0,4.069644 -3.85163,6.5405 -3.85163,2.470856 -9.08403,2.470856 -3.05223,0 -4.50568,-0.799395 -1.38077,3.706284 -1.38077,6.540501 0,6.104467 5.15973,6.104467 3.05223,0 6.24981,-2.325512 3.27025,-2.398183 5.52309,-5.305072 2.32551,-2.979561 5.2324,-8.502651 0.29069,-0.508705 0.50871,-0.508705 z m -7.70326,-5.741106 q 0,-2.107495 -2.03482,-2.107495 -2.83422,0 -6.32249,3.488267 -3.48826,3.488267 -3.48826,5.668434 0,1.453444 2.76154,1.453444 2.76155,0 5.74111,-2.688872 3.05223,-2.688872 3.34292,-5.813778 z" | ||||
|          style="font-size:72.67222595px;fill:%COLOR_BLUE%;fill-opacity:1" | ||||
|          id="path847" /> | ||||
|       <path | ||||
|          d="m 160.69931,58.899188 3.63361,0.07267 h 0.72672 q -2.39818,1.962151 -5.88645,9.956095 -3.48827,7.921273 -3.48827,11.554884 0,1.889478 1.3081,1.889478 4.21499,0 10.75549,-12.42695 0.29069,-0.508706 0.50871,-0.508706 0.43603,0 0.43603,1.380772 0,1.3081 -0.72672,2.543528 -8.93869,15.043151 -14.97048,15.043151 -3.9243,0 -3.9243,-6.031795 0,-4.505678 1.52612,-8.866011 -8.28464,14.679789 -15.33384,14.679789 -4.86904,0 -4.86904,-6.685844 0,-5.087056 2.18016,-10.319457 2.25284,-5.2324 6.39516,-8.866011 4.14232,-3.706284 9.08403,-3.706284 2.32551,0 4.36033,1.090084 2.03482,1.017411 2.68887,3.342922 0.7994,-1.671461 2.47086,-2.906889 1.67146,-1.235428 3.12491,-1.235428 z m -6.0318,4.796367 q -0.72672,-2.325511 -3.41559,-2.325511 -4.79637,0 -9.30205,6.685845 -4.50568,6.613172 -4.50568,11.845573 0,2.252839 1.74414,2.252839 1.88947,0 4.433,-2.325512 2.54353,-2.398183 4.57835,-5.595761 4.50568,-7.121878 6.46783,-10.537473 z" | ||||
|          style="font-size:72.67222595px;fill:%COLOR_BLUE%;fill-opacity:1" | ||||
|          id="path849" /> | ||||
|       <path | ||||
|          d="m 206.38516,40.440443 q -2.39819,1.744133 -6.32249,9.81075 -3.85162,7.993945 -6.83119,16.932629 -2.97956,8.866012 -2.97956,12.862984 0,2.325511 1.23543,2.325511 1.23543,0 3.05223,-1.598789 1.88948,-1.598789 2.68888,-2.688872 0.79939,-1.090084 2.10749,-3.27025 1.38077,-2.252839 2.32551,-3.851628 1.01741,-1.671461 1.16276,-1.671461 0.5087,0 0.5087,1.380772 -0.0727,2.543528 -6.03179,10.610145 -1.96215,2.6162 -4.7237,4.869039 -2.76154,2.252839 -5.01438,2.252839 -3.85163,0 -3.85163,-5.741106 0,-4.214989 2.1075,-10.828162 -3.34293,6.613173 -7.63059,11.482212 -4.28766,4.869039 -8.57532,4.869039 -4.86904,0 -4.86904,-6.685844 0,-5.087056 2.18017,-10.319457 2.25284,-5.2324 6.39515,-8.866011 4.14232,-3.706284 8.93869,-3.706284 4.86904,0 6.68584,3.197578 2.03482,-3.996972 4.14232,-9.229372 2.18017,-5.232401 2.83422,-6.758517 0.72672,-1.598789 1.01741,-2.325512 0.36336,-0.726722 0.87206,-1.380772 0.7994,-1.017411 1.88948,-1.235428 1.16276,-0.290689 3.63361,-0.290689 2.54353,-0.07267 3.05224,-0.145344 z m -17.8047,22.237701 q -0.87207,-1.3081 -2.90689,-1.3081 -3.27025,0 -6.61317,3.342922 -3.27025,3.27025 -5.30507,7.630584 -1.96215,4.287661 -1.96215,7.557912 0,2.252839 1.74413,2.252839 5.88645,0 15.04315,-19.476157 z" | ||||
|          style="font-size:72.67222595px;fill:%COLOR_BLUE%;fill-opacity:1" | ||||
|          id="path851" /> | ||||
|       <path | ||||
|          d="m 250.30984,40.077082 q 3.41559,2.761544 3.41559,7.049206 0,4.287661 -2.54352,7.557911 -4.50568,5.959123 -13.8804,7.703256 l -2.32551,0.436033 q 3.63361,12.644968 15.26117,24.999246 -1.09009,0.218017 -3.34292,0.218017 l -5.59577,-0.218017 q -2.90688,0 -6.17714,-5.668433 -3.19757,-5.741106 -5.08705,-11.627557 -1.88948,-5.959122 -1.88948,-7.921272 0,-1.090084 1.01741,-1.162756 17.07798,-2.107494 17.58668,-13.371689 0,-3.996973 -2.39818,-6.17714 -2.32551,-2.180166 -7.70326,-2.470855 -5.66843,7.630583 -12.57229,24.417868 -6.83119,17.077973 -6.83119,22.52839 0,1.090083 0.29069,1.598789 h -9.37472 q 2.03482,-8.79334 8.57532,-24.417868 8.72067,-20.784257 11.2642,-23.618474 -6.17714,0.145345 -12.5723,2.543528 -6.32248,2.325511 -7.33989,5.595762 -0.0727,0.218016 -0.29069,0.218016 -0.65405,0 -0.65405,-0.944739 0,-0.145344 0.14534,-0.726722 1.38077,-3.996972 3.99697,-6.177139 2.6162,-2.180167 5.08706,-2.688873 2.54353,-0.508705 6.24981,-0.508705 h 17.65935 q 6.68585,0 10.02877,2.834217 z" | ||||
|          style="font-size:72.67222595px;fill:%COLOR_BLUE%;fill-opacity:1" | ||||
|          id="path853" /> | ||||
|     </g> | ||||
|   </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 17 KiB | 
|  | @ -0,0 +1,249 @@ | |||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||
| 
 | ||||
| <svg | ||||
|    xmlns:dc="http://purl.org/dc/elements/1.1/" | ||||
|    xmlns:cc="http://creativecommons.org/ns#" | ||||
|    xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:xlink="http://www.w3.org/1999/xlink" | ||||
|    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||
|    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||
|    width="97.333481mm" | ||||
|    height="95.154205mm" | ||||
|    viewBox="0 0 97.333492 95.154203" | ||||
|    version="1.1" | ||||
|    id="svg8" | ||||
|    inkscape:version="0.92.3 (2405546, 2018-03-11)" | ||||
|    sodipodi:docname="ThreadR_Home.svg"> | ||||
|   <defs | ||||
|      id="defs2"> | ||||
|     <linearGradient | ||||
|        inkscape:collect="always" | ||||
|        id="linearGradient1092"> | ||||
|       <stop | ||||
|          style="stop-color:#a9dfff;stop-opacity:1;" | ||||
|          offset="0" | ||||
|          id="stop1088" /> | ||||
|       <stop | ||||
|          style="stop-color:#a9dfff;stop-opacity:0;" | ||||
|          offset="1" | ||||
|          id="stop1090" /> | ||||
|     </linearGradient> | ||||
|     <linearGradient | ||||
|        inkscape:collect="always" | ||||
|        id="linearGradient1060"> | ||||
|       <stop | ||||
|          style="stop-color:#766fff;stop-opacity:1;" | ||||
|          offset="0" | ||||
|          id="stop1056" /> | ||||
|       <stop | ||||
|          style="stop-color:#766fff;stop-opacity:0;" | ||||
|          offset="1" | ||||
|          id="stop1058" /> | ||||
|     </linearGradient> | ||||
|     <linearGradient | ||||
|        inkscape:collect="always" | ||||
|        xlink:href="#linearGradient1060" | ||||
|        id="linearGradient1062" | ||||
|        x1="30.859402" | ||||
|        y1="95.286171" | ||||
|        x2="146.35486" | ||||
|        y2="95.286171" | ||||
|        gradientUnits="userSpaceOnUse" | ||||
|        gradientTransform="matrix(0.84479197,0,0,0.98143437,4.8113177,5.9391634)" /> | ||||
|     <linearGradient | ||||
|        inkscape:collect="always" | ||||
|        xlink:href="#linearGradient1092" | ||||
|        id="linearGradient1094" | ||||
|        x1="-144.12688" | ||||
|        y1="82.6875" | ||||
|        x2="-39.544453" | ||||
|        y2="82.6875" | ||||
|        gradientUnits="userSpaceOnUse" | ||||
|        gradientTransform="matrix(0.93308299,0,0,0.96280088,6.0243672,19.844684)" /> | ||||
|   </defs> | ||||
|   <sodipodi:namedview | ||||
|      id="base" | ||||
|      pagecolor="#ffffff" | ||||
|      bordercolor="#666666" | ||||
|      borderopacity="1.0" | ||||
|      inkscape:pageopacity="0.0" | ||||
|      inkscape:pageshadow="2" | ||||
|      inkscape:zoom="0.5" | ||||
|      inkscape:cx="207.39258" | ||||
|      inkscape:cy="-260.45274" | ||||
|      inkscape:document-units="mm" | ||||
|      inkscape:current-layer="layer3" | ||||
|      showgrid="false" | ||||
|      inkscape:measure-start="0,0" | ||||
|      inkscape:measure-end="0,0" | ||||
|      inkscape:window-width="1680" | ||||
|      inkscape:window-height="997" | ||||
|      inkscape:window-x="0" | ||||
|      inkscape:window-y="1" | ||||
|      inkscape:window-maximized="1" /> | ||||
|   <metadata | ||||
|      id="metadata5"> | ||||
|     <rdf:RDF> | ||||
|       <cc:Work | ||||
|          rdf:about=""> | ||||
|         <dc:format>image/svg+xml</dc:format> | ||||
|         <dc:type | ||||
|            rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> | ||||
|         <dc:title></dc:title> | ||||
|       </cc:Work> | ||||
|     </rdf:RDF> | ||||
|   </metadata> | ||||
|   <g | ||||
|      inkscape:groupmode="layer" | ||||
|      id="layer5" | ||||
|      inkscape:label="background" | ||||
|      transform="translate(-30.999176,-96.114479)"> | ||||
|     <rect | ||||
|        style="display:inline;opacity:1;fill:#f9f9f9;fill-opacity:1;stroke:none;stroke-width:0.48078543;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect1103" | ||||
|        width="97.342438" | ||||
|        height="95.154205" | ||||
|        x="30.990202" | ||||
|        y="96.114479" /> | ||||
|   </g> | ||||
|   <g | ||||
|      inkscape:groupmode="layer" | ||||
|      id="layer3" | ||||
|      inkscape:label="window" | ||||
|      style="display:inline" | ||||
|      transform="translate(-30.999176,-96.114479)"> | ||||
|     <rect | ||||
|        style="fill:#e6e6e6;stroke:none;stroke-width:0.47835484;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect1021" | ||||
|        width="1.0631751" | ||||
|        height="88.470581" | ||||
|        x="30.999176" | ||||
|        y="102.7981" /> | ||||
|     <path | ||||
|        style="fill:#e6e6e6;stroke:none;stroke-width:0.48693055;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        d="m 127.23097,102.7981 v 88.47058 h 1.10168 V 102.7981 Z" | ||||
|        id="rect1021-9" | ||||
|        inkscape:connector-curvature="0" /> | ||||
|     <rect | ||||
|        style="fill:#e6e6e6;stroke:none;stroke-width:0.32450849;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect909-6-2-9-2" | ||||
|        width="95.168625" | ||||
|        height="5.0737643" | ||||
|        x="32.062351" | ||||
|        y="186.19492" | ||||
|        ry="0" /> | ||||
|     <rect | ||||
|        style="opacity:1;fill:url(#linearGradient1094);fill-opacity:1;stroke:none;stroke-width:0.32264262;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect1023-6" | ||||
|        width="97.333473" | ||||
|        height="6.6836185" | ||||
|        x="-128.33266" | ||||
|        y="96.114479" | ||||
|        transform="scale(-1,1)" /> | ||||
|     <rect | ||||
|        style="display:inline;opacity:1;fill:url(#linearGradient1062);fill-opacity:1;stroke:none;stroke-width:0.32577717;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect1023" | ||||
|        width="97.333466" | ||||
|        height="6.6836243" | ||||
|        x="30.999176" | ||||
|        y="96.114479" /> | ||||
|   </g> | ||||
|   <g | ||||
|      inkscape:label="contents" | ||||
|      inkscape:groupmode="layer" | ||||
|      id="layer1" | ||||
|      style="display:inline" | ||||
|      transform="translate(-30.999176,-96.114479)"> | ||||
|     <path | ||||
|        style="fill:#e6e6e6;stroke:none;stroke-width:0.46272928;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        d="m 33.365806,121.08017 4.989638,-12.31832 c 1.088722,-2.58288 1.758941,-2.58288 5.171142,-2.58288 H 111.3019 c 3.41221,0 4.08244,0 5.17115,2.58288 l 4.98963,12.31833 z" | ||||
|        id="path817" | ||||
|        inkscape:connector-curvature="0" | ||||
|        sodipodi:nodetypes="ccccccc" /> | ||||
|     <rect | ||||
|        style="fill:%COLOR_CYAN%;fill-opacity:1;stroke:none;stroke-width:0.27960116;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect909" | ||||
|        width="91.2556" | ||||
|        height="15.640329" | ||||
|        x="32.062351" | ||||
|        y="121.10081" | ||||
|        ry="0" /> | ||||
|     <rect | ||||
|        style="fill:%COLOR_ORANGE%;fill-opacity:1;stroke:none;stroke-width:0.24522424;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect909-5" | ||||
|        width="91.255592" | ||||
|        height="9.9996996" | ||||
|        x="32.062351" | ||||
|        y="136.74113" | ||||
|        ry="0" /> | ||||
|     <rect | ||||
|        style="fill:%COLOR_PINK%;fill-opacity:1;stroke:none;stroke-width:0.31609729;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect909-6-2-9" | ||||
|        width="91.2556" | ||||
|        height="10.471648" | ||||
|        x="32.062351" | ||||
|        y="175.72327" | ||||
|        ry="0" /> | ||||
|     <rect | ||||
|        style="fill:%COLOR_BEIGE%;fill-opacity:1;stroke:none;stroke-width:0.31184855;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect909-6-2-1" | ||||
|        width="91.255592" | ||||
|        height="28.982441" | ||||
|        x="32.062351" | ||||
|        y="146.74083" | ||||
|        ry="0" /> | ||||
|     <rect | ||||
|        style="fill:#ececec;stroke:none;stroke-width:0.48659384;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect983" | ||||
|        width="3.9130244" | ||||
|        height="65.094109" | ||||
|        x="123.31795" | ||||
|        y="121.10081" /> | ||||
|     <rect | ||||
|        style="fill:#b3b3b3;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect983-7" | ||||
|        width="2.182373" | ||||
|        height="45.629673" | ||||
|        x="124.17254" | ||||
|        y="125.91673" | ||||
|        ry="1.1820796" | ||||
|        rx="1.0911865" /> | ||||
|     <rect | ||||
|        style="fill:#b3b3b3;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect1002" | ||||
|        width="0.46817124" | ||||
|        height="0.48868293" | ||||
|        x="144.64522" | ||||
|        y="120.98614" | ||||
|        rx="1.5645972" | ||||
|        ry="0" /> | ||||
|     <rect | ||||
|        style="fill:#b3b3b3;stroke:none;stroke-width:0.5291667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | ||||
|        id="rect1002-0" | ||||
|        width="0.46817127" | ||||
|        height="0.48868293" | ||||
|        x="141.20036" | ||||
|        y="120.98614" | ||||
|        rx="1.5645972" | ||||
|        ry="0" /> | ||||
|   </g> | ||||
|   <g | ||||
|      inkscape:groupmode="layer" | ||||
|      id="layer4" | ||||
|      inkscape:label="ThreadR" | ||||
|      transform="translate(-0.00897398,-4.1053392)"> | ||||
|     <g | ||||
|        aria-label="ThreadR" | ||||
|        style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:98.77777863px;line-height:1.25;font-family:Playball;-inkscape-font-specification:Playball;letter-spacing:0px;word-spacing:0px;fill:%COLOR_BLUE%;fill-opacity:1;stroke:none;stroke-width:0.26458332" | ||||
|        id="text840"> | ||||
|       <path | ||||
|          d="M 66.388955,26.034411 52.461289,25.441745 q -0.987778,0 -6.519334,11.853333 -5.531555,11.754556 -9.581444,22.817667 -4.049889,11.063111 -4.049889,16.002 0,4.840111 3.062111,4.840111 3.160889,0 7.902222,-6.420555 0.493889,-0.592667 0.987778,-0.592667 0.493889,0 0.493889,0.889 0,0.889 -1.382889,3.259667 -1.284111,2.370666 -3.457222,5.136444 -2.173111,2.667 -5.630334,4.741334 -3.457222,2.173111 -7.112,2.173111 -5.432777,0 -5.432777,-7.507111 0,-9.285112 9.383888,-31.60889 6.716889,-15.705666 12.347223,-26.077333 H 42.1884 L 32.014288,24.7503 q -8.494888,0 -14.816666,2.173111 -3.457223,1.185334 -5.531556,3.556 -1.9755555,2.271889 -1.9755555,5.531556 l 0.7902225,4.148667 q 0,0.987778 -0.987778,0.987778 -1.2841112,0 -2.0743334,-2.469445 -0.7902222,-2.568222 -0.7902222,-4.445 0,-4.642556 2.1731111,-7.902222 2.1731115,-3.259667 5.9266665,-4.938889 7.112,-3.160889 15.705667,-3.160889 8.593667,0 19.459222,1.086555 10.865556,0.987778 19.755556,0.987778 8.89,0 11.557,-2.765778 0.296334,0.691445 0.296334,1.382889 0,3.259667 -4.148667,5.235223 -4.148667,1.876777 -10.964334,1.876777 z" | ||||
|          style="fill:%COLOR_BLUE%;fill-opacity:1;stroke-width:0.26458332" | ||||
|          id="path841" | ||||
|          inkscape:connector-curvature="0" /> | ||||
|     </g> | ||||
|   </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 9.4 KiB | 
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 752 B | 
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1.3 KiB | 
|  | @ -0,0 +1,371 @@ | |||
| body { | ||||
|     font-family: monospace; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
|     background-color: #fef6e4; /* beige */ | ||||
|     color: #001858; /* blue */ | ||||
| } | ||||
| 
 | ||||
| main { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     padding: 25px; | ||||
| } | ||||
| 
 | ||||
| main > header { | ||||
|     text-align: center; | ||||
|     margin-bottom: 1em; | ||||
| } | ||||
| 
 | ||||
| main > section { | ||||
|     margin: 1em; | ||||
|     padding: 14px 20px; | ||||
|     border: 1px solid #001858; | ||||
|     border-radius: 5px; | ||||
|     background-color: #f3d2c1; /* orange */ | ||||
|     box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); | ||||
|     width: 80%; | ||||
|     max-width: 800px; | ||||
| } | ||||
| 
 | ||||
| main > div { | ||||
|     width: 80%; | ||||
|     max-width: 800px; | ||||
| } | ||||
| 
 | ||||
| main > div > article { | ||||
|     border: 1px solid #001858; | ||||
|     padding: 14px 20px; | ||||
|     margin-bottom: 1em; | ||||
|     background-color: #fef6e4; | ||||
|     border-radius: 5px; | ||||
|     box-shadow: inset 0px 8px 16px 0px rgba(0,0,0,0.2); | ||||
|     transition: transform 0.2s ease, box-shadow 0.2s ease; | ||||
| } | ||||
| 
 | ||||
| main > div > article:hover { | ||||
|     transform: translateY(-2px); | ||||
|     box-shadow: 0px 4px 12px 0px rgba(0,0,0,0.15); | ||||
| } | ||||
| 
 | ||||
| article > header { | ||||
|     border-bottom: 1px solid #001858; | ||||
|     background-color: #001858; | ||||
|     color: #fef6e4; | ||||
|     padding: 0.5em; | ||||
|     margin: -14px -20px 1em -20px; | ||||
|     border-radius: 5px 5px 0 0; | ||||
|     box-shadow: 0px 4px 8px 0px rgba(0,0,0,0.2); | ||||
| } | ||||
| 
 | ||||
| ul.topnav { | ||||
|     list-style-type: none; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
|     overflow: hidden; | ||||
|     background-color: #001858; | ||||
|     position: fixed; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     width: 100%; | ||||
|     box-shadow: 0 0.7em 1.2em 0 rgba(0,0,0,0.2); | ||||
| } | ||||
| 
 | ||||
| ul.topnav li { | ||||
|     float: left; | ||||
| } | ||||
| 
 | ||||
| ul.topnav li a { | ||||
|     display: block; | ||||
|     color: #fef6e4; | ||||
|     text-align: center; | ||||
|     padding: 1.2em 1.3em; | ||||
|     text-decoration: none; | ||||
|     font-family: monospace; | ||||
|     font-size: 1em; | ||||
| } | ||||
| 
 | ||||
| ul.topnav li a:hover:not(.active) { | ||||
|     background-color: #8bd3dd; /* cyan */ | ||||
| } | ||||
| 
 | ||||
| ul.topnav li a.active { | ||||
|     background-color: #f582ae; /* pink */ | ||||
| } | ||||
| 
 | ||||
| div.topnav { | ||||
|     height: 3em; | ||||
| } | ||||
| 
 | ||||
| div.banner { | ||||
|     position: fixed; | ||||
|     bottom: 0; | ||||
|     left: 0; | ||||
|     width: 100%; | ||||
|     background-color: #001858; | ||||
|     padding: 10px; | ||||
|     text-align: center; | ||||
| } | ||||
| 
 | ||||
| div.banner p { | ||||
|     color: #fef6e4; | ||||
|     margin: 0; | ||||
| } | ||||
| 
 | ||||
| div.banner a { | ||||
|     color: #8bd3dd; | ||||
| } | ||||
| 
 | ||||
| form { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
| 
 | ||||
| input, textarea, select { | ||||
|     margin: 8px 0; | ||||
|     padding: 14px 20px; | ||||
|     border: 1px solid #001858; | ||||
|     border-radius: 4px; | ||||
|     background-color: #fef6e4; | ||||
|     color: #001858; | ||||
|     font-family: monospace; | ||||
|     box-sizing: border-box; | ||||
|     box-shadow: inset 0px 8px 16px 0px rgba(0,0,0,0.2); | ||||
| } | ||||
| 
 | ||||
| input[type="submit"] { | ||||
|     background-color: #001858; | ||||
|     color: #fef6e4; | ||||
|     border: none; | ||||
|     cursor: pointer; | ||||
| } | ||||
| 
 | ||||
| input[type="submit"]:hover { | ||||
|     background-color: #8bd3dd; | ||||
| } | ||||
| 
 | ||||
| button { | ||||
|     margin: 8px 0; | ||||
|     padding: 14px 20px; | ||||
|     border: none; | ||||
|     border-radius: 4px; | ||||
|     background-color: #001858; | ||||
|     color: #fef6e4; | ||||
|     cursor: pointer; | ||||
|     font-family: monospace; | ||||
|     width: 100%; | ||||
| } | ||||
| 
 | ||||
| button:hover { | ||||
|     background-color: #8bd3dd; | ||||
| } | ||||
| 
 | ||||
| img { | ||||
|     max-width: 100%; | ||||
|     object-fit: contain; | ||||
| } | ||||
| 
 | ||||
| h1, h2, h3, h4, h5, h6 { | ||||
|     font-family: monospace; | ||||
|     color: #001858; | ||||
| } | ||||
| 
 | ||||
| p, a, li { | ||||
|     font-family: monospace; | ||||
|     color: #001858; | ||||
| } | ||||
| 
 | ||||
| /* Enhanced styles for boards */ | ||||
| ul.board-list { | ||||
|     list-style-type: none; | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
| } | ||||
| 
 | ||||
| li.board-item { | ||||
|     margin-bottom: 1em; | ||||
|     padding: 1em; | ||||
|     background-color: #fef6e4; | ||||
|     border: 1px solid #001858; | ||||
|     border-radius: 8px; | ||||
|     transition: transform 0.2s ease, box-shadow 0.2s ease; | ||||
| } | ||||
| 
 | ||||
| li.board-item:hover { | ||||
|     transform: translateY(-3px); | ||||
|     box-shadow: 0px 6px 14px 0px rgba(0,0,0,0.15); | ||||
| } | ||||
| 
 | ||||
| li.board-item a { | ||||
|     color: #001858; | ||||
|     font-weight: bold; | ||||
|     text-decoration: none; | ||||
|     font-size: 1.2em; | ||||
| } | ||||
| 
 | ||||
| li.board-item a:hover { | ||||
|     color: #f582ae; | ||||
|     text-decoration: underline; | ||||
| } | ||||
| 
 | ||||
| p.board-desc { | ||||
|     margin: 0.5em 0 0 0; | ||||
|     color: #001858; | ||||
|     font-size: 0.9em; | ||||
| } | ||||
| 
 | ||||
| /* Enhanced styles for thread posts */ | ||||
| .thread-posts { | ||||
|     width: 80%; | ||||
|     max-width: 800px; | ||||
| } | ||||
| 
 | ||||
| .post-item { | ||||
|     background-color: #fef6e4; | ||||
|     border: 1px solid #001858; | ||||
|     border-radius: 8px; | ||||
|     margin-bottom: 1.5em; | ||||
|     padding: 1em; | ||||
|     transition: transform 0.2s ease, box-shadow 0.2s ease; | ||||
| } | ||||
| 
 | ||||
| .post-item:hover { | ||||
|     transform: translateY(-3px); | ||||
|     box-shadow: 0px 6px 14px 0px rgba(0,0,0,0.15); | ||||
| } | ||||
| 
 | ||||
| .post-item header { | ||||
|     background-color: #001858; | ||||
|     color: #fef6e4; | ||||
|     padding: 0.5em; | ||||
|     margin: -1em -1em 1em -1em; | ||||
|     border-radius: 6px 6px 0 0; | ||||
|     border-bottom: 1px solid #001858; | ||||
| } | ||||
| 
 | ||||
| .post-item header h3 { | ||||
|     margin: 0; | ||||
|     font-size: 1.1em; | ||||
| } | ||||
| 
 | ||||
| .post-item header p { | ||||
|     margin: 0.3em 0 0 0; | ||||
|     font-size: 0.85em; | ||||
|     opacity: 0.9; | ||||
| } | ||||
| 
 | ||||
| .post-content { | ||||
|     margin: 0; | ||||
|     padding: 0.5em; | ||||
|     line-height: 1.5; | ||||
|     font-size: 0.95em; | ||||
| } | ||||
| 
 | ||||
| .post-actions { | ||||
|     margin-top: 0.8em; | ||||
|     display: flex; | ||||
|     gap: 0.5em; | ||||
|     align-items: center; | ||||
| } | ||||
| 
 | ||||
| .post-actions a { | ||||
|     color: #001858; | ||||
|     text-decoration: none; | ||||
|     font-size: 0.9em; | ||||
|     padding: 0.3em 0.6em; | ||||
|     border: 1px solid #001858; | ||||
|     border-radius: 4px; | ||||
|     transition: background-color 0.2s ease; | ||||
| } | ||||
| 
 | ||||
| .post-actions a:hover { | ||||
|     background-color: #8bd3dd; | ||||
|     color: #fef6e4; | ||||
| } | ||||
| 
 | ||||
| @media (prefers-color-scheme: dark) { | ||||
|     body { | ||||
|         background-color: #333; | ||||
|         color: #fef6e4; | ||||
|     } | ||||
|     main > section { | ||||
|         background-color: #555; | ||||
|         border-color: #fef6e4; | ||||
|     } | ||||
|     main > div > article { | ||||
|         background-color: #444; | ||||
|         border-color: #fef6e4; | ||||
|     } | ||||
|     article > header { | ||||
|         background-color: #222; | ||||
|         border-color: #fef6e4; | ||||
|     } | ||||
|     input, textarea, select { | ||||
|         background-color: #444; | ||||
|         color: #fef6e4; | ||||
|         border-color: #fef6e4; | ||||
|     } | ||||
|     input[type="submit"], button { | ||||
|         background-color: #fef6e4; | ||||
|         color: #001858; | ||||
|     } | ||||
|     input[type="submit"]:hover, button:hover { | ||||
|         background-color: #8bd3dd; | ||||
|     } | ||||
|     li.board-item { | ||||
|         background-color: #444; | ||||
|         border-color: #fef6e4; | ||||
|     } | ||||
|     li.board-item a { | ||||
|         color: #fef6e4; | ||||
|     } | ||||
|     li.board-item a:hover { | ||||
|         color: #f582ae; | ||||
|     } | ||||
|     p.board-desc { | ||||
|         color: #fef6e4; | ||||
|     } | ||||
|     .post-item { | ||||
|         background-color: #444; | ||||
|         border-color: #fef6e4; | ||||
|     } | ||||
|     .post-content { | ||||
|         color: #fef6e4; | ||||
|     } | ||||
|     .post-actions a { | ||||
|         color: #fef6e4; | ||||
|         border-color: #fef6e4; | ||||
|     } | ||||
|     .post-actions a:hover { | ||||
|         background-color: #8bd3dd; | ||||
|         color: #001858; | ||||
|     } | ||||
|     h1, h2, h3, h4, h5, h6 { | ||||
|         color: #fef6e4; | ||||
|     } | ||||
|     p, a, li { | ||||
|         color: #fef6e4; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @media (max-width: 600px) { | ||||
|     ul.topnav li { | ||||
|         float: none; | ||||
|         width: 100%; | ||||
|     } | ||||
|     main { | ||||
|         padding: 10px; | ||||
|     } | ||||
|     main > section { | ||||
|         margin: 0.5em; | ||||
|         padding: 0.5em; | ||||
|         width: 95%; | ||||
|     } | ||||
|     main > div { | ||||
|         width: 95%; | ||||
|     } | ||||
|     .thread-posts { | ||||
|         width: 95%; | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,16 @@ | |||
| {{define "base"}} | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|     <title>{{.Title}}</title> | ||||
|     <link rel="stylesheet" href="{{.StaticPath}}/style.css"> | ||||
| </head> | ||||
| <body> | ||||
|     {{template "navbar" .}} | ||||
|     <main> | ||||
|         {{block "content" .}}{{end}}  <!-- Define a block for content --> | ||||
|     </main> | ||||
|     {{template "cookie_banner" .}} | ||||
| </body> | ||||
| </html> | ||||
| {{end}} | ||||
|  | @ -0,0 +1,14 @@ | |||
| {{define "about"}} | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|     <title>{{.Title}}</title> | ||||
|     <link rel="stylesheet" href="{{.StaticPath}}/style.css"> | ||||
| </head> | ||||
| <body> | ||||
|     {{template "navbar" .}} | ||||
|     {{.AboutContent}} | ||||
|     {{template "cookie_banner" .}} | ||||
| </body> | ||||
| </html> | ||||
| {{end}} | ||||
|  | @ -0,0 +1,41 @@ | |||
| {{define "board"}} | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|     <title>{{.Title}}</title> | ||||
|     <link rel="stylesheet" href="{{.StaticPath}}/style.css"> | ||||
| </head> | ||||
| <body> | ||||
|     {{template "navbar" .}} | ||||
|     <main> | ||||
|         <header> | ||||
|             <h2>{{.Board.Name}}</h2> | ||||
|             <p>{{.Board.Description}}</p> | ||||
|         </header> | ||||
|         <section> | ||||
|             <h3>Threads</h3> | ||||
|             {{if .Threads}} | ||||
|             <ul> | ||||
|                 {{range .Threads}} | ||||
|                 <li><a href="{{$.BasePath}}/thread/?id={{.ID}}">{{.Title}}</a> - Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}</li> | ||||
|                 {{end}} | ||||
|             </ul> | ||||
|             {{else}} | ||||
|             <p>No threads available in this board yet.</p> | ||||
|             {{end}} | ||||
|         </section> | ||||
|         {{if .LoggedIn}} | ||||
|         <section> | ||||
|             <h3>Create New Thread</h3> | ||||
|             <form method="post" action="{{.BasePath}}/board/?id={{.Board.ID}}&action=create_thread"> | ||||
|                 <label for="title">Thread Title:</label> | ||||
|                 <input type="text" id="title" name="title" required><br> | ||||
|                 <input type="submit" value="Create Thread"> | ||||
|             </form> | ||||
|         </section> | ||||
|         {{end}} | ||||
|     </main> | ||||
|     {{template "cookie_banner" .}} | ||||
| </body> | ||||
| </html> | ||||
| {{end}} | ||||
|  | @ -0,0 +1,62 @@ | |||
| {{define "boards"}} | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|     <title>{{.Title}}</title> | ||||
|     <link rel="stylesheet" href="{{.StaticPath}}/style.css"> | ||||
| </head> | ||||
| <body> | ||||
|     {{template "navbar" .}} | ||||
|     <main> | ||||
|         <header> | ||||
|             <h2>Boards</h2> | ||||
|         </header> | ||||
|         <section> | ||||
|             <h3>Public Boards</h3> | ||||
|             {{if .PublicBoards}} | ||||
|             <ul class="board-list"> | ||||
|                 {{range .PublicBoards}} | ||||
|                 <li class="board-item"> | ||||
|                     <a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a> | ||||
|                     <p class="board-desc">{{.Description}}</p> | ||||
|                 </li> | ||||
|                 {{end}} | ||||
|             </ul> | ||||
|             {{else}} | ||||
|             <p>No public boards available at the moment.</p> | ||||
|             {{end}} | ||||
|         </section> | ||||
|         {{if .LoggedIn}} | ||||
|         <section> | ||||
|             <h3>Private Boards</h3> | ||||
|             {{if .PrivateBoards}} | ||||
|             <ul class="board-list"> | ||||
|                 {{range .PrivateBoards}} | ||||
|                 <li class="board-item"> | ||||
|                     <a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a> | ||||
|                     <p class="board-desc">{{.Description}}</p> | ||||
|                 </li> | ||||
|                 {{end}} | ||||
|             </ul> | ||||
|             {{else}} | ||||
|             <p>No private boards available to you at the moment.</p> | ||||
|             {{end}} | ||||
|         </section> | ||||
|         {{end}} | ||||
|         {{if .IsAdmin}} | ||||
|         <section> | ||||
|             <h3>Create New Public Board</h3> | ||||
|             <form method="post" action="{{.BasePath}}/boards/"> | ||||
|                 <label for="name">Board Name:</label> | ||||
|                 <input type="text" id="name" name="name" required><br> | ||||
|                 <label for="description">Description:</label> | ||||
|                 <textarea id="description" name="description"></textarea><br> | ||||
|                 <input type="submit" value="Create Board"> | ||||
|             </form> | ||||
|         </section> | ||||
|         {{end}} | ||||
|     </main> | ||||
|     {{template "cookie_banner" .}} | ||||
| </body> | ||||
| </html> | ||||
| {{end}} | ||||
|  | @ -0,0 +1,435 @@ | |||
| {{define "chat"}} | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|     <title>{{.Title}}</title> | ||||
|     <link rel="stylesheet" href="{{.StaticPath}}/style.css"> | ||||
|     <style> | ||||
|         body { | ||||
|             margin: 0; | ||||
|             padding: 0; | ||||
|             height: 100vh; | ||||
|             overflow: hidden; | ||||
|         } | ||||
|         main { | ||||
|             padding: 0; | ||||
|             margin-top: 3em; /* Space for navbar */ | ||||
|             height: calc(100vh - 3em); | ||||
|             display: flex; | ||||
|             flex-direction: column; | ||||
|             align-items: center; | ||||
|         } | ||||
|         .chat-container { | ||||
|             width: 100%; | ||||
|             height: calc(100% - 2em); /* Adjust for header */ | ||||
|             display: flex; | ||||
|             flex-direction: column; | ||||
|             border: none; | ||||
|             border-radius: 0; | ||||
|             background-color: #fef6e4; | ||||
|             box-shadow: none; | ||||
|         } | ||||
|         .chat-messages { | ||||
|             flex: 1; | ||||
|             overflow-y: auto; | ||||
|             padding: 8px; | ||||
|             display: flex; | ||||
|             flex-direction: column; | ||||
|         } | ||||
|         .chat-message { | ||||
|             margin-bottom: 8px; | ||||
|             max-width: 90%; | ||||
|             position: relative; | ||||
|         } | ||||
|         .chat-message-header { | ||||
|             display: flex; | ||||
|             align-items: center; | ||||
|             margin-bottom: 3px; | ||||
|         } | ||||
|         .chat-message-pfp { | ||||
|             width: 30px; | ||||
|             height: 30px; | ||||
|             border-radius: 50%; | ||||
|             margin-right: 8px; | ||||
|         } | ||||
|         .chat-message-username { | ||||
|             font-weight: bold; | ||||
|             color: #001858; | ||||
|             font-size: 0.9em; | ||||
|         } | ||||
|         .chat-message-timestamp { | ||||
|             font-size: 0.7em; | ||||
|             color: #666; | ||||
|             margin-left: 8px; | ||||
|         } | ||||
|         .chat-message-content { | ||||
|             background-color: #f3d2c1; | ||||
|             padding: 6px 10px; | ||||
|             border-radius: 5px; | ||||
|             line-height: 1.3; | ||||
|             font-size: 0.9em; | ||||
|         } | ||||
|         .chat-message-reply { | ||||
|             background-color: rgba(0,0,0,0.1); | ||||
|             padding: 4px 8px; | ||||
|             border-radius: 5px; | ||||
|             margin-bottom: 3px; | ||||
|             font-size: 0.8em; | ||||
|             cursor: pointer; | ||||
|         } | ||||
|         .chat-message-mention { | ||||
|             color: #f582ae; | ||||
|             font-weight: bold; | ||||
|         } | ||||
|         .chat-input { | ||||
|             padding: 8px; | ||||
|             border-top: 1px solid #001858; | ||||
|             display: flex; | ||||
|             flex-direction: column; | ||||
|         } | ||||
|         .chat-input textarea { | ||||
|             resize: none; | ||||
|             height: 50px; | ||||
|             margin-bottom: 8px; | ||||
|             font-size: 0.9em; | ||||
|         } | ||||
|         .chat-input button { | ||||
|             align-self: flex-end; | ||||
|             width: auto; | ||||
|             padding: 6px 12px; | ||||
|             font-size: 0.9em; | ||||
|         } | ||||
|         .post-actions { | ||||
|             position: absolute; | ||||
|             top: 5px; | ||||
|             right: 5px; | ||||
|             opacity: 0; | ||||
|             transition: opacity 0.2s ease; | ||||
|         } | ||||
|         .chat-message:hover .post-actions { | ||||
|             opacity: 1; | ||||
|         } | ||||
|         .post-actions a { | ||||
|             color: #001858; | ||||
|             text-decoration: none; | ||||
|             font-size: 0.8em; | ||||
|             padding: 2px 5px; | ||||
|             border: 1px solid #001858; | ||||
|             border-radius: 3px; | ||||
|         } | ||||
|         .post-actions a:hover { | ||||
|             background-color: #8bd3dd; | ||||
|             color: #fef6e4; | ||||
|         } | ||||
|         .autocomplete-popup { | ||||
|             position: absolute; | ||||
|             background-color: #fff; | ||||
|             border: 1px solid #001858; | ||||
|             border-radius: 5px; | ||||
|             max-height: 200px; | ||||
|             overflow-y: auto; | ||||
|             box-shadow: 0px 4px 8px rgba(0,0,0,0.2); | ||||
|             z-index: 1000; | ||||
|             display: none; | ||||
|         } | ||||
|         .autocomplete-item { | ||||
|             padding: 6px 10px; | ||||
|             cursor: pointer; | ||||
|             font-size: 0.9em; | ||||
|         } | ||||
|         .autocomplete-item:hover { | ||||
|             background-color: #f3d2c1; | ||||
|         } | ||||
|         .reply-indicator { | ||||
|             background-color: #001858; | ||||
|             color: #fef6e4; | ||||
|             padding: 5px 10px; | ||||
|             border-radius: 5px; | ||||
|             margin-bottom: 8px; | ||||
|             display: none; | ||||
|             align-items: center; | ||||
|             justify-content: space-between; | ||||
|         } | ||||
|         .reply-indicator span { | ||||
|             font-size: 0.9em; | ||||
|         } | ||||
|         .reply-indicator button { | ||||
|             background: none; | ||||
|             border: none; | ||||
|             color: #fef6e4; | ||||
|             cursor: pointer; | ||||
|             font-size: 0.9em; | ||||
|             padding: 0 5px; | ||||
|             margin: 0; | ||||
|             width: auto; | ||||
|         } | ||||
|         .reply-indicator button:hover { | ||||
|             background: none; | ||||
|             color: #f582ae; | ||||
|         } | ||||
|         @media (prefers-color-scheme: dark) { | ||||
|             .chat-container { | ||||
|                 background-color: #444; | ||||
|                 border-color: #fef6e4; | ||||
|             } | ||||
|             .chat-message-username { | ||||
|                 color: #fef6e4; | ||||
|             } | ||||
|             .chat-message-timestamp { | ||||
|                 color: #aaa; | ||||
|             } | ||||
|             .chat-message-content { | ||||
|                 background-color: #555; | ||||
|             } | ||||
|             .chat-input { | ||||
|                 border-color: #fef6e4; | ||||
|             } | ||||
|             .autocomplete-popup { | ||||
|                 background-color: #444; | ||||
|                 border-color: #fef6e4; | ||||
|                 color: #fef6e4; | ||||
|             } | ||||
|             .autocomplete-item:hover { | ||||
|                 background-color: #555; | ||||
|             } | ||||
|             .post-actions a { | ||||
|                 color: #fef6e4; | ||||
|                 border-color: #fef6e4; | ||||
|             } | ||||
|             .post-actions a:hover { | ||||
|                 background-color: #8bd3dd; | ||||
|                 color: #001858; | ||||
|             } | ||||
|             .reply-indicator { | ||||
|                 background-color: #222; | ||||
|                 color: #fef6e4; | ||||
|             } | ||||
|             .reply-indicator button { | ||||
|                 color: #fef6e4; | ||||
|             } | ||||
|             .reply-indicator button:hover { | ||||
|                 color: #f582ae; | ||||
|             } | ||||
|         } | ||||
|     </style> | ||||
| </head> | ||||
| <body> | ||||
|     {{template "navbar" .}} | ||||
|     <main> | ||||
|         <header style="display: none;"> | ||||
|             <h2>General Chat</h2> | ||||
|         </header> | ||||
|         <div class="chat-container"> | ||||
|             <div class="chat-messages" id="chat-messages"> | ||||
|                 {{range .Messages}} | ||||
|                 <div class="chat-message" id="msg-{{.ID}}"> | ||||
|                     <div class="chat-message-header"> | ||||
|                         {{if .PfpURL}} | ||||
|                         <img src="{{.PfpURL}}" alt="PFP" class="chat-message-pfp"> | ||||
|                         {{else}} | ||||
|                         <div class="chat-message-pfp" style="background-color: #001858;"></div> | ||||
|                         {{end}} | ||||
|                         <span class="chat-message-username">{{.Username}}</span> | ||||
|                         <span class="chat-message-timestamp">{{.Timestamp.Format "02/01/2006 15:04"}}</span> | ||||
|                     </div> | ||||
|                     {{if gt .ReplyTo 0}} | ||||
|                     <div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to {{.Username}}</div> | ||||
|                     {{end}} | ||||
|                     <div class="chat-message-content">{{.Content | html}}</div> | ||||
|                     <div class="post-actions"> | ||||
|                         <a href="javascript:void(0)" onclick="replyToMessage({{.ID}}, '{{.Username}}')">Reply</a> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 {{end}} | ||||
|             </div> | ||||
|             <div class="chat-input"> | ||||
|                 <div id="reply-indicator" class="reply-indicator"> | ||||
|                     <span id="reply-username">Replying to </span> | ||||
|                     <button onclick="cancelReply()">X</button> | ||||
|                 </div> | ||||
|                 <textarea id="chat-input-text" placeholder="Type a message..."></textarea> | ||||
|                 <button onclick="sendMessage()">Send</button> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div id="autocomplete-popup" class="autocomplete-popup"></div> | ||||
|     </main> | ||||
|     {{template "cookie_banner" .}} | ||||
|     <script> | ||||
|         let ws; | ||||
|         let autocompleteActive = false; | ||||
|         let autocompletePrefix = ''; | ||||
|         let replyToId = -1; | ||||
|         let replyUsername = ''; | ||||
| 
 | ||||
|         function connectWebSocket() { | ||||
|             ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true', [], { credentials: 'include' }); | ||||
|             ws.onmessage = function(event) { | ||||
|                 const msg = JSON.parse(event.data); | ||||
|                 appendMessage(msg); | ||||
|             }; | ||||
|             ws.onclose = function() { | ||||
|                 console.log("WebSocket closed, reconnecting..."); | ||||
|                 setTimeout(connectWebSocket, 5000); // Reconnect after 5s | ||||
|             }; | ||||
|             ws.onerror = function(error) { | ||||
|                 console.error("WebSocket error:", error); | ||||
|             }; | ||||
|         } | ||||
| 
 | ||||
|         function sendMessage() { | ||||
|             const input = document.getElementById('chat-input-text'); | ||||
|             const content = input.value.trim(); | ||||
|             if (content === '') return; | ||||
|             const msg = { | ||||
|                 type: 'message', | ||||
|                 content: content, | ||||
|                 replyTo: replyToId | ||||
|             }; | ||||
|             if (ws && ws.readyState === WebSocket.OPEN) { | ||||
|                 ws.send(JSON.stringify(msg)); | ||||
|                 input.value = ''; | ||||
|                 cancelReply(); // Reset reply state after sending | ||||
|             } else { | ||||
|                 console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined'); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         function appendMessage(msg) { | ||||
|             const messages = document.getElementById('chat-messages'); | ||||
|             const msgDiv = document.createElement('div'); | ||||
|             msgDiv.className = 'chat-message'; | ||||
|             msgDiv.id = 'msg-' + msg.ID; | ||||
|             let pfpHTML = msg.PfpURL ? `<img src="${msg.PfpURL}" alt="PFP" class="chat-message-pfp">` : `<div class="chat-message-pfp" style="background-color: #001858;"></div>`; | ||||
|             let replyHTML = msg.ReplyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.ReplyTo})">Replying to ${msg.Username}</div>` : ''; | ||||
|             // Process content for mentions | ||||
|             let content = msg.Content.replace(/@[\w]+/g, match => `<span class="chat-message-mention">${match}</span>`); | ||||
|             msgDiv.innerHTML = ` | ||||
|                 <div class="chat-message-header"> | ||||
|                     ${pfpHTML} | ||||
|                     <span class="chat-message-username">${msg.Username}</span> | ||||
|                     <span class="chat-message-timestamp">${new Date(msg.Timestamp).toLocaleString()}</span> | ||||
|                 </div> | ||||
|                 ${replyHTML} | ||||
|                 <div class="chat-message-content">${content}</div> | ||||
|                 <div class="post-actions"> | ||||
|                     <a href="javascript:void(0)" onclick="replyToMessage(${msg.ID}, '${msg.Username}')">Reply</a> | ||||
|                 </div> | ||||
|             `; | ||||
|             messages.appendChild(msgDiv); | ||||
|             messages.scrollTop = messages.scrollHeight; | ||||
|         } | ||||
| 
 | ||||
|         function replyToMessage(id, username) { | ||||
|             replyToId = id; | ||||
|             replyUsername = username; | ||||
|             const replyIndicator = document.getElementById('reply-indicator'); | ||||
|             const replyUsernameSpan = document.getElementById('reply-username'); | ||||
|             replyUsernameSpan.textContent = `Replying to ${username}`; | ||||
|             replyIndicator.style.display = 'flex'; | ||||
|             document.getElementById('chat-input-text').focus(); | ||||
|         } | ||||
| 
 | ||||
|         function cancelReply() { | ||||
|             replyToId = -1; | ||||
|             replyUsername = ''; | ||||
|             const replyIndicator = document.getElementById('reply-indicator'); | ||||
|             replyIndicator.style.display = 'none'; | ||||
|         } | ||||
| 
 | ||||
|         function scrollToMessage(id) { | ||||
|             const msgElement = document.getElementById('msg-' + id); | ||||
|             if (msgElement) { | ||||
|                 msgElement.scrollIntoView({ behavior: 'smooth' }); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         function showAutocompletePopup(usernames, x, y) { | ||||
|             const popup = document.getElementById('autocomplete-popup'); | ||||
|             popup.innerHTML = ''; | ||||
|             popup.style.left = x + 'px'; | ||||
|             popup.style.top = y + 'px'; | ||||
|             popup.style.display = 'block'; | ||||
|             autocompleteActive = true; | ||||
|             usernames.forEach(username => { | ||||
|                 const item = document.createElement('div'); | ||||
|                 item.className = 'autocomplete-item'; | ||||
|                 item.textContent = username; | ||||
|                 item.onclick = () => { | ||||
|                     completeMention(username); | ||||
|                     popup.style.display = 'none'; | ||||
|                     autocompleteActive = false; | ||||
|                 }; | ||||
|                 popup.appendChild(item); | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         function completeMention(username) { | ||||
|             const input = document.getElementById('chat-input-text'); | ||||
|             const text = input.value; | ||||
|             const atIndex = text.lastIndexOf('@', input.selectionStart - 1); | ||||
|             if (atIndex !== -1) { | ||||
|                 const before = text.substring(0, atIndex); | ||||
|                 const after = text.substring(input.selectionStart); | ||||
|                 input.value = before + username + (after.startsWith(' ') ? '' : ' ') + after; | ||||
|                 input.focus(); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         document.getElementById('chat-input-text').addEventListener('input', async (e) => { | ||||
|             const text = e.target.value; | ||||
|             const caretPos = e.target.selectionStart; | ||||
|             const atIndex = text.lastIndexOf('@', caretPos - 1); | ||||
|             if (atIndex !== -1 && (caretPos === text.length || text[caretPos] === ' ')) { | ||||
|                 const prefix = text.substring(atIndex + 1, caretPos); | ||||
|                 autocompletePrefix = prefix; | ||||
|                 const response = await fetch('{{.BasePath}}/chat/?autocomplete=true&prefix=' + encodeURIComponent(prefix)); | ||||
|                 const usernames = await response.json(); | ||||
|                 if (usernames.length > 0) { | ||||
|                     const rect = e.target.getBoundingClientRect(); | ||||
|                     // Approximate caret position (this is a rough estimate) | ||||
|                     const charWidth = 8; // Rough estimate of character width in pixels | ||||
|                     const caretX = rect.left + (caretPos - text.lastIndexOf('\n', caretPos - 1) - 1) * charWidth; | ||||
|                     showAutocompletePopup(usernames, caretX, rect.top - 10); | ||||
|                 } else { | ||||
|                     document.getElementById('autocomplete-popup').style.display = 'none'; | ||||
|                     autocompleteActive = false; | ||||
|                 } | ||||
|             } else { | ||||
|                 document.getElementById('autocomplete-popup').style.display = 'none'; | ||||
|                 autocompleteActive = false; | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         document.getElementById('chat-input-text').addEventListener('keydown', (e) => { | ||||
|             if (autocompleteActive) { | ||||
|                 const popup = document.getElementById('autocomplete-popup'); | ||||
|                 const items = popup.getElementsByClassName('autocomplete-item'); | ||||
|                 if (e.key === 'Enter' && items.length > 0) { | ||||
|                     items[0].click(); | ||||
|                     e.preventDefault(); | ||||
|                 } else if (e.key === 'ArrowDown' && items.length > 0) { | ||||
|                     items[0].focus(); | ||||
|                     e.preventDefault(); | ||||
|                 } | ||||
|             } else if (e.key === 'Enter' && !e.shiftKey) { | ||||
|                 sendMessage(); | ||||
|                 e.preventDefault(); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         document.addEventListener('click', (e) => { | ||||
|             if (!e.target.closest('#autocomplete-popup') && !e.target.closest('#chat-input-text')) { | ||||
|                 document.getElementById('autocomplete-popup').style.display = 'none'; | ||||
|                 autocompleteActive = false; | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         // Connect WebSocket on page load | ||||
|         window.onload = function() { | ||||
|             connectWebSocket(); | ||||
|             document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight; | ||||
|         }; | ||||
|     </script> | ||||
| </body> | ||||
| </html> | ||||
| {{end}} | ||||
|  | @ -0,0 +1,21 @@ | |||
| {{define "home"}} | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|     <title>{{.Title}}</title> | ||||
|     <link rel="stylesheet" href="{{.StaticPath}}/style.css"> | ||||
| </head> | ||||
| <body> | ||||
|     {{template "navbar" .}} | ||||
|     <main> | ||||
|         <header> | ||||
|             <h1><center>Welcome to ThreadR</center></h1> | ||||
|         </header> | ||||
|         <section> | ||||
|             <img src="{{.StaticPath}}/img/ThreadR.png" alt="ThreadR" height="100%" width="100%"> | ||||
|         </section> | ||||
|     </main> | ||||
|     {{template "cookie_banner" .}} | ||||
| </body> | ||||
| </html> | ||||
| {{end}} | ||||
|  | @ -0,0 +1,30 @@ | |||
| {{define "login"}} | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|     <title>{{.Title}}</title> | ||||
|     <link rel="stylesheet" href="{{.StaticPath}}/style.css"> | ||||
| </head> | ||||
| <body> | ||||
|     {{template "navbar" .}} | ||||
|     <main> | ||||
|         <header> | ||||
|             <h2>Login</h2> | ||||
|         </header> | ||||
|         <section> | ||||
|             {{if .Error}} | ||||
|             <p style="color: red;">{{.Error}}</p> | ||||
|             {{end}} | ||||
|             <form method="post" action="{{.BasePath}}/login/"> | ||||
|                 <label for="username">Username:</label> | ||||
|                 <input type="text" id="username" name="username" required><br> | ||||
|                 <label for="password">Password:</label> | ||||
|                 <input type="password" id="password" name="password" required><br> | ||||
|                 <input type="submit" value="Login"> | ||||
|             </form> | ||||
|         </section> | ||||
|     </main> | ||||
|     {{template "cookie_banner" .}} | ||||
| </body> | ||||
| </html> | ||||
| {{end}} | ||||
|  | @ -0,0 +1,48 @@ | |||
| {{define "news"}} | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|     <title>{{.Title}}</title> | ||||
|     <link rel="stylesheet" href="{{.StaticPath}}/style.css"> | ||||
| </head> | ||||
| <body> | ||||
|     {{template "navbar" .}} | ||||
|     <main> | ||||
|         <header> | ||||
|             <h2>News</h2> | ||||
|         </header> | ||||
|         <section> | ||||
|             {{if .News}} | ||||
|             <ul> | ||||
|                 {{range .News}} | ||||
|                 <li><strong>{{.Title}}</strong> - Posted on {{.CreatedAt.Format "02/01/2006 - 15:04"}} | ||||
|                     <p>{{.Content}}</p> | ||||
|                     {{if $.IsAdmin}} | ||||
|                     <form method="post" action="{{$.BasePath}}/news/?action=delete&id={{.ID}}" style="display:inline;"> | ||||
|                         <button type="submit" onclick="return confirm('Are you sure you want to delete this news item?')">Delete</button> | ||||
|                     </form> | ||||
|                     {{end}} | ||||
|                 </li> | ||||
|                 {{end}} | ||||
|             </ul> | ||||
|             {{else}} | ||||
|             <p>No news items available at the moment.</p> | ||||
|             {{end}} | ||||
|         </section> | ||||
|         {{if .IsAdmin}} | ||||
|         <section> | ||||
|             <h3>Post New Announcement</h3> | ||||
|             <form method="post" action="{{.BasePath}}/news/"> | ||||
|                 <label for="title">Title:</label> | ||||
|                 <input type="text" id="title" name="title" required><br> | ||||
|                 <label for="content">Content:</label> | ||||
|                 <textarea id="content" name="content" required></textarea><br> | ||||
|                 <input type="submit" value="Post News"> | ||||
|             </form> | ||||
|         </section> | ||||
|         {{end}} | ||||
|     </main> | ||||
|     {{template "cookie_banner" .}} | ||||
| </body> | ||||
| </html> | ||||
| {{end}} | ||||
|  | @ -0,0 +1,30 @@ | |||
| {{define "profile"}} | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|     <title>{{.Title}}</title> | ||||
|     <link rel="stylesheet" href="{{.StaticPath}}/style.css"> | ||||
| </head> | ||||
| <body> | ||||
|     {{template "navbar" .}} | ||||
|     <main> | ||||
|         <header> | ||||
|             <h2>Profile</h2> | ||||
|         </header> | ||||
|         <section> | ||||
|             <p>Username: {{.User.Username}}</p> | ||||
|             <p>Display Name: {{.DisplayName}}</p> | ||||
|             {{if .User.PfpURL}} | ||||
|             <img src="{{.User.PfpURL}}" alt="Profile Picture"> | ||||
|             {{end}} | ||||
|             <p>Bio: {{.User.Bio}}</p> | ||||
|             <p>Joined: {{.User.CreatedAt}}</p> | ||||
|             <p>Last Updated: {{.User.UpdatedAt}}</p> | ||||
|             <p>Verified: {{.User.Verified}}</p> | ||||
|             <a href="{{.BasePath}}/profile/edit/">Edit Profile</a> | ||||
|         </section> | ||||
|     </main> | ||||
|     {{template "cookie_banner" .}} | ||||
| </body> | ||||
| </html> | ||||
| {{end}} | ||||
|  | @ -0,0 +1,29 @@ | |||
| {{define "profile_edit"}} | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|     <title>{{.Title}}</title> | ||||
|     <link rel="stylesheet" href="{{.StaticPath}}/style.css"> | ||||
| </head> | ||||
| <body> | ||||
|     {{template "navbar" .}} | ||||
|     <main> | ||||
|         <header> | ||||
|             <h2>Edit Profile</h2> | ||||
|         </header> | ||||
|         <section> | ||||
|             <form method="post" action="{{.BasePath}}/profile/edit/"> | ||||
|                 <label for="display_name">Display Name:</label> | ||||
|                 <input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}"><br> | ||||
|                 <label for="pfp_url">Profile Picture URL:</label> | ||||
|                 <input type="text" id="pfp_url" name="pfp_url" value="{{.User.PfpURL}}"><br> | ||||
|                 <label for="bio">Bio:</label> | ||||
|                 <textarea id="bio" name="bio">{{.User.Bio}}</textarea><br> | ||||
|                 <input type="submit" value="Save"> | ||||
|             </form> | ||||
|         </section> | ||||
|     </main> | ||||
|     {{template "cookie_banner" .}} | ||||
| </body> | ||||
| </html> | ||||
| {{end}} | ||||
|  | @ -0,0 +1,30 @@ | |||
| {{define "signup"}} | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|     <title>{{.Title}}</title> | ||||
|     <link rel="stylesheet" href="{{.StaticPath}}/style.css"> | ||||
| </head> | ||||
| <body> | ||||
|     {{template "navbar" .}} | ||||
|     <main> | ||||
|         <header> | ||||
|             <h2>Sign Up</h2> | ||||
|         </header> | ||||
|         <section> | ||||
|             {{if .Error}} | ||||
|             <p style="color: red;">{{.Error}}</p> | ||||
|             {{end}} | ||||
|             <form method="post" action="{{.BasePath}}/signup/"> | ||||
|                 <label for="username">Username:</label> | ||||
|                 <input type="text" id="username" name="username" required><br> | ||||
|                 <label for="password">Password:</label> | ||||
|                 <input type="password" id="password" name="password" required><br> | ||||
|                 <input type="submit" value="Sign Up"> | ||||
|             </form> | ||||
|         </section> | ||||
|     </main> | ||||
|     {{template "cookie_banner" .}} | ||||
| </body> | ||||
| </html> | ||||
| {{end}} | ||||
|  | @ -0,0 +1,57 @@ | |||
| {{define "thread"}} | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|     <title>{{.Title}}</title> | ||||
|     <link rel="stylesheet" href="{{.StaticPath}}/style.css"> | ||||
| </head> | ||||
| <body> | ||||
|     {{template "navbar" .}} | ||||
|     <main> | ||||
|         <header> | ||||
|             <h2>{{.Thread.Title}}</h2> | ||||
|         </header> | ||||
|         <div class="thread-posts"> | ||||
|             {{range .Posts}} | ||||
|             <article id="{{.ID}}" class="post-item" style="margin-left: {{if gt .ReplyTo 0}}20px{{else}}0px{{end}};"> | ||||
|                 <header> | ||||
|                     <h3>{{if .Title}}{{.Title}}{{else}}Post #{{.ID}}{{end}}</h3> | ||||
|                     <p>Posted on {{.PostTime.Format "02/01/2006 - 15:04"}}</p> | ||||
|                     {{if gt .ReplyTo 0}} | ||||
|                     <p>Reply to post <a href="#{{.ReplyTo}}">{{.ReplyTo}}</a></p> | ||||
|                     {{end}} | ||||
|                 </header> | ||||
|                 <div class="post-content">{{.Content}}</div> | ||||
|                 {{if $.LoggedIn}} | ||||
|                 <div class="post-actions"> | ||||
|                     <form method="post" action="{{$.BasePath}}/like/" style="display:inline;"> | ||||
|                         <input type="hidden" name="post_id" value="{{.ID}}"> | ||||
|                         <input type="hidden" name="type" value="like"> | ||||
|                         <button type="submit">Like</button> | ||||
|                     </form> | ||||
|                     <form method="post" action="{{$.BasePath}}/like/" style="display:inline;"> | ||||
|                         <input type="hidden" name="post_id" value="{{.ID}}"> | ||||
|                         <input type="hidden" name="type" value="dislike"> | ||||
|                         <button type="submit">Dislike</button> | ||||
|                     </form> | ||||
|                     <a href="{{$.BasePath}}/thread/?id={{$.Thread.ID}}&action=submit&to={{.ID}}">Reply</a> | ||||
|                 </div> | ||||
|                 {{end}} | ||||
|             </article> | ||||
|             {{end}} | ||||
|         </div> | ||||
|         {{if .LoggedIn}} | ||||
|         <section> | ||||
|             <h3>Post a Message</h3> | ||||
|             <form method="post" action="{{.BasePath}}/thread/?id={{.Thread.ID}}&action=submit"> | ||||
|                 <label for="content">Content:</label> | ||||
|                 <textarea id="content" name="content" required></textarea><br> | ||||
|                 <input type="submit" value="Post"> | ||||
|             </form> | ||||
|         </section> | ||||
|         {{end}} | ||||
|     </main> | ||||
|     {{template "cookie_banner" .}} | ||||
| </body> | ||||
| </html> | ||||
| {{end}} | ||||
|  | @ -0,0 +1,21 @@ | |||
| {{define "userhome"}} | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|     <title>{{.Title}}</title> | ||||
|     <link rel="stylesheet" href="{{.StaticPath}}/style.css"> | ||||
| </head> | ||||
| <body> | ||||
|     {{template "navbar" .}} | ||||
|     <main> | ||||
|         <header> | ||||
|             <h2>Welcome, {{.Username}}</h2> | ||||
|         </header> | ||||
|         <section> | ||||
|             <p>This is your user home page.</p> | ||||
|         </section> | ||||
|     </main> | ||||
|     {{template "cookie_banner" .}} | ||||
| </body> | ||||
| </html> | ||||
| {{end}} | ||||
|  | @ -0,0 +1,7 @@ | |||
| {{define "cookie_banner"}} | ||||
| {{if .ShowCookieBanner}} | ||||
| <div class="banner"> | ||||
|     <p>We use cookies to enhance your experience. <a href="{{.BasePath}}/accept_cookie/?from={{.CurrentURL | urlquery}}">Accept</a></p> | ||||
| </div> | ||||
| {{end}} | ||||
| {{end}} | ||||
|  | @ -0,0 +1,18 @@ | |||
| {{define "navbar"}} | ||||
| <ul class="topnav"> | ||||
|     <li><a {{if eq .Navbar "home"}}class="active"{{end}} href="{{.BasePath}}/">Home</a></li> | ||||
|     {{if .LoggedIn}} | ||||
|     <li><a {{if eq .Navbar "userhome"}}class="active"{{end}} href="{{.BasePath}}/userhome/">User Home</a></li> | ||||
|     <li><a {{if eq .Navbar "profile"}}class="active"{{end}} href="{{.BasePath}}/profile/">Profile</a></li> | ||||
|     <li><a {{if eq .Navbar "chat"}}class="active"{{end}} href="{{.BasePath}}/chat/">Chat</a></li> | ||||
|     <li><a href="{{.BasePath}}/logout/">Logout</a></li> | ||||
|     {{else}} | ||||
|     <li><a {{if eq .Navbar "login"}}class="active"{{end}} href="{{.BasePath}}/login/">Login</a></li> | ||||
|     <li><a {{if eq .Navbar "signup"}}class="active"{{end}} href="{{.BasePath}}/signup/">Sign Up</a></li> | ||||
|     {{end}} | ||||
|     <li><a {{if eq .Navbar "boards"}}class="active"{{end}} href="{{.BasePath}}/boards/">Boards</a></li> | ||||
|     <li><a {{if eq .Navbar "news"}}class="active"{{end}} href="{{.BasePath}}/news/">News</a></li> | ||||
|     <li><a {{if eq .Navbar "about"}}class="active"{{end}} href="{{.BasePath}}/about/">About</a></li> | ||||
| </ul> | ||||
| <div class="topnav"></div> | ||||
| {{end}} | ||||
		Loading…
	
		Reference in New Issue