diff --git a/components/sidebar/CategorySection.tsx b/components/sidebar/CategorySection.tsx
index 59579dc..5998d82 100644
--- a/components/sidebar/CategorySection.tsx
+++ b/components/sidebar/CategorySection.tsx
@@ -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({
@@ -94,20 +102,28 @@ export function CategorySection({
- {!isCollapsed && (
-
- {channels.map((channel) => (
-
- ))}
-
- )}
+
+ {!isCollapsed && (
+
+ {channels.map((channel) => (
+
+ ))}
+
+ )}
+
);
}
diff --git a/components/sidebar/ChannelSidebar.tsx b/components/sidebar/ChannelSidebar.tsx
index a31a50a..5a55fbf 100644
--- a/components/sidebar/ChannelSidebar.tsx
+++ b/components/sidebar/ChannelSidebar.tsx
@@ -74,7 +74,9 @@ export function ChannelSidebar() {
{/* Content */}
+ {/* Uncategorized channels */}
+ {/* Custom categories */}
{categories.map((category) => (
(key: string, initialValue: T) {
+ const [storedValue, setStoredValue] = useState(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;
+}
diff --git a/package-lock.json b/package-lock.json
index db24fc3..8faf756 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 31e9685..8413774 100644
--- a/package.json
+++ b/package.json
@@ -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",