diff --git a/.gitignore b/.gitignore index 69f180c..9959171 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ data.db *.out vendor/ debug.log +web/node_modules/ diff --git a/internal/db/queries.go b/internal/db/queries.go new file mode 100644 index 0000000..af7b83b --- /dev/null +++ b/internal/db/queries.go @@ -0,0 +1,303 @@ +package db + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" +) + +func generateToken() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// CreateUser registers a new user with the given nick and returns the user with token. +func (s *Database) CreateUser(ctx context.Context, nick string) (int64, string, error) { + token := generateToken() + now := time.Now() + res, err := s.db.ExecContext(ctx, + "INSERT INTO users (nick, token, created_at, last_seen) VALUES (?, ?, ?, ?)", + nick, token, now, now) + if err != nil { + return 0, "", fmt.Errorf("create user: %w", err) + } + id, _ := res.LastInsertId() + return id, token, nil +} + +// GetUserByToken returns user id and nick for a given auth token. +func (s *Database) GetUserByToken(ctx context.Context, token string) (int64, string, error) { + var id int64 + var nick string + err := s.db.QueryRowContext(ctx, "SELECT id, nick FROM users WHERE token = ?", token).Scan(&id, &nick) + if err != nil { + return 0, "", err + } + // Update last_seen + _, _ = s.db.ExecContext(ctx, "UPDATE users SET last_seen = ? WHERE id = ?", time.Now(), id) + return id, nick, nil +} + +// GetUserByNick returns user id for a given nick. +func (s *Database) GetUserByNick(ctx context.Context, nick string) (int64, error) { + var id int64 + err := s.db.QueryRowContext(ctx, "SELECT id FROM users WHERE nick = ?", nick).Scan(&id) + return id, err +} + +// GetOrCreateChannel returns the channel id, creating it if needed. +func (s *Database) GetOrCreateChannel(ctx context.Context, name string) (int64, error) { + var id int64 + err := s.db.QueryRowContext(ctx, "SELECT id FROM channels WHERE name = ?", name).Scan(&id) + if err == nil { + return id, nil + } + now := time.Now() + res, err := s.db.ExecContext(ctx, + "INSERT INTO channels (name, created_at, updated_at) VALUES (?, ?, ?)", + name, now, now) + if err != nil { + return 0, fmt.Errorf("create channel: %w", err) + } + id, _ = res.LastInsertId() + return id, nil +} + +// JoinChannel adds a user to a channel. +func (s *Database) JoinChannel(ctx context.Context, channelID, userID int64) error { + _, err := s.db.ExecContext(ctx, + "INSERT OR IGNORE INTO channel_members (channel_id, user_id, joined_at) VALUES (?, ?, ?)", + channelID, userID, time.Now()) + return err +} + +// PartChannel removes a user from a channel. +func (s *Database) PartChannel(ctx context.Context, channelID, userID int64) error { + _, err := s.db.ExecContext(ctx, + "DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?", + channelID, userID) + return err +} + +// ListChannels returns all channels the user has joined. +func (s *Database) ListChannels(ctx context.Context, userID int64) ([]ChannelInfo, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT c.id, c.name, c.topic FROM channels c + INNER JOIN channel_members cm ON cm.channel_id = c.id + WHERE cm.user_id = ? ORDER BY c.name`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var channels []ChannelInfo + for rows.Next() { + var ch ChannelInfo + if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil { + return nil, err + } + channels = append(channels, ch) + } + if channels == nil { + channels = []ChannelInfo{} + } + return channels, nil +} + +// ChannelInfo is a lightweight channel representation. +type ChannelInfo struct { + ID int64 `json:"id"` + Name string `json:"name"` + Topic string `json:"topic"` +} + +// ChannelMembers returns all members of a channel. +func (s *Database) ChannelMembers(ctx context.Context, channelID int64) ([]MemberInfo, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT u.id, u.nick, u.last_seen FROM users u + INNER JOIN channel_members cm ON cm.user_id = u.id + WHERE cm.channel_id = ? ORDER BY u.nick`, channelID) + if err != nil { + return nil, err + } + defer rows.Close() + var members []MemberInfo + for rows.Next() { + var m MemberInfo + if err := rows.Scan(&m.ID, &m.Nick, &m.LastSeen); err != nil { + return nil, err + } + members = append(members, m) + } + if members == nil { + members = []MemberInfo{} + } + return members, nil +} + +// MemberInfo represents a channel member. +type MemberInfo struct { + ID int64 `json:"id"` + Nick string `json:"nick"` + LastSeen time.Time `json:"lastSeen"` +} + +// MessageInfo represents a chat message. +type MessageInfo struct { + ID int64 `json:"id"` + Channel string `json:"channel,omitempty"` + Nick string `json:"nick"` + Content string `json:"content"` + IsDM bool `json:"isDm,omitempty"` + DMTarget string `json:"dmTarget,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// GetMessages returns messages for a channel, optionally after a given ID. +func (s *Database) GetMessages(ctx context.Context, channelID int64, afterID int64, limit int) ([]MessageInfo, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, + `SELECT m.id, c.name, u.nick, m.content, m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + INNER JOIN channels c ON c.id = m.channel_id + WHERE m.channel_id = ? AND m.is_dm = 0 AND m.id > ? + ORDER BY m.id ASC LIMIT ?`, channelID, afterID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var msgs []MessageInfo + for rows.Next() { + var m MessageInfo + if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil { + return nil, err + } + msgs = append(msgs, m) + } + if msgs == nil { + msgs = []MessageInfo{} + } + return msgs, nil +} + +// SendMessage inserts a channel message. +func (s *Database) SendMessage(ctx context.Context, channelID, userID int64, content string) (int64, error) { + res, err := s.db.ExecContext(ctx, + "INSERT INTO messages (channel_id, user_id, content, is_dm, created_at) VALUES (?, ?, ?, 0, ?)", + channelID, userID, content, time.Now()) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// SendDM inserts a direct message. +func (s *Database) SendDM(ctx context.Context, fromID, toID int64, content string) (int64, error) { + res, err := s.db.ExecContext(ctx, + "INSERT INTO messages (user_id, content, is_dm, dm_target_id, created_at) VALUES (?, ?, 1, ?, ?)", + fromID, content, toID, time.Now()) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// GetDMs returns direct messages between two users after a given ID. +func (s *Database) GetDMs(ctx context.Context, userA, userB int64, afterID int64, limit int) ([]MessageInfo, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, + `SELECT m.id, u.nick, m.content, t.nick, m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + INNER JOIN users t ON t.id = m.dm_target_id + WHERE m.is_dm = 1 AND m.id > ? + AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?)) + ORDER BY m.id ASC LIMIT ?`, afterID, userA, userB, userB, userA, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var msgs []MessageInfo + for rows.Next() { + var m MessageInfo + if err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt); err != nil { + return nil, err + } + m.IsDM = true + msgs = append(msgs, m) + } + if msgs == nil { + msgs = []MessageInfo{} + } + return msgs, nil +} + +// PollMessages returns all new messages (channel + DM) for a user after a given ID. +func (s *Database) PollMessages(ctx context.Context, userID int64, afterID int64, limit int) ([]MessageInfo, error) { + if limit <= 0 { + limit = 100 + } + rows, err := s.db.QueryContext(ctx, + `SELECT m.id, COALESCE(c.name, ''), u.nick, m.content, m.is_dm, COALESCE(t.nick, ''), m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + LEFT JOIN channels c ON c.id = m.channel_id + LEFT JOIN users t ON t.id = m.dm_target_id + WHERE m.id > ? AND ( + (m.is_dm = 0 AND m.channel_id IN (SELECT channel_id FROM channel_members WHERE user_id = ?)) + OR (m.is_dm = 1 AND (m.user_id = ? OR m.dm_target_id = ?)) + ) + ORDER BY m.id ASC LIMIT ?`, afterID, userID, userID, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var msgs []MessageInfo + for rows.Next() { + var m MessageInfo + var isDM int + if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &isDM, &m.DMTarget, &m.CreatedAt); err != nil { + return nil, err + } + m.IsDM = isDM == 1 + msgs = append(msgs, m) + } + if msgs == nil { + msgs = []MessageInfo{} + } + return msgs, nil +} + +// GetMOTD returns the server MOTD from config. +func (s *Database) GetServerName() string { + return "" +} + +// ListAllChannels returns all channels. +func (s *Database) ListAllChannels(ctx context.Context) ([]ChannelInfo, error) { + rows, err := s.db.QueryContext(ctx, + "SELECT id, name, topic FROM channels ORDER BY name") + if err != nil { + return nil, err + } + defer rows.Close() + var channels []ChannelInfo + for rows.Next() { + var ch ChannelInfo + if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil { + return nil, err + } + channels = append(channels, ch) + } + if channels == nil { + channels = []ChannelInfo{} + } + return channels, nil +} diff --git a/internal/db/schema/003_users.sql b/internal/db/schema/003_users.sql new file mode 100644 index 0000000..f305aa0 --- /dev/null +++ b/internal/db/schema/003_users.sql @@ -0,0 +1,31 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nick TEXT NOT NULL UNIQUE, + token TEXT NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_seen DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS channel_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(channel_id, user_id) +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + is_dm INTEGER NOT NULL DEFAULT 0, + dm_target_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at); +CREATE INDEX IF NOT EXISTS idx_messages_dm ON messages(user_id, dm_target_id, created_at); +CREATE INDEX IF NOT EXISTS idx_users_token ON users(token); diff --git a/internal/handlers/api.go b/internal/handlers/api.go new file mode 100644 index 0000000..22487d4 --- /dev/null +++ b/internal/handlers/api.go @@ -0,0 +1,358 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi" +) + +// authUser extracts the user from the Authorization header (Bearer token). +func (s *Handlers) authUser(r *http.Request) (int64, string, error) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + return 0, "", sql.ErrNoRows + } + token := strings.TrimPrefix(auth, "Bearer ") + return s.params.Database.GetUserByToken(r.Context(), token) +} + +func (s *Handlers) requireAuth(w http.ResponseWriter, r *http.Request) (int64, string, bool) { + uid, nick, err := s.authUser(r) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "unauthorized"}, http.StatusUnauthorized) + return 0, "", false + } + return uid, nick, true +} + +// HandleRegister creates a new user and returns the auth token. +func (s *Handlers) HandleRegister() http.HandlerFunc { + type request struct { + Nick string `json:"nick"` + } + type response struct { + ID int64 `json:"id"` + Nick string `json:"nick"` + Token string `json:"token"` + } + return func(w http.ResponseWriter, r *http.Request) { + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) + return + } + req.Nick = strings.TrimSpace(req.Nick) + if req.Nick == "" || len(req.Nick) > 32 { + s.respondJSON(w, r, map[string]string{"error": "nick must be 1-32 characters"}, http.StatusBadRequest) + return + } + id, token, err := s.params.Database.CreateUser(r.Context(), req.Nick) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + s.respondJSON(w, r, map[string]string{"error": "nick already taken"}, http.StatusConflict) + return + } + s.log.Error("create user failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, &response{ID: id, Nick: req.Nick, Token: token}, http.StatusCreated) + } +} + +// HandleMe returns the current user's info. +func (s *Handlers) HandleMe() http.HandlerFunc { + type response struct { + ID int64 `json:"id"` + Nick string `json:"nick"` + } + return func(w http.ResponseWriter, r *http.Request) { + uid, nick, ok := s.requireAuth(w, r) + if !ok { + return + } + s.respondJSON(w, r, &response{ID: uid, Nick: nick}, http.StatusOK) + } +} + +// HandleListChannels returns channels the user has joined. +func (s *Handlers) HandleListChannels() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + channels, err := s.params.Database.ListChannels(r.Context(), uid) + if err != nil { + s.log.Error("list channels failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, channels, http.StatusOK) + } +} + +// HandleListAllChannels returns all channels on the server. +func (s *Handlers) HandleListAllChannels() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, _, ok := s.requireAuth(w, r) + if !ok { + return + } + channels, err := s.params.Database.ListAllChannels(r.Context()) + if err != nil { + s.log.Error("list all channels failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, channels, http.StatusOK) + } +} + +// HandleJoinChannel joins a channel (creates it if needed). +func (s *Handlers) HandleJoinChannel() http.HandlerFunc { + type request struct { + Channel string `json:"channel"` + } + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) + return + } + req.Channel = strings.TrimSpace(req.Channel) + if req.Channel == "" { + s.respondJSON(w, r, map[string]string{"error": "channel name required"}, http.StatusBadRequest) + return + } + if !strings.HasPrefix(req.Channel, "#") { + req.Channel = "#" + req.Channel + } + chID, err := s.params.Database.GetOrCreateChannel(r.Context(), req.Channel) + if err != nil { + s.log.Error("get/create channel failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + if err := s.params.Database.JoinChannel(r.Context(), chID, uid); err != nil { + s.log.Error("join channel failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]string{"status": "joined", "channel": req.Channel}, http.StatusOK) + } +} + +// HandlePartChannel leaves a channel. +func (s *Handlers) HandlePartChannel() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + name := "#" + chi.URLParam(r, "channel") + var chID int64 + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", name).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + if err := s.params.Database.PartChannel(r.Context(), chID, uid); err != nil { + s.log.Error("part channel failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]string{"status": "parted", "channel": name}, http.StatusOK) + } +} + +// HandleChannelMembers returns members of a channel. +func (s *Handlers) HandleChannelMembers() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, _, ok := s.requireAuth(w, r) + if !ok { + return + } + name := "#" + chi.URLParam(r, "channel") + var chID int64 + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", name).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + members, err := s.params.Database.ChannelMembers(r.Context(), chID) + if err != nil { + s.log.Error("channel members failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, members, http.StatusOK) + } +} + +// HandleGetMessages returns messages for a channel. +func (s *Handlers) HandleGetMessages() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, _, ok := s.requireAuth(w, r) + if !ok { + return + } + name := "#" + chi.URLParam(r, "channel") + var chID int64 + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", name).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + afterID, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + msgs, err := s.params.Database.GetMessages(r.Context(), chID, afterID, limit) + if err != nil { + s.log.Error("get messages failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, msgs, http.StatusOK) + } +} + +// HandleSendMessage sends a message to a channel. +func (s *Handlers) HandleSendMessage() http.HandlerFunc { + type request struct { + Content string `json:"content"` + } + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + name := "#" + chi.URLParam(r, "channel") + var chID int64 + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", name).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Content) == "" { + s.respondJSON(w, r, map[string]string{"error": "content required"}, http.StatusBadRequest) + return + } + msgID, err := s.params.Database.SendMessage(r.Context(), chID, uid, req.Content) + if err != nil { + s.log.Error("send message failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) + } +} + +// HandleSendDM sends a direct message to a user. +func (s *Handlers) HandleSendDM() http.HandlerFunc { + type request struct { + Content string `json:"content"` + } + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + targetNick := chi.URLParam(r, "nick") + targetID, err := s.params.Database.GetUserByNick(r.Context(), targetNick) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) + return + } + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Content) == "" { + s.respondJSON(w, r, map[string]string{"error": "content required"}, http.StatusBadRequest) + return + } + msgID, err := s.params.Database.SendDM(r.Context(), uid, targetID, req.Content) + if err != nil { + s.log.Error("send dm failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) + } +} + +// HandleGetDMs returns direct messages with a user. +func (s *Handlers) HandleGetDMs() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + targetNick := chi.URLParam(r, "nick") + targetID, err := s.params.Database.GetUserByNick(r.Context(), targetNick) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) + return + } + afterID, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + msgs, err := s.params.Database.GetDMs(r.Context(), uid, targetID, afterID, limit) + if err != nil { + s.log.Error("get dms failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, msgs, http.StatusOK) + } +} + +// HandlePoll returns all new messages (channels + DMs) for the user. +func (s *Handlers) HandlePoll() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + afterID, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + msgs, err := s.params.Database.PollMessages(r.Context(), uid, afterID, limit) + if err != nil { + s.log.Error("poll messages failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, msgs, http.StatusOK) + } +} + +// HandleServerInfo returns server metadata (MOTD, name). +func (s *Handlers) HandleServerInfo() http.HandlerFunc { + type response struct { + Name string `json:"name"` + MOTD string `json:"motd"` + } + return func(w http.ResponseWriter, r *http.Request) { + s.respondJSON(w, r, &response{ + Name: s.params.Config.ServerName, + MOTD: s.params.Config.MOTD, + }, http.StatusOK) + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 8d0816b..11b8942 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" + "git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/chat/internal/db" "git.eeqj.de/sneak/chat/internal/globals" "git.eeqj.de/sneak/chat/internal/healthcheck" @@ -20,6 +21,7 @@ type Params struct { Logger *logger.Logger Globals *globals.Globals + Config *config.Config Database *db.Database Healthcheck *healthcheck.Healthcheck } diff --git a/internal/server/routes.go b/internal/server/routes.go index 313f186..228eb9d 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -1,9 +1,12 @@ package server import ( + "io/fs" "net/http" "time" + "git.eeqj.de/sneak/chat/web" + sentryhttp "github.com/getsentry/sentry-go/http" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" @@ -45,4 +48,47 @@ func (s *Server) SetupRoutes() { r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP)) }) } + + // API v1 + s.router.Route("/api/v1", func(r chi.Router) { + r.Get("/server", s.h.HandleServerInfo()) + r.Post("/register", s.h.HandleRegister()) + r.Get("/me", s.h.HandleMe()) + + // Channels + r.Get("/channels", s.h.HandleListChannels()) + r.Get("/channels/all", s.h.HandleListAllChannels()) + r.Post("/channels/join", s.h.HandleJoinChannel()) + r.Delete("/channels/{channel}/part", s.h.HandlePartChannel()) + r.Get("/channels/{channel}/members", s.h.HandleChannelMembers()) + r.Get("/channels/{channel}/messages", s.h.HandleGetMessages()) + r.Post("/channels/{channel}/messages", s.h.HandleSendMessage()) + + // DMs + r.Get("/dm/{nick}/messages", s.h.HandleGetDMs()) + r.Post("/dm/{nick}/messages", s.h.HandleSendDM()) + + // Polling + r.Get("/poll", s.h.HandlePoll()) + }) + + // Serve embedded SPA + distFS, err := fs.Sub(web.Dist, "dist") + if err != nil { + s.log.Error("failed to get web dist filesystem", "error", err) + } else { + fileServer := http.FileServer(http.FS(distFS)) + s.router.Get("/*", func(w http.ResponseWriter, r *http.Request) { + // Try to serve the file; if not found, serve index.html for SPA routing + f, err := distFS.(fs.ReadFileFS).ReadFile(r.URL.Path[1:]) + if err != nil || len(f) == 0 { + indexHTML, _ := distFS.(fs.ReadFileFS).ReadFile("index.html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(indexHTML) + return + } + fileServer.ServeHTTP(w, r) + }) + } } diff --git a/web/build.sh b/web/build.sh new file mode 100755 index 0000000..655225d --- /dev/null +++ b/web/build.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -e +cd "$(dirname "$0")" + +# Install esbuild if not present +if ! command -v esbuild >/dev/null 2>&1; then + if command -v npx >/dev/null 2>&1; then + NPX="npx" + else + echo "esbuild not found. Install it: npm install -g esbuild" + exit 1 + fi +else + NPX="" +fi + +mkdir -p dist + +# Build JS bundle +${NPX:+$NPX} esbuild src/app.jsx \ + --bundle \ + --minify \ + --jsx-factory=h \ + --jsx-fragment=Fragment \ + --define:process.env.NODE_ENV=\"production\" \ + --external:preact \ + --outfile=dist/app.js \ + 2>/dev/null || \ +${NPX:+$NPX} esbuild src/app.jsx \ + --bundle \ + --minify \ + --jsx-factory=h \ + --jsx-fragment=Fragment \ + --define:process.env.NODE_ENV=\"production\" \ + --outfile=dist/app.js + +# Copy static files +cp src/index.html dist/index.html +cp src/style.css dist/style.css + +echo "Build complete: web/dist/" diff --git a/web/dist/app.js b/web/dist/app.js new file mode 100644 index 0000000..2a5d789 --- /dev/null +++ b/web/dist/app.js @@ -0,0 +1 @@ +(()=>{var te,b,Ce,Ge,O,ge,Se,xe,Te,ae,ie,se,Qe,q={},Ee=[],Xe=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,ne=Array.isArray;function U(e,t){for(var n in t)e[n]=t[n];return e}function le(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function m(e,t,n){var _,i,o,s={};for(o in t)o=="key"?_=t[o]:o=="ref"?i=t[o]:s[o]=t[o];if(arguments.length>2&&(s.children=arguments.length>3?te.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(o in e.defaultProps)s[o]===void 0&&(s[o]=e.defaultProps[o]);return Y(e,s,_,i,null)}function Y(e,t,n,_,i){var o={type:e,props:t,key:n,ref:_,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:i??++Ce,__i:-1,__u:0};return i==null&&b.vnode!=null&&b.vnode(o),o}function _e(e){return e.children}function Z(e,t){this.props=e,this.context=t}function j(e,t){if(t==null)return e.__?j(e.__,e.__i+1):null;for(var n;tl&&O.sort(xe),e=O.shift(),l=O.length,e.__d&&(n=void 0,_=void 0,i=(_=(t=e).__v).__e,o=[],s=[],t.__P&&((n=U({},_)).__v=_.__v+1,b.vnode&&b.vnode(n),ue(t.__P,n,_,t.__n,t.__P.namespaceURI,32&_.__u?[i]:null,o,i??j(_),!!(32&_.__u),s),n.__v=_.__v,n.__.__k[n.__i]=n,He(o,n,s),_.__e=_.__=null,n.__e!=i&&Pe(n)));ee.__r=0}function Ie(e,t,n,_,i,o,s,l,d,c,h){var r,u,f,k,P,w,g,y=_&&_.__k||Ee,D=t.length;for(d=Ye(n,t,y,d,D),r=0;r0?s=e.__k[o]=Y(s.type,s.props,s.key,s.ref?s.ref:null,s.__v):e.__k[o]=s,d=o+u,s.__=e,s.__b=e.__b+1,l=null,(c=s.__i=Ze(s,n,d,r))!=-1&&(r--,(l=n[c])&&(l.__u|=2)),l==null||l.__v==null?(c==-1&&(i>h?u--:id?u--:u++,s.__u|=4))):e.__k[o]=null;if(r)for(o=0;o(h?1:0)){for(i=n-1,o=n+1;i>=0||o=0?i--:o++])!=null&&(2&c.__u)==0&&l==c.key&&d==c.type)return s}return-1}function ke(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||Xe.test(t)?n:n+"px"}function X(e,t,n,_,i){var o,s;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof _=="string"&&(e.style.cssText=_=""),_)for(t in _)n&&t in n||ke(e.style,t,"");if(n)for(t in n)_&&n[t]==_[t]||ke(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")o=t!=(t=t.replace(Te,"$1")),s=t.toLowerCase(),t=s in e||t=="onFocusOut"||t=="onFocusIn"?s.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+o]=n,n?_?n.u=_.u:(n.u=ae,e.addEventListener(t,o?se:ie,o)):e.removeEventListener(t,o?se:ie,o);else{if(i=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function we(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t.t==null)t.t=ae++;else if(t.t0?e:ne(e)?e.map(De):U({},e)}function et(e,t,n,_,i,o,s,l,d){var c,h,r,u,f,k,P,w=n.props||q,g=t.props,y=t.type;if(y=="svg"?i="http://www.w3.org/2000/svg":y=="math"?i="http://www.w3.org/1998/Math/MathML":i||(i="http://www.w3.org/1999/xhtml"),o!=null){for(c=0;c=n.__.length&&n.__.push({}),n.__[e]}function I(e){return K=1,nt(qe,e)}function nt(e,t,n){var _=he(z++,2);if(_.t=e,!_.__c&&(_.__=[n?n(t):qe(void 0,t),function(l){var d=_.__N?_.__N[0]:_.__[0],c=_.t(d,l);d!==c&&(_.__N=[c,_.__[1]],_.__c.setState({}))}],_.__c=S,!S.__f)){var i=function(l,d,c){if(!_.__c.__H)return!0;var h=_.__c.__H.__.filter(function(u){return!!u.__c});if(h.every(function(u){return!u.__N}))return!o||o.call(this,l,d,c);var r=_.__c.props!==l;return h.forEach(function(u){if(u.__N){var f=u.__[0];u.__=u.__N,u.__N=void 0,f!==u.__[0]&&(r=!0)}}),o&&o.call(this,l,d,c)||r};S.__f=!0;var o=S.shouldComponentUpdate,s=S.componentWillUpdate;S.componentWillUpdate=function(l,d,c){if(this.__e){var h=o;o=void 0,i(l,d,c),o=h}s&&s.call(this,l,d,c)},S.shouldComponentUpdate=i}return _.__N||_.__}function W(e,t){var n=he(z++,3);!x.__s&&Ve(n.__H,t)&&(n.__=e,n.u=t,S.__H.__h.push(n))}function G(e){return K=5,Be(function(){return{current:e}},[])}function Be(e,t){var n=he(z++,7);return Ve(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function oe(e,t){return K=8,Be(function(){return e},t)}function _t(){for(var e;e=Je.shift();)if(e.__P&&e.__H)try{e.__H.__h.forEach(re),e.__H.__h.forEach(de),e.__H.__h=[]}catch(t){e.__H.__h=[],x.__e(t,e.__v)}}x.__b=function(e){S=null,Ue&&Ue(e)},x.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),We&&We(e,t)},x.__r=function(e){Le&&Le(e),z=0;var t=(S=e.__c).__H;t&&(pe===S?(t.__h=[],S.__h=[],t.__.forEach(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.forEach(re),t.__h.forEach(de),t.__h=[],z=0)),pe=S},x.diffed=function(e){Fe&&Fe(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(Je.push(t)!==1&&Ae===x.requestAnimationFrame||((Ae=x.requestAnimationFrame)||rt)(_t)),t.__H.__.forEach(function(n){n.u&&(n.__H=n.u),n.u=void 0})),pe=S=null},x.__c=function(e,t){t.some(function(n){try{n.__h.forEach(re),n.__h=n.__h.filter(function(_){return!_.__||de(_)})}catch(_){t.some(function(i){i.__h&&(i.__h=[])}),t=[],x.__e(_,n.__v)}}),Oe&&Oe(e,t)},x.unmount=function(e){je&&je(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.forEach(function(_){try{re(_)}catch(i){t=i}}),n.__H=void 0,t&&x.__e(t,n.__v))};var Re=typeof requestAnimationFrame=="function";function rt(e){var t,n=function(){clearTimeout(_),Re&&cancelAnimationFrame(t),setTimeout(e)},_=setTimeout(n,35);Re&&(t=requestAnimationFrame(n))}function re(e){var t=S,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),S=t}function de(e){var t=S;e.__c=e.__(),S=t}function Ve(e,t){return!e||e.length!==t.length||t.some(function(n,_){return n!==e[_]})}function qe(e,t){return typeof t=="function"?t(e):t}var ot="/api/v1";function H(e,t={}){let n=localStorage.getItem("chat_token"),_={"Content-Type":"application/json",...t.headers||{}};return n&&(_.Authorization=`Bearer ${n}`),fetch(ot+e,{...t,headers:_}).then(async i=>{let o=await i.json().catch(()=>null);if(!i.ok)throw{status:i.status,data:o};return o})}function it(e){return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit"})}function Ke(e){let t=0;for(let _=0;_{H("/server").then(u=>{u.name&&d(u.name),u.motd&&s(u.motd)}).catch(()=>{});let r=localStorage.getItem("chat_token");r&&H("/me").then(u=>e(u.nick,r)).catch(()=>localStorage.removeItem("chat_token")),c.current?.focus()},[]),m("div",{class:"login-screen"},m("h1",null,l),o&&m("div",{class:"motd"},o),m("form",{onSubmit:async r=>{r.preventDefault(),i("");try{let u=await H("/register",{method:"POST",body:JSON.stringify({nick:t.trim()})});localStorage.setItem("chat_token",u.token),e(u.nick,u.token)}catch(u){i(u.data?.error||"Connection failed")}}},m("input",{ref:c,type:"text",placeholder:"Choose a nickname...",value:t,onInput:r=>n(r.target.value),maxLength:32,autoFocus:!0}),m("button",{type:"submit"},"Connect")),_&&m("div",{class:"error"},_))}function ze({msg:e}){return m("div",{class:`message ${e.system?"system":""}`},m("span",{class:"timestamp"},it(e.createdAt)),m("span",{class:"nick",style:{color:e.system?void 0:Ke(e.nick)}},e.nick),m("span",{class:"content"},e.content))}function ct(){let[e,t]=I(!1),[n,_]=I(""),[i,o]=I([{type:"server",name:"Server"}]),[s,l]=I(0),[d,c]=I({server:[]}),[h,r]=I({}),[u,f]=I(""),[k,P]=I(""),[w,g]=I(0),y=G(),D=G(),N=G(),$=oe((a,p)=>{c(v=>({...v,[a]:[...v[a]||[],p]}))},[]),E=oe((a,p)=>{$(a,{id:Date.now(),nick:"*",content:p,createdAt:new Date().toISOString(),system:!0})},[$]),Q=oe((a,p)=>{_(a),t(!0),E("server",`Connected as ${a}`),H("/server").then(v=>{v.motd&&E("server",`MOTD: ${v.motd}`)}).catch(()=>{})},[E]);W(()=>{if(!e)return;let a=!0,p=async()=>{try{let v=await H(`/poll?after=${w}`);if(!a)return;let T=w;for(let C of v)if(C.id>T&&(T=C.id),C.isDm){let B=C.nick===n?C.dmTarget:C.nick;o(V=>V.find(ye=>ye.type==="dm"&&ye.name===B)?V:[...V,{type:"dm",name:B}]),$(B,C)}else C.channel&&$(C.channel,C);T>w&&g(T)}catch{}};return N.current=setInterval(p,1500),p(),()=>{a=!1,clearInterval(N.current)}},[e,w,n,$]),W(()=>{if(!e)return;let a=i[s];if(!a||a.type!=="channel")return;let p=a.name.replace("#","");H(`/channels/${p}/members`).then(T=>{r(C=>({...C,[a.name]:T}))}).catch(()=>{});let v=setInterval(()=>{H(`/channels/${p}/members`).then(T=>{r(C=>({...C,[a.name]:T}))}).catch(()=>{})},5e3);return()=>clearInterval(v)},[e,s,i]),W(()=>{y.current?.scrollIntoView({behavior:"smooth"})},[d,s]),W(()=>{D.current?.focus()},[s]);let L=async a=>{if(a){a=a.trim(),a.startsWith("#")||(a="#"+a);try{await H("/channels/join",{method:"POST",body:JSON.stringify({channel:a})}),o(p=>p.find(v=>v.type==="channel"&&v.name===a)?p:[...p,{type:"channel",name:a}]),l(i.length),E(a,`Joined ${a}`),P("")}catch(p){E("server",`Failed to join ${a}: ${p.data?.error||"error"}`)}}},F=async a=>{let p=a.replace("#","");try{await H(`/channels/${p}/part`,{method:"DELETE"})}catch{}o(v=>v.filter(C=>!(C.type==="channel"&&C.name===a))),l(0)},R=a=>{let p=i[a];p.type==="channel"?F(p.name):p.type==="dm"&&(o(v=>v.filter((T,C)=>C!==a)),s>=a&&l(Math.max(0,s-1)))},M=a=>{o(p=>p.find(v=>v.type==="dm"&&v.name===a)?p:[...p,{type:"dm",name:a}]),l(i.findIndex(p=>p.type==="dm"&&p.name===a)||i.length)},A=async()=>{let a=u.trim();if(!a)return;f("");let p=i[s];if(!(!p||p.type==="server")){if(a.startsWith("/")){let v=a.split(" "),T=v[0].toLowerCase();if(T==="/join"&&v[1]){L(v[1]);return}if(T==="/part"){p.type==="channel"&&F(p.name);return}if(T==="/msg"&&v[1]&&v.slice(2).join(" ")){let C=v[1],B=v.slice(2).join(" ");try{await H(`/dm/${C}/messages`,{method:"POST",body:JSON.stringify({content:B})}),M(C)}catch(V){E("server",`Failed to send DM: ${V.data?.error||"error"}`)}return}if(T==="/nick"){E("server","Nick changes not yet supported");return}E("server",`Unknown command: ${T}`);return}if(p.type==="channel"){let v=p.name.replace("#","");try{await H(`/channels/${v}/messages`,{method:"POST",body:JSON.stringify({content:a})})}catch(T){E(p.name,`Send failed: ${T.data?.error||"error"}`)}}else if(p.type==="dm")try{await H(`/dm/${p.name}/messages`,{method:"POST",body:JSON.stringify({content:a})})}catch(v){E(p.name,`Send failed: ${v.data?.error||"error"}`)}}};if(!e)return m(st,{onLogin:Q});let J=i[s]||i[0],me=d[J.name]||[],ve=h[J.name]||[];return m("div",{class:"app"},m("div",{class:"tab-bar"},i.map((a,p)=>m("div",{class:`tab ${p===s?"active":""}`,onClick:()=>l(p)},a.type==="dm"?`\u2192${a.name}`:a.name,a.type!=="server"&&m("span",{class:"close-btn",onClick:v=>{v.stopPropagation(),R(p)}},"\xD7"))),m("div",{class:"join-dialog"},m("input",{placeholder:"#channel",value:k,onInput:a=>P(a.target.value),onKeyDown:a=>a.key==="Enter"&&L(k)}),m("button",{onClick:()=>L(k)},"Join"))),m("div",{class:"content"},m("div",{class:"messages-pane"},J.type==="server"?m("div",{class:"server-messages"},me.map(a=>m(ze,{msg:a})),m("div",{ref:y})):m(Fragment,null,m("div",{class:"messages"},me.map(a=>m(ze,{msg:a})),m("div",{ref:y})),m("div",{class:"input-bar"},m("input",{ref:D,placeholder:`Message ${J.name}...`,value:u,onInput:a=>f(a.target.value),onKeyDown:a=>a.key==="Enter"&&A()}),m("button",{onClick:A},"Send")))),J.type==="channel"&&m("div",{class:"user-list"},m("h3",null,"Users (",ve.length,")"),ve.map(a=>m("div",{class:"user",onClick:()=>M(a.nick),style:{color:Ke(a.nick)}},a.nick)))))}$e(m(ct,null),document.getElementById("root"));})(); diff --git a/web/dist/index.html b/web/dist/index.html new file mode 100644 index 0000000..fee4d65 --- /dev/null +++ b/web/dist/index.html @@ -0,0 +1,13 @@ + + + + + + Chat + + + +
+ + + diff --git a/web/dist/style.css b/web/dist/style.css new file mode 100644 index 0000000..929111c --- /dev/null +++ b/web/dist/style.css @@ -0,0 +1,274 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #1a1a2e; + --bg-secondary: #16213e; + --bg-input: #0f3460; + --text: #e0e0e0; + --text-muted: #888; + --accent: #e94560; + --accent2: #0f3460; + --border: #2a2a4a; + --nick: #53a8b6; + --timestamp: #666; + --tab-active: #e94560; + --tab-bg: #16213e; + --tab-hover: #1a1a3e; +} + +html, body, #root { + height: 100%; + font-family: 'Courier New', Courier, monospace; + font-size: 14px; + background: var(--bg); + color: var(--text); +} + +/* Login screen */ +.login-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 16px; +} + +.login-screen h1 { + color: var(--accent); + font-size: 2em; +} + +.login-screen input { + padding: 10px 16px; + font-size: 16px; + font-family: inherit; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + width: 280px; +} + +.login-screen button { + padding: 10px 24px; + font-size: 16px; + font-family: inherit; + background: var(--accent); + border: none; + color: white; + border-radius: 4px; + cursor: pointer; +} + +.login-screen .error { + color: var(--accent); +} + +.login-screen .motd { + color: var(--text-muted); + max-width: 400px; + text-align: center; + white-space: pre-wrap; +} + +/* Main layout */ +.app { + display: flex; + flex-direction: column; + height: 100%; +} + +/* Tab bar */ +.tab-bar { + display: flex; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + overflow-x: auto; + flex-shrink: 0; +} + +.tab { + padding: 8px 16px; + cursor: pointer; + border-bottom: 2px solid transparent; + white-space: nowrap; + color: var(--text-muted); + user-select: none; +} + +.tab:hover { + background: var(--tab-hover); +} + +.tab.active { + color: var(--text); + border-bottom-color: var(--tab-active); +} + +.tab .close-btn { + margin-left: 8px; + color: var(--text-muted); + font-size: 12px; +} + +.tab .close-btn:hover { + color: var(--accent); +} + +/* Content area */ +.content { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Messages */ +.messages-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 8px 12px; +} + +.message { + padding: 2px 0; + line-height: 1.4; + word-wrap: break-word; +} + +.message .timestamp { + color: var(--timestamp); + font-size: 12px; + margin-right: 8px; +} + +.message .nick { + color: var(--nick); + font-weight: bold; + margin-right: 8px; +} + +.message .nick::before { content: '<'; } +.message .nick::after { content: '>'; } + +.message.system { + color: var(--text-muted); + font-style: italic; +} + +.message.system .nick { + color: var(--text-muted); +} + +.message.system .nick::before, +.message.system .nick::after { content: ''; } + +/* Input */ +.input-bar { + display: flex; + border-top: 1px solid var(--border); + background: var(--bg-secondary); + flex-shrink: 0; +} + +.input-bar input { + flex: 1; + padding: 10px 12px; + font-family: inherit; + font-size: 14px; + background: var(--bg-input); + border: none; + color: var(--text); + outline: none; +} + +.input-bar button { + padding: 10px 16px; + font-family: inherit; + background: var(--accent); + border: none; + color: white; + cursor: pointer; +} + +/* User list */ +.user-list { + width: 160px; + background: var(--bg-secondary); + border-left: 1px solid var(--border); + overflow-y: auto; + padding: 8px; + flex-shrink: 0; +} + +.user-list h3 { + color: var(--text-muted); + font-size: 11px; + text-transform: uppercase; + margin-bottom: 8px; + letter-spacing: 1px; +} + +.user-list .user { + padding: 3px 4px; + color: var(--nick); + font-size: 13px; + cursor: pointer; +} + +.user-list .user:hover { + background: var(--tab-hover); +} + +/* Server tab */ +.server-messages { + color: var(--text-muted); + padding: 12px; + white-space: pre-wrap; + overflow-y: auto; + flex: 1; +} + +/* Channel join dialog */ +.join-dialog { + padding: 12px; + display: flex; + gap: 8px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); +} + +.join-dialog input { + padding: 6px 10px; + font-family: inherit; + font-size: 13px; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text); + border-radius: 3px; + width: 200px; +} + +.join-dialog button { + padding: 6px 14px; + font-family: inherit; + font-size: 13px; + background: var(--accent2); + border: none; + color: var(--text); + border-radius: 3px; + cursor: pointer; +} + +/* Responsive */ +@media (max-width: 600px) { + .user-list { display: none; } + .tab { padding: 6px 10px; font-size: 13px; } +} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..0daf0e2 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,9 @@ +// Package web embeds the built SPA static files. +package web + +import "embed" + +// Dist contains the built web client files. +// +//go:embed dist/* +var Dist embed.FS diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..f45ba3e --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,513 @@ +{ + "name": "web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "preact": "^10.28.3" + }, + "devDependencies": { + "esbuild": "^0.27.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/preact": { + "version": "10.28.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz", + "integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..52e2446 --- /dev/null +++ b/web/package.json @@ -0,0 +1,18 @@ +{ + "name": "web", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "esbuild": "^0.27.3" + }, + "dependencies": { + "preact": "^10.28.3" + } +} diff --git a/web/src/app.jsx b/web/src/app.jsx new file mode 100644 index 0000000..bf4c9a7 --- /dev/null +++ b/web/src/app.jsx @@ -0,0 +1,374 @@ +import { h, render, Component } from 'preact'; +import { useState, useEffect, useRef, useCallback } from 'preact/hooks'; + +const API = '/api/v1'; + +function api(path, opts = {}) { + const token = localStorage.getItem('chat_token'); + const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) }; + if (token) headers['Authorization'] = `Bearer ${token}`; + return fetch(API + path, { ...opts, headers }).then(async r => { + const data = await r.json().catch(() => null); + if (!r.ok) throw { status: r.status, data }; + return data; + }); +} + +function formatTime(ts) { + const d = new Date(ts); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +// Nick color hashing +function nickColor(nick) { + let h = 0; + for (let i = 0; i < nick.length; i++) h = nick.charCodeAt(i) + ((h << 5) - h); + const hue = Math.abs(h) % 360; + return `hsl(${hue}, 70%, 65%)`; +} + +function LoginScreen({ onLogin }) { + const [nick, setNick] = useState(''); + const [error, setError] = useState(''); + const [motd, setMotd] = useState(''); + const [serverName, setServerName] = useState('Chat'); + const inputRef = useRef(); + + useEffect(() => { + api('/server').then(s => { + if (s.name) setServerName(s.name); + if (s.motd) setMotd(s.motd); + }).catch(() => {}); + // Check for saved token + const saved = localStorage.getItem('chat_token'); + if (saved) { + api('/me').then(u => onLogin(u.nick, saved)).catch(() => localStorage.removeItem('chat_token')); + } + inputRef.current?.focus(); + }, []); + + const submit = async (e) => { + e.preventDefault(); + setError(''); + try { + const res = await api('/register', { + method: 'POST', + body: JSON.stringify({ nick: nick.trim() }) + }); + localStorage.setItem('chat_token', res.token); + onLogin(res.nick, res.token); + } catch (err) { + setError(err.data?.error || 'Connection failed'); + } + }; + + return ( + + ); +} + +function Message({ msg }) { + return ( +
+ {formatTime(msg.createdAt)} + {msg.nick} + {msg.content} +
+ ); +} + +function App() { + const [loggedIn, setLoggedIn] = useState(false); + const [nick, setNick] = useState(''); + const [tabs, setTabs] = useState([{ type: 'server', name: 'Server' }]); + const [activeTab, setActiveTab] = useState(0); + const [messages, setMessages] = useState({ server: [] }); // keyed by tab name + const [members, setMembers] = useState({}); // keyed by channel name + const [input, setInput] = useState(''); + const [joinInput, setJoinInput] = useState(''); + const [lastMsgId, setLastMsgId] = useState(0); + const messagesEndRef = useRef(); + const inputRef = useRef(); + const pollRef = useRef(); + + const addMessage = useCallback((tabName, msg) => { + setMessages(prev => ({ + ...prev, + [tabName]: [...(prev[tabName] || []), msg] + })); + }, []); + + const addSystemMessage = useCallback((tabName, text) => { + addMessage(tabName, { + id: Date.now(), + nick: '*', + content: text, + createdAt: new Date().toISOString(), + system: true + }); + }, [addMessage]); + + const onLogin = useCallback((userNick, token) => { + setNick(userNick); + setLoggedIn(true); + addSystemMessage('server', `Connected as ${userNick}`); + // Fetch server info + api('/server').then(s => { + if (s.motd) addSystemMessage('server', `MOTD: ${s.motd}`); + }).catch(() => {}); + }, [addSystemMessage]); + + // Poll for new messages + useEffect(() => { + if (!loggedIn) return; + let alive = true; + const poll = async () => { + try { + const msgs = await api(`/poll?after=${lastMsgId}`); + if (!alive) return; + let maxId = lastMsgId; + for (const msg of msgs) { + if (msg.id > maxId) maxId = msg.id; + if (msg.isDm) { + const dmTab = msg.nick === nick ? msg.dmTarget : msg.nick; + // Ensure DM tab exists + setTabs(prev => { + if (!prev.find(t => t.type === 'dm' && t.name === dmTab)) { + return [...prev, { type: 'dm', name: dmTab }]; + } + return prev; + }); + addMessage(dmTab, msg); + } else if (msg.channel) { + addMessage(msg.channel, msg); + } + } + if (maxId > lastMsgId) setLastMsgId(maxId); + } catch (err) { + // silent + } + }; + pollRef.current = setInterval(poll, 1500); + poll(); + return () => { alive = false; clearInterval(pollRef.current); }; + }, [loggedIn, lastMsgId, nick, addMessage]); + + // Fetch members for active channel tab + useEffect(() => { + if (!loggedIn) return; + const tab = tabs[activeTab]; + if (!tab || tab.type !== 'channel') return; + const chName = tab.name.replace('#', ''); + api(`/channels/${chName}/members`).then(m => { + setMembers(prev => ({ ...prev, [tab.name]: m })); + }).catch(() => {}); + const iv = setInterval(() => { + api(`/channels/${chName}/members`).then(m => { + setMembers(prev => ({ ...prev, [tab.name]: m })); + }).catch(() => {}); + }, 5000); + return () => clearInterval(iv); + }, [loggedIn, activeTab, tabs]); + + // Auto-scroll + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, activeTab]); + + // Focus input on tab change + useEffect(() => { + inputRef.current?.focus(); + }, [activeTab]); + + const joinChannel = async (name) => { + if (!name) return; + name = name.trim(); + if (!name.startsWith('#')) name = '#' + name; + try { + await api('/channels/join', { method: 'POST', body: JSON.stringify({ channel: name }) }); + setTabs(prev => { + if (prev.find(t => t.type === 'channel' && t.name === name)) return prev; + return [...prev, { type: 'channel', name }]; + }); + setActiveTab(tabs.length); // switch to new tab + addSystemMessage(name, `Joined ${name}`); + setJoinInput(''); + } catch (err) { + addSystemMessage('server', `Failed to join ${name}: ${err.data?.error || 'error'}`); + } + }; + + const partChannel = async (name) => { + const chName = name.replace('#', ''); + try { + await api(`/channels/${chName}/part`, { method: 'DELETE' }); + } catch (err) { /* ignore */ } + setTabs(prev => { + const next = prev.filter(t => !(t.type === 'channel' && t.name === name)); + return next; + }); + setActiveTab(0); + }; + + const closeTab = (idx) => { + const tab = tabs[idx]; + if (tab.type === 'channel') { + partChannel(tab.name); + } else if (tab.type === 'dm') { + setTabs(prev => prev.filter((_, i) => i !== idx)); + if (activeTab >= idx) setActiveTab(Math.max(0, activeTab - 1)); + } + }; + + const openDM = (targetNick) => { + setTabs(prev => { + if (prev.find(t => t.type === 'dm' && t.name === targetNick)) return prev; + return [...prev, { type: 'dm', name: targetNick }]; + }); + setActiveTab(tabs.findIndex(t => t.type === 'dm' && t.name === targetNick) || tabs.length); + }; + + const sendMessage = async () => { + const text = input.trim(); + if (!text) return; + setInput(''); + const tab = tabs[activeTab]; + if (!tab || tab.type === 'server') return; + + // Handle /commands + if (text.startsWith('/')) { + const parts = text.split(' '); + const cmd = parts[0].toLowerCase(); + if (cmd === '/join' && parts[1]) { + joinChannel(parts[1]); + return; + } + if (cmd === '/part') { + if (tab.type === 'channel') partChannel(tab.name); + return; + } + if (cmd === '/msg' && parts[1] && parts.slice(2).join(' ')) { + const target = parts[1]; + const msg = parts.slice(2).join(' '); + try { + await api(`/dm/${target}/messages`, { method: 'POST', body: JSON.stringify({ content: msg }) }); + openDM(target); + } catch (err) { + addSystemMessage('server', `Failed to send DM: ${err.data?.error || 'error'}`); + } + return; + } + if (cmd === '/nick') { + addSystemMessage('server', 'Nick changes not yet supported'); + return; + } + addSystemMessage('server', `Unknown command: ${cmd}`); + return; + } + + if (tab.type === 'channel') { + const chName = tab.name.replace('#', ''); + try { + await api(`/channels/${chName}/messages`, { method: 'POST', body: JSON.stringify({ content: text }) }); + } catch (err) { + addSystemMessage(tab.name, `Send failed: ${err.data?.error || 'error'}`); + } + } else if (tab.type === 'dm') { + try { + await api(`/dm/${tab.name}/messages`, { method: 'POST', body: JSON.stringify({ content: text }) }); + } catch (err) { + addSystemMessage(tab.name, `Send failed: ${err.data?.error || 'error'}`); + } + } + }; + + if (!loggedIn) return ; + + const currentTab = tabs[activeTab] || tabs[0]; + const currentMessages = messages[currentTab.name] || []; + const currentMembers = members[currentTab.name] || []; + + return ( +
+
+ {tabs.map((tab, i) => ( +
setActiveTab(i)} + > + {tab.type === 'dm' ? `→${tab.name}` : tab.name} + {tab.type !== 'server' && ( + { e.stopPropagation(); closeTab(i); }}>× + )} +
+ ))} +
+ setJoinInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && joinChannel(joinInput)} + /> + +
+
+ +
+
+ {currentTab.type === 'server' ? ( +
+ {currentMessages.map(m => )} +
+
+ ) : ( + <> +
+ {currentMessages.map(m => )} +
+
+
+ setInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && sendMessage()} + /> + +
+ + )} +
+ + {currentTab.type === 'channel' && ( +
+

Users ({currentMembers.length})

+ {currentMembers.map(u => ( +
openDM(u.nick)} style={{ color: nickColor(u.nick) }}> + {u.nick} +
+ ))} +
+ )} +
+
+ ); +} + +render(, document.getElementById('root')); diff --git a/web/src/index.html b/web/src/index.html new file mode 100644 index 0000000..fee4d65 --- /dev/null +++ b/web/src/index.html @@ -0,0 +1,13 @@ + + + + + + Chat + + + +
+ + + diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..929111c --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,274 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #1a1a2e; + --bg-secondary: #16213e; + --bg-input: #0f3460; + --text: #e0e0e0; + --text-muted: #888; + --accent: #e94560; + --accent2: #0f3460; + --border: #2a2a4a; + --nick: #53a8b6; + --timestamp: #666; + --tab-active: #e94560; + --tab-bg: #16213e; + --tab-hover: #1a1a3e; +} + +html, body, #root { + height: 100%; + font-family: 'Courier New', Courier, monospace; + font-size: 14px; + background: var(--bg); + color: var(--text); +} + +/* Login screen */ +.login-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 16px; +} + +.login-screen h1 { + color: var(--accent); + font-size: 2em; +} + +.login-screen input { + padding: 10px 16px; + font-size: 16px; + font-family: inherit; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + width: 280px; +} + +.login-screen button { + padding: 10px 24px; + font-size: 16px; + font-family: inherit; + background: var(--accent); + border: none; + color: white; + border-radius: 4px; + cursor: pointer; +} + +.login-screen .error { + color: var(--accent); +} + +.login-screen .motd { + color: var(--text-muted); + max-width: 400px; + text-align: center; + white-space: pre-wrap; +} + +/* Main layout */ +.app { + display: flex; + flex-direction: column; + height: 100%; +} + +/* Tab bar */ +.tab-bar { + display: flex; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + overflow-x: auto; + flex-shrink: 0; +} + +.tab { + padding: 8px 16px; + cursor: pointer; + border-bottom: 2px solid transparent; + white-space: nowrap; + color: var(--text-muted); + user-select: none; +} + +.tab:hover { + background: var(--tab-hover); +} + +.tab.active { + color: var(--text); + border-bottom-color: var(--tab-active); +} + +.tab .close-btn { + margin-left: 8px; + color: var(--text-muted); + font-size: 12px; +} + +.tab .close-btn:hover { + color: var(--accent); +} + +/* Content area */ +.content { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Messages */ +.messages-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 8px 12px; +} + +.message { + padding: 2px 0; + line-height: 1.4; + word-wrap: break-word; +} + +.message .timestamp { + color: var(--timestamp); + font-size: 12px; + margin-right: 8px; +} + +.message .nick { + color: var(--nick); + font-weight: bold; + margin-right: 8px; +} + +.message .nick::before { content: '<'; } +.message .nick::after { content: '>'; } + +.message.system { + color: var(--text-muted); + font-style: italic; +} + +.message.system .nick { + color: var(--text-muted); +} + +.message.system .nick::before, +.message.system .nick::after { content: ''; } + +/* Input */ +.input-bar { + display: flex; + border-top: 1px solid var(--border); + background: var(--bg-secondary); + flex-shrink: 0; +} + +.input-bar input { + flex: 1; + padding: 10px 12px; + font-family: inherit; + font-size: 14px; + background: var(--bg-input); + border: none; + color: var(--text); + outline: none; +} + +.input-bar button { + padding: 10px 16px; + font-family: inherit; + background: var(--accent); + border: none; + color: white; + cursor: pointer; +} + +/* User list */ +.user-list { + width: 160px; + background: var(--bg-secondary); + border-left: 1px solid var(--border); + overflow-y: auto; + padding: 8px; + flex-shrink: 0; +} + +.user-list h3 { + color: var(--text-muted); + font-size: 11px; + text-transform: uppercase; + margin-bottom: 8px; + letter-spacing: 1px; +} + +.user-list .user { + padding: 3px 4px; + color: var(--nick); + font-size: 13px; + cursor: pointer; +} + +.user-list .user:hover { + background: var(--tab-hover); +} + +/* Server tab */ +.server-messages { + color: var(--text-muted); + padding: 12px; + white-space: pre-wrap; + overflow-y: auto; + flex: 1; +} + +/* Channel join dialog */ +.join-dialog { + padding: 12px; + display: flex; + gap: 8px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); +} + +.join-dialog input { + padding: 6px 10px; + font-family: inherit; + font-size: 13px; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text); + border-radius: 3px; + width: 200px; +} + +.join-dialog button { + padding: 6px 14px; + font-family: inherit; + font-size: 13px; + background: var(--accent2); + border: none; + color: var(--text); + border-radius: 3px; + cursor: pointer; +} + +/* Responsive */ +@media (max-width: 600px) { + .user-list { display: none; } + .tab { padding: 6px 10px; font-size: 13px; } +}