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:
@@ -38,3 +38,5 @@ postgres_data/
|
||||
valkey_data/
|
||||
minio_data/
|
||||
server
|
||||
migrate
|
||||
dumpster-server
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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) {
|
||||
// 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 {
|
||||
|
||||
+9
-2
@@ -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
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
|
||||
+30
-12
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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);
|
||||
`
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -1,17 +1,33 @@
|
||||
function App() {
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center bg-gb-bg text-gb-fg">
|
||||
<div className="terminal-border p-6 bg-gb-bg-h">
|
||||
<pre className="text-gb-orange font-mono text-lg mb-4">
|
||||
{"┌─ DUMPSTER ─┐"}
|
||||
</pre>
|
||||
<p className="text-gb-fg-s mb-4">The server is running. Frontend coming soon.</p>
|
||||
<button className="terminal-button">
|
||||
[LOGIN]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
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}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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">♫</span>
|
||||
) : (
|
||||
<span className="text-gb-fg-f">#</span>
|
||||
)}
|
||||
<span className="truncate">{channel.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"><{message.author.username}></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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -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,
|
||||
})),
|
||||
}));
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user