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