diff --git a/app/api/channels/route.ts b/app/api/channels/route.ts new file mode 100644 index 0000000..57698dd --- /dev/null +++ b/app/api/channels/route.ts @@ -0,0 +1,73 @@ +/** + * @file app/api/channels/route.ts + * @description API route handler for creating new channels within a server. + */ + +import { auth } from "@/auth"; +import { db } from "@/db"; +import { channels, members } from "@/db/schema"; +import { and, eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; + +/** + * Handles the POST request to create a new channel within a specific server. + * + * @async + * @function POST + * @param {Request} req - The incoming HTTP request containing JSON payload with `name` and `serverId`. + * @returns {Promise} The created channel object with status 201, or an error response (401, 400, 403, 500). + */ +export async function POST(req: Request) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { name, serverId } = await req.json(); + + if (!name?.trim() || !serverId) { + return NextResponse.json( + { error: "Name and server ID are required" }, + { status: 400 }, + ); + } + + // Check if the user is a member of the server + const [member] = await db + .select() + .from(members) + .where( + and( + eq(members.userId, session.user.id), + eq(members.serverId, serverId), + ), + ) + .limit(1); + + if (!member) { + return NextResponse.json( + { error: "Access denied to this server" }, + { status: 403 }, + ); + } + + // Create the channel in the database + const [newChannel] = await db + .insert(channels) + .values({ + name: name.trim().toLowerCase().replace(/\s+/g, "-"), + serverId, + }) + .returning(); + + return NextResponse.json(newChannel, { status: 201 }); + } catch (error) { + console.error("API Channel POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/components/modals/CreateChannelModal.tsx b/components/modals/CreateChannelModal.tsx new file mode 100644 index 0000000..4eb1fd3 --- /dev/null +++ b/components/modals/CreateChannelModal.tsx @@ -0,0 +1,160 @@ +/** + * @file components/modals/CreateChannelModal.tsx + * @description Modal dialog component for creating a new text channel within a server. + */ + +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { X, Loader2 } from "lucide-react"; +import { useActiveServer } from "@/lib/context/ServerContext"; + +/** + * Properties for the CreateChannelModal component. + * + * @interface CreateChannelModalProps + * @property {boolean} isOpen - Determines whether the modal is visible. + * @property {() => void} onClose - Callback function executed to close the modal. + * @property {string} serverId - The unique identifier of the target server where the channel is created. + */ +interface CreateChannelModalProps { + isOpen: boolean; + onClose: () => void; + serverId: string; +} + +/** + * Renders a modal dialog allowing users to input a name and create a new server channel. + * + * @param {CreateChannelModalProps} props - The component props. + * @param {boolean} props.isOpen - Determines whether the modal is visible. + * @param {() => void} props.onClose - Callback function executed to close the modal. + * @param {string} props.serverId - The unique identifier of the target server where the channel is created. + * @returns {JSX.Element | null} The rendered modal component or null if closed. + */ +export function CreateChannelModal({ + isOpen, + onClose, + serverId, +}: CreateChannelModalProps) { + const [name, setName] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const router = useRouter(); + const { addChannel } = useActiveServer(); + + if (!isOpen) return null; + + /** + * Handles form submission to create a new channel via the API. + * + * @async + * @function handleSubmit + * @param {React.FormEvent} e - The form submission event. + * @returns {Promise} Resolves when the channel creation request completes. + */ + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || isLoading) return; + + try { + setIsLoading(true); + setError(null); + + const response = await fetch("/api/channels", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, serverId }), + }); + + if (!response.ok) { + throw new Error("Failed to create channel."); + } + + const newChannel = await response.json(); + + addChannel(newChannel); + + setName(""); + onClose(); + + router.push(`/servers/${serverId}/channels/${newChannel.id}`); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsLoading(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" + > + + +

Create Channel

+

+ Create a new text channel for messaging in this server. +

+ +
+
+ +
+ + # + + setName(e.target.value)} + placeholder="new-channel" + disabled={isLoading} + autoFocus + className="w-full bg-background border border-surface/80 rounded-xl pl-8 pr-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}

} + +
+ + +
+
+
+
+ ); +} diff --git a/components/sidebar/ChannelSidebar.tsx b/components/sidebar/ChannelSidebar.tsx index 2bffab8..9c588c2 100644 --- a/components/sidebar/ChannelSidebar.tsx +++ b/components/sidebar/ChannelSidebar.tsx @@ -1,31 +1,36 @@ /** * @file components/sidebar/ChannelSidebar.tsx - * @description Sidebar component listing channels for the active server, or rendering direct messages when no server is active. + * @description Sidebar component displaying server channels, navigation toggles, and modal triggers. */ "use client"; +import { useState } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { useActiveServer } from "@/lib/context/ServerContext"; import { DirectMessageSidebar } from "@/components/sidebar/DirectMessageSidebar"; import { useSidebarStore } from "@/lib/stores/useSidebarStore"; -import { PanelLeftClose } from "lucide-react"; +import { CreateChannelModal } from "@/components/modals/CreateChannelModal"; +import { PanelLeftClose, Plus } from "lucide-react"; /** - * Renders the channel sidebar for the active server or defaults to the direct message view. - * Handles responsive sidebar toggling and highlights active channels based on URL parameters. + * Renders the channel sidebar for active servers, allowing users to navigate channels or create new ones. * - * @returns {JSX.Element} The rendered channel sidebar or direct message sidebar component. + * @returns {JSX.Element} The rendered channel sidebar container. */ export function ChannelSidebar() { + const [isModalOpen, setIsModalOpen] = useState(false); const params = useParams(); const currentChannelId = params?.channelId as string; const { activeServer } = useActiveServer(); const { closeNav, toggleNav } = useSidebarStore(); /** - * Closes the mobile navigation drawer when a channel link is selected on viewports smaller than 768px. + * Handles channel selection clicks, closing the mobile navigation sidebar on smaller viewports. + * + * @function handleChannelClick + * @returns {void} */ const handleChannelClick = () => { if (!window.matchMedia("(min-width: 768px)").matches) { @@ -38,51 +43,67 @@ export function ChannelSidebar() { } return ( -
- {/* Server Header */} -
- {activeServer.name} - -
- - {/* Channel List */} -
-
- Text Channels + <> +
+ {/* Server Header */} +
+ {activeServer.name} +
-
- {activeServer.channels.map((channel) => { - const isActive = currentChannelId === channel.id; + {/* Channel List */} +
+
+ Text Channels + +
- return ( - - - # - - {channel.name} - - ); - })} +
+ {activeServer.channels.map((channel) => { + const isActive = currentChannelId === channel.id; + + return ( + + + # + + {channel.name} + + ); + })} +
-
+ + setIsModalOpen(false)} + serverId={activeServer.id} + /> + ); } diff --git a/lib/context/ServerContext.tsx b/lib/context/ServerContext.tsx index 06fd1a9..42a9448 100644 --- a/lib/context/ServerContext.tsx +++ b/lib/context/ServerContext.tsx @@ -39,6 +39,7 @@ export interface ServerMember { interface ServerContextType { activeServer: ServerWithChannels | null; setActiveServer: (server: ServerWithChannels | null) => void; + addChannel: (channel: Channel) => void; members: ServerMember[]; setMembers: (members: ServerMember[]) => void; } @@ -46,6 +47,7 @@ interface ServerContextType { const ServerContext = createContext({ activeServer: null, setActiveServer: () => {}, + addChannel: () => {}, members: [], setMembers: () => {}, }); @@ -63,21 +65,31 @@ export function ServerProvider({ children }: { children: React.ReactNode }) { ); const [members, setMembers] = useState([]); + const addChannel = (channel: Channel) => { + setActiveServer((prev) => { + if (!prev || prev.id !== channel.serverId) return prev; + return { + ...prev, + channels: [...prev.channels, channel], + }; + }); + }; + return ( {children} ); } -/** - * Custom hook to access the current ServerContext state. - * - * @throws {Error} Throws an error if used outside of a `ServerProvider`. - * @returns {ServerContextType} The server context value containing active server state and member management functions. - */ export function useActiveServer() { const context = useContext(ServerContext); if (!context) {