diff --git a/packages/app/package.json b/packages/app/package.json index 0fbc9c04..135013c1 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -17,7 +17,10 @@ }, "dependencies": { "@hanghae-plus/domain": "workspace:*", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.7", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.84.1", "axios": "^1.11.0", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 49947849..e7580eda 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -5,13 +5,23 @@ import { Home, User } from "@/pages"; import { BASE_URL } from "@/constants"; import { withBaseLayout } from "@/components"; +const Index = withBaseLayout(() => ); +const StudentsPage = withBaseLayout(() =>
수강생 목록 페이지
); +const StudentDetailPage = withBaseLayout(() =>
수강생 상세 페이지
); +const AssignmentsPage = withBaseLayout(() =>
과제 목록 페이지
); +const NotFound = withBaseLayout(() =>
404 - 페이지를 찾을 수 없습니다
); + export const App = () => { return ( - - + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/packages/app/src/components/AppSidebar.tsx b/packages/app/src/components/AppSidebar.tsx new file mode 100644 index 00000000..cf715eab --- /dev/null +++ b/packages/app/src/components/AppSidebar.tsx @@ -0,0 +1,83 @@ +import { NavLink, useLocation } from "react-router"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/Sidebar"; +import { Users, BookOpen } from "lucide-react"; + +const navigationItems = [ + { + title: "수강생 목록", + url: "/students", + icon: Users, + }, + { + title: "과제 목록", + url: "/assignments", + icon: BookOpen, + }, +]; + +export function AppSidebar() { + const { state } = useSidebar(); + const location = useLocation(); + const currentPath = location.pathname; + + const collapsed = state === "collapsed"; + + const isActive = (path: string) => { + if (path === "/students") { + return currentPath === "/students" || currentPath.startsWith("/students/"); + } + return currentPath === path; + }; + + const getNavCls = (path: string) => + isActive(path) + ? "bg-primary text-primary-foreground shadow-glow font-medium" + : "text-foreground hover:bg-secondary hover:text-secondary-foreground"; + + return ( + + +
+
+ {!collapsed && ( + <> +
+ +
+

항해99 플러스

+ + )} +
+
+ + + {!collapsed && "학습 관리"} + + + {navigationItems.map((item) => ( + + + + + {!collapsed && {item.title}} + + + + ))} + + + +
+
+ ); +} diff --git a/packages/app/src/components/index.ts b/packages/app/src/components/index.ts index 4750bb3a..2a31d02a 100644 --- a/packages/app/src/components/index.ts +++ b/packages/app/src/components/index.ts @@ -1,2 +1,3 @@ export * from "./ui"; export * from "./layout"; +export * from "./AppSidebar"; diff --git a/packages/app/src/components/layout/BaseLayout.tsx b/packages/app/src/components/layout/BaseLayout.tsx index 0a5d6e9e..b2c6da4d 100644 --- a/packages/app/src/components/layout/BaseLayout.tsx +++ b/packages/app/src/components/layout/BaseLayout.tsx @@ -1,30 +1,20 @@ -import { BookOpen } from "lucide-react"; import type { ComponentProps, PropsWithChildren } from "react"; +import { AppSidebar, SidebarProvider, SidebarTrigger } from "../"; export function BaseLayout({ children }: PropsWithChildren) { return ( -
- {/* 헤더 */} -
-
-
-
-
-
- -
-
-

항해플러스 6기

-

수강생 커뮤니티

-
-
-
-
+ +
+ +
+
+ +

항해플러스 6기 프론트엔드 수강생 커뮤니티

+
+
{children}
-
- - {children} -
+ + ); } diff --git a/packages/app/src/components/ui/Input.tsx b/packages/app/src/components/ui/Input.tsx new file mode 100644 index 00000000..b1a060f5 --- /dev/null +++ b/packages/app/src/components/ui/Input.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/packages/app/src/components/ui/Separator.tsx b/packages/app/src/components/ui/Separator.tsx new file mode 100644 index 00000000..72c18e33 --- /dev/null +++ b/packages/app/src/components/ui/Separator.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import { cn } from "@/lib/utils"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/packages/app/src/components/ui/Sheet.tsx b/packages/app/src/components/ui/Sheet.tsx new file mode 100644 index 00000000..22bf6eee --- /dev/null +++ b/packages/app/src/components/ui/Sheet.tsx @@ -0,0 +1,101 @@ +import * as React from "react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Sheet({ ...props }: React.ComponentProps) { + return ; +} + +function SheetTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function SheetClose({ ...props }: React.ComponentProps) { + return ; +} + +function SheetPortal({ ...props }: React.ComponentProps) { + return ; +} + +function SheetOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left"; +}) { + return ( + + + + {children} + + + Close + + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription }; diff --git a/packages/app/src/components/ui/Sidebar.tsx b/packages/app/src/components/ui/Sidebar.tsx new file mode 100644 index 00000000..aa6bbd3b --- /dev/null +++ b/packages/app/src/components/ui/Sidebar.tsx @@ -0,0 +1,675 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { PanelLeftIcon } from "lucide-react"; + +import { useIsMobile } from "@/hooks/use-mobile"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { Separator } from "@/components/ui/Separator"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/Sheet"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/Tooltip"; + +const SIDEBAR_COOKIE_NAME = "sidebar_state"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +type SidebarContextProps = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], + ); + + return ( + + +
+ {children} +
+
+
+ ); +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === "none") { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); +} + +function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar(); + + return ( +