feat: UI for server/channel creation, settings link, role management backend
Frontend: - Settings link in header next to logout - [+] button to create server (name + optional icon) - [#] button to join server via invite code - [+] button to create channel (name, type, category) - Server name shown instead of UUID in channel list header - /invites/:code route wired for JoinServer component Backend: - Wire role CRUD + member-role assignment routes (MANAGE_SERVER gated) - Seed @everyone default role on server creation
This commit is contained in:
@@ -141,6 +141,10 @@ func main() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Roles and member-role assignment
|
||||||
|
roleHandler := server.NewRoleHandler(database.DB)
|
||||||
|
roleHandler.RegisterRoleRoutes(r)
|
||||||
|
|
||||||
// Messages (under channels)
|
// Messages (under channels)
|
||||||
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
|
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
|
||||||
message.NewHandler(database.DB, hub, pushHandler, logger).RegisterRoutes(r)
|
message.NewHandler(database.DB, hub, pushHandler, logger).RegisterRoutes(r)
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed the default @everyone role (non-fatal if it fails)
|
||||||
|
_ = SeedDefaultRole(r.Context(), h.db, srv.ID)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(w).Encode(srv)
|
json.NewEncoder(w).Encode(srv)
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RoleHandler manages role CRUD and member-role assignment.
|
||||||
|
type RoleHandler struct {
|
||||||
|
db *sql.DB
|
||||||
|
checker *permissions.Checker
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRoleHandler creates a new RoleHandler.
|
||||||
|
func NewRoleHandler(db *sql.DB) *RoleHandler {
|
||||||
|
return &RoleHandler{
|
||||||
|
db: db,
|
||||||
|
checker: permissions.NewChecker(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoleRoutes mounts role and member-role endpoints on the given router.
|
||||||
|
func (h *RoleHandler) RegisterRoleRoutes(r chi.Router) {
|
||||||
|
r.Route("/servers/{serverID}/roles", func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth)
|
||||||
|
r.Post("/", h.CreateRole)
|
||||||
|
r.Get("/", h.ListRoles)
|
||||||
|
r.Patch("/{roleID}", h.UpdateRole)
|
||||||
|
r.Delete("/{roleID}", h.DeleteRole)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/servers/{serverID}/members/{userID}/roles", func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth)
|
||||||
|
r.Put("/", h.SetMemberRoles)
|
||||||
|
r.Get("/", h.GetMemberRoles)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Request / response types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type createRoleRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Permissions int64 `json:"permissions"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateRoleRequest struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Color *string `json:"color"`
|
||||||
|
Permissions *int64 `json:"permissions"`
|
||||||
|
Position *int `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type roleResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ServerID string `json:"server_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color *string `json:"color"`
|
||||||
|
Permissions int64 `json:"permissions"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type setMemberRolesRequest struct {
|
||||||
|
RoleIDs []string `json:"role_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// requireManageServer checks that the caller has MANAGE_SERVER permission.
|
||||||
|
// Returns false (and writes the error response) if denied.
|
||||||
|
func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request, serverID string) bool {
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_SERVER)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanRole scans a single role row into a roleResponse.
|
||||||
|
func scanRole(row interface{ Scan(dest ...any) error }) (roleResponse, error) {
|
||||||
|
var role roleResponse
|
||||||
|
err := row.Scan(
|
||||||
|
&role.ID, &role.ServerID, &role.Name, &role.Color,
|
||||||
|
&role.Permissions, &role.Position, &role.IsDefault, &role.CreatedAt,
|
||||||
|
)
|
||||||
|
return role, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// CreateRole handles POST /servers/{serverID}/roles.
|
||||||
|
func (h *RoleHandler) CreateRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serverID := chi.URLParam(r, "serverID")
|
||||||
|
if !h.requireManageServer(w, r, serverID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req createRoleRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
http.Error(w, `{"error":"name is required"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role, err := scanRole(h.db.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO roles (server_id, name, color, permissions, position)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, server_id, name, color, permissions, position, is_default, created_at
|
||||||
|
`, serverID, req.Name, req.Color, req.Permissions, req.Position))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to create role"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRoles handles GET /servers/{serverID}/roles.
|
||||||
|
func (h *RoleHandler) ListRoles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serverID := chi.URLParam(r, "serverID")
|
||||||
|
|
||||||
|
rows, err := h.db.QueryContext(r.Context(), `
|
||||||
|
SELECT id, server_id, name, color, permissions, position, is_default, created_at
|
||||||
|
FROM roles
|
||||||
|
WHERE server_id = $1
|
||||||
|
ORDER BY position DESC, name
|
||||||
|
`, serverID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
roles := make([]roleResponse, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
role, err := scanRole(rows)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roles = append(roles, role)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(roles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRole handles PATCH /servers/{serverID}/roles/{roleID}.
|
||||||
|
func (h *RoleHandler) UpdateRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serverID := chi.URLParam(r, "serverID")
|
||||||
|
roleID := chi.URLParam(r, "roleID")
|
||||||
|
if !h.requireManageServer(w, r, serverID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req updateRoleRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == nil && req.Color == nil && req.Permissions == nil && req.Position == nil {
|
||||||
|
http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role, err := scanRole(h.db.QueryRowContext(r.Context(), `
|
||||||
|
UPDATE roles
|
||||||
|
SET name = COALESCE($1, name),
|
||||||
|
color = COALESCE($2, color),
|
||||||
|
permissions = COALESCE($3, permissions),
|
||||||
|
position = COALESCE($4, position)
|
||||||
|
WHERE id = $5 AND server_id = $6
|
||||||
|
RETURNING id, server_id, name, color, permissions, position, is_default, created_at
|
||||||
|
`, req.Name, req.Color, req.Permissions, req.Position, roleID, serverID))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.Error(w, `{"error":"role not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, `{"error":"failed to update role"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRole handles DELETE /servers/{serverID}/roles/{roleID}.
|
||||||
|
func (h *RoleHandler) DeleteRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serverID := chi.URLParam(r, "serverID")
|
||||||
|
roleID := chi.URLParam(r, "roleID")
|
||||||
|
if !h.requireManageServer(w, r, serverID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting the @everyone (default) role.
|
||||||
|
var isDefault bool
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT is_default FROM roles WHERE id = $1 AND server_id = $2
|
||||||
|
`, roleID, serverID).Scan(&isDefault)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.Error(w, `{"error":"role not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isDefault {
|
||||||
|
http.Error(w, `{"error":"cannot delete the default @everyone role"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.ExecContext(r.Context(), `
|
||||||
|
DELETE FROM roles WHERE id = $1 AND server_id = $2
|
||||||
|
`, roleID, serverID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to delete role"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMemberRoles handles PUT /servers/{serverID}/members/{userID}/roles.
|
||||||
|
func (h *RoleHandler) SetMemberRoles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serverID := chi.URLParam(r, "serverID")
|
||||||
|
targetUserID := chi.URLParam(r, "userID")
|
||||||
|
if !h.requireManageServer(w, r, serverID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify target is a member of the server.
|
||||||
|
var exists bool
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)
|
||||||
|
`, targetUserID, serverID).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
http.Error(w, `{"error":"user is not a member of this server"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req setMemberRolesRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := h.db.BeginTx(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Remove existing roles for this member in this server.
|
||||||
|
_, err = tx.ExecContext(r.Context(), `
|
||||||
|
DELETE FROM member_roles WHERE user_id = $1 AND server_id = $2
|
||||||
|
`, targetUserID, serverID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new roles (skip the default role — it's implicit).
|
||||||
|
for _, roleID := range req.RoleIDs {
|
||||||
|
_, err = tx.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO member_roles (user_id, server_id, role_id)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`, targetUserID, serverID, roleID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to assign role"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMemberRoles handles GET /servers/{serverID}/members/{userID}/roles.
|
||||||
|
func (h *RoleHandler) GetMemberRoles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serverID := chi.URLParam(r, "serverID")
|
||||||
|
targetUserID := chi.URLParam(r, "userID")
|
||||||
|
|
||||||
|
rows, err := h.db.QueryContext(r.Context(), `
|
||||||
|
SELECT r.id, r.server_id, r.name, r.color, r.permissions, r.position, r.is_default, r.created_at
|
||||||
|
FROM roles r
|
||||||
|
INNER JOIN member_roles mr ON mr.role_id = r.id
|
||||||
|
WHERE mr.user_id = $1 AND mr.server_id = $2
|
||||||
|
ORDER BY r.position DESC, r.name
|
||||||
|
`, targetUserID, serverID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
roles := make([]roleResponse, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
role, err := scanRole(rows)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roles = append(roles, role)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(roles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedDefaultRole inserts the @everyone role for a newly created server.
|
||||||
|
// Call this after creating a server (e.g. from the server creation handler).
|
||||||
|
func SeedDefaultRole(ctx context.Context, db *sql.DB, serverID string) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO roles (server_id, name, permissions, position, is_default)
|
||||||
|
VALUES ($1, '@everyone', $2, 0, TRUE)
|
||||||
|
`, serverID, permissions.DefaultEveryonePermissions)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { UserSettings } from './components/UserSettings.tsx';
|
|||||||
import { BotManager } from './components/BotManager.tsx';
|
import { BotManager } from './components/BotManager.tsx';
|
||||||
import { CommandManager } from './components/CommandManager.tsx';
|
import { CommandManager } from './components/CommandManager.tsx';
|
||||||
import { RoleManager } from './components/RoleManager.tsx';
|
import { RoleManager } from './components/RoleManager.tsx';
|
||||||
|
import { JoinServer } from './components/JoinServer.tsx';
|
||||||
import { useAuthStore } from './stores/auth.ts';
|
import { useAuthStore } from './stores/auth.ts';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -90,6 +91,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/invites/:code"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<JoinServer />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useServerStore } from '../stores/server.ts';
|
import { useServerStore } from '../stores/server.ts';
|
||||||
import { useChannelStore } from '../stores/channel.ts';
|
import { useChannelStore } from '../stores/channel.ts';
|
||||||
import { VoiceChannel } from './VoiceChannel.tsx';
|
import { VoiceChannel } from './VoiceChannel.tsx';
|
||||||
|
import { CreateChannelModal } from './CreateChannelModal.tsx';
|
||||||
|
|
||||||
export function ChannelList() {
|
export function ChannelList() {
|
||||||
const activeServerId = useServerStore((state) => state.activeServerId);
|
const activeServerId = useServerStore((state) => state.activeServerId);
|
||||||
|
const servers = useServerStore((state) => state.servers);
|
||||||
const channelsByServer = useChannelStore((state) => state.channelsByServer);
|
const channelsByServer = useChannelStore((state) => state.channelsByServer);
|
||||||
const fetchChannels = useChannelStore((state) => state.fetchChannels);
|
const fetchChannels = useChannelStore((state) => state.fetchChannels);
|
||||||
const activeChannelId = useChannelStore((state) => state.activeChannelId);
|
const activeChannelId = useChannelStore((state) => state.activeChannelId);
|
||||||
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
|
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeServerId) {
|
if (activeServerId) {
|
||||||
@@ -16,7 +19,14 @@ export function ChannelList() {
|
|||||||
}
|
}
|
||||||
}, [activeServerId, fetchChannels]);
|
}, [activeServerId, fetchChannels]);
|
||||||
|
|
||||||
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
|
const channels = useMemo(() => {
|
||||||
|
return activeServerId ? channelsByServer[activeServerId] || [] : [];
|
||||||
|
}, [activeServerId, channelsByServer]);
|
||||||
|
|
||||||
|
const activeServer = useMemo(() => {
|
||||||
|
if (!activeServerId) return null;
|
||||||
|
return servers.find((s) => s.id === activeServerId) || null;
|
||||||
|
}, [servers, activeServerId]);
|
||||||
|
|
||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const map = new Map<string, typeof channels>();
|
const map = new Map<string, typeof channels>();
|
||||||
@@ -34,8 +44,17 @@ export function ChannelList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-56 bg-gb-bg-s border-r border-gb-bg-t flex flex-col">
|
<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">
|
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg truncate flex items-center justify-between">
|
||||||
{activeServerId ? `[SERVER ${activeServerId}]` : '[NO SERVER]'}
|
<span>{activeServerId ? `[SERVER ${activeServer?.name ?? activeServerId}]` : '[NO SERVER]'}</span>
|
||||||
|
{activeServerId && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="terminal-button text-xs"
|
||||||
|
title="Create channel"
|
||||||
|
>
|
||||||
|
[+]
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
|
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
|
||||||
{categories.length === 0 && (
|
{categories.length === 0 && (
|
||||||
@@ -68,6 +87,9 @@ export function ChannelList() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{showCreate && activeServerId && (
|
||||||
|
<CreateChannelModal serverId={activeServerId} onClose={() => setShowCreate(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../lib/api.ts';
|
||||||
|
import { useChannelStore } from '../stores/channel.ts';
|
||||||
|
|
||||||
|
interface CreateChannelModalProps {
|
||||||
|
serverId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelApiResponse {
|
||||||
|
id: string;
|
||||||
|
server_id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'text' | 'voice';
|
||||||
|
category: string;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [type, setType] = useState<'text' | 'voice'>('text');
|
||||||
|
const [category, setCategory] = useState('general');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const createChannel = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await api.post<ChannelApiResponse>(`/servers/${serverId}/channels`, {
|
||||||
|
name: name.trim(),
|
||||||
|
type,
|
||||||
|
category: category.trim() || 'general',
|
||||||
|
});
|
||||||
|
const newChannel = {
|
||||||
|
id: result.id,
|
||||||
|
serverId: result.server_id,
|
||||||
|
name: result.name,
|
||||||
|
type: result.type,
|
||||||
|
category: result.category || null,
|
||||||
|
position: result.position,
|
||||||
|
};
|
||||||
|
useChannelStore.getState().addChannel(newChannel);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create channel');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-gb-bg border border-gb-bg-t p-4 min-w-[350px] font-mono"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-gb-orange">CREATE CHANNEL</span>
|
||||||
|
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={createChannel} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gb-fg-f block mb-1">NAME:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="channel name"
|
||||||
|
required
|
||||||
|
className="terminal-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gb-fg-f block mb-1">TYPE:</label>
|
||||||
|
<select
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as 'text' | 'voice')}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
>
|
||||||
|
<option value="text">text</option>
|
||||||
|
<option value="voice">voice</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gb-fg-f block mb-1">CATEGORY:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
placeholder="general"
|
||||||
|
className="terminal-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-xs text-gb-red">{error}</div>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !name.trim()}
|
||||||
|
className="w-full px-3 py-1.5 bg-gb-orange text-gb-bg text-xs font-mono hover:bg-gb-yellow transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'CREATING...' : '[CREATE CHANNEL]'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../lib/api.ts';
|
||||||
|
import { useServerStore } from '../stores/server.ts';
|
||||||
|
|
||||||
|
interface CreateServerModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerApiResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
owner_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateServerModal({ onClose }: CreateServerModalProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [icon, setIcon] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const createServer = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await api.post<ServerApiResponse>('/servers', {
|
||||||
|
name: name.trim(),
|
||||||
|
icon: icon.trim() || undefined,
|
||||||
|
});
|
||||||
|
const newServer = {
|
||||||
|
id: result.id,
|
||||||
|
name: result.name,
|
||||||
|
icon: result.icon,
|
||||||
|
ownerId: result.owner_id,
|
||||||
|
};
|
||||||
|
useServerStore.getState().addServer(newServer);
|
||||||
|
useServerStore.getState().setActiveServer(newServer.id);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create server');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-gb-bg border border-gb-bg-t p-4 min-w-[350px] font-mono"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-gb-orange">CREATE SERVER</span>
|
||||||
|
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={createServer} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gb-fg-f block mb-1">NAME:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="server name"
|
||||||
|
required
|
||||||
|
className="terminal-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gb-fg-f block mb-1">ICON URL:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={icon}
|
||||||
|
onChange={(e) => setIcon(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="terminal-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-xs text-gb-red">{error}</div>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !name.trim()}
|
||||||
|
className="w-full px-3 py-1.5 bg-gb-orange text-gb-bg text-xs font-mono hover:bg-gb-yellow transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'CREATING...' : '[CREATE SERVER]'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../lib/api.ts';
|
||||||
|
import { useServerStore } from '../stores/server.ts';
|
||||||
|
|
||||||
|
interface JoinServerModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JoinServerApiResponse {
|
||||||
|
server_id: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JoinServerModal({ onClose }: JoinServerModalProps) {
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const joinServer = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!code.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await api.post<JoinServerApiResponse>(`/invites/${code.trim()}/join`);
|
||||||
|
await useServerStore.getState().fetchServers();
|
||||||
|
useServerStore.getState().setActiveServer(result.server_id);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to join server');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-gb-bg border border-gb-bg-t p-4 min-w-[350px] font-mono"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-gb-orange">JOIN SERVER</span>
|
||||||
|
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={joinServer} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gb-fg-f block mb-1">INVITE CODE:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
placeholder="enter invite code"
|
||||||
|
required
|
||||||
|
className="terminal-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-xs text-gb-red">{error}</div>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !code.trim()}
|
||||||
|
className="w-full px-3 py-1.5 bg-gb-orange text-gb-bg text-xs font-mono hover:bg-gb-yellow transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'JOINING...' : '[JOIN SERVER]'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../stores/auth.ts';
|
import { useAuthStore } from '../stores/auth.ts';
|
||||||
import { ServerBar } from './ServerBar.tsx';
|
import { ServerBar } from './ServerBar.tsx';
|
||||||
import { ChannelList } from './ChannelList.tsx';
|
import { ChannelList } from './ChannelList.tsx';
|
||||||
@@ -46,6 +46,7 @@ export function Layout() {
|
|||||||
<span>
|
<span>
|
||||||
USER: <span className="text-gb-aqua">{user?.username || 'unknown'}</span>
|
USER: <span className="text-gb-aqua">{user?.username || 'unknown'}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<Link to="/settings" className="terminal-button text-xs">[SETTINGS]</Link>
|
||||||
<button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs">
|
<button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs">
|
||||||
[LOGOUT]
|
[LOGOUT]
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useServerStore } from '../stores/server.ts';
|
import { useServerStore } from '../stores/server.ts';
|
||||||
import { useChannelStore } from '../stores/channel.ts';
|
import { useChannelStore } from '../stores/channel.ts';
|
||||||
|
import { CreateServerModal } from './CreateServerModal.tsx';
|
||||||
|
import { JoinServerModal } from './JoinServerModal.tsx';
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
function getInitials(name: string): string {
|
||||||
const words = name.trim().split(/\s+/);
|
const words = name.trim().split(/\s+/);
|
||||||
@@ -16,6 +18,8 @@ export function ServerBar() {
|
|||||||
const fetchServers = useServerStore((state) => state.fetchServers);
|
const fetchServers = useServerStore((state) => state.fetchServers);
|
||||||
const setActiveServer = useServerStore((state) => state.setActiveServer);
|
const setActiveServer = useServerStore((state) => state.setActiveServer);
|
||||||
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
|
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [showJoin, setShowJoin] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchServers();
|
fetchServers();
|
||||||
@@ -27,6 +31,7 @@ export function ServerBar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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) => (
|
{servers.map((server) => (
|
||||||
<button
|
<button
|
||||||
@@ -46,6 +51,25 @@ export function ServerBar() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<div className="mt-2 flex flex-col gap-2 items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="w-11 h-11 flex items-center justify-center font-mono text-sm border bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua"
|
||||||
|
title="Create server"
|
||||||
|
>
|
||||||
|
[+]
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowJoin(true)}
|
||||||
|
className="w-11 h-11 flex items-center justify-center font-mono text-xs border bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua"
|
||||||
|
title="Join server"
|
||||||
|
>
|
||||||
|
[#]
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{showCreate && <CreateServerModal onClose={() => setShowCreate(false)} />}
|
||||||
|
{showJoin && <JoinServerModal onClose={() => setShowJoin(false)} />}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user