feat(phase8.1): server groups — table, CRUD handler, collapsible channel sections
This commit is contained in:
@@ -28,6 +28,7 @@ import (
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/reaction"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/readstate"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/servergroup"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/upload"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/voice"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/webhook"
|
||||
@@ -194,6 +195,11 @@ func main() {
|
||||
channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Server groups (sub-server sections)
|
||||
r.Route("/groups", func(r chi.Router) {
|
||||
servergroup.NewHandler(database.DB).RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Availability
|
||||
r.Get("/availability", authHandler.GetServerAvailability)
|
||||
})
|
||||
|
||||
@@ -38,21 +38,23 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
}
|
||||
|
||||
type createChannelRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Category string `json:"category"`
|
||||
Position *int `json:"position"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Category string `json:"category"`
|
||||
Position *int `json:"position"`
|
||||
GroupID *string `json:"group_id"`
|
||||
}
|
||||
|
||||
type channelResponse 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"`
|
||||
SlowmodeSeconds int `json:"slowmode_seconds"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
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"`
|
||||
SlowmodeSeconds int `json:"slowmode_seconds"`
|
||||
GroupID *string `json:"group_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// isMember checks whether the given user is a member of the given server.
|
||||
@@ -133,11 +135,11 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var ch channelResponse
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO channels (server_id, name, type, category, position, slowmode_seconds)
|
||||
VALUES ($1, $2, $3, $4, $5, 0)
|
||||
RETURNING id, server_id, name, type, category, position, slowmode_seconds, created_at
|
||||
`, serverID, req.Name, channelType, category, position).Scan(
|
||||
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt,
|
||||
INSERT INTO channels (server_id, name, type, category, position, slowmode_seconds, group_id)
|
||||
VALUES ($1, $2, $3, $4, $5, 0, $6)
|
||||
RETURNING id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at
|
||||
`, serverID, req.Name, channelType, category, position, req.GroupID).Scan(
|
||||
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create channel (name may already exist in this server)"}`, http.StatusConflict)
|
||||
@@ -184,7 +186,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT id, server_id, name, type, category, position, slowmode_seconds, created_at
|
||||
SELECT id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at
|
||||
FROM channels
|
||||
WHERE server_id = $1
|
||||
ORDER BY position, name
|
||||
@@ -198,7 +200,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
channels := make([]channelResponse, 0)
|
||||
for rows.Next() {
|
||||
var ch channelResponse
|
||||
if err := rows.Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt); err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -235,9 +237,9 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var ch channelResponse
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT id, server_id, name, type, category, position, slowmode_seconds, created_at
|
||||
SELECT id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at
|
||||
FROM channels WHERE id = $1
|
||||
`, channelID).Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt)
|
||||
`, channelID).Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||
@@ -267,6 +269,7 @@ type updateChannelRequest struct {
|
||||
Category *string `json:"category"`
|
||||
Position *int `json:"position"`
|
||||
SlowmodeSeconds *int `json:"slowmode_seconds"`
|
||||
GroupID *string `json:"group_id"`
|
||||
}
|
||||
|
||||
// @Summary Update a channel
|
||||
@@ -297,7 +300,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Name == nil && req.Type == nil && req.Category == nil && req.Position == nil && req.SlowmodeSeconds == nil {
|
||||
if req.Name == nil && req.Type == nil && req.Category == nil && req.Position == nil && req.SlowmodeSeconds == nil && req.GroupID == nil {
|
||||
http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -341,11 +344,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
type = COALESCE($2, type),
|
||||
category = COALESCE($3, category),
|
||||
position = COALESCE($4, position),
|
||||
slowmode_seconds = COALESCE($5, slowmode_seconds)
|
||||
slowmode_seconds = COALESCE($5, slowmode_seconds),
|
||||
group_id = $7
|
||||
WHERE id = $6
|
||||
RETURNING id, server_id, name, type, category, position, slowmode_seconds, created_at
|
||||
`, req.Name, req.Type, req.Category, req.Position, req.SlowmodeSeconds, channelID).Scan(
|
||||
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt,
|
||||
RETURNING id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at
|
||||
`, req.Name, req.Type, req.Category, req.Position, req.SlowmodeSeconds, channelID, req.GroupID).Scan(
|
||||
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to update channel"}`, http.StatusInternalServerError)
|
||||
|
||||
@@ -479,4 +479,17 @@ DROP TRIGGER IF EXISTS messages_search_trigger ON messages;
|
||||
CREATE TRIGGER messages_search_trigger
|
||||
BEFORE INSERT OR UPDATE ON messages
|
||||
FOR EACH ROW EXECUTE FUNCTION messages_search_update();
|
||||
|
||||
-- Server groups (sub-server sections for organizing channels)
|
||||
CREATE TABLE IF NOT EXISTS server_groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (server_id, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_groups_server ON server_groups(server_id);
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS group_id UUID REFERENCES server_groups(id) ON DELETE SET NULL;
|
||||
`
|
||||
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewHandler(db *sql.DB) *Handler {
|
||||
return &Handler{db: db}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/", h.Create)
|
||||
r.Get("/", h.List)
|
||||
r.Get("/{groupID}", h.Get)
|
||||
r.Patch("/{groupID}", h.Update)
|
||||
r.Delete("/{groupID}", h.Delete)
|
||||
}
|
||||
|
||||
type groupResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerID string `json:"server_id"`
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type createGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Position *int `json:"position"`
|
||||
}
|
||||
|
||||
type updateGroupRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Position *int `json:"position"`
|
||||
}
|
||||
|
||||
// @Summary Create a server group
|
||||
// @Description Create a new channel group within a server
|
||||
// @Tags server_groups
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionAuth
|
||||
// @Param serverID path string true "Server ID"
|
||||
// @Param body body createGroupRequest true "Group data"
|
||||
// @Success 201 {object} groupResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Router /servers/{serverID}/groups [post]
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
|
||||
var req createGroupRequest
|
||||
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
|
||||
}
|
||||
|
||||
// Verify ownership or MANAGE_CHANNELS
|
||||
var ownerID string
|
||||
err := h.db.QueryRowContext(r.Context(), `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
// Check permission
|
||||
var hasPerm bool
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
JOIN member_roles mr ON mr.role_id = rp.role_id
|
||||
WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels'
|
||||
)
|
||||
`, userID, serverID).Scan(&hasPerm)
|
||||
if err != nil || !hasPerm {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var position int
|
||||
if req.Position == nil {
|
||||
// Auto-assign position (max + 1)
|
||||
err = h.db.QueryRowContext(r.Context(), `SELECT COALESCE(MAX(position), -1) + 1 FROM server_groups WHERE server_id = $1`, serverID).Scan(&position)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
position = *req.Position
|
||||
}
|
||||
|
||||
var grp groupResponse
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO server_groups (server_id, name, position)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, server_id, name, position, created_at
|
||||
`, serverID, req.Name, position).Scan(&grp.ID, &grp.ServerID, &grp.Name, &grp.Position, &grp.CreatedAt)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create group (name may already exist)"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(grp)
|
||||
}
|
||||
|
||||
// @Summary List server groups
|
||||
// @Description List all channel groups for a server, ordered by position
|
||||
// @Tags server_groups
|
||||
// @Produce json
|
||||
// @Security SessionAuth
|
||||
// @Param serverID path string true "Server ID"
|
||||
// @Success 200 {array} groupResponse
|
||||
// @Router /servers/{serverID}/groups [get]
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT id, server_id, name, position, created_at
|
||||
FROM server_groups
|
||||
WHERE server_id = $1
|
||||
ORDER BY position, name
|
||||
`, serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
groups := make([]groupResponse, 0)
|
||||
for rows.Next() {
|
||||
var grp groupResponse
|
||||
if err := rows.Scan(&grp.ID, &grp.ServerID, &grp.Name, &grp.Position, &grp.CreatedAt); err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
groups = append(groups, grp)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(groups)
|
||||
}
|
||||
|
||||
// @Summary Get a server group
|
||||
// @Tags server_groups
|
||||
// @Produce json
|
||||
// @Security SessionAuth
|
||||
// @Param serverID path string true "Server ID"
|
||||
// @Param groupID path string true "Group ID"
|
||||
// @Success 200 {object} groupResponse
|
||||
// @Router /servers/{serverID}/groups/{groupID} [get]
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
|
||||
var grp groupResponse
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT id, server_id, name, position, created_at
|
||||
FROM server_groups WHERE id = $1
|
||||
`, groupID).Scan(&grp.ID, &grp.ServerID, &grp.Name, &grp.Position, &grp.CreatedAt)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"group not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(grp)
|
||||
}
|
||||
|
||||
// @Summary Update a server group
|
||||
// @Tags server_groups
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionAuth
|
||||
// @Param serverID path string true "Server ID"
|
||||
// @Param groupID path string true "Group ID"
|
||||
// @Param body body updateGroupRequest true "Group update data"
|
||||
// @Success 200 {object} groupResponse
|
||||
// @Router /servers/{serverID}/groups/{groupID} [patch]
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
|
||||
var req updateGroupRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Name == nil && req.Position == nil {
|
||||
http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var ownerID string
|
||||
err := h.db.QueryRowContext(r.Context(), `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
var hasPerm bool
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
JOIN member_roles mr ON mr.role_id = rp.role_id
|
||||
WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels'
|
||||
)
|
||||
`, userID, serverID).Scan(&hasPerm)
|
||||
if err != nil || !hasPerm {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var grp groupResponse
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
UPDATE server_groups
|
||||
SET name = COALESCE($1, name),
|
||||
position = COALESCE($2, position)
|
||||
WHERE id = $3 AND server_id = $4
|
||||
RETURNING id, server_id, name, position, created_at
|
||||
`, req.Name, req.Position, groupID, serverID).Scan(&grp.ID, &grp.ServerID, &grp.Name, &grp.Position, &grp.CreatedAt)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"group not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(grp)
|
||||
}
|
||||
|
||||
// @Summary Delete a server group
|
||||
// @Tags server_groups
|
||||
// @Security SessionAuth
|
||||
// @Param serverID path string true "Server ID"
|
||||
// @Param groupID path string true "Group ID"
|
||||
// @Success 204
|
||||
// @Router /servers/{serverID}/groups/{groupID} [delete]
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
|
||||
// Only owner or manage_channels can delete groups
|
||||
var ownerID string
|
||||
err := h.db.QueryRowContext(r.Context(), `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
var hasPerm bool
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
JOIN member_roles mr ON mr.role_id = rp.role_id
|
||||
WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels'
|
||||
)
|
||||
`, userID, serverID).Scan(&hasPerm)
|
||||
if err != nil || !hasPerm {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Channels in this group get group_id set to NULL (via ON DELETE SET NULL)
|
||||
result, err := h.db.ExecContext(r.Context(), `DELETE FROM server_groups WHERE id = $1 AND server_id = $2`, groupID, serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to delete group"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
http.Error(w, `{"error":"group not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { InviteModal } from './InviteModal.tsx';
|
||||
import { ChannelSettingsModal } from './ChannelSettingsModal.tsx';
|
||||
import { useNotificationSettingsStore } from '../stores/notificationSettings.ts';
|
||||
import { useReadStatesStore } from '../stores/readStates.ts';
|
||||
import { api } from '../lib/api.ts';
|
||||
|
||||
type NotifLevel = 'all' | 'mentions' | 'none';
|
||||
|
||||
@@ -18,6 +19,39 @@ const NOTIF_LABELS: Record<NotifLevel, string> = {
|
||||
|
||||
const NOTIF_CYCLE: NotifLevel[] = ['all', 'mentions', 'none'];
|
||||
|
||||
interface ServerGroup {
|
||||
id: string;
|
||||
server_id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
function collapsedKey(serverId: string, groupId: string) {
|
||||
return 'collapsed:' + serverId + ':' + groupId;
|
||||
}
|
||||
|
||||
function isCollapsed(serverId: string, groupId: string): boolean {
|
||||
try {
|
||||
return localStorage.getItem(collapsedKey(serverId, groupId)) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCollapsed(serverId: string, groupId: string) {
|
||||
const key = collapsedKey(serverId, groupId);
|
||||
const now = isCollapsed(serverId, groupId);
|
||||
try {
|
||||
if (now) {
|
||||
localStorage.removeItem(key);
|
||||
} else {
|
||||
localStorage.setItem(key, '1');
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function ChannelList() {
|
||||
const activeServerId = useServerStore((state) => state.activeServerId);
|
||||
const servers = useServerStore((state) => state.servers);
|
||||
@@ -28,6 +62,8 @@ export function ChannelList() {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null);
|
||||
const [groups, setGroups] = useState<ServerGroup[]>([]);
|
||||
const [toggleTick, setToggleTick] = useState(0);
|
||||
|
||||
const notifSettings = useNotificationSettingsStore((state) => state.settings);
|
||||
const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings);
|
||||
@@ -39,6 +75,11 @@ export function ChannelList() {
|
||||
useEffect(() => {
|
||||
if (activeServerId) {
|
||||
fetchChannels(activeServerId);
|
||||
api.get<ServerGroup[]>('/servers/' + activeServerId + '/groups')
|
||||
.then(setGroups)
|
||||
.catch(() => setGroups([]));
|
||||
} else {
|
||||
setGroups([]);
|
||||
}
|
||||
}, [activeServerId, fetchChannels]);
|
||||
|
||||
@@ -56,19 +97,43 @@ export function ChannelList() {
|
||||
return servers.find((s) => s.id === activeServerId) || null;
|
||||
}, [servers, activeServerId]);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const map = new Map<string, typeof channels>();
|
||||
// Build ordered sections: sorted groups first, then ungroupped channels by category
|
||||
const sections = useMemo(() => {
|
||||
type Section = { type: 'group'; group: ServerGroup; channels: typeof channels } | { type: 'category'; name: string; channels: typeof channels };
|
||||
|
||||
const grouped: Record<string, typeof channels> = {};
|
||||
const ungrouped: Record<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);
|
||||
if (channel.group_id) {
|
||||
const list = grouped[channel.group_id] || [];
|
||||
list.push(channel);
|
||||
grouped[channel.group_id] = list;
|
||||
} else {
|
||||
const cat = channel.category || 'TEXT CHANNELS';
|
||||
const list = ungrouped[cat] || [];
|
||||
list.push(channel);
|
||||
ungrouped[cat] = list;
|
||||
}
|
||||
}
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => a.position - b.position);
|
||||
|
||||
const result: Section[] = [];
|
||||
|
||||
// Sorted groups
|
||||
const sortedGroups = [...groups].sort((a, b) => a.position - b.position);
|
||||
for (const grp of sortedGroups) {
|
||||
const chs = (grouped[grp.id] || []).sort((a, b) => a.position - b.position);
|
||||
result.push({ type: 'group', group: grp, channels: chs });
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
}, [channels]);
|
||||
|
||||
// Ungroupped channels by category
|
||||
const sortedCats = Object.keys(ungrouped).sort();
|
||||
for (const cat of sortedCats) {
|
||||
result.push({ type: 'category', name: cat, channels: ungrouped[cat].sort((a, b) => a.position - b.position) });
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [channels, groups, toggleTick]);
|
||||
|
||||
const getLevel = (channelId: string): NotifLevel => {
|
||||
return notifSettings[channelId] || 'all';
|
||||
@@ -116,59 +181,125 @@ export function ChannelList() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
|
||||
{categories.length === 0 && (
|
||||
{sections.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) =>
|
||||
channel.type === 'voice' ? (
|
||||
<VoiceChannel
|
||||
key={channel.id}
|
||||
channelId={channel.id}
|
||||
channelName={channel.name}
|
||||
/>
|
||||
) : (
|
||||
<div key={channel.id} className="group">
|
||||
<button
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<span className="text-gb-fg-f">
|
||||
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
|
||||
</span>
|
||||
<span className="truncate flex-1">{channel.name}</span>
|
||||
{hasUnread(channel.id) && (
|
||||
<span className="text-gb-orange text-xs font-bold" title="Unread messages">●</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
<button
|
||||
onClick={(e) => handleNotifClick(e, channel.id)}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
|
||||
>
|
||||
{notifIcon(channel.id)}
|
||||
</button>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
|
||||
title="Channel settings"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
[⚙]
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{sections.map((section) => {
|
||||
if (section.type === 'group') {
|
||||
const grp = section.group;
|
||||
const collapsed = isCollapsed(activeServerId || '', grp.id);
|
||||
return (
|
||||
<div key={'grp:' + grp.id} className="mb-3">
|
||||
<div
|
||||
className="text-gb-fg-t text-xs uppercase mb-1 cursor-pointer select-none hover:text-gb-fg"
|
||||
onClick={() => { toggleCollapsed(activeServerId || '', grp.id); setToggleTick(t => t + 1); }}
|
||||
>
|
||||
{collapsed ? '[+]' : '[-]'} {grp.name}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||
{section.channels.map((channel) =>
|
||||
channel.type === 'voice' ? (
|
||||
<VoiceChannel
|
||||
key={channel.id}
|
||||
channelId={channel.id}
|
||||
channelName={channel.name}
|
||||
/>
|
||||
) : (
|
||||
<div key={channel.id} className="group">
|
||||
<button
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<span className="text-gb-fg-f">
|
||||
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
|
||||
</span>
|
||||
<span className="truncate flex-1">{channel.name}</span>
|
||||
{hasUnread(channel.id) && (
|
||||
<span className="text-gb-orange text-xs font-bold" title="Unread messages">●</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
<button
|
||||
onClick={(e) => handleNotifClick(e, channel.id)}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
|
||||
>
|
||||
{notifIcon(channel.id)}
|
||||
</button>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
|
||||
title="Channel settings"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
[⚙]
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// category section (fallback for ungrouped channels)
|
||||
return (
|
||||
<div key={'cat:' + section.name} className="mb-3">
|
||||
<div className="text-gb-fg-t text-xs uppercase mb-1">{section.name}</div>
|
||||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||
{section.channels.map((channel) =>
|
||||
channel.type === 'voice' ? (
|
||||
<VoiceChannel
|
||||
key={channel.id}
|
||||
channelId={channel.id}
|
||||
channelName={channel.name}
|
||||
/>
|
||||
) : (
|
||||
<div key={channel.id} className="group">
|
||||
<button
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<span className="text-gb-fg-f">
|
||||
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
|
||||
</span>
|
||||
<span className="truncate flex-1">{channel.name}</span>
|
||||
{hasUnread(channel.id) && (
|
||||
<span className="text-gb-orange text-xs font-bold" title="Unread messages">●</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
<button
|
||||
onClick={(e) => handleNotifClick(e, channel.id)}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
|
||||
>
|
||||
{notifIcon(channel.id)}
|
||||
</button>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
|
||||
title="Channel settings"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
[⚙]
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showCreate && activeServerId && (
|
||||
<CreateChannelModal serverId={activeServerId} onClose={() => setShowCreate(false)} />
|
||||
|
||||
@@ -7,6 +7,14 @@ interface CreateChannelModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ServerGroup {
|
||||
id: string;
|
||||
server_id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ChannelApiResponse {
|
||||
id: string;
|
||||
server_id: string;
|
||||
@@ -15,6 +23,7 @@ interface ChannelApiResponse {
|
||||
category: string;
|
||||
position: number;
|
||||
slowmode_seconds: number;
|
||||
group_id: string | null;
|
||||
}
|
||||
|
||||
const SLOWMODE_OPTIONS = [
|
||||
@@ -32,9 +41,17 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
|
||||
const [type, setType] = useState<'text' | 'voice' | 'forum' | 'calendar' | 'docs' | 'list'>('text');
|
||||
const [category, setCategory] = useState('general');
|
||||
const [slowmodeSeconds, setSlowmodeSeconds] = useState(0);
|
||||
const [groups, setGroups] = useState<ServerGroup[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<ServerGroup[]>('/servers/' + serverId + '/groups')
|
||||
.then(setGroups)
|
||||
.catch(() => {});
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
@@ -53,12 +70,16 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.post<ChannelApiResponse>("/servers/" + serverId + "/channels", {
|
||||
const body: Record<string, unknown> = {
|
||||
name: name.trim(),
|
||||
type,
|
||||
category: category.trim() || 'general',
|
||||
slowmode_seconds: slowmodeSeconds,
|
||||
});
|
||||
};
|
||||
if (selectedGroupId) {
|
||||
body.group_id = selectedGroupId;
|
||||
}
|
||||
const result = await api.post<ChannelApiResponse>('/servers/' + serverId + '/channels', body);
|
||||
const newChannel = {
|
||||
id: result.id,
|
||||
serverId: result.server_id,
|
||||
@@ -67,6 +88,7 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
|
||||
category: result.category || null,
|
||||
position: result.position,
|
||||
slowmode_seconds: result.slowmode_seconds,
|
||||
group_id: result.group_id,
|
||||
};
|
||||
useChannelStore.getState().addChannel(newChannel);
|
||||
onClose();
|
||||
@@ -118,6 +140,19 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
|
||||
<option value="list">list</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gb-fg-f block mb-1">GROUP:</label>
|
||||
<select
|
||||
value={selectedGroupId}
|
||||
onChange={(e) => setSelectedGroupId(e.target.value)}
|
||||
className="terminal-input w-full"
|
||||
>
|
||||
<option value="">(none — use category)</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gb-fg-f block mb-1">CATEGORY:</label>
|
||||
<input
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Channel {
|
||||
category: string | null;
|
||||
position: number;
|
||||
slowmode_seconds?: number;
|
||||
group_id?: string | null;
|
||||
}
|
||||
|
||||
interface ChannelState {
|
||||
|
||||
Reference in New Issue
Block a user