From 0cb26f62f0ad2e396158756c864a6d5cb0d34674 Mon Sep 17 00:00:00 2001 From: Chneemann Date: Mon, 14 Sep 2026 18:54:36 +0200 Subject: [PATCH] feat(channels): add EditCategoryModal and category settings action in sidebar --- components/modals/EditCategoryModal.tsx | 189 ++++++++++++++++++++++++ components/sidebar/ChannelSidebar.tsx | 52 +++++-- lib/context/ServerContext.tsx | 83 ++++++----- 3 files changed, 272 insertions(+), 52 deletions(-) create mode 100644 components/modals/EditCategoryModal.tsx diff --git a/components/modals/EditCategoryModal.tsx b/components/modals/EditCategoryModal.tsx new file mode 100644 index 0000000..701c812 --- /dev/null +++ b/components/modals/EditCategoryModal.tsx @@ -0,0 +1,189 @@ +/** + * @file components/modals/EditCategoryModal.tsx + * @description Modal dialog component to edit category settings or delete a category. + */ + +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { X, Trash2 } from "lucide-react"; +import { useActiveServer } from "@/lib/context/ServerContext"; +import type { Category } from "@/db/schema"; +import { ActionButton } from "../ui/ActionButton"; + +interface EditCategoryModalProps { + isOpen: boolean; + onClose: () => void; + category: Category | null; +} + +/** Renders a modal dialog allowing users to edit or delete a category. */ +export function EditCategoryModal({ + isOpen, + onClose, + category, +}: EditCategoryModalProps) { + const [name, setName] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isConfirmingDelete, setIsConfirmingDelete] = useState(false); + const [error, setError] = useState(null); + + const router = useRouter(); + const { updateCategory, removeCategory } = useActiveServer(); + + useEffect(() => { + if (category) { + setName(category.name); + setIsConfirmingDelete(false); + } + }, [category]); + + if (!isOpen || !category) return null; + + const isChanged = name.trim() !== category.name; + const isValid = name.trim().length > 0; + const canSave = isChanged && isValid && !isLoading && !isDeleting; + + /** Handles the update of the category name via PATCH. */ + const handleUpdate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!canSave) return; + + try { + setIsLoading(true); + setError(null); + + const response = await fetch(`/api/categories/${category.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + + if (!response.ok) { + throw new Error("Error updating the category."); + } + + const updated = await response.json(); + updateCategory(updated); + router.refresh(); + onClose(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsLoading(false); + } + }; + + /** Handles the deletion of the category via DELETE. */ + const handleDelete = async () => { + if (!isConfirmingDelete) { + setIsConfirmingDelete(true); + return; + } + + if (isDeleting || isLoading) return; + + try { + setIsDeleting(true); + setError(null); + + const response = await fetch(`/api/categories/${category.id}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Error deleting the category."); + } + + removeCategory(category.id); + router.refresh(); + onClose(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsDeleting(false); + } + }; + + return ( +
+
e.stopPropagation()} + className="bg-surface border border-surface/50 rounded-2xl w-full max-w-md p-6 shadow-2xl relative animate-in fade-in zoom-in-95 duration-150" + > + + +

Edit Category

+

+ Change category details or delete this category. +

+ +
+
+ + setName(e.target.value)} + placeholder="CATEGORY NAME" + disabled={isLoading || isDeleting} + autoFocus + className="w-full bg-background border border-surface/80 rounded-xl px-3.5 py-2.5 text-sm text-white placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent transition-all disabled:opacity-50" + /> +
+ + {error &&

{error}

} + +
+ + {isConfirmingDelete ? "Sure?" : "Delete Category"} + + +
+ + Cancel + + + + Save + +
+
+
+
+
+ ); +} diff --git a/components/sidebar/ChannelSidebar.tsx b/components/sidebar/ChannelSidebar.tsx index c5e4751..1b1e57d 100644 --- a/components/sidebar/ChannelSidebar.tsx +++ b/components/sidebar/ChannelSidebar.tsx @@ -12,6 +12,7 @@ import { useSidebarStore } from "@/lib/stores/useSidebarStore"; import { CreateChannelModal } from "@/components/modals/CreateChannelModal"; import { CreateCategoryModal } from "@/components/modals/CreateCategoryModal"; import { EditChannelModal } from "@/components/modals/EditChannelModal"; +import { EditCategoryModal } from "@/components/modals/EditCategoryModal"; import { ChevronDown, ChevronRight, @@ -20,7 +21,7 @@ import { Plus, Settings, } from "lucide-react"; -import type { Channel } from "@/db/schema"; +import type { Category, Channel } from "@/db/schema"; import Link from "next/link"; /** Renders the channel navigation sidebar for the active server. */ @@ -29,6 +30,7 @@ export function ChannelSidebar() { const [isCreateCategoryModalOpen, setIsCreateCategoryModalOpen] = useState(false); const [editingChannel, setEditingChannel] = useState(null); + const [editingCategory, setEditingCategory] = useState(null); const [selectedCategoryId, setSelectedCategoryId] = useState( null, ); @@ -60,12 +62,22 @@ export function ChannelSidebar() { }; /** Opens the edit channel modal for a specific channel. */ - const handleOpenSettings = (e: React.MouseEvent, channel: Channel) => { + const handleOpenChannelSettings = (e: React.MouseEvent, channel: Channel) => { e.preventDefault(); e.stopPropagation(); setEditingChannel(channel); }; + /** Opens the edit category modal for a specific category. */ + const handleOpenCategorySettings = ( + e: React.MouseEvent, + category: Category, + ) => { + e.preventDefault(); + e.stopPropagation(); + setEditingCategory(category); + }; + /** Opens the create channel modal for a specific category or uncategorized. */ const handleOpenCreateModal = (categoryId: string | null = null) => { setSelectedCategoryId(categoryId); @@ -106,7 +118,7 @@ export function ChannelSidebar() { {!channel.isDefault && ( - +
+ + +
{!isCollapsed && ( @@ -225,6 +247,12 @@ export function ChannelSidebar() { serverId={activeServer.id} /> + setEditingCategory(null)} + category={editingCategory} + /> + setIsCreateModalOpen(false)} diff --git a/lib/context/ServerContext.tsx b/lib/context/ServerContext.tsx index 6abe475..51cff10 100644 --- a/lib/context/ServerContext.tsx +++ b/lib/context/ServerContext.tsx @@ -8,20 +8,20 @@ import { createContext, useContext, useState } from "react"; import type { Server, Channel, Category } from "@/db/schema"; -/** Type definition representing a server entity along with its associated channels array. */ +/** Extended server type containing associated channels and categories. */ export type ServerWithChannels = Server & { channels: Channel[]; categories: Category[]; }; -/** Represents a member within a server. */ +/** Member representation within a server context. */ export interface ServerMember { id: string; name: string; isOnline?: boolean; } -/** Interface defining the shape of the ServerContext state and update handlers. */ +/** Shape of the server context state and dispatch functions. */ interface ServerContextType { activeServer: ServerWithChannels | null; setActiveServer: (server: ServerWithChannels | null) => void; @@ -29,59 +29,60 @@ interface ServerContextType { removeChannel: (channelId: string) => void; updateChannel: (channel: Channel) => void; addCategory: (category: Category) => void; + updateCategory: (category: Category) => void; + removeCategory: (categoryId: string) => void; members: ServerMember[]; setMembers: (members: ServerMember[]) => void; } -const ServerContext = createContext({ - activeServer: null, - setActiveServer: () => {}, - addChannel: () => {}, - removeChannel: () => {}, - updateChannel: () => {}, - addCategory: () => {}, - members: [], - setMembers: () => {}, -}); +const ServerContext = createContext(null); -/** Provider component that wraps the application layout to provide global access to active server state and member listings. */ +/** Provides active server, channel, and member management state to child components. */ export function ServerProvider({ children }: { children: React.ReactNode }) { const [activeServer, setActiveServer] = useState( null, ); const [members, setMembers] = useState([]); - /** Helper function to update channels within the active server state. */ - const updateChannels = (fn: (channels: Channel[]) => Channel[]) => { - setActiveServer((prev) => - prev ? { ...prev, channels: fn(prev.channels) } : prev, - ); + /** Updates the active server state using a partial updater function. */ + const updateServer = ( + fn: (prev: ServerWithChannels) => Partial, + ) => { + setActiveServer((prev) => (prev ? { ...prev, ...fn(prev) } : null)); }; /** Adds a new channel to the active server. */ - const addChannel = (channel: Channel) => - updateChannels((prev) => [...prev, channel]); + const addChannel = (ch: Channel) => + updateServer((s) => ({ channels: [...s.channels, ch] })); - /** Removes a channel from the active server by its identifier. */ - const removeChannel = (channelId: string) => - updateChannels((prev) => prev.filter((c) => c.id !== channelId)); + /** Removes a channel by ID from the active server. */ + const removeChannel = (id: string) => + updateServer((s) => ({ channels: s.channels.filter((c) => c.id !== id) })); - /** Updates an existing channel within the active server. */ - const updateChannel = (updatedChannel: Channel) => - updateChannels((prev) => - prev.map((c) => (c.id === updatedChannel.id ? updatedChannel : c)), - ); + /** Updates an existing channel in the active server. */ + const updateChannel = (ch: Channel) => + updateServer((s) => ({ + channels: s.channels.map((c) => (c.id === ch.id ? ch : c)), + })); - const addCategory = (category: Category) => { - setActiveServer((prev) => - prev - ? { - ...prev, - categories: [...(prev.categories || []), category], - } - : prev, - ); - }; + /** Adds a new category to the active server. */ + const addCategory = (cat: Category) => + updateServer((s) => ({ categories: [...(s.categories || []), cat] })); + + /** Updates an existing category in the active server. */ + const updateCategory = (cat: Category) => + updateServer((s) => ({ + categories: s.categories.map((c) => (c.id === cat.id ? cat : c)), + })); + + /** Removes a category by ID and unassigns its channels. */ + const removeCategory = (id: string) => + updateServer((s) => ({ + categories: s.categories.filter((c) => c.id !== id), + channels: s.channels.map((c) => + c.categoryId === id ? { ...c, categoryId: null } : c, + ), + })); return (