From 25231b5e881c36ed00f707523e0e9aa180f04960 Mon Sep 17 00:00:00 2001 From: monoid Date: Wed, 8 Oct 2025 00:11:33 +0900 Subject: [PATCH] feat: refactor navigation components to use Jotai for sidebar state management and improve rendering logic --- .../src/components/gallery/GalleryCard.tsx | 12 +- .../client/src/components/layout/layout.tsx | 19 +- packages/client/src/components/layout/nav.tsx | 453 ++++++++---------- .../client/src/components/layout/navAtom.tsx | 16 +- .../src/components/layout/sidebarAtom.tsx | 3 + packages/client/src/components/ui/chart.tsx | 2 +- packages/client/src/page/galleryPage.tsx | 2 +- packages/client/src/page/reader/comicPage.tsx | 126 ++--- 8 files changed, 307 insertions(+), 326 deletions(-) create mode 100644 packages/client/src/components/layout/sidebarAtom.tsx diff --git a/packages/client/src/components/gallery/GalleryCard.tsx b/packages/client/src/components/gallery/GalleryCard.tsx index 18462c8..a3da964 100644 --- a/packages/client/src/components/gallery/GalleryCard.tsx +++ b/packages/client/src/components/gallery/GalleryCard.tsx @@ -1,5 +1,5 @@ import type { Document } from "dbtype"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"; import TagBadge, { toPrettyTagname } from "@/components/gallery/TagBadge.tsx"; import { Fragment, useLayoutEffect, useRef, useState } from "react"; import { LazyImage } from "./LazyImage.tsx"; @@ -106,10 +106,10 @@ function GalleryCardImpl({ {x.title} - +
{artists.length > 0 && (
- + {artists.map((x, i) => ( @@ -128,7 +128,7 @@ function GalleryCardImpl({ {groups.length > 0 && (
- + {groups.map((x, i) => ( @@ -146,10 +146,10 @@ function GalleryCardImpl({ )}
- + {new Date(x.created_at).toLocaleDateString()}
- +
diff --git a/packages/client/src/components/layout/layout.tsx b/packages/client/src/components/layout/layout.tsx index 2b0954a..1dfaf8d 100644 --- a/packages/client/src/components/layout/layout.tsx +++ b/packages/client/src/components/layout/layout.tsx @@ -1,5 +1,6 @@ -import { useState } from "react"; +import { useAtomValue } from "jotai"; import { SidebarNav, BottomNav, SidebarToggle } from "./nav"; +import { sidebarAtom } from "./sidebarAtom"; import { cn } from "@/lib/utils"; interface LayoutProps { @@ -7,12 +8,9 @@ interface LayoutProps { } export default function Layout({ children }: LayoutProps) { - const [isSidebarOpen, setIsSidebarOpen] = useState(true); - - const toggleSidebar = () => { - setIsSidebarOpen(!isSidebarOpen); - }; - + const sidebarState = useAtomValue(sidebarAtom); + const isSidebarOpen = !sidebarState.isCollapsed; + return (
{/* Desktop Sidebar - 데스크탑에서만 보이는 사이드바 */} @@ -24,13 +22,10 @@ export default function Layout({ children }: LayoutProps) { {isSidebarOpen && (

Ionian

)} - +
- +
diff --git a/packages/client/src/components/layout/nav.tsx b/packages/client/src/components/layout/nav.tsx index a430846..1d03ad7 100644 --- a/packages/client/src/components/layout/nav.tsx +++ b/packages/client/src/components/layout/nav.tsx @@ -1,101 +1,118 @@ import { Link } from "wouter" -import { SearchIcon, SettingsIcon, TagsIcon, ArchiveIcon, UserIcon, LayoutListIcon, PanelLeftIcon, PanelLeftCloseIcon, MenuIcon, XIcon } from "lucide-react" +import { SearchIcon, SettingsIcon, TagsIcon, ArchiveIcon, UserIcon, LayoutListIcon, PanelLeftIcon, PanelLeftCloseIcon, MenuIcon, XIcon, type LucideIcon } from "lucide-react" import { Button, buttonVariants } from "@/components/ui/button.tsx" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx" import { useLogin } from "@/state/user.ts"; import { useNavItems } from "./navAtom"; import { Separator } from "../ui/separator"; import { cn } from "@/lib/utils"; +import { useMemo } from "react"; +import { useAtom, useAtomValue } from "jotai"; +import { sidebarAtom } from "./sidebarAtom"; -interface NavItemProps { +const NAV_ICON_CLASS = "size-4"; + +const NAV_LINKS = { + search: { to: "/search", Icon: SearchIcon }, + tags: { to: "/tags", Icon: TagsIcon }, + difference: { to: "/difference", Icon: ArchiveIcon }, + queue: { to: "/queue", Icon: LayoutListIcon }, + settings: { to: "/setting", Icon: SettingsIcon } +} satisfies Record; + +type NavLinkKey = keyof typeof NAV_LINKS; + +type NavItemData = { + key: string; icon: React.ReactNode; - to: string; + to?: string; name: string; className?: string; } +const createNavItem = (key: NavLinkKey, name: string, className?: string): NavItemData => { + const { Icon, to } = NAV_LINKS[key]; + return { + key, + icon: , + to, + name, + className + }; +}; -export function NavItem({ - icon, - to, - name, - className -}: NavItemProps) { - return - - - {icon} - {name} - - - {name} - -} +function useNavItemsData() { + const loginInfo = useLogin(); + const isLoggedIn = Boolean(loginInfo); -interface NavItemButtonProps { - icon: React.ReactNode; - onClick: () => void; - name: string; - className?: string; -} + return useMemo(() => { + const accountItem: NavItemData = { + key: "account", + icon: , + to: isLoggedIn ? "/profile" : "/login", + name: isLoggedIn ? "Profiles" : "Login", + }; -export function NavItemButton({ - icon, - onClick, - name, - className -}: NavItemButtonProps) { - return - - - - {name} - + return ({ + main: [ + createNavItem("search", "Search"), + createNavItem("tags", "Tags"), + createNavItem("difference", "Difference"), + ], + footer: [ + accountItem, + createNavItem("settings", "Settings") + ], + bottomNav: (useCustomItems: boolean) => useCustomItems ? [] : [ + createNavItem("tags", "Tags"), + createNavItem("difference", "Diff"), + { ...accountItem, name: isLoggedIn ? "Profile" : "Login" }, + createNavItem("settings", "Settings") + ] + }) + }, [isLoggedIn]); } export function NavList() { - const loginInfo = useLogin(); - const navItems = useNavItems(); + const customNavItems = useNavItems(); + const { main, footer } = useNavItemsData(); - return + return ( + + ); } // 사이드바 토글 버튼 -export function SidebarToggle({ isOpen, onToggle }: { isOpen: boolean; onToggle: () => void }) { +export function SidebarToggle() { + const [sidebarState, setSidebarState] = useAtom(sidebarAtom); + const isOpen = sidebarState.isCollapsed; + const onToggle = () => setSidebarState((s) => ({ ...s, isCollapsed: !s.isCollapsed })); + return ( - @@ -107,202 +124,158 @@ export function SidebarToggle({ isOpen, onToggle }: { isOpen: boolean; onToggle: } // 모바일용 사이드바 토글 버튼 -export function MobileSidebarToggle({ isOpen, onToggle }: { isOpen: boolean; onToggle: () => void }) { +export function MobileSidebarToggle() { + const [sidebarState, setSidebarState] = useAtom(sidebarAtom); + const isOpen = sidebarState.isCollapsed; + const onToggle = () => setSidebarState((s) => ({ ...s, isCollapsed: !s.isCollapsed })); + return ( - ); } -// 데스크탑용 사이드바 네비게이션 -export function SidebarNav({ isCollapsed, onNavigate }: { isCollapsed: boolean; onNavigate?: () => void }) { - const loginInfo = useLogin(); - const navItems = useNavItems(); - - return ( -
- - -
- } - to={loginInfo ? "/profile" : "/login"} - name={loginInfo ? "Profiles" : "Login"} - isCollapsed={isCollapsed} - onNavigate={onNavigate} - /> - } - to="/setting" - name="Settings" - isCollapsed={isCollapsed} - onNavigate={onNavigate} - /> -
-
- ); -} - // 사이드바 네비게이션 아이템 interface SidebarNavItemProps { - icon: React.ReactNode; - to: string; + children: React.ReactNode; name: string; - isCollapsed: boolean; - onNavigate?: () => void; + to?: string; + className?: string; + onClick?: () => void; } -function SidebarNavItem({ icon, to, name, isCollapsed, onNavigate }: SidebarNavItemProps) { +export function SidebarNavItem({ children, name, to, className, onClick }: SidebarNavItemProps) { + const sidebarState = useAtomValue(sidebarAtom); + const isCollapsed = sidebarState.isCollapsed; + + const buttonClass = cn( + buttonVariants({ variant: "ghost", size: "sm" }), + "rounded-none md:rounded-md", + isCollapsed ? "justify-center size-10 p-0" : "justify-start gap-3 h-10 px-3", + className + ); + + const Container = to ? ({ children, ref }: { children: React.ReactNode, + ref?: React.Ref + }) => ( + + {children} + + ) : ({ children, ref }: { children: React.ReactNode, + ref?: React.Ref + }) => ( + + ); + + const linkContent = ( + + {children} + {name} + + ); + if (isCollapsed) { return ( - - - {icon} - {name} - - + {linkContent} {name} ); } + return linkContent; +} + +// 데스크탑용 사이드바 네비게이션 +export function SidebarNav() { + const customNavItems = useNavItems(); + const { main, footer } = useNavItemsData(); + return ( - - {icon} - {name} - +
+ + +
+ {footer.map(({ key, icon, to, name, className }) => ( + + {icon} + + ))} +
+
); } // 모바일용 하단 네비게이션 export function BottomNav() { - const loginInfo = useLogin(); - const navItems = useNavItems(); + const customNavItems = useNavItems(); + const { main, bottomNav } = useNavItemsData(); + const searchItem = { ...main[0] }; // Search item + const items = bottomNav(Boolean(customNavItems)); return ( ); -} - -// 하단 네비게이션 아이템 -interface BottomNavItemProps { - icon: React.ReactNode; - to: string; - name: string; - className?: string; -} - -function BottomNavItem({ icon, to, name, className }: BottomNavItemProps) { - return ( - - {icon} - {name} - - ); } \ No newline at end of file diff --git a/packages/client/src/components/layout/navAtom.tsx b/packages/client/src/components/layout/navAtom.tsx index a05b2a8..3eeeb7c 100644 --- a/packages/client/src/components/layout/navAtom.tsx +++ b/packages/client/src/components/layout/navAtom.tsx @@ -1,27 +1,23 @@ import { atom, useAtomValue, useSetAtom } from "@/lib/atom"; -import { useLayoutEffect, useRef } from "react"; +import { useLayoutEffect } from "react"; -const NavItems = atom(null); +const NavItems = atom<(() => React.ReactNode) | null>(null); // eslint-disable-next-line react-refresh/only-export-components export function useNavItems() { return useAtomValue(NavItems); } -export function PageNavItem({items, children}:{items: React.ReactNode, children: React.ReactNode}) { - const currentNavItems = useAtomValue(NavItems); +export function PageNavItem({navRender, children}:{navRender: () => React.ReactNode, children: React.ReactNode}) { const setNavItems = useSetAtom(NavItems); - const prevValueRef = useRef(null); useLayoutEffect(() => { - // Store current value before setting new one - prevValueRef.current = currentNavItems; - setNavItems(items); + setNavItems(() => navRender); return () => { - setNavItems(prevValueRef.current); + setNavItems(null); }; - }, [items, currentNavItems, setNavItems]); + }, [ setNavItems, navRender]); return children; } diff --git a/packages/client/src/components/layout/sidebarAtom.tsx b/packages/client/src/components/layout/sidebarAtom.tsx new file mode 100644 index 0000000..312d77b --- /dev/null +++ b/packages/client/src/components/layout/sidebarAtom.tsx @@ -0,0 +1,3 @@ +import { atom } from "jotai"; + +export const sidebarAtom = atom({ isCollapsed: false }); diff --git a/packages/client/src/components/ui/chart.tsx b/packages/client/src/components/ui/chart.tsx index a21d77e..9ff70a3 100644 --- a/packages/client/src/components/ui/chart.tsx +++ b/packages/client/src/components/ui/chart.tsx @@ -67,7 +67,7 @@ ChartContainer.displayName = "Chart" const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const colorConfig = Object.entries(config).filter( - ([_, config]) => config.theme || config.color + ([, itemConfig]) => itemConfig.theme || itemConfig.color ) if (!colorConfig.length) { diff --git a/packages/client/src/page/galleryPage.tsx b/packages/client/src/page/galleryPage.tsx index 3528232..94d3028 100644 --- a/packages/client/src/page/galleryPage.tsx +++ b/packages/client/src/page/galleryPage.tsx @@ -67,7 +67,7 @@ export default function Gallery() {
} else { - return
{// TODO: date based grouping virtualItems.map((item) => { diff --git a/packages/client/src/page/reader/comicPage.tsx b/packages/client/src/page/reader/comicPage.tsx index 797ac53..011b86e 100644 --- a/packages/client/src/page/reader/comicPage.tsx +++ b/packages/client/src/page/reader/comicPage.tsx @@ -1,4 +1,3 @@ -import { NavItem, NavItemButton } from "@/components/layout/nav"; import { PageNavItem } from "@/components/layout/navAtom"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -9,6 +8,7 @@ import { useEventListener } from "usehooks-ts"; import type { Document } from "dbtype"; import { useCallback, useEffect, useRef, useState } from "react"; import { Loader2 } from "lucide-react"; +import { SidebarNavItem } from "@/components/layout/nav"; interface ComicPageProps { params: { @@ -30,7 +30,8 @@ function ComicViewer({ const [fade, setFade] = useState(false); const pageDown = useCallback((step: number) => setCurPage(Math.max(curPage - step, 0)), [curPage, setCurPage]); const pageUp = useCallback((step: number) => setCurPage(Math.min(curPage + step, totalPage - 1)), [curPage, setCurPage, totalPage]); - const currentImageRef = useRef(null); + const makeSrc = useCallback((page: number) => `/api/doc/${doc.id}/comic/${page}`, [doc.id]); + const [src, setSrc] = useState(() => makeSrc(curPage)); useEffect(() => { const onKeyUp = (e: KeyboardEvent) => { @@ -48,46 +49,44 @@ function ComicViewer({ }, [pageDown, pageUp]); useEffect(() => { - if (currentImageRef.current) { - if (curPage < 0 || curPage >= totalPage) { - return; - } - const img = new Image(); - img.src = `/api/doc/${doc.id}/comic/${curPage}`; - if (img.complete) { - currentImageRef.current.src = img.src; - setFade(false); - return; - } - setFade(true); - const listener = () => { - // biome-ignore lint/style/noNonNullAssertion: - const currentImage = currentImageRef.current!; - currentImage.src = img.src; - setFade(false); - }; - img.addEventListener("load", listener); - return () => { - img.removeEventListener("load", listener); - // abort loading - img.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; - // TODO: use web worker to abort loading image in the future - }; + if (curPage < 0 || curPage >= totalPage) { + return; } + const preloadImg = new Image(); + preloadImg.src = `/api/doc/${doc.id}/comic/${curPage}`; + if (preloadImg.complete) { + setSrc(preloadImg.src); + setFade(false); + return; + } + setFade(true); + const listener = () => { + setSrc(preloadImg.src); + setFade(false); + }; + preloadImg.addEventListener("load", listener); + return () => { + preloadImg.removeEventListener("load", listener); + // abort loading + preloadImg.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + // img 객체가 GC 되도록 함 + }; + }, [curPage, doc.id, totalPage]); return (
-
pageDown(1)} /> +
pageDown(1)} /> main content -
pageUp(1)} /> + src={src} + alt="main content" + /> +
pageUp(1)} /> {curPage + 1 < totalPage && ( - next page + next page )}
); @@ -145,31 +144,46 @@ export default function ComicPage({ } return ( - - } /> - : } - onClick={() => { - toggleFullScreen(); - }} /> - - + <> + - {curPage + 1}/{data.additional.page as number} - - - - setCurPage(clip(Number.parseInt(e.target.value) - 1, - 0, - (data.additional.page as number) - 1))} /> - - - }> + + + + {isFullScreen ? : } + + + + + + {curPage + 1}/{data.additional.page as number} + + + + + + setCurPage(clip( + Number.parseInt(e.target.value) - 1, + 0, + (data.additional.page as number) - 1 + )) + } + /> + + + + }>