From bb5a56816b20985f164315b21c986ea6c13d81d7 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Fri, 26 Jun 2026 14:47:29 -0400 Subject: [PATCH] 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 --- .gitignore | 2 + Makefile | 42 ++++ cmd/migrate/main.go | 145 +++++++++++++ cmd/server/main.go | 72 ++++++- docker/Caddyfile | 11 +- docker/Dockerfile | 5 +- docker/compose.yml | 42 ++-- go.mod | 1 + go.sum | 2 + internal/channel/handlers.go | 318 ++++++++++++++++++++++++++++ internal/db/db.go | 18 ++ internal/gateway/client.go | 154 ++++++++++++++ internal/gateway/events.go | 50 +++++ internal/gateway/hub.go | 90 ++++++++ internal/message/handlers.go | 326 +++++++++++++++++++++++++++++ web/src/App.css | 184 ---------------- web/src/App.tsx | 46 ++-- web/src/components/ChannelList.tsx | 68 ++++++ web/src/components/ChatArea.tsx | 74 +++++++ web/src/components/Layout.tsx | 68 ++++++ web/src/components/LoginForm.tsx | 108 ++++++++++ web/src/components/MemberList.tsx | 90 ++++++++ web/src/components/ServerBar.tsx | 51 +++++ web/src/index.css | 111 ---------- web/src/lib/api.ts | 45 ++++ web/src/main.tsx | 10 +- web/src/stores/auth.ts | 93 ++++++++ web/src/stores/channel.ts | 84 ++++++++ web/src/stores/message.ts | 96 +++++++++ web/src/stores/server.ts | 60 ++++++ web/src/stores/ws.ts | 181 ++++++++++++++++ web/tsconfig.app.tsbuildinfo | 2 +- 32 files changed, 2310 insertions(+), 339 deletions(-) create mode 100644 Makefile create mode 100644 cmd/migrate/main.go create mode 100644 internal/channel/handlers.go create mode 100644 internal/gateway/client.go create mode 100644 internal/gateway/events.go create mode 100644 internal/gateway/hub.go create mode 100644 internal/message/handlers.go delete mode 100644 web/src/App.css create mode 100644 web/src/components/ChannelList.tsx create mode 100644 web/src/components/ChatArea.tsx create mode 100644 web/src/components/Layout.tsx create mode 100644 web/src/components/LoginForm.tsx create mode 100644 web/src/components/MemberList.tsx create mode 100644 web/src/components/ServerBar.tsx delete mode 100644 web/src/index.css create mode 100644 web/src/lib/api.ts create mode 100644 web/src/stores/auth.ts create mode 100644 web/src/stores/channel.ts create mode 100644 web/src/stores/message.ts create mode 100644 web/src/stores/server.ts create mode 100644 web/src/stores/ws.ts diff --git a/.gitignore b/.gitignore index 023c891..731c65e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ postgres_data/ valkey_data/ minio_data/ server +migrate +dumpster-server diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..576316a --- /dev/null +++ b/Makefile @@ -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 diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..7bfc74c --- /dev/null +++ b/cmd/migrate/main.go @@ -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 +} diff --git a/cmd/server/main.go b/cmd/server/main.go index e09bab9..b04d022 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -7,10 +7,15 @@ import ( "os" "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/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/middleware" + chimw "github.com/go-chi/chi/v5/middleware" ) func main() { @@ -30,20 +35,73 @@ func main() { os.Exit(1) } - r := chi.NewRouter() - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) - r.Use(middleware.RequestID) + // Session store for middleware + sessionStore := auth.NewSessionStore(database.DB, cfg) + // 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) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) }) - r.Route("/api/v1/auth", func(r chi.Router) { - auth.NewHandler(database.DB, cfg).RegisterRoutes(r) + // 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) + }) + + // 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) logger.Info("starting server", "addr", addr) if err := http.ListenAndServe(addr, r); err != nil { diff --git a/docker/Caddyfile b/docker/Caddyfile index c4da009..db0432a 100644 --- a/docker/Caddyfile +++ b/docker/Caddyfile @@ -3,18 +3,25 @@ } :80 { + # API routes handle_path /api/* { reverse_proxy app:8080 } - handle_path /ws { - reverse_proxy app:8080 + # WebSocket gateway + handle /ws { + reverse_proxy app:8080 { + header_up Connection {>Connection} + header_up Upgrade {>Upgrade} + } } + # LiveKit (if proxied) handle_path /livekit/* { reverse_proxy livekit:7880 } + # SPA fallback - serve index.html for all other routes handle { root * /srv/web try_files {path} /index.html diff --git a/docker/Dockerfile b/docker/Dockerfile index 2ad36f6..ff3aab5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,13 +2,14 @@ FROM golang:1.24-alpine AS go-builder WORKDIR /app COPY go.mod go.sum ./ 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 FROM node:22-alpine AS web-builder WORKDIR /app COPY web/package.json web/package-lock.json ./ -RUN npm ci +RUN npm ci --include=dev COPY web/ . RUN npm run build diff --git a/docker/compose.yml b/docker/compose.yml index 04bc4a9..8c16554 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -1,6 +1,26 @@ version: '3.8' 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: image: postgres:16 container_name: dumpster-postgres @@ -13,7 +33,7 @@ services: ports: - "5432:5432" 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 timeout: 5s retries: 5 @@ -47,8 +67,7 @@ services: livekit: image: livekit/livekit-server:latest container_name: dumpster-livekit - command: > - --config /etc/livekit.yaml + command: --config /etc/livekit.yaml ports: - "7880:7880" - "7881:7881" @@ -57,19 +76,18 @@ services: volumes: - ./livekit.yaml:/etc/livekit.yaml:ro environment: - LIVEKIT_KEYS: "${LIVEKIT_API_KEY}:${LIVEKIT_API_SECRET}" - depends_on: - - coturn + LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}" coturn: image: coturn/coturn:latest container_name: dumpster-coturn - command: > - -n --log-file=stdout - --external-ip=$$(detect-external-ip) - --min-port=10000 --max-port=20000 - --realm=${DUMPSTER_HOST:-localhost} - --user=${LIVEKIT_API_KEY}:${LIVEKIT_API_SECRET} + command: + - "-n" + - "--log-file=stdout" + - "--min-port=10000" + - "--max-port=20000" + - "--realm=${DUMPSTER_HOST:-localhost}" + - "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}" ports: - "3478:3478/tcp" - "3478:3478/udp" diff --git a/go.mod b/go.mod index 82a1a88..e63af94 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.4 require ( github.com/go-chi/chi/v5 v5.3.0 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.10.0 golang.org/x/crypto v0.53.0 ) diff --git a/go.sum b/go.sum index 7b54612..3eadd49 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/internal/channel/handlers.go b/internal/channel/handlers.go new file mode 100644 index 0000000..3d65329 --- /dev/null +++ b/internal/channel/handlers.go @@ -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}) +} diff --git a/internal/db/db.go b/internal/db/db.go index 25f6311..8b70f97 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -87,6 +87,24 @@ CREATE TABLE IF NOT EXISTS messages ( 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); ` diff --git a/internal/gateway/client.go b/internal/gateway/client.go new file mode 100644 index 0000000..ec19990 --- /dev/null +++ b/internal/gateway/client.go @@ -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() +} diff --git a/internal/gateway/events.go b/internal/gateway/events.go new file mode 100644 index 0000000..dc1bfd3 --- /dev/null +++ b/internal/gateway/events.go @@ -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 +} diff --git a/internal/gateway/hub.go b/internal/gateway/hub.go new file mode 100644 index 0000000..50651e0 --- /dev/null +++ b/internal/gateway/hub.go @@ -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) +} diff --git a/internal/message/handlers.go b/internal/message/handlers.go new file mode 100644 index 0000000..93ab9a5 --- /dev/null +++ b/internal/message/handlers.go @@ -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}) +} diff --git a/web/src/App.css b/web/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/web/src/App.css +++ /dev/null @@ -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); - } -} diff --git a/web/src/App.tsx b/web/src/App.tsx index 9ef397a..690a502 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,17 +1,33 @@ -function App() { - return ( -
-
-
-          {"┌─ DUMPSTER ─┐"}
-        
-

The server is running. Frontend coming soon.

- -
-
- ) +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { LoginForm } from './components/LoginForm.tsx'; +import { Layout } from './components/Layout.tsx'; +import { ChatArea } from './components/ChatArea.tsx'; +import { useAuthStore } from './stores/auth.ts'; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + return isAuthenticated ? <>{children} : ; } -export default App +function App() { + return ( + + + } /> + + + + } + > + } /> + } /> + + + + ); +} + +export default App; diff --git a/web/src/components/ChannelList.tsx b/web/src/components/ChannelList.tsx new file mode 100644 index 0000000..a75ea6d --- /dev/null +++ b/web/src/components/ChannelList.tsx @@ -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(); + 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 ( +
+
+ {activeServerId ? `[SERVER ${activeServerId}]` : '[NO SERVER]'} +
+
+ {categories.length === 0 && ( +

[no channels]

+ )} + {categories.map(([category, list]) => ( +
+
{category}
+
---
+ {list.map((channel) => ( + + ))} +
+ ))} +
+
+ ); +} diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx new file mode 100644 index 0000000..122633d --- /dev/null +++ b/web/src/components/ChatArea.tsx @@ -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(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 ( +
+
+ {activeChannelId ? `#${activeChannelId}` : '[NO CHANNEL SELECTED]'} +
+
+ {isLoading &&

[loading...]

} + {!isLoading && messages.length === 0 && ( +

[no messages in this channel]

+ )} + {messages.map((message) => ( +
+ [{formatTime(message.createdAt)}]{' '} + <{message.author.username}>{' '} + {message.content} +
+ ))} +
+
+
+ {'>'} + setInput(e.target.value)} + placeholder="_" + className="terminal-input" + disabled={!activeChannelId} + /> +
+
+ ); +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx new file mode 100644 index 0000000..ee1a047 --- /dev/null +++ b/web/src/components/Layout.tsx @@ -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 ( +
+ [booting...] +
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return ( +
+
+
+ DUMPSTER +
+ + USER: {user?.username || 'unknown'} + + +
+
+
+ + +
+ +
+ +
+
+ TERM v1.0 + {new Date().toISOString().slice(0, 10)} +
+
+
+ ); +} diff --git a/web/src/components/LoginForm.tsx b/web/src/components/LoginForm.tsx new file mode 100644 index 0000000..018310d --- /dev/null +++ b/web/src/components/LoginForm.tsx @@ -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 ( +
+
+
{'┌─ DUMPSTER ─┐'}
+

{isRegister ? '[REGISTER]' : '[LOGIN]'}

+
+
+ + setEmail(e.target.value)} + className="terminal-input" + required + autoComplete="email" + /> +
+ {isRegister && ( +
+ + setUsername(e.target.value)} + className="terminal-input" + required + autoComplete="username" + /> +
+ )} +
+ + setPassword(e.target.value)} + className="terminal-input" + required + autoComplete={isRegister ? 'new-password' : 'current-password'} + /> +
+ {isRegister && ( +
+ + setConfirmPassword(e.target.value)} + className="terminal-input" + required + /> +
+ )} + {error &&

ERR: {error}

} + +
+
+ +
+ {!isRegister && ( +

+ Use Link component: [HOME] +

+ )} +
+
+ ); +} diff --git a/web/src/components/MemberList.tsx b/web/src/components/MemberList.tsx new file mode 100644 index 0000000..83bbf72 --- /dev/null +++ b/web/src/components/MemberList.tsx @@ -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 ( +
+
+ [MEMBERS] +
+
+ {members.length === 0 && ( +

[no members]

+ )} + {online.length > 0 && ( +
+
ONLINE
+
---
+ {online.map((member) => ( +
+ {statusIcon(member.status)} + + {member.username} + +
+ ))} +
+ )} + {offline.length > 0 && ( +
+
OFFLINE
+
---
+ {offline.map((member) => ( +
+ {statusIcon(member.status)} + + {member.username} + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/web/src/components/ServerBar.tsx b/web/src/components/ServerBar.tsx new file mode 100644 index 0000000..25a372d --- /dev/null +++ b/web/src/components/ServerBar.tsx @@ -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 ( +
+ {servers.map((server) => ( + + ))} +
+ ); +} diff --git a/web/src/index.css b/web/src/index.css deleted file mode 100644 index 5fb3313..0000000 --- a/web/src/index.css +++ /dev/null @@ -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); -} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..dc0cd0b --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,45 @@ +const API_BASE = '/api/v1'; + +async function request(method: string, path: string, body?: unknown): Promise { + const headers: Record = {}; + 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; +} + +export const api = { + get: (path: string) => request('GET', path), + post: (path: string, body?: unknown) => request('POST', path, body), + patch: (path: string, body?: unknown) => request('PATCH', path, body), + delete: (path: string) => request('DELETE', path), +}; + +export default api; diff --git a/web/src/main.tsx b/web/src/main.tsx index d68fac9..bba60df 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,10 +1,10 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './styles/index.css' -import App from './App.tsx' +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './styles/index.css'; +import App from './App.tsx'; createRoot(document.getElementById('root')!).render( , -) +); diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts new file mode 100644 index 0000000..edf0967 --- /dev/null +++ b/web/src/stores/auth.ts @@ -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; + register: (email: string, username: string, password: string) => Promise; + logout: () => Promise; + fetchMe: () => Promise; + clearError: () => void; +} + +export const useAuthStore = create((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('/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('/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('/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 }), +})); diff --git a/web/src/stores/channel.ts b/web/src/stores/channel.ts new file mode 100644 index 0000000..3cb7605 --- /dev/null +++ b/web/src/stores/channel.ts @@ -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; + activeChannelId: string | null; + isLoading: boolean; + error: string | null; + fetchChannels: (serverId: string) => Promise; + setActiveChannel: (id: string | null) => void; + addChannel: (channel: Channel) => void; + updateChannel: (channel: Channel) => void; + removeChannel: (id: string) => void; +} + +export const useChannelStore = create((set) => ({ + channelsByServer: {}, + activeChannelId: null, + isLoading: false, + error: null, + + fetchChannels: async (serverId) => { + set({ isLoading: true, error: null }); + try { + const channels = await api.get(`/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 = {}; + 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, + }; + }), +})); diff --git a/web/src/stores/message.ts b/web/src/stores/message.ts new file mode 100644 index 0000000..01448df --- /dev/null +++ b/web/src/stores/message.ts @@ -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; + isLoading: boolean; + error: string | null; + fetchMessages: (channelId: string, before?: string) => Promise; + sendMessage: (channelId: string, content: string) => Promise; + addMessage: (message: Message) => void; + updateMessage: (message: Message) => void; + removeMessage: (channelId: string, messageId: string) => void; +} + +export const useMessageStore = create((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(`/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(`/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), + }, + }; + }), +})); diff --git a/web/src/stores/server.ts b/web/src/stores/server.ts new file mode 100644 index 0000000..563ff3b --- /dev/null +++ b/web/src/stores/server.ts @@ -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; + setActiveServer: (id: string | null) => void; + addServer: (server: Server) => void; + updateServer: (server: Server) => void; + removeServer: (id: string) => void; +} + +export const useServerStore = create((set) => ({ + servers: [], + activeServerId: null, + isLoading: false, + error: null, + + fetchServers: async () => { + set({ isLoading: true, error: null }); + try { + const servers = await api.get('/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, + })), +})); diff --git a/web/src/stores/ws.ts b/web/src/stores/ws.ts new file mode 100644 index 0000000..cf315a6 --- /dev/null +++ b/web/src/stores/ws.ts @@ -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; + +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 { + 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((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)); + } + }, +})); diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo index 4653b05..b7cabd2 100644 --- a/web/tsconfig.app.tsbuildinfo +++ b/web/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx"],"version":"5.9.3"} \ No newline at end of file +{"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"} \ No newline at end of file