Compare commits
	
		
			No commits in common. "bdf81e7c689c03b38e713f73b854990a19a3bf85" and "af91df4986dd3b7e38ae939d74093c42692377fa" have entirely different histories. 
		
	
	
		
			bdf81e7c68
			...
			af91df4986
		
	
		|  | @ -1,5 +0,0 @@ | ||||||
| config/config.json |  | ||||||
| config/about_page.htmlbody |  | ||||||
| 
 |  | ||||||
| # nano |  | ||||||
| .swp |  | ||||||
							
								
								
									
										52
									
								
								README.md
								
								
								
								
							
							
						
						
									
										52
									
								
								README.md
								
								
								
								
							|  | @ -1,52 +0,0 @@ | ||||||
| # 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 |  | ||||||
|  | @ -1,31 +0,0 @@ | ||||||
| <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> |  | ||||||
|  | @ -1,8 +0,0 @@ | ||||||
| { |  | ||||||
|     "domain_name": "localhost", |  | ||||||
|     "threadr_dir": "/threadr", |  | ||||||
|     "db_username": "threadr_user", |  | ||||||
|     "db_password": "threadr_password", |  | ||||||
|     "db_database": "threadr_db", |  | ||||||
|     "db_svr_host": "localhost:3306" |  | ||||||
| } |  | ||||||
							
								
								
									
										16
									
								
								go.mod
								
								
								
								
							
							
						
						
									
										16
									
								
								go.mod
								
								
								
								
							|  | @ -1,16 +0,0 @@ | ||||||
| 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 |  | ||||||
| ) |  | ||||||
							
								
								
									
										16
									
								
								go.sum
								
								
								
								
							
							
						
						
									
										16
									
								
								go.sum
								
								
								
								
							|  | @ -1,16 +0,0 @@ | ||||||
| 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= |  | ||||||
|  | @ -1,46 +0,0 @@ | ||||||
| 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 |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,22 +0,0 @@ | ||||||
| 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) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,78 +0,0 @@ | ||||||
| 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) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,126 +0,0 @@ | ||||||
| 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 |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,106 +0,0 @@ | ||||||
| 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 |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
							
								
								
									
										188
									
								
								handlers/chat.go
								
								
								
								
							
							
						
						
									
										188
									
								
								handlers/chat.go
								
								
								
								
							|  | @ -1,188 +0,0 @@ | ||||||
| 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 |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  | @ -1,34 +0,0 @@ | ||||||
| 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 |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,78 +0,0 @@ | ||||||
| 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")) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,68 +0,0 @@ | ||||||
| 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 |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,21 +0,0 @@ | ||||||
| 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) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,98 +0,0 @@ | ||||||
| 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 |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,55 +0,0 @@ | ||||||
| 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 |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,65 +0,0 @@ | ||||||
| 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 |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,66 +0,0 @@ | ||||||
| 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 |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,135 +0,0 @@ | ||||||
| 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 |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,49 +0,0 @@ | ||||||
| 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 |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
							
								
								
									
										366
									
								
								main.go
								
								
								
								
							
							
						
						
									
										366
									
								
								main.go
								
								
								
								
							|  | @ -1,366 +0,0 @@ | ||||||
| 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)) |  | ||||||
| } |  | ||||||
							
								
								
									
										101
									
								
								models/board.go
								
								
								
								
							
							
						
						
									
										101
									
								
								models/board.go
								
								
								
								
							|  | @ -1,101 +0,0 @@ | ||||||
| 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 |  | ||||||
| } |  | ||||||
|  | @ -1,46 +0,0 @@ | ||||||
| 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 |  | ||||||
| } |  | ||||||
							
								
								
									
										132
									
								
								models/chat.go
								
								
								
								
							
							
						
						
									
										132
									
								
								models/chat.go
								
								
								
								
							|  | @ -1,132 +0,0 @@ | ||||||
| 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 |  | ||||||
| } |  | ||||||
|  | @ -1,62 +0,0 @@ | ||||||
| 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 |  | ||||||
| } |  | ||||||
|  | @ -1,53 +0,0 @@ | ||||||
| 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 |  | ||||||
| } |  | ||||||
|  | @ -1,49 +0,0 @@ | ||||||
| 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 |  | ||||||
| } |  | ||||||
|  | @ -1,61 +0,0 @@ | ||||||
| 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 |  | ||||||
| } |  | ||||||
|  | @ -1,44 +0,0 @@ | ||||||
| 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 |  | ||||||
| } |  | ||||||
|  | @ -1,41 +0,0 @@ | ||||||
| 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 |  | ||||||
| } |  | ||||||
|  | @ -1,76 +0,0 @@ | ||||||
| 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 |  | ||||||
| } |  | ||||||
							
								
								
									
										161
									
								
								models/user.go
								
								
								
								
							
							
						
						
									
										161
									
								
								models/user.go
								
								
								
								
							|  | @ -1,161 +0,0 @@ | ||||||
| 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.
										
									
								
							| Before Width: | Height: | Size: 93 KiB | 
|  | @ -1,272 +0,0 @@ | ||||||
| <?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> |  | ||||||
| Before Width: | Height: | Size: 17 KiB | 
|  | @ -1,249 +0,0 @@ | ||||||
| <?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> |  | ||||||
| Before Width: | Height: | Size: 9.4 KiB | 
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 752 B | 
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 1.3 KiB | 
							
								
								
									
										371
									
								
								static/style.css
								
								
								
								
							
							
						
						
									
										371
									
								
								static/style.css
								
								
								
								
							|  | @ -1,371 +0,0 @@ | ||||||
| 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%; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,16 +0,0 @@ | ||||||
| {{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}} |  | ||||||
|  | @ -1,14 +0,0 @@ | ||||||
| {{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}} |  | ||||||
|  | @ -1,41 +0,0 @@ | ||||||
| {{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}} |  | ||||||
|  | @ -1,62 +0,0 @@ | ||||||
| {{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}} |  | ||||||
|  | @ -1,435 +0,0 @@ | ||||||
| {{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}} |  | ||||||
|  | @ -1,21 +0,0 @@ | ||||||
| {{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}} |  | ||||||
|  | @ -1,30 +0,0 @@ | ||||||
| {{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}} |  | ||||||
|  | @ -1,48 +0,0 @@ | ||||||
| {{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}} |  | ||||||
|  | @ -1,30 +0,0 @@ | ||||||
| {{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}} |  | ||||||
|  | @ -1,29 +0,0 @@ | ||||||
| {{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}} |  | ||||||
|  | @ -1,30 +0,0 @@ | ||||||
| {{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}} |  | ||||||
|  | @ -1,57 +0,0 @@ | ||||||
| {{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}} |  | ||||||
|  | @ -1,21 +0,0 @@ | ||||||
| {{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}} |  | ||||||
|  | @ -1,7 +0,0 @@ | ||||||
| {{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}} |  | ||||||
|  | @ -1,18 +0,0 @@ | ||||||
| {{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