diff --git a/app/api/channels/[channelId]/route.ts b/app/api/channels/[channelId]/route.ts new file mode 100644 index 0000000..c093f1f --- /dev/null +++ b/app/api/channels/[channelId]/route.ts @@ -0,0 +1,144 @@ +/** + * @file app/api/channels/[channelId]/route.ts + * @description API route handler for updating and deleting channels. + */ + +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 PATCH requests to update an existing channel's name. + * + * @async + * @function PATCH + * @param {Request} req - The incoming HTTP request object containing the updated channel name in the body. + * @param {Object} context - The route context parameters. + * @param {Promise<{ channelId: string }>} context.params - A promise resolving to the route parameters containing the channel ID. + * @returns {Promise} A JSON response containing the updated channel object or an error message. + */ +export async function PATCH( + req: Request, + { params }: { params: Promise<{ channelId: string }> }, +) { + try { + const { channelId } = await params; + const session = await auth(); + const { name } = await req.json(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!name || !name.trim()) { + return NextResponse.json( + { error: "Channel name cannot be empty" }, + { status: 400 }, + ); + } + + const [existingChannel] = await db + .select() + .from(channels) + .where(eq(channels.id, channelId)) + .limit(1); + + if (!existingChannel) { + return NextResponse.json({ error: "Channel not found" }, { status: 404 }); + } + + // 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, existingChannel.serverId), + ), + ) + .limit(1); + + if (!member) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Update Channel + const [updatedChannel] = await db + .update(channels) + .set({ name: name.trim() }) + .where(eq(channels.id, channelId)) + .returning(); + + return NextResponse.json(updatedChannel); + } catch (error) { + console.error("API Channel PATCH error:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} + +/** + * Handles DELETE requests to remove an existing channel. + * + * @async + * @function DELETE + * @param {Request} req - The incoming HTTP request object. + * @param {Object} context - The route context parameters. + * @param {Promise<{ channelId: string }>} context.params - A promise resolving to the route parameters containing the channel ID. + * @returns {Promise} A JSON response confirming deletion or returning an error message. + */ +export async function DELETE( + req: Request, + { params }: { params: Promise<{ channelId: string }> }, +) { + try { + const { channelId } = await params; + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const [existingChannel] = await db + .select() + .from(channels) + .where(eq(channels.id, channelId)) + .limit(1); + + if (!existingChannel) { + return NextResponse.json({ error: "Channel not found" }, { status: 404 }); + } + + // 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, existingChannel.serverId), + ), + ) + .limit(1); + + if (!member) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Delete Channel + await db.delete(channels).where(eq(channels.id, channelId)); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("API Channel DELETE error:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/components/modals/EditChannelModal.tsx b/components/modals/EditChannelModal.tsx new file mode 100644 index 0000000..a7b9593 --- /dev/null +++ b/components/modals/EditChannelModal.tsx @@ -0,0 +1,231 @@ +/** + * @file components/modals/EditChannelModal.tsx + * @description Modal dialog to edit channel settings or delete the channel. + */ + +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { X, Loader2, Trash2 } from "lucide-react"; +import { useActiveServer } from "@/lib/context/ServerContext"; +import type { Channel } from "@/db/schema"; + +/** + * Properties for the EditChannelModal component. + * + * @interface EditChannelModalProps + * @property {boolean} isOpen - Determines whether the modal dialog is currently visible. + * @property {() => void} onClose - Callback function triggered to close the modal. + * @property {Channel | null} channel - The channel object being edited, or null if none is selected. + */ +interface EditChannelModalProps { + isOpen: boolean; + onClose: () => void; + channel: Channel | null; +} + +/** + * Renders a modal dialog allowing users to modify channel properties or delete the channel. + * + * @param {EditChannelModalProps} props - The component props. + * @param {boolean} props.isOpen - Determines whether the modal dialog is currently visible. + * @param {() => void} props.onClose - Callback function triggered to close the modal. + * @param {Channel | null} props.channel - The channel object being edited, or null if none is selected. + * @returns {JSX.Element | null} The rendered edit channel modal, or null if closed or no channel is selected. + */ +export function EditChannelModal({ + isOpen, + onClose, + channel, +}: EditChannelModalProps) { + const [name, setName] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + + const router = useRouter(); + const { updateChannel, removeChannel, activeServer } = useActiveServer(); + + useEffect(() => { + if (channel) { + setName(channel.name); + } + }, [channel]); + + if (!isOpen || !channel) return null; + + const isChanged = name.trim() !== channel.name; + const isValid = name.trim().length > 0; + const canSave = isChanged && isValid && !isLoading && !isDeleting; + + /** + * Handles the asynchronous update of the channel name. + * + * @async + * @function handleUpdate + * @param {React.FormEvent} e - The form submission event. + * @returns {Promise} Resolves when the channel update process completes or fails. + */ + const handleUpdate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!canSave) return; + + try { + setIsLoading(true); + setError(null); + + const response = await fetch(`/api/channels/${channel.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + + if (!response.ok) { + throw new Error("Fehler beim Aktualisieren des Channels."); + } + + const updated = await response.json(); + updateChannel(updated); + onClose(); + } catch (err: unknown) { + setError( + err instanceof Error ? err.message : "Etwas ist schiefgelaufen.", + ); + } finally { + setIsLoading(false); + } + }; + + /** + * Handles the asynchronous deletion of the channel and redirects the user accordingly. + * + * @async + * @function handleDelete + * @returns {Promise} Resolves when the channel deletion and navigation processes complete or fail. + */ + const handleDelete = async () => { + if (isDeleting || isLoading) return; + + try { + setIsDeleting(true); + setError(null); + + const response = await fetch(`/api/channels/${channel.id}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Fehler beim Löschen des Channels."); + } + + removeChannel(channel.id); + onClose(); + + if (activeServer) { + const remainingChannels = activeServer.channels.filter( + (c) => c.id !== channel.id, + ); + if (remainingChannels.length > 0) { + router.push( + `/servers/${activeServer.id}/channels/${remainingChannels[0].id}`, + ); + } else { + router.push(`/servers/${activeServer.id}`); + } + } + } catch (err: unknown) { + setError( + err instanceof Error ? err.message : "Etwas ist schiefgelaufen.", + ); + } 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 Channel

+

+ Change channel details or delete this channel. +

+ +
+
+ +
+ + # + + setName(e.target.value)} + placeholder="channel-name" + disabled={isLoading || isDeleting} + 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 9c588c2..dae3d4c 100644 --- a/components/sidebar/ChannelSidebar.tsx +++ b/components/sidebar/ChannelSidebar.tsx @@ -1,6 +1,6 @@ /** * @file components/sidebar/ChannelSidebar.tsx - * @description Sidebar component displaying server channels, navigation toggles, and modal triggers. + * @description Sidebar component for navigating text channels within an active server, featuring channel creation, editing, and mobile responsiveness. */ "use client"; @@ -12,22 +12,26 @@ import { useActiveServer } from "@/lib/context/ServerContext"; import { DirectMessageSidebar } from "@/components/sidebar/DirectMessageSidebar"; import { useSidebarStore } from "@/lib/stores/useSidebarStore"; import { CreateChannelModal } from "@/components/modals/CreateChannelModal"; -import { PanelLeftClose, Plus } from "lucide-react"; +import { EditChannelModal } from "@/components/modals/EditChannelModal"; +import { PanelLeftClose, Plus, Settings } from "lucide-react"; +import type { Channel } from "@/db/schema"; /** - * Renders the channel sidebar for active servers, allowing users to navigate channels or create new ones. + * Renders the channel sidebar for the active server with text channel lists, creation triggers, and settings handlers. * * @returns {JSX.Element} The rendered channel sidebar container. */ export function ChannelSidebar() { - const [isModalOpen, setIsModalOpen] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [editingChannel, setEditingChannel] = useState(null); + const params = useParams(); const currentChannelId = params?.channelId as string; const { activeServer } = useActiveServer(); const { closeNav, toggleNav } = useSidebarStore(); /** - * Handles channel selection clicks, closing the mobile navigation sidebar on smaller viewports. + * Handles channel selection clicks, automatically closing the mobile navigation drawer on smaller screens. * * @function handleChannelClick * @returns {void} @@ -38,6 +42,20 @@ export function ChannelSidebar() { } }; + /** + * Opens the channel settings modal for the selected channel, preventing event propagation. + * + * @function handleOpenSettings + * @param {React.MouseEvent} e - The mouse click event. + * @param {Channel} channel - The channel object to edit. + * @returns {void} + */ + const handleOpenSettings = (e: React.MouseEvent, channel: Channel) => { + e.preventDefault(); + e.stopPropagation(); + setEditingChannel(channel); + }; + if (!activeServer) { return ; } @@ -64,7 +82,7 @@ export function ChannelSidebar() { Text Channels ); })} @@ -99,11 +130,18 @@ export function ChannelSidebar() { + {/* Modals */} setIsModalOpen(false)} + isOpen={isCreateModalOpen} + onClose={() => setIsCreateModalOpen(false)} serverId={activeServer.id} /> + + setEditingChannel(null)} + channel={editingChannel} + /> ); } diff --git a/lib/context/ServerContext.tsx b/lib/context/ServerContext.tsx index 42a9448..935d52a 100644 --- a/lib/context/ServerContext.tsx +++ b/lib/context/ServerContext.tsx @@ -33,6 +33,9 @@ export interface ServerMember { * @interface ServerContextType * @property {ServerWithChannels | null} activeServer - The currently active server instance with its associated channels. * @property {(server: ServerWithChannels | null) => void} setActiveServer - State setter function for updating the active server. + * @property {(channel: Channel) => void} addChannel - Function to add a new channel to the active server. + * @property {(channelId: string) => void} removeChannel - Function to remove a channel by its identifier. + * @property {(channel: Channel) => void} updateChannel - Function to update an existing channel. * @property {ServerMember[]} members - The list of members belonging to the active server. * @property {(members: ServerMember[]) => void} setMembers - State setter function for updating the server members list. */ @@ -40,6 +43,8 @@ interface ServerContextType { activeServer: ServerWithChannels | null; setActiveServer: (server: ServerWithChannels | null) => void; addChannel: (channel: Channel) => void; + removeChannel: (channelId: string) => void; + updateChannel: (channel: Channel) => void; members: ServerMember[]; setMembers: (members: ServerMember[]) => void; } @@ -48,6 +53,8 @@ const ServerContext = createContext({ activeServer: null, setActiveServer: () => {}, addChannel: () => {}, + removeChannel: () => {}, + updateChannel: () => {}, members: [], setMembers: () => {}, }); @@ -65,22 +72,59 @@ 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], - }; - }); + /** + * Helper function to update channels within the active server state. + * + * @function updateChannels + * @param {(channels: Channel[]) => Channel[]} fn - The updater function receiving current channels and returning new channels. + * @returns {void} + */ + const updateChannels = (fn: (channels: Channel[]) => Channel[]) => { + setActiveServer((prev) => + prev ? { ...prev, channels: fn(prev.channels) } : prev, + ); }; + /** + * Adds a new channel to the active server. + * + * @function addChannel + * @param {Channel} channel - The channel object to add. + * @returns {void} + */ + const addChannel = (channel: Channel) => + updateChannels((prev) => [...prev, channel]); + + /** + * Removes a channel from the active server by its identifier. + * + * @function removeChannel + * @param {string} channelId - The unique identifier of the channel to remove. + * @returns {void} + */ + const removeChannel = (channelId: string) => + updateChannels((prev) => prev.filter((c) => c.id !== channelId)); + + /** + * Updates an existing channel within the active server. + * + * @function updateChannel + * @param {Channel} updatedChannel - The updated channel object. + * @returns {void} + */ + const updateChannel = (updatedChannel: Channel) => + updateChannels((prev) => + prev.map((c) => (c.id === updatedChannel.id ? updatedChannel : c)), + ); + return (