feat(channels): display categories and fallback to default text channels
All checks were successful
Deploy Waveform to VPS / deploy (push) Successful in 6m56s
All checks were successful
Deploy Waveform to VPS / deploy (push) Successful in 6m56s
This commit is contained in:
parent
4b9256e570
commit
2321551518
3 changed files with 185 additions and 161 deletions
|
|
@ -1,64 +1,118 @@
|
|||
/**
|
||||
* @file components/sidebar/ChannelSidebar.tsx
|
||||
* @description Sidebar component for navigating text channels within an active server, featuring channel creation, editing, and mobile responsiveness.
|
||||
* @description Channel sidebar component listing categories and channels with actions for creation and editing.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useActiveServer } from "@/lib/context/ServerContext";
|
||||
import { useSidebarStore } from "@/lib/stores/useSidebarStore";
|
||||
import { CreateChannelModal } from "@/components/modals/CreateChannelModal";
|
||||
import { EditChannelModal } from "@/components/modals/EditChannelModal";
|
||||
import { PanelLeftClose, Plus, Settings } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
PanelLeftClose,
|
||||
Plus,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import type { Channel } from "@/db/schema";
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* Renders the channel sidebar for the active server with text channel lists, creation triggers, and settings handlers.
|
||||
*
|
||||
* @returns {JSX.Element | null} The rendered channel sidebar container or null if no active server exists.
|
||||
*/
|
||||
/** Renders the channel navigation sidebar for the active server. */
|
||||
export function ChannelSidebar() {
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingChannel, setEditingChannel] = useState<Channel | null>(null);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const params = useParams();
|
||||
const currentChannelId = params?.channelId as string;
|
||||
const { activeServer } = useActiveServer();
|
||||
const { closeNav, toggleNav } = useSidebarStore();
|
||||
|
||||
if (!activeServer) {
|
||||
return null;
|
||||
}
|
||||
if (!activeServer) return null;
|
||||
|
||||
/**
|
||||
* Handles channel selection clicks, automatically closing the mobile navigation drawer on smaller screens.
|
||||
*
|
||||
* @function handleChannelClick
|
||||
* @returns {void}
|
||||
*/
|
||||
/** Toggles the collapsed state of a channel category. */
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
setCollapsedCategories((prev) => ({
|
||||
...prev,
|
||||
[categoryId]: !prev[categoryId],
|
||||
}));
|
||||
};
|
||||
|
||||
/** Handles mobile navigation closure when clicking a channel link. */
|
||||
const handleChannelClick = () => {
|
||||
if (!window.matchMedia("(min-width: 768px)").matches) {
|
||||
closeNav();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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}
|
||||
*/
|
||||
/** Opens the edit channel modal for a specific channel. */
|
||||
const handleOpenSettings = (e: React.MouseEvent, channel: Channel) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditingChannel(channel);
|
||||
};
|
||||
|
||||
/** Opens the create channel modal for a specific category or uncategorized. */
|
||||
const handleOpenCreateModal = (categoryId: string | null = null) => {
|
||||
setSelectedCategoryId(categoryId);
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const uncategorizedChannels = activeServer.channels.filter(
|
||||
(c) => !c.categoryId,
|
||||
);
|
||||
|
||||
const categoriesList = (activeServer.categories || []).map((cat) => ({
|
||||
...cat,
|
||||
channels: activeServer.channels.filter((c) => c.categoryId === cat.id),
|
||||
}));
|
||||
|
||||
/** Renders an individual channel item in the list. */
|
||||
const renderChannelItem = (channel: Channel) => {
|
||||
const isActive = currentChannelId === channel.id;
|
||||
return (
|
||||
<Link
|
||||
key={channel.id}
|
||||
href={`/servers/${activeServer.id}/channels/${channel.id}`}
|
||||
onClick={handleChannelClick}
|
||||
prefetch={false}
|
||||
className={`flex items-center justify-between w-full px-2 py-1.5 rounded-md text-sm transition-all group min-w-0 overflow-hidden ${
|
||||
isActive
|
||||
? "bg-accent/50 text-white font-medium"
|
||||
: "text-muted hover:bg-surface hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
|
||||
<span className="text-muted group-hover:text-white text-base shrink-0">
|
||||
#
|
||||
</span>
|
||||
<span className="truncate">{channel.name}</span>
|
||||
</div>
|
||||
|
||||
{!channel.isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleOpenSettings(e, channel)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-muted hover:text-white focus:outline-none transition-all cursor-pointer shrink-0 ml-2"
|
||||
aria-label="Channel Settings"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 w-full bg-surface/50 border-r border-background flex flex-col h-full min-w-0 overflow-hidden">
|
||||
|
|
@ -76,59 +130,77 @@ export function ChannelSidebar() {
|
|||
</div>
|
||||
|
||||
{/* Channel List */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-1 min-w-0">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-muted px-2 py-1 uppercase tracking-wider">
|
||||
<span>Text Channels</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="p-1 rounded text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer"
|
||||
aria-label="Create channel"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-4 min-w-0">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-muted px-1 py-1 uppercase tracking-wider group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCategory("uncategorized")}
|
||||
className="flex items-center gap-1 hover:text-white transition-colors cursor-pointer min-w-0 truncate"
|
||||
>
|
||||
{collapsedCategories["uncategorized"] ? (
|
||||
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">Text Channels</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenCreateModal(null)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 rounded text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer"
|
||||
aria-label="Create channel"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsedCategories["uncategorized"] && (
|
||||
<div className="space-y-0.5 pl-2">
|
||||
{uncategorizedChannels.map(renderChannelItem)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5 w-full">
|
||||
{activeServer.channels.map((channel) => {
|
||||
const isActive = currentChannelId === channel.id;
|
||||
{/* 2. Custom Kategorien */}
|
||||
{categoriesList.map((category) => {
|
||||
const isCollapsed = collapsedCategories[category.id];
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={channel.id}
|
||||
href={`/servers/${activeServer.id}/channels/${channel.id}`}
|
||||
onClick={handleChannelClick}
|
||||
prefetch={false}
|
||||
className={`flex items-center justify-between w-full px-2 py-1.5 rounded-md text-sm transition-all group min-w-0 overflow-hidden ${
|
||||
isActive
|
||||
? "bg-accent/50 text-white font-medium"
|
||||
: "text-muted hover:bg-surface hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{/* Text Area */}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
|
||||
<span className="text-muted group-hover:text-white text-base shrink-0">
|
||||
#
|
||||
</span>
|
||||
<span className="truncate">{channel.name}</span>
|
||||
return (
|
||||
<div key={category.id} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-muted px-1 py-1 uppercase tracking-wider group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
className="flex items-center gap-1 hover:text-white transition-colors cursor-pointer min-w-0 truncate"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{category.name}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenCreateModal(category.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 rounded text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer"
|
||||
aria-label="Channel in Kategorie erstellen"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-0.5 pl-2">
|
||||
{category.channels.map(renderChannelItem)}
|
||||
</div>
|
||||
|
||||
{/* Gear Button */}
|
||||
{!channel.isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleOpenSettings(e, channel)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-muted hover:text-white focus:outline-none transition-all cursor-pointer shrink-0 ml-2"
|
||||
aria-label="Channel Settings"
|
||||
title="Channel Settings"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,45 +6,29 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import type { Server, Channel } from "@/db/schema";
|
||||
import type { Server, Channel, Category } from "@/db/schema";
|
||||
|
||||
/**
|
||||
* Type definition representing a server entity along with its associated channels array.
|
||||
*/
|
||||
export type ServerWithChannels = Server & { channels: Channel[] };
|
||||
/** Type definition representing a server entity along with its associated channels array. */
|
||||
export type ServerWithChannels = Server & {
|
||||
channels: Channel[];
|
||||
categories: Category[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a member within a server.
|
||||
*
|
||||
* @interface ServerMember
|
||||
* @property {string} id - The unique identifier of the server member.
|
||||
* @property {string} name - The display name of the server member.
|
||||
* @property {boolean} [isOnline] - Optional flag indicating whether the member is currently online.
|
||||
*/
|
||||
/** Represents a member within a server. */
|
||||
export interface ServerMember {
|
||||
id: string;
|
||||
name: string;
|
||||
isOnline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface defining the shape of the ServerContext state and update handlers.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
/** Interface defining the shape of the ServerContext state and update handlers. */
|
||||
interface ServerContextType {
|
||||
activeServer: ServerWithChannels | null;
|
||||
setActiveServer: (server: ServerWithChannels | null) => void;
|
||||
addChannel: (channel: Channel) => void;
|
||||
removeChannel: (channelId: string) => void;
|
||||
updateChannel: (channel: Channel) => void;
|
||||
addCategory: (category: Category) => void;
|
||||
members: ServerMember[];
|
||||
setMembers: (members: ServerMember[]) => void;
|
||||
}
|
||||
|
|
@ -55,68 +39,50 @@ const ServerContext = createContext<ServerContextType>({
|
|||
addChannel: () => {},
|
||||
removeChannel: () => {},
|
||||
updateChannel: () => {},
|
||||
addCategory: () => {},
|
||||
members: [],
|
||||
setMembers: () => {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Provider component that wraps the application layout to provide global access to active server state and member listings.
|
||||
*
|
||||
* @param {Object} props - React component properties.
|
||||
* @param {React.ReactNode} props.children - The child components wrapped by the provider.
|
||||
* @returns {JSX.Element} The rendered React provider wrapping the child elements.
|
||||
*/
|
||||
/** Provider component that wraps the application layout to provide global access to active server state and member listings. */
|
||||
export function ServerProvider({ children }: { children: React.ReactNode }) {
|
||||
const [activeServer, setActiveServer] = useState<ServerWithChannels | null>(
|
||||
null,
|
||||
);
|
||||
const [members, setMembers] = useState<ServerMember[]>([]);
|
||||
|
||||
/**
|
||||
* 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}
|
||||
*/
|
||||
/** 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,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new channel to the active server.
|
||||
*
|
||||
* @function addChannel
|
||||
* @param {Channel} channel - The channel object to add.
|
||||
* @returns {void}
|
||||
*/
|
||||
/** Adds a new channel to the active server. */
|
||||
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}
|
||||
*/
|
||||
/** Removes a channel from the active server by its identifier. */
|
||||
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}
|
||||
*/
|
||||
/** Updates an existing channel within the active server. */
|
||||
const updateChannel = (updatedChannel: Channel) =>
|
||||
updateChannels((prev) =>
|
||||
prev.map((c) => (c.id === updatedChannel.id ? updatedChannel : c)),
|
||||
);
|
||||
|
||||
const addCategory = (category: Category) => {
|
||||
setActiveServer((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
categories: [...(prev.categories || []), category],
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ServerContext.Provider
|
||||
value={{
|
||||
|
|
@ -125,6 +91,7 @@ export function ServerProvider({ children }: { children: React.ReactNode }) {
|
|||
addChannel,
|
||||
removeChannel,
|
||||
updateChannel,
|
||||
addCategory,
|
||||
members,
|
||||
setMembers,
|
||||
}}
|
||||
|
|
@ -134,13 +101,7 @@ export function ServerProvider({ children }: { children: React.ReactNode }) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to access the active server context.
|
||||
*
|
||||
* @function useActiveServer
|
||||
* @throws {Error} Throws an error if used outside of a ServerProvider.
|
||||
* @returns {ServerContextType} The active server context value.
|
||||
*/
|
||||
/** Custom hook to access the active server context. */
|
||||
export function useActiveServer() {
|
||||
const context = useContext(ServerContext);
|
||||
if (!context) {
|
||||
|
|
|
|||
|
|
@ -10,14 +10,7 @@ import { z } from "zod";
|
|||
|
||||
const uuidSchema = z.uuid();
|
||||
|
||||
/**
|
||||
* Retrieves a server along with its channels sorted chronologically if the specified user is a verified member.
|
||||
* Validates UUID formats prior to database execution to prevent database errors.
|
||||
*
|
||||
* @param {string} serverId - The unique identifier of the server to retrieve.
|
||||
* @param {string} userId - The unique identifier of the requesting user.
|
||||
* @returns {Promise<Object | null>} The server record with nested channels array, or null if the user is not a member, the server does not exist, or an invalid ID was provided.
|
||||
*/
|
||||
/** Retrieves a server along with its channels and categories sorted chronologically if the specified user is a verified member. */
|
||||
export async function getServerWithChannels(serverId: string, userId: string) {
|
||||
if (
|
||||
!uuidSchema.safeParse(serverId).success ||
|
||||
|
|
@ -39,6 +32,10 @@ export async function getServerWithChannels(serverId: string, userId: string) {
|
|||
channels: {
|
||||
orderBy: (channels, { asc }) => [asc(channels.createdAt)],
|
||||
},
|
||||
// NEU: Categories mitladen
|
||||
categories: {
|
||||
orderBy: (categories, { asc }) => [asc(categories.createdAt)],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -49,12 +46,7 @@ export async function getServerWithChannels(serverId: string, userId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all servers that the specified user belongs to, including each server's sorted channels list.
|
||||
*
|
||||
* @param {string} userId - The unique identifier of the user whose servers are to be fetched.
|
||||
* @returns {Promise<Array<Object>>} An array of server objects associated with the user.
|
||||
*/
|
||||
/** Fetches all servers that the specified user belongs to, including each server's sorted channels and categories list. */
|
||||
export async function getUserServers(userId: string) {
|
||||
if (!uuidSchema.safeParse(userId).success) {
|
||||
return [];
|
||||
|
|
@ -69,6 +61,10 @@ export async function getUserServers(userId: string) {
|
|||
channels: {
|
||||
orderBy: (channels, { asc }) => [asc(channels.createdAt)],
|
||||
},
|
||||
// NEU: Categories mitladen
|
||||
categories: {
|
||||
orderBy: (categories, { asc }) => [asc(categories.createdAt)],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -83,12 +79,7 @@ export async function getUserServers(userId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single server by its ID without checking membership.
|
||||
*
|
||||
* @param {string} serverId - The unique identifier of the server to retrieve.
|
||||
* @returns {Promise<Object | null>} The server record or null if not found/invalid ID.
|
||||
*/
|
||||
/** Retrieves a single server by its ID without checking membership. */
|
||||
export async function getServerById(serverId: string) {
|
||||
if (!uuidSchema.safeParse(serverId).success) {
|
||||
return null;
|
||||
|
|
|
|||
Loading…
Reference in a new issue