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/
|
valkey_data/
|
||||||
minio_data/
|
minio_data/
|
||||||
server
|
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
|
||||||
|
}
|
||||||
+65
-7
@@ -7,10 +7,15 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/auth"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/auth"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/channel"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -30,20 +35,73 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
// Session store for middleware
|
||||||
r.Use(middleware.Logger)
|
sessionStore := auth.NewSessionStore(database.DB, cfg)
|
||||||
r.Use(middleware.Recoverer)
|
|
||||||
r.Use(middleware.RequestID)
|
|
||||||
|
|
||||||
|
// WebSocket hub
|
||||||
|
hub := gateway.NewHub(logger)
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(chimw.Logger)
|
||||||
|
r.Use(chimw.Recoverer)
|
||||||
|
r.Use(chimw.RequestID)
|
||||||
|
|
||||||
|
// Health check
|
||||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/api/v1/auth", func(r chi.Router) {
|
// WebSocket endpoint
|
||||||
auth.NewHandler(database.DB, cfg).RegisterRoutes(r)
|
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)
|
addr := fmt.Sprintf(":%s", cfg.Port)
|
||||||
logger.Info("starting server", "addr", addr)
|
logger.Info("starting server", "addr", addr)
|
||||||
if err := http.ListenAndServe(addr, r); err != nil {
|
if err := http.ListenAndServe(addr, r); err != nil {
|
||||||
|
|||||||
+9
-2
@@ -3,18 +3,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:80 {
|
:80 {
|
||||||
|
# API routes
|
||||||
handle_path /api/* {
|
handle_path /api/* {
|
||||||
reverse_proxy app:8080
|
reverse_proxy app:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
handle_path /ws {
|
# WebSocket gateway
|
||||||
reverse_proxy app:8080
|
handle /ws {
|
||||||
|
reverse_proxy app:8080 {
|
||||||
|
header_up Connection {>Connection}
|
||||||
|
header_up Upgrade {>Upgrade}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# LiveKit (if proxied)
|
||||||
handle_path /livekit/* {
|
handle_path /livekit/* {
|
||||||
reverse_proxy livekit:7880
|
reverse_proxy livekit:7880
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# SPA fallback - serve index.html for all other routes
|
||||||
handle {
|
handle {
|
||||||
root * /srv/web
|
root * /srv/web
|
||||||
try_files {path} /index.html
|
try_files {path} /index.html
|
||||||
|
|||||||
+3
-2
@@ -2,13 +2,14 @@ FROM golang:1.24-alpine AS go-builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY cmd/ cmd/
|
||||||
|
COPY internal/ internal/
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/dumpster-server ./cmd/server
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/dumpster-server ./cmd/server
|
||||||
|
|
||||||
FROM node:22-alpine AS web-builder
|
FROM node:22-alpine AS web-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/package.json web/package-lock.json ./
|
COPY web/package.json web/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci --include=dev
|
||||||
COPY web/ .
|
COPY web/ .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
+30
-12
@@ -1,6 +1,26 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
container_name: dumpster-app
|
||||||
|
env_file:
|
||||||
|
- ../.env
|
||||||
|
environment:
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
POSTGRES_PORT: 5432
|
||||||
|
VALKEY_URL: redis://valkey:6379
|
||||||
|
MINIO_ENDPOINT: minio:9000
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
valkey:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
container_name: dumpster-postgres
|
container_name: dumpster-postgres
|
||||||
@@ -13,7 +33,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dumpster} -d ${POSTGRES_DB:-dumpster}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -47,8 +67,7 @@ services:
|
|||||||
livekit:
|
livekit:
|
||||||
image: livekit/livekit-server:latest
|
image: livekit/livekit-server:latest
|
||||||
container_name: dumpster-livekit
|
container_name: dumpster-livekit
|
||||||
command: >
|
command: --config /etc/livekit.yaml
|
||||||
--config /etc/livekit.yaml
|
|
||||||
ports:
|
ports:
|
||||||
- "7880:7880"
|
- "7880:7880"
|
||||||
- "7881:7881"
|
- "7881:7881"
|
||||||
@@ -57,19 +76,18 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||||
environment:
|
environment:
|
||||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}:${LIVEKIT_API_SECRET}"
|
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
|
||||||
depends_on:
|
|
||||||
- coturn
|
|
||||||
|
|
||||||
coturn:
|
coturn:
|
||||||
image: coturn/coturn:latest
|
image: coturn/coturn:latest
|
||||||
container_name: dumpster-coturn
|
container_name: dumpster-coturn
|
||||||
command: >
|
command:
|
||||||
-n --log-file=stdout
|
- "-n"
|
||||||
--external-ip=$$(detect-external-ip)
|
- "--log-file=stdout"
|
||||||
--min-port=10000 --max-port=20000
|
- "--min-port=10000"
|
||||||
--realm=${DUMPSTER_HOST:-localhost}
|
- "--max-port=20000"
|
||||||
--user=${LIVEKIT_API_KEY}:${LIVEKIT_API_SECRET}
|
- "--realm=${DUMPSTER_HOST:-localhost}"
|
||||||
|
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
|
||||||
ports:
|
ports:
|
||||||
- "3478:3478/tcp"
|
- "3478:3478/tcp"
|
||||||
- "3478:3478/udp"
|
- "3478:3478/udp"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.26.4
|
|||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.3.0
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
golang.org/x/crypto v0.53.0
|
golang.org/x/crypto v0.53.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
|||||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
|||||||
@@ -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()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
color VARCHAR(7),
|
||||||
|
permissions BIGINT NOT NULL DEFAULT 0,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS member_roles (
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_members_server_user ON members(server_id, user_id);
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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() {
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
return (
|
import { LoginForm } from './components/LoginForm.tsx';
|
||||||
<div className="h-full w-full flex flex-col items-center justify-center bg-gb-bg text-gb-fg">
|
import { Layout } from './components/Layout.tsx';
|
||||||
<div className="terminal-border p-6 bg-gb-bg-h">
|
import { ChatArea } from './components/ChatArea.tsx';
|
||||||
<pre className="text-gb-orange font-mono text-lg mb-4">
|
import { useAuthStore } from './stores/auth.ts';
|
||||||
{"┌─ DUMPSTER ─┐"}
|
|
||||||
</pre>
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
<p className="text-gb-fg-s mb-4">The server is running. Frontend coming soon.</p>
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
<button className="terminal-button">
|
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||||
[LOGIN]
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginForm />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<ChatArea />} />
|
||||||
|
<Route path="channels/:channelId" element={<ChatArea />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|||||||
@@ -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 { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client';
|
||||||
import './styles/index.css'
|
import './styles/index.css';
|
||||||
import App from './App.tsx'
|
import App from './App.tsx';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -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