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
|
* @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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue