feat(phase8.1): server groups — table, CRUD handler, collapsible channel sections

This commit is contained in:
2026-06-30 12:50:53 -04:00
parent 6d66d7775f
commit d663638387
7 changed files with 587 additions and 88 deletions
+6
View File
@@ -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)
})
+30 -26
View File
@@ -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)
+13
View File
@@ -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;
`
+309
View File
@@ -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)
}
+191 -60
View File
@@ -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)} />
+37 -2
View File
@@ -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
+1
View File
@@ -11,6 +11,7 @@ export interface Channel {
category: string | null;
position: number;
slowmode_seconds?: number;
group_id?: string | null;
}
interface ChannelState {