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",