diff --git a/app/api/servers/route.ts b/app/api/servers/route.ts new file mode 100644 index 0000000..477b83b --- /dev/null +++ b/app/api/servers/route.ts @@ -0,0 +1,87 @@ +/** + * @file app/api/servers/route.ts + * @description API route handler for creating a new server along with default member and general channel entries. + */ + +import { auth } from "@/auth"; +import { db } from "@/db"; +import { channels, members, servers } from "@/db/schema"; +import { revalidatePath } from "next/cache"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +/** + * Zod validation schema for server creation requests. + */ +const createServerSchema = z.object({ + name: z.string().min(1, "Server name is required.").max(50), + color: z.string().default("bg-indigo-500"), +}); + +/** + * Handles HTTP POST requests to create a new server, assigning the creator as OWNER and creating a default "general" channel inside a transaction. + * + * @param {Request} req - The incoming HTTP request containing the server creation payload. + * @returns {Promise} A JSON response with the newly created server details (status 201), validation errors (status 400), or an error status (401/500). + */ +export async function POST(req: Request) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const body = await req.json(); + const validation = createServerSchema.safeParse(body); + + if (!validation.success) { + return NextResponse.json( + { errors: z.treeifyError(validation.error) }, + { status: 400 }, + ); + } + + const { name, color } = validation.data; + const inviteCode = crypto.randomUUID().replace(/-/g, "").slice(0, 12); + + // Transaction: Create server, owner membership, and default channel "general" + const newServer = await db.transaction(async (tx) => { + const [server] = await tx + .insert(servers) + .values({ + name, + color, + ownerId: session.user.id, + inviteCode, + }) + .returning(); + + await tx.insert(members).values({ + userId: session.user.id, + serverId: server.id, + role: "OWNER", + }); + + const [defaultChannel] = await tx + .insert(channels) + .values({ + name: "general", + serverId: server.id, + }) + .returning(); + + return { + ...server, + defaultChannelId: defaultChannel.id, + }; + }); + + revalidatePath("/", "layout"); + + return NextResponse.json(newServer, { status: 201 }); + } catch (error) { + console.error("[SERVERS_POST]", error); + return new NextResponse("Internal Error", { status: 500 }); + } +} diff --git a/components/modals/CreateServerModal.tsx b/components/modals/CreateServerModal.tsx new file mode 100644 index 0000000..18d9f54 --- /dev/null +++ b/components/modals/CreateServerModal.tsx @@ -0,0 +1,170 @@ +/** + * @file components/modals/CreateServerModal.tsx + * @description Modal dialog allowing users to create a new server with custom name and accent color. + */ + +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { X, Loader2 } from "lucide-react"; +import { + SERVER_COLOR_CLASSES, + SERVER_COLOR_OPTIONS, +} from "@/lib/constants/server.styles"; + +/** + * Properties for the CreateServerModal component. + * + * @interface CreateServerModalProps + * @property {boolean} isOpen - Indicates whether the modal dialog is currently visible. + * @property {() => void} onClose - Callback function to handle closing the modal dialog. + */ +interface CreateServerModalProps { + isOpen: boolean; + onClose: () => void; +} + +/** + * Renders the modal dialog for creating a new server with custom properties. + * + * @param {CreateServerModalProps} props - The component props. + * @returns {JSX.Element | null} The rendered modal component or null when hidden. + */ +export function CreateServerModal({ isOpen, onClose }: CreateServerModalProps) { + const router = useRouter(); + const [name, setName] = useState(""); + const [color, setColor] = useState("bg-indigo-500"); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + if (!isOpen) return null; + + /** + * Handles server creation form submission via API POST request. + * + * @param {React.FormEvent} e - The form submission event instance. + * @returns {Promise} Resolves when request is completed or redirects on success. + */ + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + + try { + setIsLoading(true); + setError(null); + + const response = await fetch("/api/servers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, color }), + }); + + if (!response.ok) { + throw new Error("Failed to create server."); + } + + const server = await response.json(); + + setName(""); + onClose(); + + const channelId = server.defaultChannelId || server.channels?.[0]?.id; + const targetUrl = channelId + ? `/servers/${server.id}/channels/${channelId}` + : `/servers/${server.id}`; + + window.location.href = targetUrl; + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsLoading(false); + } + }; + + return ( + /* Outer Backdrop: Schließt das Modal bei Klick */ +
+ {/* Inner Modal Content: Verhindert Event-Bubbling, damit Klicks hier drinnen das Modal NICHT schließen */} +
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 cursor-default" + > + + +

Create Server

+

+ Give your new server a name and choose an accent color. +

+ +
+
+ + setName(e.target.value)} + placeholder="My Awesome Server" + disabled={isLoading} + 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" + /> +
+ +
+ +
+ {SERVER_COLOR_OPTIONS.map((c) => ( +
+
+ + {error &&

{error}

} + +
+ + +
+
+
+
+ ); +} diff --git a/components/sidebar/ServerSidebar.tsx b/components/sidebar/ServerSidebar.tsx index 6fc2ab9..5f7ce02 100644 --- a/components/sidebar/ServerSidebar.tsx +++ b/components/sidebar/ServerSidebar.tsx @@ -1,10 +1,11 @@ /** * @file components/sidebar/ServerSidebar.tsx - * @description Sidebar navigation component for switching between servers and home view. + * @description Sidebar navigation component for switching between servers, home view, and server creation. */ "use client"; +import { useState, useEffect } from "react"; import Link from "next/link"; import NextImage from "next/image"; import { usePathname } from "next/navigation"; @@ -12,109 +13,143 @@ import { useActiveServer, type ServerWithChannels, } from "@/lib/context/ServerContext"; +import { cn } from "@/lib/utils"; +import { + SERVER_COLOR_CLASSES, + BASE_ICON_STYLES, + ACTIVE_ICON_STYLES, + INACTIVE_ICON_STYLES, +} from "@/lib/constants/server.styles"; +import { CreateServerModal } from "@/components/modals/CreateServerModal"; /** - * ServerSidebar component rendering the list of available servers, home navigation, and server creation trigger. - * - * @param {Object} props - The component props. - * @param {ServerWithChannels[]} props.servers - Array of server objects containing channel and display metadata. - * @returns {JSX.Element} The rendered server sidebar navigation. + * Component props for ServerItem. */ -export function ServerSidebar({ servers }: { servers: ServerWithChannels[] }) { - const pathname = usePathname(); - const { setActiveServer } = useActiveServer(); +interface ServerItemProps { + server: ServerWithChannels; + pathname: string; + onSelect: (server: ServerWithChannels) => void; +} - /** - * Mapping of Tailwind CSS background color classes for server icons. - */ - const COLOR_CLASSES: Record = { - "bg-indigo-500": "bg-indigo-500", - "bg-emerald-500": "bg-emerald-500", - "bg-rose-500": "bg-rose-500", - "bg-amber-500": "bg-amber-500", - "bg-sky-500": "bg-sky-500", - "bg-violet-500": "bg-violet-500", - }; +/** + * Renders an individual server icon link with active status styling and target channel routing. + * + * @param {ServerItemProps} props - The component props. + * @param {ServerWithChannels} props.server - The server object containing channels and styling attributes. + * @param {string} props.pathname - The current active route pathname. + * @param {(server: ServerWithChannels) => void} props.onSelect - Callback handler triggered when the server is selected. + * @returns {JSX.Element} The rendered server navigation link item. + */ +function ServerItem({ server, pathname, onSelect }: ServerItemProps) { + const isActive = pathname.startsWith(`/servers/${server.id}`); + const initial = server.name.charAt(0).toUpperCase(); + const serverBg = SERVER_COLOR_CLASSES[server.color] || "bg-indigo-500"; - /** - * Shared base CSS utility classes for server icon buttons. - */ - const baseIconStyles = - "w-12 h-12 flex items-center justify-center transition-all duration-200 shadow-md shrink-0"; - /** - * CSS utility classes applied to the currently active server icon. - */ - const activeIconStyles = - "rounded-xl ring-2 ring-accent ring-offset-2 ring-offset-surface cursor-default pointer-events-none opacity-100"; - /** - * CSS utility classes applied to inactive server icons. - */ - const inactiveIconStyles = - "rounded-3xl opacity-80 hover:opacity-100 hover:rounded-xl hover:scale-105 hover:shadow-lg hover:ring-2 hover:ring-accent/40 cursor-pointer active:scale-95"; + const firstChannelId = server.channels?.[0]?.id; + const targetHref = firstChannelId + ? `/servers/${server.id}/channels/${firstChannelId}` + : `/servers/${server.id}`; return ( - + onSelect(server)} + style={{ textShadow: "0 1px 2px rgba(0, 0, 0, 0.8)" }} + className={cn( + BASE_ICON_STYLES, + serverBg, + "text-white text-2xl font-semibold", + isActive ? ACTIVE_ICON_STYLES : INACTIVE_ICON_STYLES, + )} + > + {initial} + + ); +} + +/** + * Renders the main server navigation sidebar including the home link, server list, and trigger for adding new servers. + * + * @param {Object} props - The component props. + * @param {ServerWithChannels[]} props.servers - Initial list of servers with channels passed from the parent component. + * @returns {JSX.Element} The rendered server sidebar and accompanying creation modal. + */ +export function ServerSidebar({ + servers: initialServers, +}: { + servers: ServerWithChannels[]; +}) { + const pathname = usePathname(); + const { setActiveServer } = useActiveServer(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [servers, setServers] = useState(initialServers); + + // Synchronisiert den Zustand sofort bei Änderungen serverseitiger Daten + useEffect(() => { + setServers(initialServers); + }, [initialServers]); + + return ( + <> + + + setIsModalOpen(false)} + /> + ); } diff --git a/lib/constants/server.styles.ts b/lib/constants/server.styles.ts new file mode 100644 index 0000000..b1a82d6 --- /dev/null +++ b/lib/constants/server.styles.ts @@ -0,0 +1,34 @@ +/** + * @file lib/constants/server.styles.ts + * @description Shared Tailwind CSS styling constants, color mappings, and active/inactive state utility classes for server navigation icons. + */ + +/** + * Shared Tailwind CSS class constants mapping available server background color keys to their respective CSS utility classes. + */ +export const SERVER_COLOR_CLASSES: Record = { + "bg-indigo-500": "bg-indigo-500", + "bg-emerald-500": "bg-emerald-500", + "bg-rose-500": "bg-rose-500", + "bg-amber-500": "bg-amber-500", + "bg-sky-500": "bg-sky-500", + "bg-violet-500": "bg-violet-500", + "bg-fuchsia-500": "bg-fuchsia-500", + "bg-cyan-500": "bg-cyan-500", +}; + +/** + * List of available color option class names for server icon selection. + * Dynamically generated from SERVER_COLOR_CLASSES to avoid duplicate maintenance. + */ +export const SERVER_COLOR_OPTIONS = Object.keys(SERVER_COLOR_CLASSES); + +/** + * Common Tailwind CSS class constants defining base, active, and inactive visual states for server navigation icons. + */ +export const BASE_ICON_STYLES = + "w-12 h-12 flex items-center justify-center transition-all duration-200 shadow-md shrink-0"; +export const ACTIVE_ICON_STYLES = + "rounded-xl ring-2 ring-accent ring-offset-2 ring-offset-surface cursor-default pointer-events-none opacity-100"; +export const INACTIVE_ICON_STYLES = + "rounded-3xl opacity-80 hover:opacity-100 hover:rounded-xl hover:scale-105 hover:shadow-lg hover:ring-2 hover:ring-accent/40 cursor-pointer active:scale-95"; diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..bf91959 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,18 @@ +/** + * @file lib/utils.ts + * @description Utility module providing helper functions for conditionally merging and combining Tailwind CSS classes. + */ + +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** + * Merges multiple class names or conditional class objects into a single string using clsx and tailwind-merge. + * Resolves Tailwind CSS class conflicts intelligently. + * + * @param {...ClassValue[]} inputs - An array of class names, objects, or expressions to be combined. + * @returns {string} The merged and optimized class string. + */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/package-lock.json b/package-lock.json index 650f7d8..db24fc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "postgres": "^3.4.9", "react": "^19.2.8", "react-dom": "^19.2.8", + "tailwind-merge": "^3.6.0", "zustand": "^5.0.15" }, "devDependencies": { @@ -7745,6 +7746,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", diff --git a/package.json b/package.json index 2d285df..31e9685 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "postgres": "^3.4.9", "react": "^19.2.8", "react-dom": "^19.2.8", + "tailwind-merge": "^3.6.0", "zustand": "^5.0.15" }, "devDependencies": {