Add embedded web chat client with C2S HTTP API
- New DB schema: users, channel_members, messages tables (migration 003) - Full C2S HTTP API: register, channels, messages, DMs, polling - Preact SPA embedded via embed.FS, served at GET / - IRC-style UI: tab bar, channel messages, user list, DM tabs, /commands - Dark theme, responsive, esbuild-bundled (~19KB) - Polling-based message delivery (1.5s interval) - Commands: /join, /part, /msg, /nick
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ data.db
|
|||||||
*.out
|
*.out
|
||||||
vendor/
|
vendor/
|
||||||
debug.log
|
debug.log
|
||||||
|
web/node_modules/
|
||||||
|
|||||||
303
internal/db/queries.go
Normal file
303
internal/db/queries.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
31
internal/db/schema/003_users.sql
Normal file
31
internal/db/schema/003_users.sql
Normal file
@@ -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);
|
||||||
358
internal/handlers/api.go
Normal file
358
internal/handlers/api.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/chat/internal/config"
|
||||||
"git.eeqj.de/sneak/chat/internal/db"
|
"git.eeqj.de/sneak/chat/internal/db"
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
||||||
@@ -20,6 +21,7 @@ type Params struct {
|
|||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
|
Config *config.Config
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/chat/web"
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
@@ -45,4 +48,47 @@ func (s *Server) SetupRoutes() {
|
|||||||
r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
web/build.sh
Executable file
41
web/build.sh
Executable file
@@ -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/"
|
||||||
1
web/dist/app.js
vendored
Normal file
1
web/dist/app.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
web/dist/index.html
vendored
Normal file
13
web/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chat</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
274
web/dist/style.css
vendored
Normal file
274
web/dist/style.css
vendored
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
9
web/embed.go
Normal file
9
web/embed.go
Normal file
@@ -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
|
||||||
513
web/package-lock.json
generated
Normal file
513
web/package-lock.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
web/package.json
Normal file
18
web/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
374
web/src/app.jsx
Normal file
374
web/src/app.jsx
Normal file
@@ -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 (
|
||||||
|
<div class="login-screen">
|
||||||
|
<h1>{serverName}</h1>
|
||||||
|
{motd && <div class="motd">{motd}</div>}
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Choose a nickname..."
|
||||||
|
value={nick}
|
||||||
|
onInput={e => setNick(e.target.value)}
|
||||||
|
maxLength={32}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="submit">Connect</button>
|
||||||
|
</form>
|
||||||
|
{error && <div class="error">{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Message({ msg }) {
|
||||||
|
return (
|
||||||
|
<div class={`message ${msg.system ? 'system' : ''}`}>
|
||||||
|
<span class="timestamp">{formatTime(msg.createdAt)}</span>
|
||||||
|
<span class="nick" style={{ color: msg.system ? undefined : nickColor(msg.nick) }}>{msg.nick}</span>
|
||||||
|
<span class="content">{msg.content}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <LoginScreen onLogin={onLogin} />;
|
||||||
|
|
||||||
|
const currentTab = tabs[activeTab] || tabs[0];
|
||||||
|
const currentMessages = messages[currentTab.name] || [];
|
||||||
|
const currentMembers = members[currentTab.name] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="app">
|
||||||
|
<div class="tab-bar">
|
||||||
|
{tabs.map((tab, i) => (
|
||||||
|
<div
|
||||||
|
class={`tab ${i === activeTab ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab(i)}
|
||||||
|
>
|
||||||
|
{tab.type === 'dm' ? `→${tab.name}` : tab.name}
|
||||||
|
{tab.type !== 'server' && (
|
||||||
|
<span class="close-btn" onClick={(e) => { e.stopPropagation(); closeTab(i); }}>×</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div class="join-dialog">
|
||||||
|
<input
|
||||||
|
placeholder="#channel"
|
||||||
|
value={joinInput}
|
||||||
|
onInput={e => setJoinInput(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && joinChannel(joinInput)}
|
||||||
|
/>
|
||||||
|
<button onClick={() => joinChannel(joinInput)}>Join</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="messages-pane">
|
||||||
|
{currentTab.type === 'server' ? (
|
||||||
|
<div class="server-messages">
|
||||||
|
{currentMessages.map(m => <Message msg={m} />)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div class="messages">
|
||||||
|
{currentMessages.map(m => <Message msg={m} />)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
<div class="input-bar">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder={`Message ${currentTab.name}...`}
|
||||||
|
value={input}
|
||||||
|
onInput={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && sendMessage()}
|
||||||
|
/>
|
||||||
|
<button onClick={sendMessage}>Send</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentTab.type === 'channel' && (
|
||||||
|
<div class="user-list">
|
||||||
|
<h3>Users ({currentMembers.length})</h3>
|
||||||
|
{currentMembers.map(u => (
|
||||||
|
<div class="user" onClick={() => openDM(u.nick)} style={{ color: nickColor(u.nick) }}>
|
||||||
|
{u.nick}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<App />, document.getElementById('root'));
|
||||||
13
web/src/index.html
Normal file
13
web/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chat</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
274
web/src/style.css
Normal file
274
web/src/style.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user