feat(sidebar): add smooth animations and persistent category collapse state
All checks were successful
Deploy Waveform to VPS / deploy (push) Successful in 5m0s

This commit is contained in:
Chneemann 2026-09-15 04:28:53 +02:00
parent ff54346a35
commit 94fb7285a6
No known key found for this signature in database
5 changed files with 127 additions and 29 deletions

View file

@ -1,17 +1,18 @@
/**
* @file components/sidebar/CategorySection.tsx
* @description Sub-component for rendering a category group and its list of channels.
* @description Sub-component for rendering a category group and its list of channels with persistent collapse state and animations.
*/
"use client";
import { useState } from "react";
import { ChevronDown, ChevronRight, Plus, Settings } from "lucide-react";
import { ChevronDown, Plus, Settings } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { ChannelItem } from "./ChannelItem";
import type { Channel } from "@/db/schema";
import { useLocalStorage } from "@/lib/hooks/useLocalStorage";
/** Props for the CategorySection component. */
interface CategorySectionProps {
id: string;
title: string;
channels: Channel[];
currentChannelId: string;
@ -22,8 +23,9 @@ interface CategorySectionProps {
onEditCategory?: () => void;
}
/** Renders a collapsible category section containing channels and contextual actions. */
/** Renders a collapsible category section with persistent collapse state and smooth animation. */
export function CategorySection({
id,
title,
channels,
currentChannelId,
@ -33,23 +35,27 @@ export function CategorySection({
onEditChannel,
onEditCategory,
}: CategorySectionProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const [isCollapsed, setIsCollapsed] = useLocalStorage(
`category_collapsed_${id}`,
false,
);
const toggleCollapsed = () => {
setIsCollapsed((prev) => !prev);
};
/** Handles closing mobile navigation when clicking a channel. */
const handleChannelClick = () => {
if (!window.matchMedia("(min-width: 768px)").matches) {
onCloseNav();
}
};
/** Opens settings for a specific channel. */
const handleOpenChannelSettings = (e: React.MouseEvent, channel: Channel) => {
e.preventDefault();
e.stopPropagation();
onEditChannel(channel);
};
/** Opens settings for the current category. */
const handleOpenCategorySettings = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@ -61,14 +67,16 @@ export function CategorySection({
<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={() => setIsCollapsed((prev) => !prev)}
onClick={toggleCollapsed}
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" />
)}
<motion.div
animate={{ rotate: isCollapsed ? -90 : 0 }}
transition={{ duration: 0.15, ease: "easeInOut" }}
className="shrink-0"
>
<ChevronDown className="w-3.5 h-3.5" />
</motion.div>
<span className="truncate">{title}</span>
</button>
@ -94,20 +102,28 @@ export function CategorySection({
</div>
</div>
{!isCollapsed && (
<div className="space-y-0.5 pl-2">
{channels.map((channel) => (
<ChannelItem
key={channel.id}
channel={channel}
serverId={serverId}
isActive={currentChannelId === channel.id}
onChannelClick={handleChannelClick}
onOpenSettings={handleOpenChannelSettings}
/>
))}
</div>
)}
<AnimatePresence initial={false}>
{!isCollapsed && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden space-y-0.5 pl-2"
>
{channels.map((channel) => (
<ChannelItem
key={channel.id}
channel={channel}
serverId={serverId}
isActive={currentChannelId === channel.id}
onChannelClick={handleChannelClick}
onOpenSettings={handleOpenChannelSettings}
/>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View file

@ -74,7 +74,9 @@ export function ChannelSidebar() {
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-4 min-w-0">
{/* Uncategorized channels */}
<CategorySection
id="uncategorized"
title="Text Channels"
channels={uncategorizedChannels}
currentChannelId={currentChannelId}
@ -84,9 +86,11 @@ export function ChannelSidebar() {
onEditChannel={setEditingChannel}
/>
{/* Custom categories */}
{categories.map((category) => (
<CategorySection
key={category.id}
id={category.id}
title={category.name}
channels={category.channels}
currentChannelId={currentChannelId}

View file

@ -0,0 +1,38 @@
/**
* @file lib/hooks/useLocalStorage.ts
* @description Custom React hook for synchronized state persistence with browser local storage.
*/
import { useState, useEffect } from "react";
/** Custom hook that synchronizes a React state variable with local storage. */
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(initialValue);
/** Loads the stored value from local storage on initial mount or key change. */
useEffect(() => {
try {
const item = localStorage.getItem(key);
if (item !== null) {
setStoredValue(JSON.parse(item));
}
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
}
}, [key]);
/** Updates the state and persists the new value to local storage. */
const setValue = (value: T | ((val: T) => T)) => {
try {
setStoredValue((prev) => {
const valueToStore = value instanceof Function ? value(prev) : value;
localStorage.setItem(key, JSON.stringify(valueToStore));
return valueToStore;
});
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue] as const;
}

39
package-lock.json generated
View file

@ -12,6 +12,7 @@
"clsx": "^2.1.1",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"framer-motion": "^13.3.0",
"lucide-react": "^1.35.0",
"next": "16.3.3",
"next-auth": "^5.0.0-beta.32",
@ -5041,6 +5042,29 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/framer-motion": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.3.0.tgz",
"integrity": "sha512-nry9figMPgd/7tTy9zzGRsD+kJ9tjZ74sKJ3jFBa7j6DTBIM04T6eBgneFLXCB0151wychIxsiojPlcG4JxeoA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^13.3.0",
"motion-utils": "^13.3.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -6430,6 +6454,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/motion-dom": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.3.0.tgz",
"integrity": "sha512-AmAnB6pHdZ1vHGzWzuzteG6Q3j1lHKeX/lbST6jGlgm0cjvLUgaCk1xhOuPMOy5SgovN6wnnUdwFrkcsus7Ftg==",
"license": "MIT",
"dependencies": {
"motion-utils": "^13.3.0"
}
},
"node_modules/motion-utils": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.3.0.tgz",
"integrity": "sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View file

@ -17,6 +17,7 @@
"clsx": "^2.1.1",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"framer-motion": "^13.3.0",
"lucide-react": "^1.35.0",
"next": "16.3.3",
"next-auth": "^5.0.0-beta.32",