From f36fb1ffdc80192ca49ebd77940dec27b96beadf Mon Sep 17 00:00:00 2001 From: jevb Date: Wed, 18 Mar 2026 11:28:13 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20channel=20management=20=E2=80=94=20crea?= =?UTF-8?q?te,=20edit,=20delete,=20reorder=20with=20category-type=20enforc?= =?UTF-8?q?ement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: - Enforce category-type validation: text/announcement only under text categories, voice only under voice categories (400 on mismatch) - Admin panel category field changed to dropdown with auto-filtered type options - Default setup creates both Text Channels and Voice Channels categories Client: - Add create/edit/delete channel modals (admin/owner only) - "+" button on category headers to create channels with pre-filled category - Right-click context menu on channels for edit/delete - Mouse-based drag-and-drop reordering within categories - Admin API methods: adminCreateChannel, adminUpdateChannel, adminDeleteChannel - Immediate local store update on reorder for instant feedback Tests: 7 server integration tests, 31 client unit tests (create/edit/delete modals) --- .../src/components/ChannelSidebar.ts | 341 +++++++++++++++++- .../src/components/CreateChannelModal.ts | 208 +++++++++++ .../src/components/DeleteChannelModal.ts | 122 +++++++ .../src/components/EditChannelModal.ts | 161 +++++++++ Client/tauri-client/src/lib/api.ts | 93 +++++ Client/tauri-client/src/pages/MainPage.ts | 73 ++++ .../tauri-client/src/stores/channels.store.ts | 14 + Client/tauri-client/src/styles/app.css | 17 + .../tests/unit/create-channel-modal.test.ts | 172 +++++++++ .../tests/unit/delete-channel-modal.test.ts | 86 +++++ .../tests/unit/edit-channel-modal.test.ts | 96 +++++ Server/admin/handlers_channels.go | 42 +++ Server/admin/handlers_channels_test.go | 142 ++++++++ Server/admin/setup_handler.go | 5 +- Server/admin/static/index.html | 25 +- 15 files changed, 1585 insertions(+), 12 deletions(-) create mode 100644 Client/tauri-client/src/components/CreateChannelModal.ts create mode 100644 Client/tauri-client/src/components/DeleteChannelModal.ts create mode 100644 Client/tauri-client/src/components/EditChannelModal.ts create mode 100644 Client/tauri-client/tests/unit/create-channel-modal.test.ts create mode 100644 Client/tauri-client/tests/unit/delete-channel-modal.test.ts create mode 100644 Client/tauri-client/tests/unit/edit-channel-modal.test.ts create mode 100644 Server/admin/handlers_channels_test.go diff --git a/Client/tauri-client/src/components/ChannelSidebar.ts b/Client/tauri-client/src/components/ChannelSidebar.ts index 757e6459..c6868701 100644 --- a/Client/tauri-client/src/components/ChannelSidebar.ts +++ b/Client/tauri-client/src/components/ChannelSidebar.ts @@ -16,9 +16,10 @@ import { getChannelsByCategory, setActiveChannel, clearUnread, + updateChannelPosition, } from "@stores/channels.store"; import type { Channel } from "@stores/channels.store"; -import { authStore } from "@stores/auth.store"; +import { authStore, getCurrentUser } from "@stores/auth.store"; import { uiStore, toggleCategory, @@ -26,11 +27,34 @@ import { } from "@stores/ui.store"; import { voiceStore, getChannelVoiceUsers } from "@stores/voice.store"; +export interface ChannelReorderData { + readonly channelId: number; + readonly newPosition: number; +} + export interface ChannelSidebarOptions { readonly onVoiceJoin: (channelId: number) => void; readonly onVoiceLeave: () => void; + /** Called when the user clicks the "+" on a category header. */ + readonly onCreateChannel?: (category: string) => void; + /** Called when the user right-clicks a channel and selects Edit. */ + readonly onEditChannel?: (channel: Channel) => void; + /** Called when the user right-clicks a channel and selects Delete. */ + readonly onDeleteChannel?: (channel: Channel) => void; + /** Called when the user drags a channel to a new position. */ + readonly onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void; } +// ── Drag state (mouse-based, avoids WebView2 HTML5 DnD issues) ── +interface DragState { + channelId: number; + sourceEl: HTMLElement; + containerEl: HTMLElement; + channels: readonly Channel[]; + onReorder: (reorders: readonly ChannelReorderData[]) => void; +} +let activeDrag: DragState | null = null; + const AVATAR_COLORS = ["#5865f2", "#57f287", "#fee75c", "#eb459e", "#ed4245"]; function pickAvatarColor(username: string): string { @@ -158,17 +182,285 @@ function renderVoiceChannelItem( return wrapper; } +/** Attach a right-click context menu to a channel element for edit/delete. */ +function attachChannelContextMenu( + el: HTMLElement, + channel: Channel, + signal: AbortSignal, + onEdit?: (channel: Channel) => void, + onDelete?: (channel: Channel) => void, +): void { + if (onEdit === undefined && onDelete === undefined) { + return; + } + const user = getCurrentUser(); + const role = user?.role?.toLowerCase() ?? ""; + if (role !== "owner" && role !== "admin") { + return; + } + + el.addEventListener( + "contextmenu", + (e) => { + e.preventDefault(); + e.stopPropagation(); + + // Remove any existing context menu + document.querySelector(".channel-ctx-menu")?.remove(); + + const menu = createElement("div", { + class: "context-menu channel-ctx-menu", + "data-testid": "channel-context-menu", + }); + menu.style.left = `${e.clientX}px`; + menu.style.top = `${e.clientY}px`; + + if (onEdit !== undefined) { + const editItem = createElement( + "div", + { class: "context-menu-item", "data-testid": "ctx-edit-channel" }, + "Edit Channel", + ); + editItem.addEventListener( + "click", + () => { + menu.remove(); + onEdit(channel); + }, + { signal }, + ); + menu.appendChild(editItem); + } + + if (onDelete !== undefined) { + if (onEdit !== undefined) { + menu.appendChild(createElement("div", { class: "context-menu-sep" })); + } + const deleteItem = createElement( + "div", + { class: "context-menu-item danger", "data-testid": "ctx-delete-channel" }, + "Delete Channel", + ); + deleteItem.addEventListener( + "click", + () => { + menu.remove(); + onDelete(channel); + }, + { signal }, + ); + menu.appendChild(deleteItem); + } + + document.body.appendChild(menu); + + // Close menu on click elsewhere + const closeMenu = (): void => { + menu.remove(); + document.removeEventListener("click", closeMenu); + }; + // Defer so this click event doesn't immediately close it + setTimeout(() => { + document.addEventListener("click", closeMenu, { signal }); + }, 0); + }, + { signal }, + ); +} + +/** Global mousemove/mouseup handlers for drag reordering. Registered once. */ +let globalDragListenersAttached = false; + +function ensureGlobalDragListeners(): void { + if (globalDragListenersAttached) { + return; + } + globalDragListenersAttached = true; + + document.addEventListener("mousemove", (e) => { + if (activeDrag === null) { + return; + } + // Clear old indicators + activeDrag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => { + x.classList.remove("channel-drop-indicator"); + }); + + // Find which channel item we're hovering over + const items = activeDrag.containerEl.querySelectorAll("[data-drag-channel-id]"); + for (const item of items) { + const rect = item.getBoundingClientRect(); + if (e.clientY >= rect.top && e.clientY <= rect.bottom) { + const targetId = Number((item as HTMLElement).dataset.dragChannelId); + if (targetId !== activeDrag.channelId) { + item.classList.add("channel-drop-indicator"); + } + break; + } + } + }); + + document.addEventListener("mouseup", (e) => { + if (activeDrag === null) { + return; + } + const drag = activeDrag; + activeDrag = null; + + // Clean up visual state + drag.sourceEl.classList.remove("dragging"); + document.body.classList.remove("channel-reordering"); + drag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => { + x.classList.remove("channel-drop-indicator"); + }); + + // Find drop target + const items = drag.containerEl.querySelectorAll("[data-drag-channel-id]"); + let dropTargetId: number | null = null; + let dropBefore = false; + for (const item of items) { + const rect = item.getBoundingClientRect(); + if (e.clientY >= rect.top && e.clientY <= rect.bottom) { + dropTargetId = Number((item as HTMLElement).dataset.dragChannelId); + dropBefore = e.clientY < rect.top + rect.height / 2; + break; + } + } + + if (dropTargetId === null || dropTargetId === drag.channelId) { + return; + } + + // Compute new order + const orderedIds = drag.channels.map((ch) => ch.id); + const dragIdx = orderedIds.indexOf(drag.channelId); + if (dragIdx === -1) { + return; + } + orderedIds.splice(dragIdx, 1); + + const targetIdx = orderedIds.indexOf(dropTargetId); + if (targetIdx === -1) { + return; + } + const insertIdx = dropBefore ? targetIdx : targetIdx + 1; + orderedIds.splice(insertIdx, 0, drag.channelId); + + // Build reorder data and update store immediately + const reorders: ChannelReorderData[] = []; + for (let i = 0; i < orderedIds.length; i++) { + const id = orderedIds[i]; + if (id === undefined) { + continue; + } + const ch = drag.channels.find((c) => c.id === id); + if (ch !== undefined && ch.position !== i) { + reorders.push({ channelId: id, newPosition: i }); + updateChannelPosition(id, i); + } + } + + if (reorders.length > 0) { + drag.onReorder(reorders); + } + }); +} + +/** Make a channel element draggable via mousedown (admin/owner only). */ +function attachDragHandlers( + el: HTMLElement, + channel: Channel, + containerEl: HTMLElement, + channels: readonly Channel[], + signal: AbortSignal, + onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void, +): void { + if (onReorderChannel === undefined) { + return; + } + const user = getCurrentUser(); + const role = user?.role?.toLowerCase() ?? ""; + if (role !== "owner" && role !== "admin") { + return; + } + + ensureGlobalDragListeners(); + + el.classList.add("channel-draggable"); + el.dataset.dragChannelId = String(channel.id); + + let pendingDrag: { startX: number; startY: number } | null = null; + + el.addEventListener( + "mousedown", + (e) => { + if (e.button !== 0) { + return; + } + // Start tracking — only activate drag after movement threshold + pendingDrag = { startX: e.clientX, startY: e.clientY }; + }, + { signal }, + ); + + el.addEventListener( + "mousemove", + (e) => { + if (pendingDrag === null || activeDrag !== null) { + return; + } + const dx = Math.abs(e.clientX - pendingDrag.startX); + const dy = Math.abs(e.clientY - pendingDrag.startY); + // Require 5px movement to start drag (avoids hijacking clicks) + if (dx + dy < 5) { + return; + } + pendingDrag = null; + activeDrag = { + channelId: channel.id, + sourceEl: el, + containerEl, + channels, + onReorder: onReorderChannel, + }; + el.classList.add("dragging"); + document.body.classList.add("channel-reordering"); + }, + { signal }, + ); + + el.addEventListener( + "mouseup", + () => { + pendingDrag = null; + }, + { signal }, + ); +} + function renderChannelItem( channel: Channel, isActive: boolean, signal: AbortSignal, onVoiceJoin: (channelId: number) => void, onVoiceLeave: () => void, + onEditChannel?: (channel: Channel) => void, + onDeleteChannel?: (channel: Channel) => void, + containerEl?: HTMLElement, + channels?: readonly Channel[], + onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void, ): HTMLDivElement { + let el: HTMLDivElement; if (channel.type === "voice") { - return renderVoiceChannelItem(channel, signal, onVoiceJoin, onVoiceLeave); + el = renderVoiceChannelItem(channel, signal, onVoiceJoin, onVoiceLeave); + } else { + el = renderTextChannelItem(channel, isActive, signal); } - return renderTextChannelItem(channel, isActive, signal); + attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel); + if (containerEl !== undefined && channels !== undefined) { + attachDragHandlers(el, channel, containerEl, channels, signal, onReorderChannel); + } + return el; } function renderCategoryGroup( @@ -178,6 +470,10 @@ function renderCategoryGroup( signal: AbortSignal, onVoiceJoin: (channelId: number) => void, onVoiceLeave: () => void, + onCreateChannel?: (category: string) => void, + onEditChannel?: (channel: Channel) => void, + onDeleteChannel?: (channel: Channel) => void, + onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void, ): HTMLDivElement { const group = createElement("div", {}); @@ -197,6 +493,29 @@ function renderCategoryGroup( appendChildren(header, arrow, label); + if (onCreateChannel !== undefined) { + const user = getCurrentUser(); + const role = user?.role?.toLowerCase() ?? ""; + const canManageChannels = role === "owner" || role === "admin"; + + if (canManageChannels) { + const addBtn = createElement("span", { + class: "category-add-btn", + title: "Create Channel", + "data-testid": `create-channel-${categoryName.toLowerCase().replace(/\s+/g, "-")}`, + }, "+"); + addBtn.addEventListener( + "click", + (e) => { + e.stopPropagation(); + onCreateChannel(categoryName); + }, + { signal }, + ); + header.appendChild(addBtn); + } + } + header.addEventListener( "click", () => { @@ -208,26 +527,30 @@ function renderCategoryGroup( group.appendChild(header); if (!collapsed) { + const channelsContainer = createElement("div", { class: "category-channels-container" }); for (const ch of channels) { - group.appendChild( - renderChannelItem(ch, ch.id === activeChannelId, signal, onVoiceJoin, onVoiceLeave), + channelsContainer.appendChild( + renderChannelItem(ch, ch.id === activeChannelId, signal, onVoiceJoin, onVoiceLeave, onEditChannel, onDeleteChannel, channelsContainer, channels, onReorderChannel), ); } + group.appendChild(channelsContainer); } } else { // Uncategorized channels render directly + const channelsContainer = createElement("div", { class: "category-channels-container" }); for (const ch of channels) { - group.appendChild( - renderChannelItem(ch, ch.id === activeChannelId, signal, onVoiceJoin, onVoiceLeave), + channelsContainer.appendChild( + renderChannelItem(ch, ch.id === activeChannelId, signal, onVoiceJoin, onVoiceLeave, onEditChannel, onDeleteChannel, channelsContainer, channels, onReorderChannel), ); } + group.appendChild(channelsContainer); } return group; } export function createChannelSidebar(options: ChannelSidebarOptions): MountableComponent { - const { onVoiceJoin, onVoiceLeave } = options; + const { onVoiceJoin, onVoiceLeave, onCreateChannel, onEditChannel, onDeleteChannel, onReorderChannel } = options; const ac = new AbortController(); let root: HTMLDivElement | null = null; let channelList: HTMLDivElement | null = null; @@ -246,7 +569,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC for (const [category, channels] of grouped) { channelList.appendChild( - renderCategoryGroup(category, channels, state.activeChannelId, ac.signal, onVoiceJoin, onVoiceLeave), + renderCategoryGroup(category, channels, state.activeChannelId, ac.signal, onVoiceJoin, onVoiceLeave, onCreateChannel, onEditChannel, onDeleteChannel, onReorderChannel), ); } } diff --git a/Client/tauri-client/src/components/CreateChannelModal.ts b/Client/tauri-client/src/components/CreateChannelModal.ts new file mode 100644 index 00000000..6a13ecb4 --- /dev/null +++ b/Client/tauri-client/src/components/CreateChannelModal.ts @@ -0,0 +1,208 @@ +/** + * CreateChannelModal — modal for creating a new channel under a specific + * category. The channel type is automatically restricted based on the + * category: voice categories only allow voice channels, text categories + * allow text and announcement channels. + */ + +import { createElement, setText, appendChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; +import type { ChannelType } from "@lib/types"; + +export interface CreateChannelModalOptions { + /** The category this channel will be created under. */ + readonly category: string; + /** Called when the user submits the form. */ + readonly onCreate: (data: { + name: string; + type: ChannelType; + category: string; + }) => Promise; + /** Called when the modal is closed without creating. */ + readonly onClose: () => void; +} + +/** Returns true if the category name indicates a voice section. */ +export function isVoiceCategory(category: string): boolean { + return category.toLowerCase().includes("voice"); +} + +/** Returns the allowed channel types for a given category. */ +export function allowedTypesForCategory( + category: string, +): readonly ChannelType[] { + if (isVoiceCategory(category)) { + return ["voice"] as const; + } + return ["text", "announcement"] as const; +} + +export function createCreateChannelModal( + options: CreateChannelModalOptions, +): MountableComponent { + const { category, onCreate, onClose } = options; + const ac = new AbortController(); + let overlay: HTMLDivElement | null = null; + + const allowedTypes = allowedTypesForCategory(category); + + function mount(container: Element): void { + overlay = createElement("div", { + class: "modal-overlay visible", + "data-testid": "create-channel-modal", + }); + + const modal = createElement("div", { class: "modal" }); + + // Header + const header = createElement("div", { class: "modal-header" }); + const title = createElement("h3", {}, "Create Channel"); + const closeBtn = createElement("button", { + class: "modal-close", + type: "button", + }); + setText(closeBtn, "\u2715"); + closeBtn.addEventListener("click", onClose, { signal: ac.signal }); + appendChildren(header, title, closeBtn); + + // Body + const body = createElement("div", { class: "modal-body" }); + + // Category (read-only display) + const categoryGroup = createElement("div", { class: "form-group" }); + const categoryLabel = createElement( + "label", + { class: "form-label" }, + "Category", + ); + const categoryDisplay = createElement("div", { + class: "form-input", + style: "opacity: 0.7; cursor: default;", + }); + setText(categoryDisplay, category); + appendChildren(categoryGroup, categoryLabel, categoryDisplay); + + // Channel name + const nameGroup = createElement("div", { class: "form-group" }); + const nameLabel = createElement("label", { class: "form-label" }, "Name"); + const nameInput = createElement("input", { + class: "form-input", + type: "text", + placeholder: isVoiceCategory(category) ? "lounge" : "general", + "data-testid": "channel-name-input", + }) as HTMLInputElement; + appendChildren(nameGroup, nameLabel, nameInput); + + // Channel type + const typeGroup = createElement("div", { class: "form-group" }); + const typeLabel = createElement("label", { class: "form-label" }, "Type"); + const typeSelect = createElement("select", { + class: "form-input", + "data-testid": "channel-type-select", + }) as HTMLSelectElement; + + for (const t of allowedTypes) { + const opt = createElement( + "option", + { value: t }, + t.charAt(0).toUpperCase() + t.slice(1), + ); + typeSelect.appendChild(opt); + } + appendChildren(typeGroup, typeLabel, typeSelect); + + // Error display + const errorEl = createElement("div", { + class: "form-group", + style: "color: var(--red); font-size: 13px; display: none;", + "data-testid": "channel-create-error", + }); + + appendChildren(body, categoryGroup, nameGroup, typeGroup, errorEl); + + // Footer + const footer = createElement("div", { class: "modal-footer" }); + const cancelBtn = createElement( + "button", + { class: "btn-modal-cancel", type: "button" }, + "Cancel", + ); + cancelBtn.addEventListener("click", onClose, { signal: ac.signal }); + + const createBtn = createElement( + "button", + { + class: "btn-modal-save", + type: "button", + "data-testid": "channel-create-submit", + }, + "Create Channel", + ); + + createBtn.addEventListener( + "click", + async () => { + const name = nameInput.value.trim(); + if (name === "") { + errorEl.style.display = "block"; + setText(errorEl, "Channel name is required"); + nameInput.classList.add("error"); + return; + } + + // Clear previous errors + errorEl.style.display = "none"; + nameInput.classList.remove("error"); + createBtn.setAttribute("disabled", "true"); + setText(createBtn, "Creating..."); + + try { + await onCreate({ + name, + type: typeSelect.value as ChannelType, + category, + }); + } catch (err) { + errorEl.style.display = "block"; + setText( + errorEl, + err instanceof Error ? err.message : "Failed to create channel", + ); + createBtn.removeAttribute("disabled"); + setText(createBtn, "Create Channel"); + } + }, + { signal: ac.signal }, + ); + + appendChildren(footer, cancelBtn, createBtn); + appendChildren(modal, header, body, footer); + overlay.appendChild(modal); + + // Close on backdrop click + overlay.addEventListener( + "click", + (e) => { + if (e.target === overlay) { + onClose(); + } + }, + { signal: ac.signal }, + ); + + container.appendChild(overlay); + + // Focus the name input + nameInput.focus(); + } + + function destroy(): void { + ac.abort(); + if (overlay !== null) { + overlay.remove(); + overlay = null; + } + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/DeleteChannelModal.ts b/Client/tauri-client/src/components/DeleteChannelModal.ts new file mode 100644 index 00000000..3469a0ac --- /dev/null +++ b/Client/tauri-client/src/components/DeleteChannelModal.ts @@ -0,0 +1,122 @@ +/** + * DeleteChannelModal — confirmation dialog for deleting a channel. + * Shows channel name and requires explicit confirmation. + */ + +import { createElement, setText, appendChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; + +export interface DeleteChannelModalOptions { + readonly channelId: number; + readonly channelName: string; + readonly onConfirm: () => Promise; + readonly onClose: () => void; +} + +export function createDeleteChannelModal( + options: DeleteChannelModalOptions, +): MountableComponent { + const { channelName, onConfirm, onClose } = options; + const ac = new AbortController(); + let overlay: HTMLDivElement | null = null; + + function mount(container: Element): void { + overlay = createElement("div", { + class: "modal-overlay visible", + "data-testid": "delete-channel-modal", + }); + + const modal = createElement("div", { class: "modal" }); + + // Header + const header = createElement("div", { class: "modal-header" }); + const title = createElement("h3", {}, "Delete Channel"); + const closeBtn = createElement("button", { + class: "modal-close", + type: "button", + }); + setText(closeBtn, "\u2715"); + closeBtn.addEventListener("click", onClose, { signal: ac.signal }); + appendChildren(header, title, closeBtn); + + // Body + const body = createElement("div", { class: "modal-body" }); + const warning = createElement("div", { class: "modal-danger-text" }); + warning.innerHTML = `Are you sure you want to delete #${channelName}? This action cannot be undone and all messages in this channel will be lost.`; + body.appendChild(warning); + + // Error display + const errorEl = createElement("div", { + style: "color: var(--red); font-size: 13px; display: none; margin-top: 8px;", + "data-testid": "delete-channel-error", + }); + body.appendChild(errorEl); + + // Footer + const footer = createElement("div", { class: "modal-footer" }); + const cancelBtn = createElement( + "button", + { class: "btn-modal-cancel", type: "button" }, + "Cancel", + ); + cancelBtn.addEventListener("click", onClose, { signal: ac.signal }); + + const deleteBtn = createElement( + "button", + { + class: "btn-danger", + type: "button", + "data-testid": "delete-channel-confirm", + }, + "Delete Channel", + ); + + deleteBtn.addEventListener( + "click", + async () => { + deleteBtn.setAttribute("disabled", "true"); + setText(deleteBtn, "Deleting..."); + + try { + await onConfirm(); + } catch (err) { + errorEl.style.display = "block"; + setText( + errorEl, + err instanceof Error ? err.message : "Failed to delete channel", + ); + deleteBtn.removeAttribute("disabled"); + setText(deleteBtn, "Delete Channel"); + } + }, + { signal: ac.signal }, + ); + + appendChildren(footer, cancelBtn, deleteBtn); + appendChildren(modal, header, body, footer); + overlay.appendChild(modal); + + // Close on backdrop click + overlay.addEventListener( + "click", + (e) => { + if (e.target === overlay) { + onClose(); + } + }, + { signal: ac.signal }, + ); + + container.appendChild(overlay); + } + + function destroy(): void { + ac.abort(); + if (overlay !== null) { + overlay.remove(); + overlay = null; + } + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/EditChannelModal.ts b/Client/tauri-client/src/components/EditChannelModal.ts new file mode 100644 index 00000000..9e145c96 --- /dev/null +++ b/Client/tauri-client/src/components/EditChannelModal.ts @@ -0,0 +1,161 @@ +/** + * EditChannelModal — modal for editing an existing channel's name and topic. + * Only visible to admin/owner users. + */ + +import { createElement, setText, appendChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; + +export interface EditChannelModalOptions { + /** Current channel ID. */ + readonly channelId: number; + /** Current channel name. */ + readonly channelName: string; + /** Current channel type (displayed, not editable). */ + readonly channelType: string; + /** Called when the user saves changes. */ + readonly onSave: (data: { name: string }) => Promise; + /** Called when the modal is closed. */ + readonly onClose: () => void; +} + +export function createEditChannelModal( + options: EditChannelModalOptions, +): MountableComponent { + const { channelName, channelType, onSave, onClose } = options; + const ac = new AbortController(); + let overlay: HTMLDivElement | null = null; + + function mount(container: Element): void { + overlay = createElement("div", { + class: "modal-overlay visible", + "data-testid": "edit-channel-modal", + }); + + const modal = createElement("div", { class: "modal" }); + + // Header + const header = createElement("div", { class: "modal-header" }); + const title = createElement("h3", {}, "Edit Channel"); + const closeBtn = createElement("button", { + class: "modal-close", + type: "button", + }); + setText(closeBtn, "\u2715"); + closeBtn.addEventListener("click", onClose, { signal: ac.signal }); + appendChildren(header, title, closeBtn); + + // Body + const body = createElement("div", { class: "modal-body" }); + + // Channel type (read-only) + const typeGroup = createElement("div", { class: "form-group" }); + const typeLabel = createElement("label", { class: "form-label" }, "Type"); + const typeDisplay = createElement("div", { + class: "form-input", + style: "opacity: 0.7; cursor: default;", + }); + setText(typeDisplay, channelType.charAt(0).toUpperCase() + channelType.slice(1)); + appendChildren(typeGroup, typeLabel, typeDisplay); + + // Channel name + const nameGroup = createElement("div", { class: "form-group" }); + const nameLabel = createElement("label", { class: "form-label" }, "Name"); + const nameInput = createElement("input", { + class: "form-input", + type: "text", + value: channelName, + "data-testid": "edit-channel-name-input", + }) as HTMLInputElement; + nameInput.value = channelName; + appendChildren(nameGroup, nameLabel, nameInput); + + // Error display + const errorEl = createElement("div", { + class: "form-group", + style: "color: var(--red); font-size: 13px; display: none;", + "data-testid": "edit-channel-error", + }); + + appendChildren(body, typeGroup, nameGroup, errorEl); + + // Footer + const footer = createElement("div", { class: "modal-footer" }); + const cancelBtn = createElement( + "button", + { class: "btn-modal-cancel", type: "button" }, + "Cancel", + ); + cancelBtn.addEventListener("click", onClose, { signal: ac.signal }); + + const saveBtn = createElement( + "button", + { + class: "btn-modal-save", + type: "button", + "data-testid": "edit-channel-submit", + }, + "Save Changes", + ); + + saveBtn.addEventListener( + "click", + async () => { + const name = nameInput.value.trim(); + if (name === "") { + errorEl.style.display = "block"; + setText(errorEl, "Channel name is required"); + nameInput.classList.add("error"); + return; + } + + errorEl.style.display = "none"; + nameInput.classList.remove("error"); + saveBtn.setAttribute("disabled", "true"); + setText(saveBtn, "Saving..."); + + try { + await onSave({ name }); + } catch (err) { + errorEl.style.display = "block"; + setText( + errorEl, + err instanceof Error ? err.message : "Failed to update channel", + ); + saveBtn.removeAttribute("disabled"); + setText(saveBtn, "Save Changes"); + } + }, + { signal: ac.signal }, + ); + + appendChildren(footer, cancelBtn, saveBtn); + appendChildren(modal, header, body, footer); + overlay.appendChild(modal); + + // Close on backdrop click + overlay.addEventListener( + "click", + (e) => { + if (e.target === overlay) { + onClose(); + } + }, + { signal: ac.signal }, + ); + + container.appendChild(overlay); + nameInput.focus(); + nameInput.select(); + } + + function destroy(): void { + ac.abort(); + if (overlay !== null) { + overlay.remove(); + overlay = null; + } + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/lib/api.ts b/Client/tauri-client/src/lib/api.ts index ee029d66..39d7968e 100644 --- a/Client/tauri-client/src/lib/api.ts +++ b/Client/tauri-client/src/lib/api.ts @@ -10,6 +10,8 @@ import type { MessagesResponse, SearchResponse, ApiError, + ChannelType, + ChannelResponse, EmojiResponse, SoundResponse, InviteResponse, @@ -53,6 +55,10 @@ export function createApiClient( return `https://${config.host}/api/v1`; } + function adminBaseUrl(): string { + return `https://${config.host}/admin/api`; + } + function headers(): Record { const h: Record = { "Content-Type": "application/json", @@ -116,6 +122,57 @@ export function createApiClient( return res.json() as Promise; } + async function adminRequest( + method: string, + path: string, + body?: unknown, + signal?: AbortSignal, + ): Promise { + const url = `${adminBaseUrl()}${path}`; + const init: RequestInit & { danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean } } = { + method, + headers: headers(), + signal, + danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false }, + }; + if (body !== undefined) { + init.body = JSON.stringify(body); + } + + log.debug("Admin API →", { method, path }); + + let res: Response; + try { + res = await fetch(url, init as RequestInit); + } catch (fetchErr) { + log.error("Admin API fetch failed", { method, path, error: String(fetchErr) }); + if (fetchErr instanceof Error) { + throw fetchErr; + } + throw new Error(typeof fetchErr === "string" ? fetchErr : String(fetchErr)); + } + + log.debug("Admin API ←", { method, path, status: res.status }); + + if (res.status === 401) { + onUnauthorized?.(); + const err = await parseError(res); + throw new ApiClientError(401, err.error, err.message); + } + + if (!res.ok) { + const err = await parseError(res); + log.warn("Admin API error", { method, path, status: res.status, code: err.error, message: err.message }); + throw new ApiClientError(res.status, err.error, err.message); + } + + if (res.status === 204) { + return undefined as T; + } + + return res.json() as Promise; + } + async function parseError(res: Response): Promise { try { const body = await res.json(); @@ -425,6 +482,42 @@ export function createApiClient( clearTimeout(timer); } }, + + // ── Admin: Channels ────────────────────────────────────── + + adminCreateChannel( + data: { + name: string; + type: ChannelType; + category: string; + topic?: string; + position?: number; + }, + signal?: AbortSignal, + ): Promise { + return adminRequest("POST", "/channels", data, signal); + }, + + adminUpdateChannel( + id: number, + data: { + name?: string; + topic?: string; + slow_mode?: number; + position?: number; + archived?: boolean; + }, + signal?: AbortSignal, + ): Promise { + return adminRequest("PATCH", `/channels/${id}`, data, signal); + }, + + adminDeleteChannel( + id: number, + signal?: AbortSignal, + ): Promise { + return adminRequest("DELETE", `/channels/${id}`, undefined, signal); + }, }; } diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index eba6d967..ee71b26b 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -9,6 +9,9 @@ import { createLogger } from "@lib/logger"; import { createRateLimiterSet } from "@lib/rate-limiter"; import { createServerStrip } from "@components/ServerStrip"; import { createChannelSidebar } from "@components/ChannelSidebar"; +import { createCreateChannelModal } from "@components/CreateChannelModal"; +import { createEditChannelModal } from "@components/EditChannelModal"; +import { createDeleteChannelModal } from "@components/DeleteChannelModal"; import { createUserBar } from "@components/UserBar"; import { createVoiceWidget } from "@components/VoiceWidget"; import { createMemberList } from "@components/MemberList"; @@ -405,6 +408,8 @@ export function createMainPage(options: MainPageOptions): MountableComponent { const sidebarWrapper = createElement("div", { class: "channel-sidebar", "data-testid": "channel-sidebar" }); const channelSidebarSlot = createElement("div", {}); + let activeModal: MountableComponent | null = null; + const channelSidebar = createChannelSidebar({ onVoiceJoin: (channelId) => { log.info("Joining voice channel", { channelId }); @@ -417,6 +422,74 @@ export function createMainPage(options: MainPageOptions): MountableComponent { leaveVoiceChannel(); ws.send({ type: "voice_leave", payload: {} }); }, + onCreateChannel: (category) => { + if (activeModal !== null) { + return; + } + const modal = createCreateChannelModal({ + category, + onCreate: async (data) => { + await api.adminCreateChannel(data); + // Server broadcasts channel_create via WS — store updates automatically + modal.destroy?.(); + activeModal = null; + }, + onClose: () => { + modal.destroy?.(); + activeModal = null; + }, + }); + activeModal = modal; + modal.mount(document.body); + }, + onEditChannel: (channel) => { + if (activeModal !== null) { + return; + } + const modal = createEditChannelModal({ + channelId: channel.id, + channelName: channel.name, + channelType: channel.type, + onSave: async (data) => { + await api.adminUpdateChannel(channel.id, data); + // Server broadcasts channel_update via WS — store updates automatically + modal.destroy?.(); + activeModal = null; + }, + onClose: () => { + modal.destroy?.(); + activeModal = null; + }, + }); + activeModal = modal; + modal.mount(document.body); + }, + onDeleteChannel: (channel) => { + if (activeModal !== null) { + return; + } + const modal = createDeleteChannelModal({ + channelId: channel.id, + channelName: channel.name, + onConfirm: async () => { + await api.adminDeleteChannel(channel.id); + // Server broadcasts channel_delete via WS — store updates automatically + modal.destroy?.(); + activeModal = null; + }, + onClose: () => { + modal.destroy?.(); + activeModal = null; + }, + }); + activeModal = modal; + modal.mount(document.body); + }, + onReorderChannel: (reorders) => { + for (const r of reorders) { + void api.adminUpdateChannel(r.channelId, { position: r.newPosition }); + } + }, }); channelSidebar.mount(channelSidebarSlot); children.push(channelSidebar); diff --git a/Client/tauri-client/src/stores/channels.store.ts b/Client/tauri-client/src/stores/channels.store.ts index ece6eb6c..7fda210c 100644 --- a/Client/tauri-client/src/stores/channels.store.ts +++ b/Client/tauri-client/src/stores/channels.store.ts @@ -88,6 +88,20 @@ export function updateChannel(update: ChannelUpdatePayload): void { }); } +/** Update a single channel's position immutably. */ +export function updateChannelPosition(id: number, position: number): void { + channelsStore.setState((prev) => { + const existing = prev.channels.get(id); + if (existing === undefined || existing.position === position) { + return prev; + } + const updated: Channel = { ...existing, position }; + const next = new Map(prev.channels); + next.set(id, updated); + return { ...prev, channels: next }; + }); +} + /** Remove a channel. Clears activeChannelId if it was the removed channel. */ export function removeChannel(id: number): void { channelsStore.setState((prev) => { diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index 404ddbca..a2dd811a 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -66,6 +66,13 @@ font-size: 11px; font-weight: 700; color: var(--text-faint); letter-spacing: .5px; text-transform: uppercase; } +.category-add-btn { + margin-left: auto; font-size: 16px; color: var(--text-faint); + cursor: pointer; padding: 0 4px; line-height: 1; opacity: 0; + transition: opacity .15s, color .15s; +} +.category:hover .category-add-btn { opacity: 1; } +.category-add-btn:hover { color: var(--text-normal); } .category-channels { overflow: hidden; transition: max-height .2s; } .category.collapsed + .category-channels { max-height: 0 !important; overflow: hidden; } @@ -77,6 +84,16 @@ } .channel-item:hover { background: var(--bg-hover); color: var(--text-normal); } .channel-item.active { background: var(--bg-active); color: white; } +.channel-draggable { cursor: grab; } +.channel-draggable:active { cursor: grabbing; } +.channel-reordering { user-select: none; cursor: grabbing !important; } +.channel-reordering * { cursor: grabbing !important; } +.channel-draggable.dragging { opacity: .3; } +.channel-drop-indicator { + box-shadow: 0 2px 0 var(--accent) inset, 0 -2px 0 var(--accent) inset; + background: rgba(88, 101, 242, .15); + border-radius: var(--radius-sm); +} .channel-item .ch-icon { font-size: 18px; opacity: .6; flex-shrink: 0; width: 20px; text-align: center; } .channel-item.active .ch-icon { opacity: 1; } .channel-item .ch-name { font-size: 14px; flex: 1; } diff --git a/Client/tauri-client/tests/unit/create-channel-modal.test.ts b/Client/tauri-client/tests/unit/create-channel-modal.test.ts new file mode 100644 index 00000000..a75eb37c --- /dev/null +++ b/Client/tauri-client/tests/unit/create-channel-modal.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + isVoiceCategory, + allowedTypesForCategory, + createCreateChannelModal, +} from "@components/CreateChannelModal"; +import type { CreateChannelModalOptions } from "@components/CreateChannelModal"; + +// --------------------------------------------------------------------------- +// Pure function tests +// --------------------------------------------------------------------------- + +describe("isVoiceCategory", () => { + it("returns true for 'Voice Channels'", () => { + expect(isVoiceCategory("Voice Channels")).toBe(true); + }); + + it("returns true for uppercase 'VOICE CHANNELS'", () => { + expect(isVoiceCategory("VOICE CHANNELS")).toBe(true); + }); + + it("returns true for 'voice'", () => { + expect(isVoiceCategory("voice")).toBe(true); + }); + + it("returns false for 'Text Channels'", () => { + expect(isVoiceCategory("Text Channels")).toBe(false); + }); + + it("returns false for 'Chat'", () => { + expect(isVoiceCategory("Chat")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(isVoiceCategory("")).toBe(false); + }); +}); + +describe("allowedTypesForCategory", () => { + it("returns only voice for voice categories", () => { + expect(allowedTypesForCategory("Voice Channels")).toEqual(["voice"]); + }); + + it("returns text and announcement for text categories", () => { + expect(allowedTypesForCategory("Text Channels")).toEqual([ + "text", + "announcement", + ]); + }); + + it("returns text and announcement for 'Chat'", () => { + expect(allowedTypesForCategory("Chat")).toEqual([ + "text", + "announcement", + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Component tests +// --------------------------------------------------------------------------- + +describe("CreateChannelModal", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + // Clean up any modals attached to document.body + document.querySelectorAll("[data-testid='create-channel-modal']").forEach((el) => el.remove()); + }); + + function makeModal(category: string, overrides?: Partial) { + const options: CreateChannelModalOptions = { + category, + onCreate: overrides?.onCreate ?? vi.fn(async () => {}), + onClose: overrides?.onClose ?? vi.fn(), + }; + const modal = createCreateChannelModal(options); + modal.mount(container); + return { modal, options }; + } + + it("renders the modal overlay", () => { + const { modal } = makeModal("Text Channels"); + const overlay = container.querySelector("[data-testid='create-channel-modal']"); + expect(overlay).not.toBeNull(); + modal.destroy?.(); + }); + + it("shows only text and announcement types for text categories", () => { + const { modal } = makeModal("Text Channels"); + const select = container.querySelector("[data-testid='channel-type-select']") as HTMLSelectElement; + const options = Array.from(select.options).map((o) => o.value); + expect(options).toEqual(["text", "announcement"]); + expect(options).not.toContain("voice"); + modal.destroy?.(); + }); + + it("shows only voice type for voice categories", () => { + const { modal } = makeModal("Voice Channels"); + const select = container.querySelector("[data-testid='channel-type-select']") as HTMLSelectElement; + const options = Array.from(select.options).map((o) => o.value); + expect(options).toEqual(["voice"]); + expect(options).not.toContain("text"); + modal.destroy?.(); + }); + + it("displays the category name as read-only", () => { + const { modal } = makeModal("Voice Channels"); + const overlay = container.querySelector("[data-testid='create-channel-modal']"); + expect(overlay?.textContent).toContain("Voice Channels"); + modal.destroy?.(); + }); + + it("shows error when submitting with empty name", () => { + const onCreate = vi.fn(async () => {}); + const { modal } = makeModal("Text Channels", { onCreate }); + + const submitBtn = container.querySelector("[data-testid='channel-create-submit']") as HTMLButtonElement; + submitBtn.click(); + + const error = container.querySelector("[data-testid='channel-create-error']"); + expect(error?.textContent).toContain("required"); + expect(onCreate).not.toHaveBeenCalled(); + modal.destroy?.(); + }); + + it("calls onCreate with correct data when name is provided", async () => { + const onCreate = vi.fn(async () => {}); + const { modal } = makeModal("Text Channels", { onCreate }); + + const nameInput = container.querySelector("[data-testid='channel-name-input']") as HTMLInputElement; + nameInput.value = "test-channel"; + + const submitBtn = container.querySelector("[data-testid='channel-create-submit']") as HTMLButtonElement; + submitBtn.click(); + + // Wait for async handler + await vi.waitFor(() => { + expect(onCreate).toHaveBeenCalledWith({ + name: "test-channel", + type: "text", + category: "Text Channels", + }); + }); + + modal.destroy?.(); + }); + + it("calls onClose when close button is clicked", () => { + const onClose = vi.fn(); + const { modal } = makeModal("Text Channels", { onClose }); + + const closeBtn = container.querySelector(".modal-close") as HTMLButtonElement; + closeBtn.click(); + + expect(onClose).toHaveBeenCalled(); + modal.destroy?.(); + }); + + it("removes overlay on destroy", () => { + const { modal } = makeModal("Text Channels"); + expect(container.querySelector("[data-testid='create-channel-modal']")).not.toBeNull(); + modal.destroy?.(); + expect(container.querySelector("[data-testid='create-channel-modal']")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/delete-channel-modal.test.ts b/Client/tauri-client/tests/unit/delete-channel-modal.test.ts new file mode 100644 index 00000000..bc0b6ab3 --- /dev/null +++ b/Client/tauri-client/tests/unit/delete-channel-modal.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createDeleteChannelModal } from "@components/DeleteChannelModal"; +import type { DeleteChannelModalOptions } from "@components/DeleteChannelModal"; + +describe("DeleteChannelModal", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.querySelectorAll("[data-testid='delete-channel-modal']").forEach((el) => el.remove()); + }); + + function makeModal(overrides?: Partial) { + const options: DeleteChannelModalOptions = { + channelId: 1, + channelName: "general", + onConfirm: overrides?.onConfirm ?? vi.fn(async () => {}), + onClose: overrides?.onClose ?? vi.fn(), + }; + const modal = createDeleteChannelModal(options); + modal.mount(container); + return { modal, options }; + } + + it("renders the modal overlay", () => { + const { modal } = makeModal(); + expect(container.querySelector("[data-testid='delete-channel-modal']")).not.toBeNull(); + modal.destroy?.(); + }); + + it("displays channel name in warning message", () => { + const { modal } = makeModal(); + const overlay = container.querySelector("[data-testid='delete-channel-modal']"); + expect(overlay?.textContent).toContain("#general"); + modal.destroy?.(); + }); + + it("displays cannot be undone warning", () => { + const { modal } = makeModal(); + const overlay = container.querySelector("[data-testid='delete-channel-modal']"); + expect(overlay?.textContent).toContain("cannot be undone"); + modal.destroy?.(); + }); + + it("calls onConfirm when delete button is clicked", async () => { + const onConfirm = vi.fn(async () => {}); + const { modal } = makeModal({ onConfirm }); + const deleteBtn = container.querySelector("[data-testid='delete-channel-confirm']") as HTMLButtonElement; + deleteBtn.click(); + + await vi.waitFor(() => { + expect(onConfirm).toHaveBeenCalled(); + }); + modal.destroy?.(); + }); + + it("calls onClose when close button is clicked", () => { + const onClose = vi.fn(); + const { modal } = makeModal({ onClose }); + const closeBtn = container.querySelector(".modal-close") as HTMLButtonElement; + closeBtn.click(); + expect(onClose).toHaveBeenCalled(); + modal.destroy?.(); + }); + + it("calls onClose when cancel button is clicked", () => { + const onClose = vi.fn(); + const { modal } = makeModal({ onClose }); + const cancelBtn = container.querySelector(".btn-modal-cancel") as HTMLButtonElement; + cancelBtn.click(); + expect(onClose).toHaveBeenCalled(); + modal.destroy?.(); + }); + + it("removes overlay on destroy", () => { + const { modal } = makeModal(); + expect(container.querySelector("[data-testid='delete-channel-modal']")).not.toBeNull(); + modal.destroy?.(); + expect(container.querySelector("[data-testid='delete-channel-modal']")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/edit-channel-modal.test.ts b/Client/tauri-client/tests/unit/edit-channel-modal.test.ts new file mode 100644 index 00000000..cd0b14e8 --- /dev/null +++ b/Client/tauri-client/tests/unit/edit-channel-modal.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createEditChannelModal } from "@components/EditChannelModal"; +import type { EditChannelModalOptions } from "@components/EditChannelModal"; + +describe("EditChannelModal", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.querySelectorAll("[data-testid='edit-channel-modal']").forEach((el) => el.remove()); + }); + + function makeModal(overrides?: Partial) { + const options: EditChannelModalOptions = { + channelId: 1, + channelName: "general", + channelType: "text", + onSave: overrides?.onSave ?? vi.fn(async () => {}), + onClose: overrides?.onClose ?? vi.fn(), + }; + const modal = createEditChannelModal(options); + modal.mount(container); + return { modal, options }; + } + + it("renders the modal overlay", () => { + const { modal } = makeModal(); + expect(container.querySelector("[data-testid='edit-channel-modal']")).not.toBeNull(); + modal.destroy?.(); + }); + + it("pre-fills the name input with current channel name", () => { + const { modal } = makeModal(); + const input = container.querySelector("[data-testid='edit-channel-name-input']") as HTMLInputElement; + expect(input.value).toBe("general"); + modal.destroy?.(); + }); + + it("displays the channel type as read-only", () => { + const { modal } = makeModal(); + const overlay = container.querySelector("[data-testid='edit-channel-modal']"); + expect(overlay?.textContent).toContain("Text"); + modal.destroy?.(); + }); + + it("shows error when saving with empty name", () => { + const onSave = vi.fn(async () => {}); + const { modal } = makeModal({ onSave }); + const input = container.querySelector("[data-testid='edit-channel-name-input']") as HTMLInputElement; + input.value = ""; + + const saveBtn = container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement; + saveBtn.click(); + + const error = container.querySelector("[data-testid='edit-channel-error']"); + expect(error?.textContent).toContain("required"); + expect(onSave).not.toHaveBeenCalled(); + modal.destroy?.(); + }); + + it("calls onSave with updated name", async () => { + const onSave = vi.fn(async () => {}); + const { modal } = makeModal({ onSave }); + const input = container.querySelector("[data-testid='edit-channel-name-input']") as HTMLInputElement; + input.value = "renamed-channel"; + + const saveBtn = container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement; + saveBtn.click(); + + await vi.waitFor(() => { + expect(onSave).toHaveBeenCalledWith({ name: "renamed-channel" }); + }); + modal.destroy?.(); + }); + + it("calls onClose when close button is clicked", () => { + const onClose = vi.fn(); + const { modal } = makeModal({ onClose }); + const closeBtn = container.querySelector(".modal-close") as HTMLButtonElement; + closeBtn.click(); + expect(onClose).toHaveBeenCalled(); + modal.destroy?.(); + }); + + it("removes overlay on destroy", () => { + const { modal } = makeModal(); + expect(container.querySelector("[data-testid='edit-channel-modal']")).not.toBeNull(); + modal.destroy?.(); + expect(container.querySelector("[data-testid='edit-channel-modal']")).toBeNull(); + }); +}); diff --git a/Server/admin/handlers_channels.go b/Server/admin/handlers_channels.go index 0c36208a..92ef1a2b 100644 --- a/Server/admin/handlers_channels.go +++ b/Server/admin/handlers_channels.go @@ -10,6 +10,43 @@ import ( "github.com/owncord/server/db" ) +// ─── Category-Type Validation ──────────────────────────────────────────────── + +// isVoiceCategory returns true if the category name indicates a voice section. +// Uses case-insensitive substring matching for "voice". +func isVoiceCategory(category string) bool { + return strings.Contains(strings.ToLower(category), "voice") +} + +// allowedChannelTypes returns the set of channel types valid for a category. +func allowedChannelTypes(category string) []string { + if category == "" { + return []string{"text", "voice", "announcement"} + } + if isVoiceCategory(category) { + return []string{"voice"} + } + return []string{"text", "announcement"} +} + +// validateCategoryType checks that the channel type is allowed under the given +// category. Returns an error message if invalid, or empty string if OK. +func validateCategoryType(channelType, category string) string { + if category == "" { + return "" + } + allowed := allowedChannelTypes(category) + for _, t := range allowed { + if t == channelType { + return "" + } + } + if isVoiceCategory(category) { + return "only voice channels can be created under a voice category" + } + return "voice channels can only be created under a voice category" +} + // ─── Channel Handlers ──────────────────────────────────────────────────────── func handleListChannels(database *db.DB) http.HandlerFunc { @@ -48,6 +85,11 @@ func handleCreateChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { req.Type = "text" } + if msg := validateCategoryType(req.Type, req.Category); msg != "" { + writeErr(w, http.StatusBadRequest, "INVALID_INPUT", msg) + return + } + id, err := database.AdminCreateChannel(req.Name, req.Type, req.Category, req.Topic, req.Position) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create channel") diff --git a/Server/admin/handlers_channels_test.go b/Server/admin/handlers_channels_test.go new file mode 100644 index 00000000..e435740a --- /dev/null +++ b/Server/admin/handlers_channels_test.go @@ -0,0 +1,142 @@ +package admin_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/owncord/server/admin" +) + +// ─── Category-Type Validation (via POST /channels) ────────────────────────── + +func TestCreateChannel_TextUnderTextCategory_OK(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + token := createAdminUser(t, database) + + body := map[string]any{ + "name": "general", + "type": "text", + "category": "Chat", + } + w := doRequest(t, handler, http.MethodPost, "/channels", token, body) + if w.Code != http.StatusCreated { + t.Errorf("text channel under Chat: status = %d, want 201; body: %s", w.Code, w.Body.String()) + } +} + +func TestCreateChannel_AnnouncementUnderTextCategory_OK(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + token := createAdminUser(t, database) + + body := map[string]any{ + "name": "announcements", + "type": "announcement", + "category": "Text Channels", + } + w := doRequest(t, handler, http.MethodPost, "/channels", token, body) + if w.Code != http.StatusCreated { + t.Errorf("announcement under Text Channels: status = %d, want 201; body: %s", w.Code, w.Body.String()) + } +} + +func TestCreateChannel_VoiceUnderVoiceCategory_OK(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + token := createAdminUser(t, database) + + body := map[string]any{ + "name": "lounge", + "type": "voice", + "category": "Voice Channels", + } + w := doRequest(t, handler, http.MethodPost, "/channels", token, body) + if w.Code != http.StatusCreated { + t.Errorf("voice under Voice Channels: status = %d, want 201; body: %s", w.Code, w.Body.String()) + } +} + +func TestCreateChannel_VoiceUnderTextCategory_Rejected(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + token := createAdminUser(t, database) + + body := map[string]any{ + "name": "bad-voice", + "type": "voice", + "category": "Chat", + } + w := doRequest(t, handler, http.MethodPost, "/channels", token, body) + if w.Code != http.StatusBadRequest { + t.Errorf("voice under Chat: status = %d, want 400; body: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err == nil { + if resp["error"] != "INVALID_INPUT" { + t.Errorf("error code = %q, want INVALID_INPUT", resp["error"]) + } + } +} + +func TestCreateChannel_TextUnderVoiceCategory_Rejected(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + token := createAdminUser(t, database) + + body := map[string]any{ + "name": "bad-text", + "type": "text", + "category": "Voice Channels", + } + w := doRequest(t, handler, http.MethodPost, "/channels", token, body) + if w.Code != http.StatusBadRequest { + t.Errorf("text under Voice Channels: status = %d, want 400; body: %s", w.Code, w.Body.String()) + } +} + +func TestCreateChannel_EmptyCategory_Allowed(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + token := createAdminUser(t, database) + + body := map[string]any{ + "name": "uncategorized", + "type": "voice", + "category": "", + } + w := doRequest(t, handler, http.MethodPost, "/channels", token, body) + if w.Code != http.StatusCreated { + t.Errorf("voice with empty category: status = %d, want 201; body: %s", w.Code, w.Body.String()) + } +} + +func TestCreateChannel_CaseInsensitiveVoiceCategory(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + token := createAdminUser(t, database) + + // "VOICE" in uppercase should still be treated as a voice category + body := map[string]any{ + "name": "vc", + "type": "voice", + "category": "VOICE CHANNELS", + } + w := doRequest(t, handler, http.MethodPost, "/channels", token, body) + if w.Code != http.StatusCreated { + t.Errorf("voice under VOICE CHANNELS: status = %d, want 201; body: %s", w.Code, w.Body.String()) + } + + // Text under uppercase VOICE should be rejected + body2 := map[string]any{ + "name": "bad", + "type": "text", + "category": "VOICE CHANNELS", + } + w2 := doRequest(t, handler, http.MethodPost, "/channels", token, body2) + if w2.Code != http.StatusBadRequest { + t.Errorf("text under VOICE CHANNELS: status = %d, want 400; body: %s", w2.Code, w2.Body.String()) + } +} diff --git a/Server/admin/setup_handler.go b/Server/admin/setup_handler.go index 7fc6901b..89d7e77d 100644 --- a/Server/admin/setup_handler.go +++ b/Server/admin/setup_handler.go @@ -108,8 +108,9 @@ func handleSetup(database *db.DB) http.HandlerFunc { return } - // Create default "general" text channel. - _, _ = database.CreateChannel("general", "text", "Chat", "Welcome to the server!", 0) + // Create default channels under canonical categories. + _, _ = database.CreateChannel("general", "text", "Text Channels", "Welcome to the server!", 0) + _, _ = database.CreateChannel("General", "voice", "Voice Channels", "", 0) // Generate a bootstrap invite code so the owner can invite others. inviteCode, err := database.CreateInvite(uid, 0, nil) // unlimited uses, no expiry diff --git a/Server/admin/static/index.html b/Server/admin/static/index.html index 43a07fa8..361517c2 100644 --- a/Server/admin/static/index.html +++ b/Server/admin/static/index.html @@ -326,7 +326,12 @@ -
+
+ +
@@ -816,6 +821,24 @@ document.getElementById('show-create-channel').onclick = () => { document.getElementById('create-channel-form').classList.toggle('hidden'); }; +// Filter channel type dropdown based on selected category +document.getElementById('ch-category').addEventListener('change', function() { + const typeSelect = document.getElementById('ch-type'); + const isVoice = this.value.toLowerCase().includes('voice'); + for (const opt of typeSelect.options) { + if (isVoice) { + opt.hidden = opt.value !== 'voice'; + } else { + opt.hidden = opt.value === 'voice'; + } + } + // Auto-select the first visible option + const visible = [...typeSelect.options].find(o => !o.hidden); + if (visible) typeSelect.value = visible.value; +}); +// Trigger initial filter +document.getElementById('ch-category').dispatchEvent(new Event('change')); + document.getElementById('cancel-create-channel').onclick = () => { document.getElementById('create-channel-form').classList.add('hidden'); };