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 * @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"; "use client";
import { useState } from "react"; import { ChevronDown, Plus, Settings } from "lucide-react";
import { ChevronDown, ChevronRight, Plus, Settings } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion";
import { ChannelItem } from "./ChannelItem"; import { ChannelItem } from "./ChannelItem";
import type { Channel } from "@/db/schema"; import type { Channel } from "@/db/schema";
import { useLocalStorage } from "@/lib/hooks/useLocalStorage";
/** Props for the CategorySection component. */
interface CategorySectionProps { interface CategorySectionProps {
id: string;
title: string; title: string;
channels: Channel[]; channels: Channel[];
currentChannelId: string; currentChannelId: string;
@ -22,8 +23,9 @@ interface CategorySectionProps {
onEditCategory?: () => void; 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({ export function CategorySection({
id,
title, title,
channels, channels,
currentChannelId, currentChannelId,
@ -33,23 +35,27 @@ export function CategorySection({
onEditChannel, onEditChannel,
onEditCategory, onEditCategory,
}: CategorySectionProps) { }: 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 = () => { const handleChannelClick = () => {
if (!window.matchMedia("(min-width: 768px)").matches) { if (!window.matchMedia("(min-width: 768px)").matches) {
onCloseNav(); onCloseNav();
} }
}; };
/** Opens settings for a specific channel. */
const handleOpenChannelSettings = (e: React.MouseEvent, channel: Channel) => { const handleOpenChannelSettings = (e: React.MouseEvent, channel: Channel) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onEditChannel(channel); onEditChannel(channel);
}; };
/** Opens settings for the current category. */
const handleOpenCategorySettings = (e: React.MouseEvent) => { const handleOpenCategorySettings = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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"> <div className="flex items-center justify-between text-xs font-semibold text-muted px-1 py-1 uppercase tracking-wider group">
<button <button
type="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" className="flex items-center gap-1 hover:text-white transition-colors cursor-pointer min-w-0 truncate"
> >
{isCollapsed ? ( <motion.div
<ChevronRight className="w-3.5 h-3.5 shrink-0" /> animate={{ rotate: isCollapsed ? -90 : 0 }}
) : ( transition={{ duration: 0.15, ease: "easeInOut" }}
<ChevronDown className="w-3.5 h-3.5 shrink-0" /> className="shrink-0"
)} >
<ChevronDown className="w-3.5 h-3.5" />
</motion.div>
<span className="truncate">{title}</span> <span className="truncate">{title}</span>
</button> </button>
@ -94,20 +102,28 @@ export function CategorySection({
</div> </div>
</div> </div>
{!isCollapsed && ( <AnimatePresence initial={false}>
<div className="space-y-0.5 pl-2"> {!isCollapsed && (
{channels.map((channel) => ( <motion.div
<ChannelItem initial={{ height: 0, opacity: 0 }}
key={channel.id} animate={{ height: "auto", opacity: 1 }}
channel={channel} exit={{ height: 0, opacity: 0 }}
serverId={serverId} transition={{ duration: 0.2, ease: "easeInOut" }}
isActive={currentChannelId === channel.id} className="overflow-hidden space-y-0.5 pl-2"
onChannelClick={handleChannelClick} >
onOpenSettings={handleOpenChannelSettings} {channels.map((channel) => (
/> <ChannelItem
))} key={channel.id}
</div> channel={channel}
)} serverId={serverId}
isActive={currentChannelId === channel.id}
onChannelClick={handleChannelClick}
onOpenSettings={handleOpenChannelSettings}
/>
))}
</motion.div>
)}
</AnimatePresence>
</div> </div>
); );
} }

View file

@ -74,7 +74,9 @@ export function ChannelSidebar() {
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-4 min-w-0"> <div className="flex-1 overflow-y-auto p-3 space-y-4 min-w-0">
{/* Uncategorized channels */}
<CategorySection <CategorySection
id="uncategorized"
title="Text Channels" title="Text Channels"
channels={uncategorizedChannels} channels={uncategorizedChannels}
currentChannelId={currentChannelId} currentChannelId={currentChannelId}
@ -84,9 +86,11 @@ export function ChannelSidebar() {
onEditChannel={setEditingChannel} onEditChannel={setEditingChannel}
/> />
{/* Custom categories */}
{categories.map((category) => ( {categories.map((category) => (
<CategorySection <CategorySection
key={category.id} key={category.id}
id={category.id}
title={category.name} title={category.name}
channels={category.channels} channels={category.channels}
currentChannelId={currentChannelId} 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", "clsx": "^2.1.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"framer-motion": "^13.3.0",
"lucide-react": "^1.35.0", "lucide-react": "^1.35.0",
"next": "16.3.3", "next": "16.3.3",
"next-auth": "^5.0.0-beta.32", "next-auth": "^5.0.0-beta.32",
@ -5041,6 +5042,29 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -6430,6 +6454,21 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View file

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