All: Enhance Chat Mentions, Fix Threads CSS, Migrate Boards, and Add File-Based Avatar System #69

Open
jocadbz wants to merge 10 commits from jocadbz into master
22 changed files with 1338 additions and 451 deletions

3
.gitignore vendored
View File

@ -1,5 +1,8 @@
config/config.json
config/about_page.htmlbody
# Testing
files/
# nano
.swp

403
DOCUMENTATION.md Normal file
View File

@ -0,0 +1,403 @@
# ThreadR Rewritten - Technical Specification
## Project Overview
ThreadR Rewritten is a free and open-source forum engine, re-implemented in Go. It aims to provide a robust and extensible platform for users to host their own forum instances. The project, initially a PHP/MySQL school project, has been completely rewritten to leverage Go's performance and concurrency features. It supports traditional forum boards, real-time chat boards, user profiles, news announcements, and file uploads (specifically for profile pictures).
## File-by-File Explanation
This section details the purpose and functionality of each significant file and directory within the ThreadR project.
This, of course, assumes you have a decent understanding of Go.
### Configuration Files
* **config/config.json.sample**:
This file provides a template for the main application configuration. It defines critical parameters for the application to run, such as database credentials, domain, and file storage locations.
Example content:
{
"domain_name": "localhost",
"threadr_dir": "/threadr",
"db_username": "threadr_user",
"db_password": "threadr_password",
"db_database": "threadr_db",
"db_svr_host": "localhost:3306",
"file_storage_dir": "files"
}
* **config/config.json**:
The active configuration file, copied from `config.json.sample` and modified for the specific deployment. Contains sensitive information like database passwords.
* **config/about_page.htmlbody.sample**:
A template HTML snippet for the "About" page content. This allows administrators to customize the about page without modifying Go templates.
* **config/about_page.htmlbody**:
The active HTML content for the "About" page, copied from `about_page.htmlbody.sample` and modified as needed.
### Core Application Files
* **main.go**:
The entry point of the ThreadR application.
- Parses command-line flags (e.g., `--initialize` for database setup).
- Loads the `config/config.json` file.
- Establishes a connection to the MariaDB database.
- If `--initialize` is set:
- Calls `createTablesIfNotExist` to set up all necessary database tables.
- Calls `ensureAdminUser` to guide the creation of an initial admin user.
- Initializes Gorilla Sessions for session management.
- Loads HTML templates for rendering pages.
- Sets up all HTTP routes and maps them to their respective handler functions, wrapped with session and login middleware as needed.
- Starts the HTTP server on port 8080.
### Handlers Directory (`handlers/`)
This directory contains the HTTP handler functions that process incoming requests, interact with models, and render responses.
* **handlers/app.go**:
Defines common application-wide structures and middleware:
- `PageData`: A struct holding data passed to HTML templates for rendering common elements (title, navbar state, login status, cookie banner, base paths, current URL).
- `Config`: A struct to unmarshal application configuration from `config.json`.
Example JSON for `Config`:
{
"domain_name": "localhost",
"threadr_dir": "/threadr",
"db_username": "threadr_user",
"db_password": "threadr_password",
"db_database": "threadr_db",
"db_svr_host": "localhost:3306",
"file_storage_dir": "files"
}
- `App`: The main application context struct, holding pointers to the database connection, session store, configuration, and templates.
- `SessionMW`: Middleware to retrieve or create a new Gorilla session for each request, making the session available in the request context.
- `RequireLoginMW`: Middleware to enforce user authentication for specific routes, redirecting unauthenticated users to the login page.
* **handlers/about.go**:
Handles requests for the `/about/` page. Reads the `config/about_page.htmlbody` file and renders it within the `about.html` template.
* **handlers/accept_cookie.go**:
Handles the cookie banner acceptance. Sets a `threadr_cookie_banner` cookie in the user's browser for 30 days and redirects them back to their previous page or the home page.
* **handlers/board.go**:
Handles requests for individual forum boards (`/board/?id=<id>`).
- Fetches board details and threads within it.
- Enforces permissions for private boards (redirects if not logged in or unauthorized).
- Handles POST requests to create new threads within the board if the user is logged in and has permission.
- Renders the `board.html` template with board and thread data.
* **handlers/boards.go**:
Handles requests for the `/boards/` page, listing all available public and private boards.
- Checks if the logged-in user has admin privileges (`PermCreateBoard`).
- Handles POST requests to create new boards (classic or chat type) if the user is an admin.
- Filters private boards based on user permissions.
- Renders the `boards.html` template with lists of public and accessible private boards.
* **handlers/chat.go**:
Handles both rendering the chat interface and managing WebSocket connections for real-time chat.
- **HTTP Request (`/chat/?id=<id>`):**
- Authenticates the user and fetches board details.
- Enforces permissions for private chat boards.
- Fetches recent chat messages for the specified board.
- Renders the `chat.html` template.
- **WebSocket Request (`/chat/?ws=true&id=<id>`):**
- Upgrades the HTTP connection to a WebSocket.
- Manages client connections via a `ChatHub`.
- Receives JSON messages from clients, creates `models.ChatMessage`, saves them to the DB, and broadcasts them to all clients in the same board.
- `Client` struct: Represents an individual WebSocket connection with user and board ID.
- `ChatHub` struct: Manages active WebSocket clients, message broadcasting, and client registration/unregistration.
Example JSON message for broadcast:
{
"id": 123,
"boardId": 1,
"userId": 456,
"content": "Hello, world! @username",
"replyTo": -1,
"timestamp": "2024-07-30T10:30:00Z",
"username": "chatter1",
"pfpFileId": {
"Int64": 789,
"Valid": true
},
"mentions": ["username"]
}
* **handlers/file.go**:
Handles requests for serving uploaded files, primarily profile pictures (`/file?id=<id>`).
- Retrieves file metadata from the database using `models.GetFileByID`.
- Constructs the file path and serves the file using `http.ServeFile`.
* **handlers/home.go**:
Handles requests for the root path (`/`). Renders the `home.html` template, displaying a welcome message and the ThreadR logo.
* **handlers/like.go**:
Handles POST requests for liking or disliking posts (`/like/`).
- Requires login.
- Checks for existing likes/dislikes and updates or deletes them based on user action.
- Interacts with `models.Like` for database operations.
* **handlers/login.go**:
Handles user login (`/login/`).
- On GET: Renders the `login.html` template.
- On POST: Authenticates the user against the database, sets session values upon successful login, and redirects to user home. Shows an error if login fails.
* **handlers/logout.go**:
Handles user logout (`/logout/`). Clears the user's session and redirects to the home page.
* **handlers/news.go**:
Handles requests for the `/news/` page.
- Fetches and displays all news items from the database.
- If the user is an admin, it allows posting new news items via POST requests and deleting existing ones.
- Renders the `news.html` template.
* **handlers/profile.go**:
Handles requests for the user's profile page (`/profile/`).
- Requires login.
- Fetches user details from the database using `models.GetUserByID`.
- Renders the `profile.html` template, displaying user information.
* **handlers/profile_edit.go**:
Handles editing of user profiles, including display name, bio, and profile picture upload (`/profile/edit/`).
- Requires login.
- On GET: Fetches current user data and renders `profile_edit.html`.
- On POST: Processes form data, including file uploads.
- For profile pictures, it hashes the file, creates a `models.File` record, saves the file to disk, and updates the user's `pfp_file_id`.
- Updates the user's display name and bio in the database.
- Redirects to the profile page after successful update.
* **handlers/signup.go**:
Handles new user registration (`/signup/`).
- On GET: Renders the `signup.html` template.
- On POST: Creates a new user in the database after hashing the password. Redirects to the login page on success.
* **handlers/thread.go**:
Handles requests for individual discussion threads (`/thread/?id=<id>`).
- Fetches thread and associated posts.
- Enforces permissions for private boards (if the thread belongs to one).
- Handles POST requests to create new posts within the thread if the user is logged in and has permission.
- Renders the `thread.html` template with thread and post data.
* **handlers/userhome.go**:
Handles requests for the user's personal home page (`/userhome/`).
- Requires login.
- Fetches current user details.
- Renders the `userhome.html` template.
### Models Directory (`models/`)
This directory contains data structures and functions for interacting with the database. Each file typically corresponds to a database table or a logical data entity.
* **models/board.go**:
- `Board` struct: Represents a forum board.
Example `Board` struct:
type Board struct {
ID int
Name string
Description string
Private bool
PublicVisible bool
PinnedThreads []int // Stored as JSON array in DB
CustomLandingPage string
ColorScheme string
Type string // "classic" or "chat"
}
- `GetBoardByID`: Fetches a single board by its ID.
- `GetAllBoards`: Fetches all boards, optionally filtered by `private` status.
* **models/board_permission.go**:
- `BoardPermission` struct: Represents user permissions for a specific board.
- Defines bitmask constants for different permissions (`PermPostInBoard`, `PermModerateBoard`, `PermViewBoard`).
- `GetBoardPermission`: Retrieves a user's permissions for a given board.
- `SetBoardPermission`: Inserts or updates a user's permissions for a board.
- `HasBoardPermission`: Checks if a user has a specific permission for a board.
* **models/chat.go**:
- `ChatMessage` struct: Represents a single chat message in a chat board. Includes fields for user, content, reply, timestamp, and mentions.
Example `ChatMessage` struct:
type ChatMessage struct {
ID int `json:"id"`
BoardID int `json:"boardId"`
UserID int `json:"userId"`
Content string `json:"content"`
ReplyTo int `json:"replyTo"`
Timestamp time.Time `json:"timestamp"`
Username string `json:"username"`
PfpFileID sql.NullInt64 `json:"pfpFileId"`
Mentions []string `json:"mentions"`
}
Example JSON output (as seen in `handlers/chat.go` broadcast):
{
"id": 123,
"boardId": 1,
"userId": 456,
"content": "Hello, world! @username",
"replyTo": -1,
"timestamp": "2024-07-30T10:30:00Z",
"username": "chatter1",
"pfpFileId": {
"Int64": 789,
"Valid": true
},
"mentions": ["username"]
}
- `CreateChatMessage`: Inserts a new chat message into the database.
- `GetRecentChatMessages`: Retrieves a limited number of the most recent messages for a board.
- `GetChatMessageByID`: Fetches a single chat message by its ID.
- `extractMentions`: Utility function to parse usernames mentioned in a message.
* **models/file.go**:
- `File` struct: Represents metadata for an uploaded file (e.g., profile pictures).
Example `File` struct:
type File struct {
ID int
OriginalName string
Hash string
HashAlgorithm string
}
Hypothetical JSON representation:
{
"id": 789,
"originalName": "my_pfp.png",
"hash": "a1b2c3d4...",
"hashAlgorithm": "sha256"
}
- `GetFileByID`: Fetches file metadata by its ID.
- `CreateFile`: Creates a new file record in the database and returns its ID.
* **models/like.go**:
- `Like` struct: Represents a user's "like" or "dislike" on a post.
- `GetLikesByPostID`: Retrieves all likes/dislikes for a specific post.
- `GetLikeByPostAndUser`: Retrieves a specific like/dislike by post and user.
- `CreateLike`: Adds a new like/dislike.
- `UpdateLikeType`: Changes an existing like to a dislike or vice-versa.
- `DeleteLike`: Removes a like/dislike.
* **models/news.go**:
- `News` struct: Represents a news announcement.
- `GetAllNews`: Retrieves all news items, ordered by creation time.
- `CreateNews`: Adds a new news item.
- `DeleteNews`: Removes a news item.
* **models/notification.go**:
- `Notification` struct: Represents a user notification (stubbed for future expansion).
- `GetNotificationsByUserID`: Retrieves notifications for a specific user.
- `CreateNotification`: Stub for creating a notification.
- `MarkNotificationAsRead`: Stub for marking a notification as read.
* **models/post.go**:
- `Post` struct: Represents a forum post within a thread.
- `GetPostsByThreadID`: Retrieves all posts for a given thread.
- `CreatePost`: Adds a new post to a thread.
* **models/reaction.go**:
- `Reaction` struct: Represents a user's emoji reaction to a post (stubbed for future expansion).
- `GetReactionsByPostID`: Retrieves all reactions for a post.
- `CreateReaction`: Stub for creating a reaction.
- `DeleteReaction`: Stub for deleting a reaction.
* **models/repost.go**:
- `Repost` struct: Represents a re-post of a thread to another board (stubbed for future expansion).
- `GetRepostsByThreadID`: Retrieves all reposts for a thread.
- `CreateRepost`: Stub for creating a repost.
* **models/thread.go**:
- `Thread` struct: Represents a discussion thread within a board.
- `GetThreadByID`: Fetches a single thread by its ID.
- `GetThreadsByBoardID`: Fetches all threads for a given board.
- `CreateThread`: Adds a new thread to a board.
* **models/user.go**:
- `User` struct: Represents a user in the system, including authentication details, profile info, and global permissions.
Example `User` struct:
type User struct {
ID int
Username string
DisplayName string
PfpFileID sql.NullInt64 // Nullable foreign key to files.id
Bio string
AuthenticationString string
AuthenticationSalt string
AuthenticationAlgorithm string
CreatedAt time.Time
UpdatedAt time.Time
Verified bool
Permissions int64 // Bitmask for global permissions
}
Hypothetical JSON representation (sensitive fields omitted):
{
"id": 456,
"username": "testuser",
"displayName": "Test User",
"pfpFileId": { "Int64": 789, "Valid": true },
"bio": "Just a test user.",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-07-30T10:00:00Z",
"verified": false,
"permissions": 3 // Assuming PermCreateBoard | PermManageUsers
}
- Defines bitmask constants for global permissions (`PermCreateBoard`, `PermManageUsers`).
- `GetUserByID`: Fetches a user by their ID.
- `GetUserByUsername`: Fetches a user by their username.
- `CheckPassword`: Verifies a given password against the stored hash, salt, and algorithm.
- `HashPassword`: Hashes a password using a salt and specified algorithm (currently SHA256).
- `CreateUser`: Creates a new user with hashed password.
- `UpdateUserProfile`: Updates a user's display name and bio.
- `UpdateUserPfp`: Updates a user's profile picture file ID.
- `HasGlobalPermission`: Checks if a user has a specific global permission.
### Static Directory (`static/`)
* **static/style.css**:
The main stylesheet for the ThreadR application, defining the visual theme, layout, and responsive design elements. It includes light and dark mode support.
* **static/img/ThreadR.png**:
The main logo or banner image for the ThreadR application, displayed on the home page.
### Templates Directory (`templates/`)
This directory holds HTML templates for rendering the user interface. It follows a structure of a base template, partials (reusable components), and page-specific templates.
* **templates/base.html**:
The foundational HTML template. It defines the basic HTML structure, includes the stylesheet, and incorporates the `navbar` and `cookie_banner` partials. It also defines a `content` block where page-specific content will be inserted.
* **templates/partials/cookie_banner.html**:
A reusable template snippet that renders a cookie consent banner at the bottom of the page if `ShowCookieBanner` is true in `PageData`.
* **templates/partials/navbar.html**:
A reusable template snippet that renders the navigation bar at the top of the page. It dynamically highlights the active page and shows different links based on `LoggedIn` status (e.g., Login/Signup vs. User Home/Profile/Logout).
* **templates/pages/about.html**:
Page-specific template for the "About" section. It's unique in that it directly renders `AboutContent` from the handler, allowing for fully custom HTML content without needing an additional `content` block.
* **templates/pages/board.html**:
Page-specific template for displaying an individual forum board, listing its threads and providing a form to create new threads.
* **templates/pages/boards.html**:
Page-specific template for listing all public and accessible private forum boards, and an admin form for creating new boards.
* **templates/pages/chat.html**:
Page-specific template for a real-time chat board. It includes:
- A header with board name and description.
- A scrollable `div` for chat messages.
- An input area with a `textarea` for messages and a "Send" button.
- Client-side JavaScript for WebSocket communication, message appending, replying to messages, and username autocomplete.
- Extensive inline CSS for chat-specific styling.
* **templates/pages/home.html**:
Page-specific template for the home page, displaying a welcome message and the `ThreadR.png` image.
* **templates/pages/login.html**:
Page-specific template for the user login form. Displays error messages if authentication fails.
* **templates/pages/news.html**:
Page-specific template for displaying news announcements. Includes forms for admins to post and delete news items.
* **templates/pages/profile.html**:
Page-specific template for displaying a user's profile information. Shows username, display name, profile picture (if uploaded), bio, and account creation/update times.
* **templates/pages/profile_edit.html**:
Page-specific template for editing a user's profile. Provides forms to update display name, bio, and upload a new profile picture.
* **templates/pages/signup.html**:
Page-specific template for the new user registration form.
* **templates/pages/thread.html**:
Page-specific template for displaying an individual discussion thread, listing its posts, and providing forms for users to post new messages or reply to existing ones.

View File

@ -4,5 +4,6 @@
"db_username": "threadr_user",
"db_password": "threadr_password",
"db_database": "threadr_db",
"db_svr_host": "localhost:3306"
"db_svr_host": "localhost:3306",
"file_storage_dir": "files"
}

View File

@ -4,7 +4,9 @@ import (
"context"
"database/sql"
"html/template"
"log"
"net/http"
"github.com/gorilla/sessions"
)
@ -25,6 +27,7 @@ type Config struct {
DBPassword string `json:"db_password"`
DBDatabase string `json:"db_database"`
DBServerHost string `json:"db_svr_host"`
FileStorageDir string `json:"file_storage_dir"`
}
type App struct {
@ -45,24 +48,21 @@ func (app *App) SessionMW(next http.HandlerFunc) http.HandlerFunc {
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)
if err := session.Save(r, w); err != nil {
/*
Ok, so here's the thing
Errors coming from this function here "can" be ignored.
They mostly come from errors while setting cookies, so in some
environments this will trigger a lot, but they are harmless.
*/
log.Printf("Error saving session in SessionMW: %v", err)
}
}
}

View File

@ -33,6 +33,11 @@ func BoardHandler(app *App) http.HandlerFunc {
return
}
if board.Type == "chat" {
http.Redirect(w, r, app.Config.ThreadrDir+"/chat/?id="+boardIDStr, http.StatusFound)
return
}
if board.Private {
if !loggedIn {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)

View File

@ -28,25 +28,39 @@ func BoardsHandler(app *App) http.HandlerFunc {
if r.Method == http.MethodPost && loggedIn && isAdmin {
name := r.FormValue("name")
description := r.FormValue("description")
boardType := r.FormValue("type")
if name == "" {
http.Error(w, "Board name is required", http.StatusBadRequest)
return
}
if boardType != "classic" && boardType != "chat" {
boardType = "classic"
}
board := models.Board{
Name: name,
Description: description,
Private: false,
PublicVisible: true,
Type: boardType,
}
query := "INSERT INTO boards (name, description, private, public_visible) VALUES (?, ?, ?, ?)"
result, err := app.DB.Exec(query, board.Name, board.Description, board.Private, board.PublicVisible)
query := "INSERT INTO boards (name, description, private, public_visible, type) VALUES (?, ?, ?, ?, ?)"
result, err := app.DB.Exec(query, board.Name, board.Description, board.Private, board.PublicVisible, board.Type)
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)
var redirectURL string
if boardType == "chat" {
redirectURL = app.Config.ThreadrDir + "/chat/?id=" + strconv.FormatInt(boardID, 10)
} else {
redirectURL = app.Config.ThreadrDir + "/board/?id=" + strconv.FormatInt(boardID, 10)
}
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}

View File

@ -2,10 +2,13 @@ package handlers
import (
"encoding/json"
"html/template"
"log"
"net/http"
"strconv"
"sync"
"threadr/models"
"github.com/gorilla/sessions"
"github.com/gorilla/websocket"
)
@ -18,21 +21,26 @@ var upgrader = websocket.Upgrader{
},
}
// ChatHub manages WebSocket connections and broadcasts messages
type Client struct {
conn *websocket.Conn
userID int
boardID int
}
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
clients map[*Client]bool
broadcast chan models.ChatMessage
register chan *Client
unregister chan *Client
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),
clients: make(map[*Client]bool),
broadcast: make(chan models.ChatMessage),
register: make(chan *Client),
unregister: make(chan *Client),
}
}
@ -41,23 +49,28 @@ func (h *ChatHub) Run() {
select {
case client := <-h.register:
h.mutex.Lock()
h.clients[client] = 0 // UserID set later
h.clients[client] = true
h.mutex.Unlock()
case client := <-h.unregister:
h.mutex.Lock()
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
client.conn.Close()
}
h.mutex.Unlock()
client.Close()
case message := <-h.broadcast:
h.mutex.Lock()
for client := range h.clients {
err := client.WriteMessage(websocket.TextMessage, message)
if client.boardID == message.BoardID {
response, _ := json.Marshal(message)
err := client.conn.WriteMessage(websocket.TextMessage, response)
if err != nil {
log.Printf("Error broadcasting message: %v", err)
client.Close()
client.conn.Close()
delete(h.clients, client)
}
}
}
h.mutex.Unlock()
}
}
@ -69,6 +82,12 @@ func init() {
go hub.Run()
}
type IncomingChatMessage struct {
Type string `json:"type"`
Content string `json:"content"`
ReplyTo int `json:"replyTo"`
}
func ChatHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
@ -79,20 +98,64 @@ func ChatHandler(app *App) http.HandlerFunc {
}
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, "Chat board not found", http.StatusNotFound)
return
}
if board.Type != "chat" {
http.Error(w, "This is not a chat board", http.StatusBadRequest)
return
}
if board.Private {
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 chat", http.StatusForbidden)
return
}
}
currentUser, err := models.GetUserByID(app.DB, userID)
if err != nil {
log.Printf("Error fetching current user: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if currentUser == nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
currentUsername := currentUser.Username
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()
client := &Client{conn: ws, userID: userID, boardID: boardID}
hub.register <- client
defer func() {
hub.unregister <- ws
hub.unregister <- client
}()
for {
@ -101,83 +164,72 @@ func ChatHandler(app *App) http.HandlerFunc {
log.Printf("Error reading WebSocket message: %v", err)
break
}
var chatMsg struct {
Type string `json:"type"`
Content string `json:"content"`
ReplyTo int `json:"replyTo"`
}
var chatMsg IncomingChatMessage
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 {
if err := models.CreateChatMessage(app.DB, boardID, userID, chatMsg.Content, chatMsg.ReplyTo); 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)
err = app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&msgID)
if err != nil {
log.Printf("Error fetching saved message: %v", err)
log.Printf("Error getting last insert id: %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)
savedMsg, err := models.GetChatMessageByID(app.DB, msgID)
if err != nil {
log.Printf("Error fetching usernames for autocomplete: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
log.Printf("Error fetching saved message for broadcast: %v", err)
continue
}
hub.broadcast <- *savedMsg
}
}
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)
messages, err := models.GetRecentChatMessages(app.DB, boardID, 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]
}
allUsernames, err := models.GetUsernamesInBoard(app.DB, boardID)
if err != nil {
log.Printf("Error fetching usernames for board: %v", err)
allUsernames = []string{}
}
allUsernamesJSON, _ := json.Marshal(allUsernames)
data := struct {
PageData
Board models.Board
Messages []models.ChatMessage
AllUsernames template.JS
CurrentUsername string
}{
PageData: PageData{
Title: "ThreadR - Chat",
Navbar: "chat",
Title: "ThreadR Chat - " + board.Name,
Navbar: "boards",
LoggedIn: true,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Board: *board,
Messages: messages,
AllUsernames: template.JS(allUsernamesJSON),
CurrentUsername: currentUsername,
}
if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil {
log.Printf("Error executing template in ChatHandler: %v", err)

32
handlers/file.go Normal file
View File

@ -0,0 +1,32 @@
package handlers
import (
"fmt"
"net/http"
"path/filepath"
"strconv"
"threadr/models"
)
func FileHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fileIDStr := r.URL.Query().Get("id")
fileID, err := strconv.ParseInt(fileIDStr, 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
file, err := models.GetFileByID(app.DB, fileID)
if err != nil || file == nil {
http.NotFound(w, r)
return
}
fileExt := filepath.Ext(file.OriginalName)
fileName := fmt.Sprintf("%d%s", fileID, fileExt)
filePath := filepath.Join(app.Config.FileStorageDir, fileName)
http.ServeFile(w, r, filePath)
}
}

View File

@ -1,9 +1,15 @@
package handlers
import (
"crypto/sha256"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"threadr/models"
"github.com/gorilla/sessions"
)
@ -17,15 +23,74 @@ func ProfileEditHandler(app *App) http.HandlerFunc {
}
if r.Method == http.MethodPost {
// Handle file upload
file, handler, err := r.FormFile("pfp")
if err == nil {
defer file.Close()
// Create a hash of the file
h := sha256.New()
if _, err := io.Copy(h, file); err != nil {
log.Printf("Error hashing file: %v", err)
http.Error(w, "Failed to process file", http.StatusInternalServerError)
return
}
fileHash := fmt.Sprintf("%x", h.Sum(nil))
// Create file record in the database
fileRecord := models.File{
OriginalName: handler.Filename,
Hash: fileHash,
HashAlgorithm: "sha256",
}
fileID, err := models.CreateFile(app.DB, fileRecord)
if err != nil {
log.Printf("Error creating file record: %v", err)
http.Error(w, "Failed to save file information", http.StatusInternalServerError)
return
}
// Save the file to disk
fileExt := filepath.Ext(handler.Filename)
newFileName := fmt.Sprintf("%d%s", fileID, fileExt)
filePath := filepath.Join(app.Config.FileStorageDir, newFileName)
// Reset file pointer
file.Seek(0, 0)
dst, err := os.Create(filePath)
if err != nil {
log.Printf("Error creating file on disk: %v", err)
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
log.Printf("Error saving file to disk: %v", err)
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
// Update user's pfp_file_id
err = models.UpdateUserPfp(app.DB, userID, fileID)
if err != nil {
log.Printf("Error updating user pfp: %v", err)
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
return
}
}
// Update other profile fields
displayName := r.FormValue("display_name")
pfpURL := r.FormValue("pfp_url")
bio := r.FormValue("bio")
err := models.UpdateUserProfile(app.DB, userID, displayName, pfpURL, bio)
err = models.UpdateUserProfile(app.DB, userID, displayName, 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
}

73
main.go
View File

@ -43,33 +43,14 @@ func createTablesIfNotExist(db *sql.DB) error {
public_visible BOOLEAN DEFAULT TRUE,
pinned_threads TEXT,
custom_landing_page TEXT,
color_scheme VARCHAR(255)
color_scheme VARCHAR(255),
type VARCHAR(20) DEFAULT 'classic' NOT NULL
)`)
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)
// Create threads table
_, err = db.Exec(`
CREATE TABLE threads (
id INT AUTO_INCREMENT PRIMARY KEY,
@ -190,15 +171,52 @@ func createTablesIfNotExist(db *sql.DB) error {
_, err = db.Exec(`
CREATE TABLE chat_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
board_id INT NOT NULL,
user_id INT NOT NULL,
content TEXT NOT NULL,
reply_to INT DEFAULT -1,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
)`)
if err != nil {
return fmt.Errorf("error creating chat_messages table: %v", err)
}
// Create files table (Hope this does not break anything)
_, err = db.Exec(`
CREATE TABLE files (
id INT AUTO_INCREMENT PRIMARY KEY,
original_name VARCHAR(255) NOT NULL,
hash VARCHAR(255) NOT NULL,
hash_algorithm VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("error creating files table: %v", err)
}
// Create users table (KEEP THIS HERE!)
// Otherwise SQL bitches about the foreign key.
_, err = db.Exec(`
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(255),
pfp_file_id INT,
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,
FOREIGN KEY (pfp_file_id) REFERENCES files(id)
)`)
if err != nil {
return fmt.Errorf("error creating users table: %v", err)
}
log.Println("Database tables created.")
return nil
}
@ -283,6 +301,14 @@ func main() {
}
defer db.Close()
// Create the file directory
// TODO: Wouldn't this be better suited on the initialize function?
// Discussion pending.
err = os.MkdirAll(config.FileStorageDir, 0700)
if err != nil {
log.Fatal("Error creating file storage directory:", err)
}
// Perform initialization if the flag is set
if *initialize {
log.Println("Initializing database...")
@ -360,6 +386,7 @@ func main() {
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))))
http.HandleFunc(config.ThreadrDir+"/file", app.SessionMW(handlers.FileHandler(app)))
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))

View File

@ -14,17 +14,18 @@ type Board struct {
PinnedThreads []int // Stored as JSON
CustomLandingPage string
ColorScheme string
Type 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 = ?"
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme, type 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)
err := row.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme, &board.Type)
if err == sql.ErrNoRows {
return nil, nil
}
@ -56,7 +57,7 @@ func GetBoardByID(db *sql.DB, id int) (*Board, error) {
}
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"
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme, type FROM boards WHERE private = ? ORDER BY id ASC"
rows, err := db.Query(query, private)
if err != nil {
return nil, err
@ -70,7 +71,7 @@ func GetAllBoards(db *sql.DB, private bool) ([]Board, error) {
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)
err := rows.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme, &board.Type)
if err != nil {
return nil, err
}

View File

@ -2,34 +2,41 @@ package models
import (
"database/sql"
"fmt"
"html"
"html/template"
"regexp"
"strings"
"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
ID int `json:"id"`
BoardID int `json:"boardId"`
UserID int `json:"userId"`
Content template.HTML `json:"content"`
ReplyTo int `json:"replyTo"`
Timestamp time.Time `json:"timestamp"`
Username string `json:"username"`
PfpFileID sql.NullInt64 `json:"pfpFileId"`
Mentions []string `json:"mentions"`
}
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)
func CreateChatMessage(db *sql.DB, boardID, userID int, rawContent string, replyTo int) error {
query := "INSERT INTO chat_messages (board_id, user_id, content, reply_to, timestamp) VALUES (?, ?, ?, ?, NOW())"
_, err := db.Exec(query, boardID, userID, rawContent, replyTo)
return err
}
func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
func GetRecentChatMessages(db *sql.DB, boardID int, limit int) ([]ChatMessage, error) {
query := `
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_url
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_file_id
FROM chat_messages cm
JOIN users u ON cm.user_id = u.id
WHERE cm.board_id = ?
ORDER BY cm.timestamp DESC
LIMIT ?`
rows, err := db.Query(query, limit)
rows, err := db.Query(query, boardID, limit)
if err != nil {
return nil, err
}
@ -39,20 +46,18 @@ func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
for rows.Next() {
var msg ChatMessage
var timestampStr string
var pfpURL sql.NullString
err := rows.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, &timestampStr, &msg.Username, &pfpURL)
var rawContent string
err := rows.Scan(&msg.ID, &msg.UserID, &rawContent, &msg.ReplyTo, &timestampStr, &msg.Username, &msg.PfpFileID)
if err != nil {
return nil, err
}
msg.BoardID = boardID
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)
msg.Content = renderMarkdown(rawContent)
msg.Mentions = extractMentions(rawContent)
messages = append(messages, msg)
}
return messages, nil
@ -60,15 +65,15 @@ func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
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
SELECT cm.id, cm.board_id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_file_id
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, &timestampStr, &msg.Username, &pfpURL)
var rawContent string
err := row.Scan(&msg.ID, &msg.BoardID, &msg.UserID, &rawContent, &msg.ReplyTo, &timestampStr, &msg.Username, &msg.PfpFileID)
if err == sql.ErrNoRows {
return nil, nil
}
@ -79,54 +84,122 @@ func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
if err != nil {
msg.Timestamp = time.Time{}
}
if pfpURL.Valid {
msg.PfpURL = pfpURL.String
}
msg.Mentions = extractMentions(msg.Content)
msg.Content = renderMarkdown(rawContent)
msg.Mentions = extractMentions(rawContent)
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)
re := regexp.MustCompile(`@(\w+)`)
matches := re.FindAllStringSubmatch(content, -1)
mentions := make([]string, len(matches))
for i, match := range matches {
mentions[i] = match[1]
}
return mentions
}
func processInlineMarkdown(line string) string {
line = regexp.MustCompile("`([^`]+)`").ReplaceAllString(line, "<code>$1</code>")
line = regexp.MustCompile(`\*\*([^\*]+)\*\*`).ReplaceAllString(line, "<strong>$1</strong>")
line = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(line, "<strong>$1</strong>")
line = regexp.MustCompile(`\*([^\*]+)\*`).ReplaceAllString(line, "<em>$1</em>")
line = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(line, "<em>$1</em>")
line = regexp.MustCompile(`@(\w+)`).ReplaceAllString(line, `<span class="chat-message-mention">@$1</span>`)
return line
}
func renderMarkdown(content string) template.HTML {
var sb strings.Builder
codeBlockRegex := regexp.MustCompile("(?s)```(\\w*)\n(.*?)\n```")
codeBlocks := make(map[string]string)
codeBlockCounter := 0
contentWithoutCodeBlocks := codeBlockRegex.ReplaceAllStringFunc(content, func(match string) string {
submatches := codeBlockRegex.FindStringSubmatch(match)
lang := submatches[1]
codeContent := submatches[2]
escapedCode := html.EscapeString(codeContent)
var renderedCodeBlock string
if lang != "" {
renderedCodeBlock = fmt.Sprintf("<pre><code class=\"language-%s\">%s</code></pre>", lang, escapedCode)
} else {
renderedCodeBlock = fmt.Sprintf("<pre><code>%s</code></pre>", escapedCode)
}
placeholder := fmt.Sprintf("<!--CODEBLOCK_%d-->", codeBlockCounter)
codeBlocks[placeholder] = renderedCodeBlock
codeBlockCounter++
return placeholder
})
lines := strings.Split(contentWithoutCodeBlocks, "\n")
inList := false
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
if strings.HasPrefix(trimmedLine, "### ") {
if inList { sb.WriteString("</ul>\n"); inList = false }
sb.WriteString("<h3>")
sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "### ")))
sb.WriteString("</h3>\n")
continue
} else if strings.HasPrefix(trimmedLine, "## ") {
if inList { sb.WriteString("</ul>\n"); inList = false }
sb.WriteString("<h2>")
sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "## ")))
sb.WriteString("</h2>\n")
continue
} else if strings.HasPrefix(trimmedLine, "# ") {
if inList { sb.WriteString("</ul>\n"); inList = false }
sb.WriteString("<h1>")
sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "# ")))
sb.WriteString("</h1>\n")
continue
}
if strings.HasPrefix(trimmedLine, "* ") || strings.HasPrefix(trimmedLine, "- ") {
if !inList {
sb.WriteString("<ul>\n")
inList = true
}
listItemContent := strings.TrimPrefix(strings.TrimPrefix(trimmedLine, "* "), "- ")
sb.WriteString("<li>")
sb.WriteString(processInlineMarkdown(listItemContent))
sb.WriteString("</li>\n")
continue
}
if inList {
sb.WriteString("</ul>\n")
inList = false
}
if trimmedLine != "" {
sb.WriteString("<p>")
sb.WriteString(processInlineMarkdown(trimmedLine))
sb.WriteString("</p>\n")
} else {
sb.WriteString("\n")
}
}
if inList {
sb.WriteString("</ul>\n")
}
finalContent := sb.String()
for placeholder, blockHTML := range codeBlocks {
finalContent = strings.ReplaceAll(finalContent, placeholder, blockHTML)
}
finalContent = regexp.MustCompile(`\n{3,}`).ReplaceAllString(finalContent, "\n\n")
return template.HTML(finalContent)
}

35
models/file.go Normal file
View File

@ -0,0 +1,35 @@
package models
import (
"database/sql"
)
type File struct {
ID int
OriginalName string
Hash string
HashAlgorithm string
}
func GetFileByID(db *sql.DB, id int64) (*File, error) {
query := "SELECT id, original_name, hash, hash_algorithm FROM files WHERE id = ?"
row := db.QueryRow(query, id)
file := &File{}
err := row.Scan(&file.ID, &file.OriginalName, &file.Hash, &file.HashAlgorithm)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return file, nil
}
func CreateFile(db *sql.DB, file File) (int64, error) {
query := "INSERT INTO files (original_name, hash, hash_algorithm) VALUES (?, ?, ?)"
result, err := db.Exec(query, file.OriginalName, file.Hash, file.HashAlgorithm)
if err != nil {
return 0, err
}
return result.LastInsertId()
}

View File

@ -70,7 +70,7 @@ func GetThreadsByBoardID(db *sql.DB, boardID int) ([]Thread, error) {
}
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')"
query := "INSERT INTO threads (board_id, title, created_by_user_id, created_at, updated_at) VALUES (?, ?, ?, NOW(), NOW())"
_, err := db.Exec(query, thread.BoardID, thread.Title, thread.CreatedByUserID)
return err
}

View File

@ -11,7 +11,7 @@ type User struct {
ID int
Username string
DisplayName string
PfpURL string
PfpFileID sql.NullInt64
Bio string
AuthenticationString string
AuthenticationSalt string
@ -23,15 +23,14 @@ type User struct {
}
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 = ?"
query := "SELECT id, username, display_name, pfp_file_id, 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)
err := row.Scan(&user.ID, &user.Username, &displayName, &user.PfpFileID, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
if err == sql.ErrNoRows {
return nil, nil
}
@ -43,11 +42,6 @@ func GetUserByID(db *sql.DB, id int) (*User, error) {
} else {
user.DisplayName = ""
}
if pfpURL.Valid {
user.PfpURL = pfpURL.String
} else {
user.PfpURL = ""
}
if bio.Valid {
user.Bio = bio.String
} else {
@ -73,15 +67,14 @@ func GetUserByID(db *sql.DB, id int) (*User, error) {
}
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 = ?"
query := "SELECT id, username, display_name, pfp_file_id, 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)
err := row.Scan(&user.ID, &user.Username, &displayName, &user.PfpFileID, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
if err != nil {
return nil, err
}
@ -90,11 +83,6 @@ func GetUserByUsername(db *sql.DB, username string) (*User, error) {
} else {
user.DisplayName = ""
}
if pfpURL.Valid {
user.PfpURL = pfpURL.String
} else {
user.PfpURL = ""
}
if bio.Valid {
user.Bio = bio.String
} else {
@ -145,9 +133,15 @@ func CreateUser(db *sql.DB, username, password string) error {
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)
func UpdateUserProfile(db *sql.DB, userID int, displayName, bio string) error {
query := "UPDATE users SET display_name = ?, bio = ?, updated_at = NOW() WHERE id = ?"
_, err := db.Exec(query, displayName, bio, userID)
return err
}
func UpdateUserPfp(db *sql.DB, userID int, pfpFileID int64) error {
query := "UPDATE users SET pfp_file_id = ? WHERE id = ?"
_, err := db.Exec(query, pfpFileID, userID)
return err
}
@ -159,3 +153,27 @@ const (
func HasGlobalPermission(user *User, perm int64) bool {
return user.Permissions&perm != 0
}
func GetUsernamesInBoard(db *sql.DB, boardID int) ([]string, error) {
query := `
SELECT DISTINCT u.username
FROM users u
JOIN chat_messages cm ON u.id = cm.user_id
WHERE cm.board_id = ?
ORDER BY u.username ASC`
rows, err := db.Query(query, boardID)
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
}

View File

@ -176,7 +176,7 @@ p, a, li {
color: #001858;
}
/* Enhanced styles for boards */
/* Styles for board lists */
ul.board-list {
list-style-type: none;
padding: 0;
@ -185,7 +185,7 @@ ul.board-list {
li.board-item {
margin-bottom: 1em;
padding: 1em;
padding: 1.2em 1.5em;
background-color: #fef6e4;
border: 1px solid #001858;
border-radius: 8px;
@ -201,7 +201,7 @@ li.board-item a {
color: #001858;
font-weight: bold;
text-decoration: none;
font-size: 1.2em;
font-size: 1.4em;
}
li.board-item a:hover {
@ -212,10 +212,50 @@ li.board-item a:hover {
p.board-desc {
margin: 0.5em 0 0 0;
color: #001858;
font-size: 0.9em;
font-size: 1em;
}
/* Enhanced styles for thread posts */
/* Styles for thread lists */
ul.thread-list {
list-style-type: none;
padding: 0;
margin: 0;
}
li.thread-item {
margin-bottom: 1em;
padding: 1.2em 1.5em;
background-color: #fef6e4;
border: 1px solid #001858;
border-radius: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
li.thread-item:hover {
transform: translateY(-3px);
box-shadow: 0px 6px 14px 0px rgba(0,0,0,0.15);
}
li.thread-item a {
color: #001858;
font-weight: bold;
text-decoration: none;
font-size: 1.4em;
}
li.thread-item a:hover {
color: #f582ae;
text-decoration: underline;
}
p.thread-info {
margin: 0.5em 0 0 0;
color: #001858;
font-size: 1em;
}
/* Specific styles for individual thread posts */
.thread-posts {
width: 80%;
max-width: 800px;
@ -226,7 +266,7 @@ p.board-desc {
border: 1px solid #001858;
border-radius: 8px;
margin-bottom: 1.5em;
padding: 1em;
padding: 1.2em 1.5em;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
@ -239,27 +279,27 @@ p.board-desc {
background-color: #001858;
color: #fef6e4;
padding: 0.5em;
margin: -1em -1em 1em -1em;
margin: -1.2em -1.5em 1em -1.5em;
border-radius: 6px 6px 0 0;
border-bottom: 1px solid #001858;
}
.post-item header h3 {
margin: 0;
font-size: 1.1em;
font-size: 1.2em;
}
.post-item header p {
margin: 0.3em 0 0 0;
font-size: 0.85em;
font-size: 0.95em;
opacity: 0.9;
}
.post-content {
margin: 0;
padding: 0.5em;
padding: 0.8em;
line-height: 1.5;
font-size: 0.95em;
font-size: 1em;
}
.post-actions {
@ -272,8 +312,8 @@ p.board-desc {
.post-actions a {
color: #001858;
text-decoration: none;
font-size: 0.9em;
padding: 0.3em 0.6em;
font-size: 1em;
padding: 0.4em 0.8em;
border: 1px solid #001858;
border-radius: 4px;
transition: background-color 0.2s ease;
@ -284,6 +324,23 @@ p.board-desc {
color: #fef6e4;
}
/* New style for highlighted chat messages */
.chat-message-highlighted {
border: 2px solid #f582ae; /* Pink border */
background-color: #ffe0f0; /* Light pink background */
animation: highlight-fade 2s ease-out;
}
@keyframes highlight-fade {
from {
background-color: #f582ae;
border-color: #f582ae;
}
to {
background-color: #ffe0f0;
border-color: #f582ae;
}
}
@media (prefers-color-scheme: dark) {
body {
background-color: #333;
@ -313,17 +370,17 @@ p.board-desc {
input[type="submit"]:hover, button:hover {
background-color: #8bd3dd;
}
li.board-item {
li.board-item, li.thread-item {
background-color: #444;
border-color: #fef6e4;
}
li.board-item a {
li.board-item a, li.thread-item a {
color: #fef6e4;
}
li.board-item a:hover {
li.board-item a:hover, li.thread-item a:hover {
color: #f582ae;
}
p.board-desc {
p.board-desc, p.thread-info {
color: #fef6e4;
}
.post-item {
@ -347,6 +404,22 @@ p.board-desc {
p, a, li {
color: #fef6e4;
}
/* Dark mode highlight */
.chat-message-highlighted {
border: 2px solid #f582ae; /* Pink border */
background-color: #6a0e3f; /* Darker pink background */
animation: highlight-fade-dark 2s ease-out;
}
@keyframes highlight-fade-dark {
from {
background-color: #f582ae;
border-color: #f582ae;
}
to {
background-color: #6a0e3f;
border-color: #f582ae;
}
}
}
@media (max-width: 600px) {

View File

@ -15,9 +15,12 @@
<section>
<h3>Threads</h3>
{{if .Threads}}
<ul>
<ul class="thread-list">
{{range .Threads}}
<li><a href="{{$.BasePath}}/thread/?id={{.ID}}">{{.Title}}</a> - Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}</li>
<li class="thread-item">
<a href="{{$.BasePath}}/thread/?id={{.ID}}">{{.Title}}</a>
<p class="thread-info">Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}</p>
</li>
{{end}}
</ul>
{{else}}

View File

@ -17,7 +17,11 @@
<ul class="board-list">
{{range .PublicBoards}}
<li class="board-item">
{{if eq .Type "chat"}}
<a href="{{$.BasePath}}/chat/?id={{.ID}}">{{.Name}} (Chat)</a>
{{else}}
<a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a>
{{end}}
<p class="board-desc">{{.Description}}</p>
</li>
{{end}}
@ -33,7 +37,11 @@
<ul class="board-list">
{{range .PrivateBoards}}
<li class="board-item">
{{if eq .Type "chat"}}
<a href="{{$.BasePath}}/chat/?id={{.ID}}">{{.Name}} (Chat)</a>
{{else}}
<a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a>
{{end}}
<p class="board-desc">{{.Description}}</p>
</li>
{{end}}
@ -51,6 +59,11 @@
<input type="text" id="name" name="name" required><br>
<label for="description">Description:</label>
<textarea id="description" name="description"></textarea><br>
<label for="type">Board Type:</label>
<select id="type" name="type">
<option value="classic">Classic Board</option>
<option value="chat">Chat Board</option>
</select><br>
<input type="submit" value="Create Board">
</form>
</section>

View File

@ -21,7 +21,7 @@
}
.chat-container {
width: 100%;
height: calc(100% - 2em); /* Adjust for header */
height: 100%;
display: flex;
flex-direction: column;
border: none;
@ -29,6 +29,11 @@
background-color: #fef6e4;
box-shadow: none;
}
.chat-header {
padding: 10px;
text-align: center;
border-bottom: 1px solid #001858;
}
.chat-messages {
flex: 1;
overflow-y: auto;
@ -167,9 +172,27 @@
background: none;
color: #f582ae;
}
/* New style for highlighted messages */
.chat-message-highlighted {
border: 2px solid #f582ae; /* Pink border */
background-color: #ffe0f0; /* Light pink background */
animation: highlight-fade 2s ease-out;
}
@keyframes highlight-fade {
from {
background-color: #f582ae;
border-color: #f582ae;
}
to {
background-color: #ffe0f0;
border-color: #f582ae;
}
}
@media (prefers-color-scheme: dark) {
.chat-container {
background-color: #444;
}
.chat-header {
border-color: #fef6e4;
}
.chat-message-username {
@ -210,22 +233,39 @@
.reply-indicator button:hover {
color: #f582ae;
}
/* Dark mode highlight */
.chat-message-highlighted {
border: 2px solid #f582ae; /* Pink border */
background-color: #6a0e3f; /* Darker pink background */
animation: highlight-fade-dark 2s ease-out;
}
@keyframes highlight-fade-dark {
from {
background-color: #f582ae;
border-color: #f582ae;
}
to {
background-color: #6a0e3f;
border-color: #f582ae;
}
}
}
</style>
</head>
<body>
{{template "navbar" .}}
<main>
<header style="display: none;">
<h2>General Chat</h2>
</header>
<div class="chat-container">
<header class="chat-header">
<h2>{{.Board.Name}}</h2>
<p>{{.Board.Description}}</p>
</header>
<div class="chat-messages" id="chat-messages">
{{range .Messages}}
<div class="chat-message" id="msg-{{.ID}}">
<div class="chat-message{{if .Mentions}}{{range .Mentions}}{{if eq . $.CurrentUsername}} chat-message-highlighted{{end}}{{end}}{{end}}" id="msg-{{.ID}}">
<div class="chat-message-header">
{{if .PfpURL}}
<img src="{{.PfpURL}}" alt="PFP" class="chat-message-pfp">
{{if .PfpFileID.Valid}}
<img src="{{$.BasePath}}/file?id={{.PfpFileID.Int64}}" alt="PFP" class="chat-message-pfp">
{{else}}
<div class="chat-message-pfp" style="background-color: #001858;"></div>
{{end}}
@ -233,11 +273,11 @@
<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>
<div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to message...</div>
{{end}}
<div class="chat-message-content">{{.Content | html}}</div>
<div class="chat-message-content">{{.Content}}</div>
<div class="post-actions">
<a href="javascript:void(0)" onclick="replyToMessage({{.ID}}, '{{.Username}}')">Reply</a>
<a href="javascript:void(0)" onclick="replyToMessage({{printf "%v" .ID}}, '{{.Username}}')">Reply</a>
</div>
</div>
{{end}}
@ -257,19 +297,20 @@
<script>
let ws;
let autocompleteActive = false;
let autocompletePrefix = '';
let replyToId = -1;
let replyUsername = '';
const allUsernames = {{.AllUsernames}};
const currentUsername = "{{.CurrentUsername}}";
function connectWebSocket() {
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true', [], { credentials: 'include' });
const boardID = {{.Board.ID}};
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true&id=' + boardID);
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
setTimeout(connectWebSocket, 5000);
};
ws.onerror = function(error) {
console.error("WebSocket error:", error);
@ -288,7 +329,7 @@
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
input.value = '';
cancelReply(); // Reset reply state after sending
cancelReply();
} else {
console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
}
@ -297,22 +338,28 @@
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>`);
let highlightClass = '';
if (msg.mentions && msg.mentions.includes(currentUsername)) {
highlightClass = ' chat-message-highlighted';
}
msgDiv.className = 'chat-message' + highlightClass;
msgDiv.id = 'msg-' + msg.id;
let pfpHTML = `<div class="chat-message-pfp" style="background-color: #001858;"></div>`;
if (msg.pfpFileId && msg.pfpFileId.Valid) {
pfpHTML = `<img src="{{.BasePath}}/file?id=${msg.pfpFileId.Int64}&t=${new Date().getTime()}" alt="PFP" class="chat-message-pfp">`;
}
let replyHTML = msg.replyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.replyTo})">Replying to message...</div>` : '';
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>
<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="chat-message-content">${msg.content}</div>
<div class="post-actions">
<a href="javascript:void(0)" onclick="replyToMessage(${msg.ID}, '${msg.Username}')">Reply</a>
<a href="javascript:void(0)" onclick="replyToMessage(${msg.id}, '${msg.username}')">Reply</a>
</div>
`;
messages.appendChild(msgDiv);
@ -321,7 +368,6 @@
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}`;
@ -331,7 +377,6 @@
function cancelReply() {
replyToId = -1;
replyUsername = '';
const replyIndicator = document.getElementById('reply-indicator');
replyIndicator.style.display = 'none';
}
@ -339,22 +384,27 @@
function scrollToMessage(id) {
const msgElement = document.getElementById('msg-' + id);
if (msgElement) {
msgElement.scrollIntoView({ behavior: 'smooth' });
msgElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
msgElement.style.transition = 'background-color 0.5s';
msgElement.style.backgroundColor = '#f582ae';
setTimeout(() => { msgElement.style.backgroundColor = ''; }, 1000);
}
}
function showAutocompletePopup(usernames, x, y) {
function showAutocompletePopup(suggestions, x, y) {
const popup = document.getElementById('autocomplete-popup');
popup.innerHTML = '';
popup.style.position = 'fixed';
popup.style.left = x + 'px';
popup.style.top = y + 'px';
popup.style.display = 'block';
autocompleteActive = true;
usernames.forEach(username => {
suggestions.forEach(username => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.textContent = username;
item.onclick = () => {
item.onmousedown = (e) => {
e.preventDefault();
completeMention(username);
popup.style.display = 'none';
autocompleteActive = false;
@ -366,65 +416,85 @@
function completeMention(username) {
const input = document.getElementById('chat-input-text');
const text = input.value;
const atIndex = text.lastIndexOf('@', input.selectionStart - 1);
const caretPos = input.selectionStart;
const textBeforeCaret = text.substring(0, caretPos);
const atIndex = textBeforeCaret.lastIndexOf('@');
if (atIndex !== -1) {
const before = text.substring(0, atIndex);
const after = text.substring(input.selectionStart);
input.value = before + username + (after.startsWith(' ') ? '' : ' ') + after;
const prefix = text.substring(0, atIndex);
const suffix = text.substring(caretPos);
input.value = prefix + '@' + username + ' ' + suffix;
const newCaretPos = prefix.length + 1 + username.length + 1;
input.focus();
input.setSelectionRange(newCaretPos, newCaretPos);
}
}
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;
}
function getCaretCoordinates(element, position) {
const mirrorDivId = 'input-mirror-div';
let div = document.createElement('div');
div.id = mirrorDivId;
document.body.appendChild(div);
const style = window.getComputedStyle(element);
const properties = ['border', 'boxSizing', 'fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight', 'padding', 'textDecoration', 'textIndent', 'textTransform', 'whiteSpace', 'wordSpacing', 'wordWrap', 'width'];
properties.forEach(prop => { div.style[prop] = style[prop]; });
div.style.position = 'absolute';
div.style.top = '-9999px';
div.style.left = '0px';
div.textContent = element.value.substring(0, position);
const span = document.createElement('span');
span.textContent = element.value.substring(position) || '.';
div.appendChild(span);
const coords = { top: span.offsetTop, left: span.offsetLeft };
document.body.removeChild(div);
return coords;
}
document.getElementById('chat-input-text').addEventListener('input', (e) => {
const input = e.target;
const text = input.value;
const caretPos = input.selectionStart;
const popup = document.getElementById('autocomplete-popup');
const textBeforeCaret = text.substring(0, caretPos);
const atIndex = textBeforeCaret.lastIndexOf('@');
if (atIndex !== -1 && (atIndex === 0 || /\s/.test(text.charAt(atIndex - 1)))) {
const query = textBeforeCaret.substring(atIndex + 1);
if (!/\s/.test(query)) {
const suggestions = allUsernames.filter(u => u.toLowerCase().startsWith(query.toLowerCase())).slice(0, 10);
if (suggestions.length > 0 && query.length > 0) {
const coords = getCaretCoordinates(input, atIndex);
const rect = input.getBoundingClientRect();
showAutocompletePopup(suggestions, rect.left + coords.left, rect.top + coords.top + 20);
} else {
popup.style.display = 'none';
autocompleteActive = false;
}
return;
}
}
popup.style.display = 'none';
autocompleteActive = false;
});
document.getElementById('chat-input-text').addEventListener('blur', () => {
setTimeout(() => {
if (!document.querySelector('.autocomplete-popup:hover')) {
document.getElementById('autocomplete-popup').style.display = 'none';
autocompleteActive = false;
}
}, 150);
});
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();
if (e.key === 'Enter' && !e.shiftKey) {
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;

View File

@ -14,8 +14,8 @@
<section>
<p>Username: {{.User.Username}}</p>
<p>Display Name: {{.DisplayName}}</p>
{{if .User.PfpURL}}
<img src="{{.User.PfpURL}}" alt="Profile Picture">
{{if .User.PfpFileID.Valid}}
<img src="{{.BasePath}}/file?id={{.User.PfpFileID.Int64}}" alt="Profile Picture">
{{end}}
<p>Bio: {{.User.Bio}}</p>
<p>Joined: {{.User.CreatedAt}}</p>

View File

@ -12,11 +12,11 @@
<h2>Edit Profile</h2>
</header>
<section>
<form method="post" action="{{.BasePath}}/profile/edit/">
<form method="post" action="{{.BasePath}}/profile/edit/" enctype="multipart/form-data">
<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="pfp">Profile Picture:</label>
<input type="file" id="pfp" name="pfp"><br>
<label for="bio">Bio:</label>
<textarea id="bio" name="bio">{{.User.Bio}}</textarea><br>
<input type="submit" value="Save">

View File

@ -4,7 +4,6 @@
{{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>