diff --git a/app/api/servers/[serverId]/route.ts b/app/api/servers/[serverId]/route.ts index 572bf1d..edf9a15 100644 --- a/app/api/servers/[serverId]/route.ts +++ b/app/api/servers/[serverId]/route.ts @@ -1,52 +1,144 @@ /** * @file app/api/servers/[serverId]/route.ts - * @description API route handler for deleting a server. + * @description API route handler for updating and deleting servers. */ import { auth } from "@/auth"; import { db } from "@/db"; import { servers } from "@/db/schema"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { NextResponse } from "next/server"; /** - * Handles DELETE requests to remove a server if the requesting user is the owner. + * Handles PATCH requests to update an existing server's name. * - * @param {Request} req - The incoming HTTP request. - * @param {Object} context - Route parameters context. - * @param {Promise<{ serverId: string }>} context.params - Async route parameters containing the `serverId`. - * @returns {Promise} A JSON response with the deleted server data on success, or an appropriate error response. + * @async + * @function PATCH + * @param {Request} req - The incoming HTTP request object containing the updated server name in the body. + * @param {Object} context - The route context parameters. + * @param {Promise<{ serverId: string }>} context.params - A promise resolving to the route parameters containing the server ID. + * @returns {Promise} A JSON response containing the updated server object or an error message. + */ +export async function PATCH( + req: Request, + { params }: { params: Promise<{ serverId: string }> }, +) { + try { + const { serverId } = await params; + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json().catch(() => null); + const name = body?.name; + + // Ensure name is present, valid string type, and not just whitespace + if (!name || typeof name !== "string" || !name.trim()) { + return NextResponse.json( + { error: "Server name is required and cannot be empty." }, + { status: 400 }, + ); + } + + const trimmedName = name.trim(); + + // Ensure server name stays within allowable character limits + if (trimmedName.length > 32) { + return NextResponse.json( + { error: "Server name cannot exceed 32 characters." }, + { status: 400 }, + ); + } + + // Check if the server exists + const [existingServer] = await db + .select() + .from(servers) + .where(eq(servers.id, serverId)) + .limit(1); + + if (!existingServer) { + return NextResponse.json({ error: "Server not found." }, { status: 404 }); + } + + // Check if the user is the owner of the server + if (existingServer.ownerId !== session.user.id) { + return NextResponse.json( + { error: "You do not have permission to edit this server." }, + { status: 403 }, + ); + } + + // Update Server + const [updatedServer] = await db + .update(servers) + .set({ name: trimmedName }) + .where(eq(servers.id, serverId)) + .returning(); + + revalidatePath("/", "layout"); + return NextResponse.json(updatedServer); + } catch (error) { + console.error("API Server PATCH error:", error); + return NextResponse.json( + { error: "An unexpected internal server error occurred." }, + { status: 500 }, + ); + } +} + +/** + * Handles DELETE requests to remove an existing server. + * + * @async + * @function DELETE + * @param {Request} req - The incoming HTTP request object. + * @param {Object} context - The route context parameters. + * @param {Promise<{ serverId: string }>} context.params - A promise resolving to the route parameters containing the server ID. + * @returns {Promise} A JSON response confirming deletion or returning an error message. */ export async function DELETE( req: Request, { params }: { params: Promise<{ serverId: string }> }, ) { try { + const { serverId } = await params; const session = await auth(); if (!session?.user?.id) { - return new NextResponse("Unauthorized", { status: 401 }); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { serverId } = await params; + // Check if the server exists + const [existingServer] = await db + .select() + .from(servers) + .where(eq(servers.id, serverId)) + .limit(1); - // Delete the server only if the current user is the OWNER - const [deletedServer] = await db - .delete(servers) - .where( - and(eq(servers.id, serverId), eq(servers.ownerId, session.user.id)), - ) - .returning(); - - if (!deletedServer) { - return new NextResponse("Server not found or forbidden", { status: 404 }); + if (!existingServer) { + return NextResponse.json({ error: "Server not found." }, { status: 404 }); } - revalidatePath("/", "layout"); - return NextResponse.json(deletedServer); + // Check if the user is the owner of the server + if (existingServer.ownerId !== session.user.id) { + return NextResponse.json( + { error: "You do not have permission to delete this server." }, + { status: 403 }, + ); + } + + // Delete Server + await db.delete(servers).where(eq(servers.id, serverId)); + return NextResponse.json({ success: true }); } catch (error) { - console.error("[SERVER_DELETE]", error); - return new NextResponse("Internal Error", { status: 500 }); + console.error("API Server DELETE error:", error); + return NextResponse.json( + { error: "An unexpected internal server error occurred." }, + { status: 500 }, + ); } } diff --git a/components/layout/ServerSettingsMenu.tsx b/components/layout/ServerSettingsMenu.tsx index 1745a13..d10acf7 100644 --- a/components/layout/ServerSettingsMenu.tsx +++ b/components/layout/ServerSettingsMenu.tsx @@ -1,20 +1,20 @@ /** * @file components/layout/ServerSettingsMenu.tsx - * @description Header menu button providing server-level action options like deleting a server. + * @description Header button triggering the server overview/settings modal. */ "use client"; -import { useState, useRef, useEffect } from "react"; -import { Settings, Trash2 } from "lucide-react"; -import { DeleteServerModal } from "@/components/modals/DeleteServerModal"; +import { useState } from "react"; +import { Settings } from "lucide-react"; +import { EditServerModal } from "@/components/modals/EditServerModal"; /** * Props for the ServerSettingsMenu component. * * @interface ServerSettingsMenuProps * @property {string} serverId - The unique identifier of the server. - * @property {string} serverName - The name of the server used for display and verification during deletion. + * @property {string} serverName - The display name of the server. */ interface ServerSettingsMenuProps { serverId: string; @@ -22,73 +22,36 @@ interface ServerSettingsMenuProps { } /** - * Renders a dropdown menu button for server settings, including an option to open the server deletion modal. + * Renders a settings button that opens the edit server modal. * * @param {ServerSettingsMenuProps} props - The component props. * @param {string} props.serverId - The unique identifier of the server. * @param {string} props.serverName - The display name of the server. - * @returns {JSX.Element} The rendered server settings dropdown menu and modal component. + * @returns {JSX.Element} The rendered server settings trigger and modal component. */ export function ServerSettingsMenu({ serverId, serverName, }: ServerSettingsMenuProps) { - const [isOpen, setIsOpen] = useState(false); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const menuRef = useRef(null); - - // Close dropdown when clicking outside - useEffect(() => { - /** - * Handles mouse click events outside of the menu container to close the dropdown. - * - * @param {MouseEvent} event - The mouse click event object. - */ - function handleClickOutside(event: MouseEvent) { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - } - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); + const [isModalOpen, setIsModalOpen] = useState(false); return ( -
- {/* Icon Button */} + <> - {/* Dropdown Menu */} - {isOpen && ( -
- -
- )} - - {/* Delete Modal */} - setIsDeleteModalOpen(false)} + initialName={serverName} + onClose={() => setIsModalOpen(false)} /> -
+ ); } diff --git a/components/modals/DeleteServerModal.tsx b/components/modals/DeleteServerModal.tsx deleted file mode 100644 index f1b47b9..0000000 --- a/components/modals/DeleteServerModal.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/** - * @file components/modals/DeleteServerModal.tsx - * @description Confirmation modal for permanently deleting a server. - */ - -"use client"; - -import { useState } from "react"; -import { X, Loader2, AlertTriangle } from "lucide-react"; - -/** - * Properties for the DeleteServerModal component. - * - * @interface DeleteServerModalProps - * @property {boolean} isOpen - Indicates whether the modal dialog is currently visible. - * @property {() => void} onClose - Callback function to handle closing the modal dialog. - * @property {string} serverId - The unique identifier of the server to be deleted. - * @property {string} serverName - The name of the server displayed in the confirmation message. - */ -interface DeleteServerModalProps { - isOpen: boolean; - onClose: () => void; - serverId: string; - serverName: string; -} - -/** - * Renders a confirmation modal allowing users to permanently delete a server. - * - * @param {DeleteServerModalProps} props - Component properties. - * @returns {JSX.Element | null} The modal component or null if not open. - */ -export function DeleteServerModal({ - isOpen, - onClose, - serverId, - serverName, -}: DeleteServerModalProps) { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - if (!isOpen) return null; - - /** - * Sends a DELETE request to remove the server and redirects to the home route upon success. - */ - const handleDelete = async () => { - try { - setIsLoading(true); - setError(null); - - const response = await fetch(`/api/servers/${serverId}`, { - method: "DELETE", - }); - - if (!response.ok) { - throw new Error("Failed to delete server."); - } - - onClose(); - - window.location.href = "/"; - } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Something went wrong."); - } finally { - setIsLoading(false); - } - }; - - return ( - /* Outer Backdrop */ -
- {/* Inner Modal Content */} -
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" - > - - -
-
- -
-

Delete Server

-
- -

- Are you sure you want to delete{" "} - {serverName}? This - action cannot be undone and will permanently remove all channels and - messages. -

- - {error &&

{error}

} - -
- - -
-
-
- ); -} diff --git a/components/modals/EditServerModal.tsx b/components/modals/EditServerModal.tsx new file mode 100644 index 0000000..d44be3a --- /dev/null +++ b/components/modals/EditServerModal.tsx @@ -0,0 +1,208 @@ +/** + * @file components/modals/EditServerModal.tsx + * @description Modal dialog to edit server settings or delete the server. + */ + +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { X, Loader2, Trash2 } from "lucide-react"; + +/** + * Properties for the EditServerModal component. + * + * @interface EditServerModalProps + * @property {boolean} isOpen - Determines whether the modal dialog is currently visible. + * @property {() => void} onClose - Callback function triggered to close the modal. + * @property {string} serverId - The unique identifier of the server being edited. + * @property {string} initialName - The current name of the server. + */ +interface EditServerModalProps { + isOpen: boolean; + onClose: () => void; + serverId: string; + initialName: string; +} + +/** + * Renders a modal dialog allowing users to modify server properties or delete the server. + * + * @param {EditServerModalProps} 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 {string} props.serverId - The unique identifier of the server. + * @param {string} props.initialName - The current name of the server. + * @returns {JSX.Element | null} The rendered edit server modal, or null if closed. + */ +export function EditServerModal({ + isOpen, + onClose, + serverId, + initialName, +}: EditServerModalProps) { + const [name, setName] = useState(initialName); + const [isLoading, setIsLoading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + + const router = useRouter(); + + useEffect(() => { + setName(initialName); + }, [initialName]); + + if (!isOpen) return null; + + const isChanged = name.trim() !== initialName; + const isValid = name.trim().length > 0; + const canSave = isChanged && isValid && !isLoading && !isDeleting; + + /** + * Handles the asynchronous update of the server name. + * + * @async + * @function handleUpdate + * @param {React.FormEvent} e - The form submission event. + * @returns {Promise} Resolves when the server 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/servers/${serverId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name.trim() }), + }); + + if (!response.ok) { + throw new Error("Error updating the server."); + } + + router.refresh(); + onClose(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsLoading(false); + } + }; + + /** + * Handles the asynchronous deletion of the server and redirects the user to the home page. + * + * @async + * @function handleDelete + * @returns {Promise} Resolves when the server deletion process completes or fails. + */ + const handleDelete = async () => { + if (isDeleting || isLoading) return; + + try { + setIsDeleting(true); + setError(null); + + const response = await fetch(`/api/servers/${serverId}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Error deleting the server."); + } + + onClose(); + window.location.href = "/"; + } 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 Server

+

+ Change server details or delete this server. +

+ +
+
+ + setName(e.target.value)} + placeholder="server-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}

} + +
+ + +
+ + +
+
+
+
+
+ ); +} diff --git a/db/schema.ts b/db/schema.ts index a51b784..c22bb0a 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -56,9 +56,9 @@ export const users = pgTable("users", { */ export const servers = pgTable("servers", { id: uuid("id").primaryKey().defaultRandom(), - name: text("name").notNull(), + name: varchar("name", { length: 32 }).notNull(), color: varchar("color", { length: 50 }).default("bg-indigo-500").notNull(), - inviteCode: text("invite_code").notNull().unique(), + inviteCode: varchar("invite_code", { length: 20 }).notNull().unique(), ownerId: uuid("owner_id") .references(() => users.id, { onDelete: "cascade" }) .notNull(),