Phase 1 MVP: gateway, CRUD handlers, frontend components

Backend:
- WebSocket gateway (hub, client, events) with fanout broadcast
- Server CRUD handlers (create, list, get, update, delete)
- Channel CRUD handlers (create, list, get, update, delete)
- Message CRUD handlers (list with cursor pagination, create, update, delete)
- cmd/migrate standalone migration CLI (up/down)
- cmd/server wired to all handlers + WebSocket + static file serving

Frontend:
- Zustand stores: auth, server, channel, message, websocket
- API client with fetch wrapper
- Terminal-styled components: Layout, LoginForm, ChatArea, ChannelList, ServerBar, MemberList
- React Router with login and main routes
- Gruvbox dark palette throughout

Ops:
- Docker Compose with app service (multi-stage build)
- Caddyfile with WebSocket upgrade support
- Makefile for common tasks
This commit is contained in:
2026-06-26 14:47:29 -04:00
parent aa7854aee2
commit bb5a56816b
32 changed files with 2310 additions and 339 deletions
+2
View File
@@ -38,3 +38,5 @@ postgres_data/
valkey_data/ valkey_data/
minio_data/ minio_data/
server server
migrate
dumpster-server
+42
View File
@@ -0,0 +1,42 @@
.PHONY: build build-web build-server run dev docker-up docker-down clean
# Build everything
build: build-web build-server
# Build the Go server
build-server:
CGO_ENABLED=0 go build -o dumpster-server ./cmd/server
# Build the web frontend
build-web:
cd web && npm run build
# Run the server locally (dev mode)
run: build-server
./dumpster-server
# Run web dev server (in a separate terminal)
dev-web:
cd web && npm run dev
# Start all backing services (no app, run that locally)
docker-dev:
docker compose -f docker/compose.yml up postgres valkey minio livekit coturn -d
# Start everything in Docker
docker-up:
docker compose -f docker/compose.yml up -d --build
# Stop everything
docker-down:
docker compose -f docker/compose.yml down
# Stop and remove volumes
docker-clean:
docker compose -f docker/compose.yml down -v
# Clean build artifacts
clean:
rm -f dumpster-server
rm -rf web/dist
rm -rf web/node_modules
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"database/sql"
"flag"
"fmt"
"log"
"os"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
direction := flag.String("direction", "up", "migration direction: up or down")
flag.Parse()
cfg := config.Load()
db, err := sql.Open("pgx", cfg.DatabaseDSN())
if err != nil {
log.Fatalf("failed to open database: %v", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatalf("failed to ping database: %v", err)
}
switch *direction {
case "up":
fmt.Println("running migrations up...")
if err := runUp(db); err != nil {
log.Fatalf("migration failed: %v", err)
}
fmt.Println("migrations complete")
case "down":
fmt.Println("running migrations down...")
if err := runDown(db); err != nil {
log.Fatalf("migration failed: %v", err)
}
fmt.Println("migrations complete")
default:
fmt.Fprintf(os.Stderr, "unknown direction: %s\n", *direction)
os.Exit(1)
}
}
const upSQL = `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(32) NOT NULL UNIQUE,
display_name VARCHAR(32),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255),
avatar TEXT,
status VARCHAR(16) NOT NULL DEFAULT 'offline',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS servers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
icon TEXT,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
type VARCHAR(16) NOT NULL DEFAULT 'text',
category VARCHAR(100) NOT NULL DEFAULT 'general',
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (server_id, name)
);
CREATE TABLE IF NOT EXISTS members (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, server_id)
);
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
edited_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
color VARCHAR(7),
permissions BIGINT NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS member_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_members_server_user ON members(server_id, user_id);
`
const downSQL = `
DROP TABLE IF EXISTS member_roles;
DROP TABLE IF EXISTS roles;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS members;
DROP TABLE IF EXISTS channels;
DROP TABLE IF EXISTS servers;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
`
func runUp(db *sql.DB) error {
_, err := db.Exec(upSQL)
return err
}
func runDown(db *sql.DB) error {
_, err := db.Exec(downSQL)
return err
}
+64 -6
View File
@@ -7,10 +7,15 @@ import (
"os" "os"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/auth" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/auth"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/channel"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" chimw "github.com/go-chi/chi/v5/middleware"
) )
func main() { func main() {
@@ -30,20 +35,73 @@ func main() {
os.Exit(1) os.Exit(1)
} }
r := chi.NewRouter() // Session store for middleware
r.Use(middleware.Logger) sessionStore := auth.NewSessionStore(database.DB, cfg)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
// WebSocket hub
hub := gateway.NewHub(logger)
go hub.Run()
r := chi.NewRouter()
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
r.Use(chimw.RequestID)
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) { r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("ok")) w.Write([]byte("ok"))
}) })
r.Route("/api/v1/auth", func(r chi.Router) { // WebSocket endpoint
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
gateway.ServeWS(database.DB, hub, logger, w, r)
})
// API routes
r.Route("/api/v1", func(r chi.Router) {
// Auth (public)
r.Route("/auth", func(r chi.Router) {
auth.NewHandler(database.DB, cfg).RegisterRoutes(r) auth.NewHandler(database.DB, cfg).RegisterRoutes(r)
}) })
// Protected routes
r.Group(func(r chi.Router) {
r.Use(middleware.Session(sessionStore, cfg))
r.Use(middleware.RequireAuth)
// Servers
r.Route("/servers", func(r chi.Router) {
server.NewHandler(database.DB).RegisterRoutes(r)
// Channels (nested under servers)
r.Route("/{serverID}/channels", func(r chi.Router) {
channel.NewHandler(database.DB).RegisterRoutes(r)
})
})
// Messages (under channels)
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
message.NewHandler(database.DB, hub).RegisterRoutes(r)
})
})
})
// Static file serving for production (SPA)
staticDir := "/srv/web"
if _, err := os.Stat(staticDir); err == nil {
fileServer := http.FileServer(http.Dir(staticDir))
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
// If the file exists, serve it; otherwise serve index.html (SPA fallback)
path := staticDir + r.URL.Path
if _, err := os.Stat(path); os.IsNotExist(err) {
http.ServeFile(w, r, staticDir+"/index.html")
return
}
fileServer.ServeHTTP(w, r)
})
}
addr := fmt.Sprintf(":%s", cfg.Port) addr := fmt.Sprintf(":%s", cfg.Port)
logger.Info("starting server", "addr", addr) logger.Info("starting server", "addr", addr)
if err := http.ListenAndServe(addr, r); err != nil { if err := http.ListenAndServe(addr, r); err != nil {
+9 -2
View File
@@ -3,18 +3,25 @@
} }
:80 { :80 {
# API routes
handle_path /api/* { handle_path /api/* {
reverse_proxy app:8080 reverse_proxy app:8080
} }
handle_path /ws { # WebSocket gateway
reverse_proxy app:8080 handle /ws {
reverse_proxy app:8080 {
header_up Connection {>Connection}
header_up Upgrade {>Upgrade}
}
} }
# LiveKit (if proxied)
handle_path /livekit/* { handle_path /livekit/* {
reverse_proxy livekit:7880 reverse_proxy livekit:7880
} }
# SPA fallback - serve index.html for all other routes
handle { handle {
root * /srv/web root * /srv/web
try_files {path} /index.html try_files {path} /index.html
+3 -2
View File
@@ -2,13 +2,14 @@ FROM golang:1.24-alpine AS go-builder
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY cmd/ cmd/
COPY internal/ internal/
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/dumpster-server ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/dumpster-server ./cmd/server
FROM node:22-alpine AS web-builder FROM node:22-alpine AS web-builder
WORKDIR /app WORKDIR /app
COPY web/package.json web/package-lock.json ./ COPY web/package.json web/package-lock.json ./
RUN npm ci RUN npm ci --include=dev
COPY web/ . COPY web/ .
RUN npm run build RUN npm run build
+30 -12
View File
@@ -1,6 +1,26 @@
version: '3.8' version: '3.8'
services: services:
app:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: dumpster-app
env_file:
- ../.env
environment:
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
VALKEY_URL: redis://valkey:6379
MINIO_ENDPOINT: minio:9000
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
postgres: postgres:
image: postgres:16 image: postgres:16
container_name: dumpster-postgres container_name: dumpster-postgres
@@ -13,7 +33,7 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dumpster} -d ${POSTGRES_DB:-dumpster}"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -47,8 +67,7 @@ services:
livekit: livekit:
image: livekit/livekit-server:latest image: livekit/livekit-server:latest
container_name: dumpster-livekit container_name: dumpster-livekit
command: > command: --config /etc/livekit.yaml
--config /etc/livekit.yaml
ports: ports:
- "7880:7880" - "7880:7880"
- "7881:7881" - "7881:7881"
@@ -57,19 +76,18 @@ services:
volumes: volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro - ./livekit.yaml:/etc/livekit.yaml:ro
environment: environment:
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}:${LIVEKIT_API_SECRET}" LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
depends_on:
- coturn
coturn: coturn:
image: coturn/coturn:latest image: coturn/coturn:latest
container_name: dumpster-coturn container_name: dumpster-coturn
command: > command:
-n --log-file=stdout - "-n"
--external-ip=$$(detect-external-ip) - "--log-file=stdout"
--min-port=10000 --max-port=20000 - "--min-port=10000"
--realm=${DUMPSTER_HOST:-localhost} - "--max-port=20000"
--user=${LIVEKIT_API_KEY}:${LIVEKIT_API_SECRET} - "--realm=${DUMPSTER_HOST:-localhost}"
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
ports: ports:
- "3478:3478/tcp" - "3478:3478/tcp"
- "3478:3478/udp" - "3478:3478/udp"
+1
View File
@@ -5,6 +5,7 @@ go 1.26.4
require ( require (
github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/chi/v5 v5.3.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
golang.org/x/crypto v0.53.0 golang.org/x/crypto v0.53.0
) )
+2
View File
@@ -5,6 +5,8 @@ github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+318
View File
@@ -0,0 +1,318 @@
package channel
import (
"database/sql"
"encoding/json"
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// Handler holds the database dependency for channel CRUD operations.
type Handler struct {
db *sql.DB
}
// NewHandler creates a new channel Handler.
func NewHandler(db *sql.DB) *Handler {
return &Handler{db: db}
}
// RegisterRoutes registers channel routes on the given chi.Router.
// Expects to be mounted under /servers/{serverID}/channels.
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/", h.Create)
r.Get("/", h.List)
r.Get("/{channelID}", h.Get)
r.Patch("/{channelID}", h.Update)
r.Delete("/{channelID}", h.Delete)
}
// Channel is the JSON representation of a channel.
type Channel struct {
ID string `json:"id"`
ServerID string `json:"server_id"`
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Position int `json:"position"`
CreatedAt string `json:"created_at"`
}
type createChannelRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Position *int `json:"position,omitempty"`
}
type updateChannelRequest struct {
Name *string `json:"name,omitempty"`
Type *string `json:"type,omitempty"`
Category *string `json:"category,omitempty"`
Position *int `json:"position,omitempty"`
}
// Create handles POST / — creates a new channel in a server.
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
serverID := chi.URLParam(r, "serverID")
if _, err := uuid.Parse(serverID); err != nil {
writeError(w, http.StatusBadRequest, "invalid server id")
return
}
// Verify membership
if !h.isMember(r, userID, serverID) {
writeError(w, http.StatusForbidden, "not a member of this server")
return
}
var req createChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.Type == "" {
req.Type = "text"
}
if req.Type != "text" && req.Type != "voice" {
writeError(w, http.StatusBadRequest, "type must be text or voice")
return
}
if req.Category == "" {
req.Category = "General"
}
position := 0
if req.Position != nil {
position = *req.Position
}
var channelID string
err := h.db.QueryRowContext(r.Context(), `
INSERT INTO channels (server_id, name, type, category, position)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, serverID, req.Name, req.Type, req.Category, position).Scan(&channelID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create channel")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"id": channelID})
}
// List handles GET / — returns all channels in a server.
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
serverID := chi.URLParam(r, "serverID")
if _, err := uuid.Parse(serverID); err != nil {
writeError(w, http.StatusBadRequest, "invalid server id")
return
}
if !h.isMember(r, userID, serverID) {
writeError(w, http.StatusForbidden, "not a member of this server")
return
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT id, server_id, name, type, category, position, created_at
FROM channels
WHERE server_id = $1
ORDER BY position ASC, created_at ASC
`, serverID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list channels")
return
}
defer rows.Close()
channels := []Channel{}
for rows.Next() {
var c Channel
if err := rows.Scan(&c.ID, &c.ServerID, &c.Name, &c.Type, &c.Category, &c.Position, &c.CreatedAt); err != nil {
writeError(w, http.StatusInternalServerError, "server error")
return
}
channels = append(channels, c)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "server error")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(channels)
}
// Get handles GET /{channelID} — returns a single channel.
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
serverID := chi.URLParam(r, "serverID")
channelID := chi.URLParam(r, "channelID")
if _, err := uuid.Parse(channelID); err != nil {
writeError(w, http.StatusBadRequest, "invalid channel id")
return
}
if !h.isMember(r, userID, serverID) {
writeError(w, http.StatusForbidden, "not a member of this server")
return
}
var c Channel
err := h.db.QueryRowContext(r.Context(), `
SELECT id, server_id, name, type, category, position, created_at
FROM channels
WHERE id = $1 AND server_id = $2
`, channelID, serverID).Scan(&c.ID, &c.ServerID, &c.Name, &c.Type, &c.Category, &c.Position, &c.CreatedAt)
if err != nil {
writeError(w, http.StatusNotFound, "channel not found")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(c)
}
// Update handles PATCH /{channelID} — updates a channel's properties.
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
serverID := chi.URLParam(r, "serverID")
channelID := chi.URLParam(r, "channelID")
if _, err := uuid.Parse(channelID); err != nil {
writeError(w, http.StatusBadRequest, "invalid channel id")
return
}
if !h.isMember(r, userID, serverID) {
writeError(w, http.StatusForbidden, "not a member of this server")
return
}
var req updateChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request")
return
}
// Validate type if provided
if req.Type != nil && *req.Type != "text" && *req.Type != "voice" {
writeError(w, http.StatusBadRequest, "type must be text or voice")
return
}
result, err := h.db.ExecContext(r.Context(), `
UPDATE channels SET
name = COALESCE($1, name),
type = COALESCE($2, type),
category = COALESCE($3, category),
position = COALESCE($4, position)
WHERE id = $5 AND server_id = $6
`, req.Name, req.Type, req.Category, req.Position, channelID, serverID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update channel")
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
writeError(w, http.StatusNotFound, "channel not found")
return
}
var c Channel
h.db.QueryRowContext(r.Context(), `
SELECT id, server_id, name, type, category, position, created_at
FROM channels WHERE id = $1
`, channelID).Scan(&c.ID, &c.ServerID, &c.Name, &c.Type, &c.Category, &c.Position, &c.CreatedAt)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(c)
}
// Delete handles DELETE /{channelID} — deletes a channel.
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
serverID := chi.URLParam(r, "serverID")
channelID := chi.URLParam(r, "channelID")
if _, err := uuid.Parse(channelID); err != nil {
writeError(w, http.StatusBadRequest, "invalid channel id")
return
}
if !h.isMember(r, userID, serverID) {
writeError(w, http.StatusForbidden, "not a member of this server")
return
}
result, err := h.db.ExecContext(r.Context(),
`DELETE FROM channels WHERE id = $1 AND server_id = $2`,
channelID, serverID,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete channel")
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
writeError(w, http.StatusNotFound, "channel not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
// isMember checks if a user is a member of the given server.
func (h *Handler) isMember(r *http.Request, userID, serverID string) bool {
var exists bool
h.db.QueryRowContext(r.Context(),
`SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)`,
userID, serverID,
).Scan(&exists)
return exists
}
// writeError sends a JSON error response.
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}
+18
View File
@@ -87,6 +87,24 @@ CREATE TABLE IF NOT EXISTS messages (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
color VARCHAR(7),
permissions BIGINT NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS member_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_members_server_user ON members(server_id, user_id);
` `
+154
View File
@@ -0,0 +1,154 @@
package gateway
import (
"context"
"database/sql"
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/gorilla/websocket"
)
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
maxMessageSize = 4096
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
}
// Client is a middleman between the websocket connection and the hub.
type Client struct {
Hub *Hub
Conn *websocket.Conn
UserID string
send chan []byte
}
// readPump pumps messages from the websocket connection to the hub.
func (c *Client) readPump() {
defer func() {
c.Hub.Unregister(c)
c.Conn.Close()
}()
c.Conn.SetReadLimit(maxMessageSize)
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
c.Conn.SetPongHandler(func(string) error {
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, raw, err := c.Conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
c.Hub.logger.Warn("websocket read error", "error", err, "user_id", c.UserID)
}
break
}
var event Event
if err := json.Unmarshal(raw, &event); err != nil {
c.Hub.logger.Warn("invalid event JSON", "error", err, "user_id", c.UserID)
continue
}
// Handle client-sent events (e.g. TYPING_START, PRESENCE_UPDATE)
// For now, broadcast them to all clients
switch event.Type {
case EventTypingStart, EventPresenceUpdate:
c.Hub.BroadcastEvent(event)
default:
c.Hub.logger.Info("received event from client", "type", event.Type, "user_id", c.UserID)
}
}
}
// writePump pumps messages from the hub to the websocket connection.
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.Conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.Conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
// Drain queued messages into the current write
n := len(c.send)
for i := 0; i < n; i++ {
w.Write([]byte{'\n'})
w.Write(<-c.send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// ServeWS handles websocket requests from the peer. It authenticates the
// client via a session token passed as the "token" query parameter.
func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, `{"error":"missing token"}`, http.StatusUnauthorized)
return
}
var userID string
err := db.QueryRowContext(context.Background(),
`SELECT user_id FROM sessions WHERE token = $1 AND expires_at > NOW()`,
token,
).Scan(&userID)
if err != nil {
http.Error(w, `{"error":"invalid or expired session"}`, http.StatusUnauthorized)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
logger.Error("websocket upgrade failed", "error", err)
return
}
client := &Client{
Hub: hub,
Conn: conn,
UserID: userID,
send: make(chan []byte, 256),
}
hub.Register(client)
go client.writePump()
go client.readPump()
}
+50
View File
@@ -0,0 +1,50 @@
package gateway
import "encoding/json"
// Event type constants.
const (
EventMessageCreate = "MESSAGE_CREATE"
EventMessageUpdate = "MESSAGE_UPDATE"
EventMessageDelete = "MESSAGE_DELETE"
EventChannelCreate = "CHANNEL_CREATE"
EventChannelUpdate = "CHANNEL_UPDATE"
EventChannelDelete = "CHANNEL_DELETE"
EventMemberAdd = "SERVER_MEMBER_ADD"
EventMemberRemove = "SERVER_MEMBER_REMOVE"
EventPresenceUpdate = "PRESENCE_UPDATE"
EventTypingStart = "TYPING_START"
)
// Event represents a WebSocket event sent to clients.
type Event struct {
Type string `json:"type"`
Data interface{} `json:"data,omitempty"`
}
// MarshalJSON implements custom JSON marshaling for Event.
func (e Event) MarshalJSON() ([]byte, error) {
type eventAlias struct {
Type string `json:"type"`
Data interface{} `json:"data,omitempty"`
}
return json.Marshal(eventAlias{
Type: e.Type,
Data: e.Data,
})
}
// UnmarshalJSON implements custom JSON unmarshaling for Event.
func (e *Event) UnmarshalJSON(data []byte) error {
type eventAlias struct {
Type string `json:"type"`
Data json.RawMessage `json:"data,omitempty"`
}
var raw eventAlias
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
e.Type = raw.Type
e.Data = raw.Data
return nil
}
+90
View File
@@ -0,0 +1,90 @@
package gateway
import (
"encoding/json"
"log/slog"
"sync"
)
// Hub maintains the set of active clients and broadcasts messages to them.
type Hub struct {
mu sync.RWMutex
clients map[*Client]struct{}
register chan *Client
unregister chan *Client
broadcast chan []byte
logger *slog.Logger
}
// NewHub creates a new Hub.
func NewHub(logger *slog.Logger) *Hub {
return &Hub{
clients: make(map[*Client]struct{}),
register: make(chan *Client),
unregister: make(chan *Client),
broadcast: make(chan []byte, 256),
logger: logger,
}
}
// Run starts the hub's main event loop. Call this in a goroutine.
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.mu.Lock()
h.clients[client] = struct{}{}
h.mu.Unlock()
h.logger.Info("client connected", "user_id", client.UserID)
case client := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
}
h.mu.Unlock()
h.logger.Info("client disconnected", "user_id", client.UserID)
case message := <-h.broadcast:
h.mu.RLock()
for client := range h.clients {
select {
case client.send <- message:
default:
go func(c *Client) {
h.unregister <- c
}(client)
}
}
h.mu.RUnlock()
}
}
}
// Register queues a client for registration with the hub.
func (h *Hub) Register(client *Client) {
h.register <- client
}
// Unregister queues a client for removal from the hub.
func (h *Hub) Unregister(client *Client) {
h.unregister <- client
}
// BroadcastEvent serializes an Event and broadcasts it to all connected clients.
func (h *Hub) BroadcastEvent(event Event) {
data, err := json.Marshal(event)
if err != nil {
h.logger.Error("failed to marshal event", "type", event.Type, "error", err)
return
}
h.broadcast <- data
}
// ClientCount returns the number of connected clients.
func (h *Hub) ClientCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.clients)
}
+326
View File
@@ -0,0 +1,326 @@
package message
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// Handler holds dependencies for message CRUD operations.
type Handler struct {
db *sql.DB
hub *gateway.Hub
}
// NewHandler creates a new message Handler.
func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
return &Handler{db: db, hub: hub}
}
// RegisterRoutes registers message routes on the given chi.Router.
// Expects to be mounted under /channels/{channelID}/messages.
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/", h.List)
r.Post("/", h.Create)
r.Patch("/{messageID}", h.Update)
r.Delete("/{messageID}", h.Delete)
}
// Message is the JSON representation of a message.
type Message struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
AuthorID string `json:"author_id"`
Content string `json:"content"`
EditedAt *string `json:"edited_at,omitempty"`
CreatedAt string `json:"created_at"`
}
type createMessageRequest struct {
Content string `json:"content"`
}
type updateMessageRequest struct {
Content string `json:"content"`
}
// List handles GET / — returns messages in a channel with cursor-based pagination.
// Query params: limit (default 50, max 100), before (message ID cursor).
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
channelID := chi.URLParam(r, "channelID")
if _, err := uuid.Parse(channelID); err != nil {
writeError(w, http.StatusBadRequest, "invalid channel id")
return
}
// Verify the user is a member of the server that owns this channel
if !h.isChannelMember(r, userID, channelID) {
writeError(w, http.StatusForbidden, "not a member of this server")
return
}
// Parse pagination params
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
limit = parsed
}
}
before := r.URL.Query().Get("before")
var rows *sql.Rows
var err error
if before != "" {
if _, err := uuid.Parse(before); err != nil {
writeError(w, http.StatusBadRequest, "invalid before cursor")
return
}
rows, err = h.db.QueryContext(r.Context(), `
SELECT id, channel_id, author_id, content, edited_at, created_at
FROM messages
WHERE channel_id = $1
AND created_at < (SELECT created_at FROM messages WHERE id = $2)
ORDER BY created_at DESC
LIMIT $3
`, channelID, before, limit)
} else {
rows, err = h.db.QueryContext(r.Context(), `
SELECT id, channel_id, author_id, content, edited_at, created_at
FROM messages
WHERE channel_id = $1
ORDER BY created_at DESC
LIMIT $2
`, channelID, limit)
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list messages")
return
}
defer rows.Close()
messages := []Message{}
for rows.Next() {
var m Message
if err := rows.Scan(&m.ID, &m.ChannelID, &m.AuthorID, &m.Content, &m.EditedAt, &m.CreatedAt); err != nil {
writeError(w, http.StatusInternalServerError, "server error")
return
}
messages = append(messages, m)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "server error")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages)
}
// Create handles POST / — creates a new message and broadcasts MESSAGE_CREATE.
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
channelID := chi.URLParam(r, "channelID")
if _, err := uuid.Parse(channelID); err != nil {
writeError(w, http.StatusBadRequest, "invalid channel id")
return
}
if !h.isChannelMember(r, userID, channelID) {
writeError(w, http.StatusForbidden, "not a member of this server")
return
}
var req createMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Content == "" {
writeError(w, http.StatusBadRequest, "content is required")
return
}
var msg Message
err := h.db.QueryRowContext(r.Context(), `
INSERT INTO messages (channel_id, author_id, content)
VALUES ($1, $2, $3)
RETURNING id, channel_id, author_id, content, edited_at, created_at
`, channelID, userID, req.Content).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &msg.EditedAt, &msg.CreatedAt,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create message")
return
}
// Broadcast MESSAGE_CREATE event via the gateway hub
h.hub.BroadcastEvent(gateway.Event{
Type: gateway.EventMessageCreate,
Data: msg,
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(msg)
}
// Update handles PATCH /{messageID} — updates a message (author only).
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
channelID := chi.URLParam(r, "channelID")
messageID := chi.URLParam(r, "messageID")
if _, err := uuid.Parse(messageID); err != nil {
writeError(w, http.StatusBadRequest, "invalid message id")
return
}
var req updateMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Content == "" {
writeError(w, http.StatusBadRequest, "content is required")
return
}
// Verify authorship
var authorID string
err := h.db.QueryRowContext(r.Context(),
`SELECT author_id FROM messages WHERE id = $1 AND channel_id = $2`,
messageID, channelID,
).Scan(&authorID)
if err != nil {
writeError(w, http.StatusNotFound, "message not found")
return
}
if authorID != userID {
writeError(w, http.StatusForbidden, "you can only edit your own messages")
return
}
var msg Message
err = h.db.QueryRowContext(r.Context(), `
UPDATE messages SET content = $1, edited_at = NOW()
WHERE id = $2 AND channel_id = $3
RETURNING id, channel_id, author_id, content, edited_at, created_at
`, req.Content, messageID, channelID).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &msg.EditedAt, &msg.CreatedAt,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update message")
return
}
// Broadcast MESSAGE_UPDATE event
h.hub.BroadcastEvent(gateway.Event{
Type: gateway.EventMessageUpdate,
Data: msg,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(msg)
}
// Delete handles DELETE /{messageID} — deletes a message (author only).
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
channelID := chi.URLParam(r, "channelID")
messageID := chi.URLParam(r, "messageID")
if _, err := uuid.Parse(messageID); err != nil {
writeError(w, http.StatusBadRequest, "invalid message id")
return
}
// Verify authorship
var authorID string
err := h.db.QueryRowContext(r.Context(),
`SELECT author_id FROM messages WHERE id = $1 AND channel_id = $2`,
messageID, channelID,
).Scan(&authorID)
if err != nil {
writeError(w, http.StatusNotFound, "message not found")
return
}
if authorID != userID {
writeError(w, http.StatusForbidden, "you can only delete your own messages")
return
}
result, err := h.db.ExecContext(r.Context(),
`DELETE FROM messages WHERE id = $1 AND channel_id = $2`,
messageID, channelID,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete message")
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
writeError(w, http.StatusNotFound, "message not found")
return
}
// Broadcast MESSAGE_DELETE event
h.hub.BroadcastEvent(gateway.Event{
Type: gateway.EventMessageDelete,
Data: map[string]string{
"id": messageID,
"channel_id": channelID,
},
})
w.WriteHeader(http.StatusNoContent)
}
// isChannelMember checks if a user is a member of the server that owns the channel.
func (h *Handler) isChannelMember(r *http.Request, userID, channelID string) bool {
var exists bool
h.db.QueryRowContext(r.Context(), `
SELECT EXISTS(
SELECT 1 FROM members m
INNER JOIN channels c ON c.server_id = m.server_id
WHERE m.user_id = $1 AND c.id = $2
)
`, userID, channelID).Scan(&exists)
return exists
}
// writeError sends a JSON error response.
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}
-184
View File
@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+31 -15
View File
@@ -1,17 +1,33 @@
function App() { import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
return ( import { LoginForm } from './components/LoginForm.tsx';
<div className="h-full w-full flex flex-col items-center justify-center bg-gb-bg text-gb-fg"> import { Layout } from './components/Layout.tsx';
<div className="terminal-border p-6 bg-gb-bg-h"> import { ChatArea } from './components/ChatArea.tsx';
<pre className="text-gb-orange font-mono text-lg mb-4"> import { useAuthStore } from './stores/auth.ts';
{"┌─ DUMPSTER ─┐"}
</pre> function ProtectedRoute({ children }: { children: React.ReactNode }) {
<p className="text-gb-fg-s mb-4">The server is running. Frontend coming soon.</p> const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
<button className="terminal-button"> return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
[LOGIN]
</button>
</div>
</div>
)
} }
export default App function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<ChatArea />} />
<Route path="channels/:channelId" element={<ChatArea />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useMemo } from 'react';
import { useServerStore } from '../stores/server.ts';
import { useChannelStore } from '../stores/channel.ts';
export function ChannelList() {
const activeServerId = useServerStore((state) => state.activeServerId);
const channelsByServer = useChannelStore((state) => state.channelsByServer);
const fetchChannels = useChannelStore((state) => state.fetchChannels);
const activeChannelId = useChannelStore((state) => state.activeChannelId);
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
useEffect(() => {
if (activeServerId) {
fetchChannels(activeServerId);
}
}, [activeServerId, fetchChannels]);
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
const categories = useMemo(() => {
const map = new Map<string, typeof channels>();
for (const channel of channels) {
const category = channel.category || 'TEXT CHANNELS';
const list = map.get(category) || [];
list.push(channel);
map.set(category, list);
}
for (const list of map.values()) {
list.sort((a, b) => a.position - b.position);
}
return Array.from(map.entries());
}, [channels]);
return (
<div className="h-full w-56 bg-gb-bg-s border-r border-gb-bg-t flex flex-col">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg truncate">
{activeServerId ? `[SERVER ${activeServerId}]` : '[NO SERVER]'}
</div>
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
{categories.length === 0 && (
<p className="text-gb-fg-f">[no channels]</p>
)}
{categories.map(([category, list]) => (
<div key={category} className="mb-3">
<div className="text-gb-fg-t text-xs uppercase mb-1">{category}</div>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{list.map((channel) => (
<button
key={channel.id}
onClick={() => setActiveChannel(channel.id)}
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
}`}
>
{channel.type === 'voice' ? (
<span className="text-gb-fg-f">&#9835;</span>
) : (
<span className="text-gb-fg-f">#</span>
)}
<span className="truncate">{channel.name}</span>
</button>
))}
</div>
))}
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from 'react';
import { useMessageStore } from '../stores/message.ts';
import { useChannelStore } from '../stores/channel.ts';
function formatTime(iso: string): string {
const date = new Date(iso);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
}
export function ChatArea() {
const activeChannelId = useChannelStore((state) => state.activeChannelId);
const messages = useMessageStore((state) =>
activeChannelId ? state.messagesByChannel[activeChannelId] || [] : []
);
const isLoading = useMessageStore((state) => state.isLoading);
const fetchMessages = useMessageStore((state) => state.fetchMessages);
const sendMessage = useMessageStore((state) => state.sendMessage);
const [input, setInput] = useState('');
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (activeChannelId) {
fetchMessages(activeChannelId);
}
}, [activeChannelId, fetchMessages]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'auto' });
}, [messages]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!activeChannelId || !input.trim()) {
return;
}
await sendMessage(activeChannelId, input.trim());
setInput('');
};
return (
<div className="flex flex-col h-full bg-gb-bg">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s">
{activeChannelId ? `#${activeChannelId}` : '[NO CHANNEL SELECTED]'}
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
{!isLoading && messages.length === 0 && (
<p className="text-gb-fg-f">[no messages in this channel]</p>
)}
{messages.map((message) => (
<div key={message.id} className="break-words">
<span className="text-gb-fg-f">[{formatTime(message.createdAt)}]</span>{' '}
<span className="text-gb-aqua">&lt;{message.author.username}&gt;</span>{' '}
<span className="text-gb-fg">{message.content}</span>
</div>
))}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2">
<span className="text-gb-fg-f select-none">{'>'}</span>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="_"
className="terminal-input"
disabled={!activeChannelId}
/>
</form>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth.ts';
import { ServerBar } from './ServerBar.tsx';
import { ChannelList } from './ChannelList.tsx';
import { MemberList } from './MemberList.tsx';
export function Layout() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading);
const user = useAuthStore((state) => state.user);
const fetchMe = useAuthStore((state) => state.fetchMe);
const logout = useAuthStore((state) => state.logout);
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
fetchMe();
}, [fetchMe]);
useEffect(() => {
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
navigate('/login', { replace: true });
}
}, [isLoading, isAuthenticated, location.pathname, navigate]);
if (isLoading) {
return (
<div className="h-full w-full flex items-center justify-center bg-gb-bg text-gb-fg-f font-mono">
[booting...]
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col p-2">
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0">
<div className="flex items-center justify-between px-3 py-1 border-b border-gb-bg-t bg-gb-bg-s">
<span className="text-gb-orange font-bold">DUMPSTER</span>
<div className="flex items-center gap-4 text-xs text-gb-fg-s">
<span>
USER: <span className="text-gb-aqua">{user?.username || 'unknown'}</span>
</span>
<button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs">
[LOGOUT]
</button>
</div>
</div>
<div className="flex-1 flex min-h-0">
<ServerBar />
<ChannelList />
<div className="flex-1 min-w-0 flex flex-col">
<Outlet />
</div>
<MemberList />
</div>
<div className="px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f flex justify-between bg-gb-bg-s">
<span>TERM v1.0</span>
<span>{new Date().toISOString().slice(0, 10)}</span>
</div>
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth.ts';
export function LoginForm() {
const [isRegister, setIsRegister] = useState(false);
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const { login, register, isLoading, error, clearError } = useAuthStore();
const navigate = useNavigate();
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
clearError();
if (isRegister) {
if (password !== confirmPassword) {
return;
}
await register(email, username, password);
} else {
await login(email, password);
}
navigate('/');
};
return (
<div className="h-full w-full flex items-center justify-center bg-gb-bg p-4">
<div className="terminal-border bg-gb-bg-h p-6 w-full max-w-md">
<pre className="text-gb-orange font-mono text-lg mb-6">{'┌─ DUMPSTER ─┐'}</pre>
<h2 className="text-gb-fg-s mb-4">{isRegister ? '[REGISTER]' : '[LOGIN]'}</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-gb-fg-t mb-1">EMAIL:</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="terminal-input"
required
autoComplete="email"
/>
</div>
{isRegister && (
<div>
<label className="block text-gb-fg-t mb-1">USERNAME:</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="terminal-input"
required
autoComplete="username"
/>
</div>
)}
<div>
<label className="block text-gb-fg-t mb-1">PASSWORD:</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="terminal-input"
required
autoComplete={isRegister ? 'new-password' : 'current-password'}
/>
</div>
{isRegister && (
<div>
<label className="block text-gb-fg-t mb-1">CONFIRM PASSWORD:</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="terminal-input"
required
/>
</div>
)}
{error && <p className="text-gb-red text-sm">ERR: {error}</p>}
<button type="submit" className="terminal-button w-full" disabled={isLoading}>
{isLoading ? '[PROCESSING...]' : isRegister ? '[REGISTER]' : '[LOGIN]'}
</button>
</form>
<div className="mt-4 text-center">
<button
type="button"
onClick={() => {
setIsRegister(!isRegister);
clearError();
}}
className="text-gb-blue hover:text-gb-aqua text-sm"
>
{isRegister ? '[BACK TO LOGIN]' : '[CREATE ACCOUNT]'}
</button>
</div>
{!isRegister && (
<p className="text-gb-fg-f text-xs mt-4 text-center">
Use Link component: <Link to="/" className="text-gb-blue hover:text-gb-aqua">[HOME]</Link>
</p>
)}
</div>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import type { User, UserStatus } from '../stores/auth.ts';
interface MemberListProps {
members?: User[];
}
function statusIcon(status: UserStatus): string {
switch (status) {
case 'online':
return '\u25cf';
case 'idle':
return '\u25d0';
case 'dnd':
return '\u26d5';
default:
return '\u25cb';
}
}
function statusColor(status: UserStatus): string {
switch (status) {
case 'online':
return 'text-gb-green';
case 'idle':
return 'text-gb-yellow';
case 'dnd':
return 'text-gb-red';
default:
return 'text-gb-gray';
}
}
function usernameColor(status: UserStatus): string {
switch (status) {
case 'online':
return 'text-gb-aqua';
case 'idle':
return 'text-gb-yellow';
case 'dnd':
return 'text-gb-red';
default:
return 'text-gb-fg-f';
}
}
export function MemberList({ members = [] }: MemberListProps) {
const online = members.filter((m) => m.status !== 'offline');
const offline = members.filter((m) => m.status === 'offline');
return (
<div className="h-full w-52 bg-gb-bg-s border-l border-gb-bg-t flex flex-col">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s">
[MEMBERS]
</div>
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
{members.length === 0 && (
<p className="text-gb-fg-f">[no members]</p>
)}
{online.length > 0 && (
<div className="mb-3">
<div className="text-gb-fg-t text-xs uppercase mb-1">ONLINE</div>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{online.map((member) => (
<div key={member.id} className="flex items-center gap-2 px-2 py-1">
<span className={statusColor(member.status)}>{statusIcon(member.status)}</span>
<span className={`truncate ${usernameColor(member.status)}`}>
{member.username}
</span>
</div>
))}
</div>
)}
{offline.length > 0 && (
<div>
<div className="text-gb-fg-t text-xs uppercase mb-1">OFFLINE</div>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{offline.map((member) => (
<div key={member.id} className="flex items-center gap-2 px-2 py-1">
<span className={statusColor(member.status)}>{statusIcon(member.status)}</span>
<span className={`truncate ${usernameColor(member.status)}`}>
{member.username}
</span>
</div>
))}
</div>
)}
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect } from 'react';
import { useServerStore } from '../stores/server.ts';
import { useChannelStore } from '../stores/channel.ts';
function getInitials(name: string): string {
const words = name.trim().split(/\s+/);
if (words.length === 1) {
return words[0].slice(0, 2).toUpperCase();
}
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
}
export function ServerBar() {
const servers = useServerStore((state) => state.servers);
const activeServerId = useServerStore((state) => state.activeServerId);
const fetchServers = useServerStore((state) => state.fetchServers);
const setActiveServer = useServerStore((state) => state.setActiveServer);
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
useEffect(() => {
fetchServers();
}, [fetchServers]);
const handleSelect = (id: string) => {
setActiveServer(id);
setActiveChannel(null);
};
return (
<div className="h-full w-16 bg-gb-bg-h border-r border-gb-bg-t flex flex-col items-center py-3 gap-2 overflow-y-auto">
{servers.map((server) => (
<button
key={server.id}
onClick={() => handleSelect(server.id)}
className={`w-11 h-11 flex items-center justify-center font-mono text-sm border ${
server.id === activeServerId
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua'
}`}
title={server.name}
>
{server.unread && server.id !== activeServerId ? (
<span className="text-gb-aqua">[{getInitials(server.name)}]</span>
) : (
`[${getInitials(server.name)}]`
)}
</button>
))}
</div>
);
}
-111
View File
@@ -1,111 +0,0 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
+45
View File
@@ -0,0 +1,45 @@
const API_BASE = '/api/v1';
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {};
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(`${API_BASE}${path}`, {
method,
headers,
credentials: 'include',
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
let errorMessage = `Request failed: ${response.status}`;
try {
const errorData = await response.json();
if (typeof errorData?.message === 'string') {
errorMessage = errorData.message;
} else if (typeof errorData?.error === 'string') {
errorMessage = errorData.error;
}
} catch {
// ignore parse error
}
throw new Error(errorMessage);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
};
export default api;
+5 -5
View File
@@ -1,10 +1,10 @@
import { StrictMode } from 'react' import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client';
import './styles/index.css' import './styles/index.css';
import App from './App.tsx' import App from './App.tsx';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>,
) );
+93
View File
@@ -0,0 +1,93 @@
import { create } from 'zustand';
import { api } from '../lib/api.ts';
export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline';
export interface User {
id: string;
username: string;
displayName: string;
email: string;
avatar: string | null;
status: UserStatus;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
register: (email: string, username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
fetchMe: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: async (email, password) => {
set({ isLoading: true, error: null });
try {
await api.post('/auth/login/password', { email, password });
const user = await api.get<User>('/auth/me');
set({ user, isAuthenticated: true, isLoading: false });
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Login failed',
});
throw error;
}
},
register: async (email, username, password) => {
set({ isLoading: true, error: null });
try {
await api.post('/auth/register', { email, username, password });
const user = await api.get<User>('/auth/me');
set({ user, isAuthenticated: true, isLoading: false });
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Registration failed',
});
throw error;
}
},
logout: async () => {
set({ isLoading: true, error: null });
try {
await api.post('/auth/logout');
set({ user: null, isAuthenticated: false, isLoading: false });
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Logout failed',
});
throw error;
}
},
fetchMe: async () => {
set({ isLoading: true, error: null });
try {
const user = await api.get<User>('/auth/me');
set({ user, isAuthenticated: true, isLoading: false });
} catch (error) {
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch user',
});
}
},
clearError: () => set({ error: null }),
}));
+84
View File
@@ -0,0 +1,84 @@
import { create } from 'zustand';
import { api } from '../lib/api.ts';
export type ChannelType = 'text' | 'voice';
export interface Channel {
id: string;
serverId: string;
name: string;
type: ChannelType;
category: string | null;
position: number;
}
interface ChannelState {
channelsByServer: Record<string, Channel[]>;
activeChannelId: string | null;
isLoading: boolean;
error: string | null;
fetchChannels: (serverId: string) => Promise<void>;
setActiveChannel: (id: string | null) => void;
addChannel: (channel: Channel) => void;
updateChannel: (channel: Channel) => void;
removeChannel: (id: string) => void;
}
export const useChannelStore = create<ChannelState>((set) => ({
channelsByServer: {},
activeChannelId: null,
isLoading: false,
error: null,
fetchChannels: async (serverId) => {
set({ isLoading: true, error: null });
try {
const channels = await api.get<Channel[]>(`/servers/${serverId}/channels`);
set((state) => ({
channelsByServer: { ...state.channelsByServer, [serverId]: channels },
isLoading: false,
}));
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch channels',
});
}
},
setActiveChannel: (id) => set({ activeChannelId: id }),
addChannel: (channel) =>
set((state) => {
const list = state.channelsByServer[channel.serverId] || [];
return {
channelsByServer: {
...state.channelsByServer,
[channel.serverId]: [...list, channel],
},
};
}),
updateChannel: (channel) =>
set((state) => {
const list = state.channelsByServer[channel.serverId] || [];
return {
channelsByServer: {
...state.channelsByServer,
[channel.serverId]: list.map((c) => (c.id === channel.id ? channel : c)),
},
};
}),
removeChannel: (id) =>
set((state) => {
const next: Record<string, Channel[]> = {};
for (const serverId of Object.keys(state.channelsByServer)) {
next[serverId] = state.channelsByServer[serverId].filter((c) => c.id !== id);
}
return {
channelsByServer: next,
activeChannelId: state.activeChannelId === id ? null : state.activeChannelId,
};
}),
}));
+96
View File
@@ -0,0 +1,96 @@
import { create } from 'zustand';
import { api } from '../lib/api.ts';
import type { User } from './auth.ts';
export interface Message {
id: string;
channelId: string;
author: User;
content: string;
createdAt: string;
updatedAt?: string;
}
interface MessageState {
messagesByChannel: Record<string, Message[]>;
isLoading: boolean;
error: string | null;
fetchMessages: (channelId: string, before?: string) => Promise<void>;
sendMessage: (channelId: string, content: string) => Promise<Message>;
addMessage: (message: Message) => void;
updateMessage: (message: Message) => void;
removeMessage: (channelId: string, messageId: string) => void;
}
export const useMessageStore = create<MessageState>((set) => ({
messagesByChannel: {},
isLoading: false,
error: null,
fetchMessages: async (channelId, before) => {
set({ isLoading: true, error: null });
try {
const params = before ? `?before=${encodeURIComponent(before)}` : '';
const messages = await api.get<Message[]>(`/channels/${channelId}/messages${params}`);
set((state) => ({
messagesByChannel: { ...state.messagesByChannel, [channelId]: messages },
isLoading: false,
}));
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch messages',
});
}
},
sendMessage: async (channelId, content) => {
const message = await api.post<Message>(`/channels/${channelId}/messages`, { content });
set((state) => {
const list = state.messagesByChannel[channelId] || [];
return {
messagesByChannel: {
...state.messagesByChannel,
[channelId]: [...list, message],
},
};
});
return message;
},
addMessage: (message) =>
set((state) => {
const list = state.messagesByChannel[message.channelId] || [];
if (list.some((m) => m.id === message.id)) {
return state;
}
return {
messagesByChannel: {
...state.messagesByChannel,
[message.channelId]: [...list, message],
},
};
}),
updateMessage: (message) =>
set((state) => {
const list = state.messagesByChannel[message.channelId] || [];
return {
messagesByChannel: {
...state.messagesByChannel,
[message.channelId]: list.map((m) => (m.id === message.id ? message : m)),
},
};
}),
removeMessage: (channelId, messageId) =>
set((state) => {
const list = state.messagesByChannel[channelId] || [];
return {
messagesByChannel: {
...state.messagesByChannel,
[channelId]: list.filter((m) => m.id !== messageId),
},
};
}),
}));
+60
View File
@@ -0,0 +1,60 @@
import { create } from 'zustand';
import { api } from '../lib/api.ts';
export interface Server {
id: string;
name: string;
icon: string | null;
ownerId: string;
unread?: boolean;
}
interface ServerState {
servers: Server[];
activeServerId: string | null;
isLoading: boolean;
error: string | null;
fetchServers: () => Promise<void>;
setActiveServer: (id: string | null) => void;
addServer: (server: Server) => void;
updateServer: (server: Server) => void;
removeServer: (id: string) => void;
}
export const useServerStore = create<ServerState>((set) => ({
servers: [],
activeServerId: null,
isLoading: false,
error: null,
fetchServers: async () => {
set({ isLoading: true, error: null });
try {
const servers = await api.get<Server[]>('/servers');
set({ servers, isLoading: false });
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch servers',
});
}
},
setActiveServer: (id) => set({ activeServerId: id }),
addServer: (server) =>
set((state) => ({
servers: [...state.servers, server],
})),
updateServer: (server) =>
set((state) => ({
servers: state.servers.map((s) => (s.id === server.id ? server : s)),
})),
removeServer: (id) =>
set((state) => ({
servers: state.servers.filter((s) => s.id !== id),
activeServerId: state.activeServerId === id ? null : state.activeServerId,
})),
}));
+181
View File
@@ -0,0 +1,181 @@
import { create } from 'zustand';
import { useMessageStore } from './message.ts';
import { useChannelStore } from './channel.ts';
import { useServerStore } from './server.ts';
import type { Message } from './message.ts';
import type { Channel } from './channel.ts';
import type { Server } from './server.ts';
type UnknownPayload = Record<string, unknown>;
export interface WsEvent {
type: string;
payload: UnknownPayload;
}
interface WebSocketState {
socket: WebSocket | null;
connected: boolean;
connect: (token: string) => void;
disconnect: () => void;
send: (event: WsEvent) => void;
}
function getWsHost(): string {
const { protocol, host } = window.location;
const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:';
return `${wsProtocol}//${host}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function extractMessage(payload: UnknownPayload): Message | null {
if (!isRecord(payload.message)) return null;
return payload.message as unknown as Message;
}
function extractChannel(payload: UnknownPayload): Channel | null {
if (!isRecord(payload.channel)) return null;
return payload.channel as unknown as Channel;
}
function extractServer(payload: UnknownPayload): Server | null {
if (!isRecord(payload.server)) return null;
return payload.server as unknown as Server;
}
function extractIds(payload: UnknownPayload): { channelId: string; messageId: string } | null {
if (typeof payload.channelId !== 'string' || typeof payload.messageId !== 'string') {
return null;
}
return { channelId: payload.channelId, messageId: payload.messageId };
}
function extractChannelId(payload: UnknownPayload): string | null {
return typeof payload.channelId === 'string' ? payload.channelId : null;
}
export const useWebSocketStore = create<WebSocketState>((set, get) => ({
socket: null,
connected: false,
connect: (token: string) => {
const existing = get().socket;
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
return;
}
const socket = new WebSocket(`${getWsHost()}/ws?token=${encodeURIComponent(token)}`);
let reconnectDelay = 1000;
const maxReconnectDelay = 30000;
let reconnectTimeout: number | null = null;
const scheduleReconnect = () => {
if (reconnectTimeout) {
window.clearTimeout(reconnectTimeout);
}
reconnectTimeout = window.setTimeout(() => {
if (!get().connected) {
get().connect(token);
}
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
};
socket.onopen = () => {
set({ connected: true });
reconnectDelay = 1000;
};
socket.onmessage = (event) => {
let data: WsEvent;
try {
data = JSON.parse(event.data) as WsEvent;
} catch {
return;
}
if (data.type === 'ping') {
get().send({ type: 'pong', payload: {} });
return;
}
const addMessage = useMessageStore.getState().addMessage;
const updateMessage = useMessageStore.getState().updateMessage;
const removeMessage = useMessageStore.getState().removeMessage;
const addChannel = useChannelStore.getState().addChannel;
const updateChannel = useChannelStore.getState().updateChannel;
const removeChannel = useChannelStore.getState().removeChannel;
const updateServer = useServerStore.getState().updateServer;
switch (data.type) {
case 'message': {
const msg = extractMessage(data.payload);
if (msg) addMessage(msg);
break;
}
case 'message_updated': {
const msg = extractMessage(data.payload);
if (msg) updateMessage(msg);
break;
}
case 'message_deleted': {
const ids = extractIds(data.payload);
if (ids) removeMessage(ids.channelId, ids.messageId);
break;
}
case 'channel_created': {
const channel = extractChannel(data.payload);
if (channel) addChannel(channel);
break;
}
case 'channel_updated': {
const channel = extractChannel(data.payload);
if (channel) updateChannel(channel);
break;
}
case 'channel_deleted': {
const channelId = extractChannelId(data.payload);
if (channelId) removeChannel(channelId);
break;
}
case 'server_updated': {
const server = extractServer(data.payload);
if (server) updateServer(server);
break;
}
default:
break;
}
};
socket.onclose = () => {
set({ connected: false, socket: null });
scheduleReconnect();
};
socket.onerror = () => {
socket.close();
};
set({ socket });
},
disconnect: () => {
const socket = get().socket;
if (socket) {
socket.close();
}
set({ socket: null, connected: false });
},
send: (event) => {
const socket = get().socket;
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(event));
}
},
}));
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/ServerBar.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/server.ts","./src/stores/ws.ts"],"version":"5.9.3"}