diff --git a/hooks/use-mobile.ts b/hooks/use-mobile.ts new file mode 100644 index 00000000..2b0fe1df --- /dev/null +++ b/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx new file mode 100644 index 00000000..af31e79a --- /dev/null +++ b/src/components/layout/sidebar.tsx @@ -0,0 +1,86 @@ +import { Link } from 'react-router-dom'; +import { + Sidebar, + SidebarContent, SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" +import { t } from 'i18next'; +import { cn } from '@root/lib/utils'; + +import { + Home, + Users, + Server, + Cable, + ListChecks, + FileText, + Settings, EarthLock, +} from 'lucide-react'; +import { UpdateButton } from "@/components/layout/update-button"; +import React from "react"; + +const menuItems = [ + { title: 'Home', url: '/home', icon: Home }, + { title: 'Profiles', url: '/profile', icon: Users }, + { title: 'Proxies', url: '/proxies', icon: Server }, + { title: 'Connections', url: '/connections', icon: Cable }, + { title: 'Rules', url: '/rules', icon: ListChecks }, + { title: 'Logs', url: '/logs', icon: FileText }, + { title: 'Settings', url: '/settings', icon: Settings }, +]; + +export function AppSidebar() { + return ( + + + + + + Clash Koala + + + + + + + + {menuItems.map((item) => { + const isActive = location.pathname === item.url; + return ( + + + + + {t(item.title)} + + + + ) + })} + + + + + +
+ +
+
+
+ ) +} diff --git a/src/components/layout/update-button.tsx b/src/components/layout/update-button.tsx index f91b0772..414e75c3 100644 --- a/src/components/layout/update-button.tsx +++ b/src/components/layout/update-button.tsx @@ -5,6 +5,9 @@ import { UpdateViewer } from "../setting/mods/update-viewer"; import { DialogRef } from "../base"; import { useVerge } from "@/hooks/use-verge"; import { Button } from "@/components/ui/button"; +import { t } from "i18next"; +import {Download, RefreshCw} from "lucide-react"; +import { useSidebar } from "../ui/sidebar"; interface Props { className?: string; @@ -14,6 +17,7 @@ export const UpdateButton = (props: Props) => { const { className } = props; const { verge } = useVerge(); const { auto_check_update } = verge || {}; + const { state: sidebarState } = useSidebar(); const viewerRef = useRef(null); @@ -32,15 +36,26 @@ export const UpdateButton = (props: Props) => { return ( <> - - + {sidebarState === 'collapsed' ? ( + + ) : ( + + )} ); }; diff --git a/src/components/setting/mods/update-viewer.tsx b/src/components/setting/mods/update-viewer.tsx index 63608ece..e05354b4 100644 --- a/src/components/setting/mods/update-viewer.tsx +++ b/src/components/setting/mods/update-viewer.tsx @@ -118,7 +118,7 @@ export const UpdateViewer = forwardRef((props, ref) => { -
+
{t("New Version")} v{updateInfo?.version} diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx new file mode 100644 index 00000000..6999325c --- /dev/null +++ b/src/components/ui/sidebar.tsx @@ -0,0 +1,724 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, VariantProps } from "class-variance-authority" +import { PanelLeftIcon } from "lucide-react" + +import { useIsMobile } from "@root/hooks/use-mobile" +import { cn } from "@root/lib/utils" +import { Button } from "@root/src/components/ui/button" +import { Input } from "@root/src/components/ui/input" +import { Separator } from "@root/src/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@root/src/components/ui/sheet" +import { Skeleton } from "@root/src/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@root/src/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 ( + - - - - - - {t("Menu")} - - {menuItems.map((item) => ( - navigate(item.path)} - disabled={location.pathname === item.path} - > - {item.label} - - ))} - -
diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 4d8597c2..d385b839 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -1,5 +1,4 @@ import React, {useRef, useMemo, useCallback, useState, useEffect} from "react"; -import { useLocation, useNavigate } from "react-router-dom"; import { useLockFn } from "ahooks"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -22,7 +21,6 @@ import { ChevronsUpDown, Check, PlusCircle, - Menu, Wrench, AlertTriangle, Loader2, @@ -38,10 +36,10 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { closeAllConnections } from "@/services/api"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { updateProfile } from "@/services/cmds"; +import { SidebarTrigger } from "@/components/ui/sidebar"; const MinimalHomePage: React.FC = () => { const { t } = useTranslation(); - const navigate = useNavigate(); const [isToggling, setIsToggling] = useState(false); const [isUpdating, setIsUpdating] = useState(false); const { profiles, patchProfiles, activateSelected, mutateProfiles } = @@ -167,29 +165,19 @@ const MinimalHomePage: React.FC = () => { } }); - const navMenuItems = [ - { label: "Profiles", path: "/profile" }, - { label: "Settings", path: "/settings" }, - { label: "Logs", path: "/logs" }, - { label: "Proxies", path: "/proxies" }, - { label: "Connections", path: "/connections" }, - { label: "Rules", path: "/rules" }, - ]; - return ( -
-
-
-
-
+
+
+
+ +
+
+
{profileItems.length > 0 && (
- @@ -218,59 +206,35 @@ const MinimalHomePage: React.FC = () => {
)} {currentProfile?.type === 'remote' && ( - - - - - - -

{t("Update")}

-
-
-
+
+ + + + + +

{t("Update Profile")}

+
+
+
)}
- -
- - - - - - {t("Menu")} - - {navMenuItems.map((item) => ( - navigate(item.path)} - > - {t(item.label)} - - ))} - - +
-
-
+
+
{currentProfile?.announce && ( -
+
{currentProfile.announce_url ? ( { ) : ( -

+

{currentProfile.announce}

)} @@ -357,8 +321,8 @@ const MinimalHomePage: React.FC = () => { )}
-
+