feat(sidebar): add smooth animations and persistent category collapse state
All checks were successful
Deploy Waveform to VPS / deploy (push) Successful in 5m0s
All checks were successful
Deploy Waveform to VPS / deploy (push) Successful in 5m0s
This commit is contained in:
parent
ff54346a35
commit
94fb7285a6
5 changed files with 127 additions and 29 deletions
|
|
@ -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,8 +102,15 @@ export function CategorySection({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-0.5 pl-2">
|
||||
<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}
|
||||
|
|
@ -106,8 +121,9 @@ export function CategorySection({
|
|||
onOpenSettings={handleOpenChannelSettings}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
38
lib/hooks/useLocalStorage.ts
Normal file
38
lib/hooks/useLocalStorage.ts
Normal 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
39
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue