feat: extend mobile layout support

This commit is contained in:
Henry
2025-07-22 22:55:42 +01:00
parent 09066fa47d
commit 6c6fd4bd32
7 changed files with 131 additions and 54 deletions

View File

@@ -61,6 +61,10 @@ export default function Dashboard({
} }
}; };
const closeSideNav = () => {
setIsSideNavOpen(false);
};
const toggleRightPanel = () => { const toggleRightPanel = () => {
setIsRightPanelOpen(!isRightPanelOpen); setIsRightPanelOpen(!isRightPanelOpen);
if (!isRightPanelOpen) { if (!isRightPanelOpen) {
@@ -98,9 +102,9 @@ export default function Dashboard({
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#1c1c1c"}; background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#1c1c1c"};
} }
`}</style> `}</style>
<div className="relative flex h-screen flex-col bg-light-100 px-3 py-3 dark:bg-dark-100"> <div className="relative flex h-screen flex-col bg-light-50 dark:bg-dark-50 md:bg-light-100 md:p-3 md:dark:bg-dark-100">
{/* Mobile Navigation Controls - positioned absolutely over content */} {/* Mobile Header */}
<div className="absolute left-8 top-8 z-50 flex items-center gap-2 md:hidden"> <div className="flex h-12 items-center justify-between border-b border-light-300 bg-light-50 px-3 dark:border-dark-300 dark:bg-dark-50 md:hidden">
<button <button
ref={sideNavButtonRef} ref={sideNavButtonRef}
onClick={toggleSideNav} onClick={toggleSideNav}
@@ -118,10 +122,8 @@ export default function Dashboard({
/> />
)} )}
</button> </button>
</div>
{/* {hasRightPanel && ( {hasRightPanel && (
<div className="absolute right-8 top-8 z-50 md:hidden">
<button <button
ref={rightPanelButtonRef} ref={rightPanelButtonRef}
onClick={toggleRightPanel} onClick={toggleRightPanel}
@@ -139,21 +141,22 @@ export default function Dashboard({
/> />
)} )}
</button> </button>
</div> )}
)} */} </div>
<div className="flex h-[calc(100dvh-1.5rem)] min-h-0 w-full"> <div className="flex h-[calc(100dvh-4.5rem)] min-h-0 w-full md:h-[calc(100dvh-1.5rem)]">
<div <div
ref={sideNavRef} ref={sideNavRef}
className={`fixed left-3 top-3 z-40 h-[calc(100dvh-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:left-0 md:top-0 md:h-full md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `} className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
> >
<SideNavigation <SideNavigation
user={{ email: session?.user.email, image: session?.user.image }} user={{ email: session?.user.email, image: session?.user.image }}
isLoading={sessionLoading} isLoading={sessionLoading}
onCloseSideNav={closeSideNav}
/> />
</div> </div>
<div className="relative h-full min-h-0 w-full overflow-hidden rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50"> <div className="relative h-full min-h-0 w-full overflow-hidden md:rounded-lg md:border md:border-light-300 md:bg-light-50 md:dark:border-dark-300 md:dark:bg-dark-50">
<div className="relative flex h-full min-h-0 w-full overflow-hidden"> <div className="relative flex h-full min-h-0 w-full overflow-hidden">
<div className="h-full w-full overflow-y-auto">{children}</div> <div className="h-full w-full overflow-y-auto">{children}</div>
@@ -161,13 +164,11 @@ export default function Dashboard({
{hasRightPanel && rightPanel && ( {hasRightPanel && rightPanel && (
<div <div
ref={rightPanelRef} ref={rightPanelRef}
className={`fixed right-3 top-3 z-40 h-[calc(100dvh-1.5rem)] w-80 transform bg-light-200 transition-transform duration-300 ease-in-out dark:bg-dark-100 md:hidden ${ className={`fixed right-0 top-12 z-40 h-[calc(100dvh-3rem)] w-80 transform border-l border-light-300 bg-light-200 transition-transform duration-300 ease-in-out dark:border-dark-300 dark:bg-dark-100 md:hidden ${
isRightPanelOpen ? "translate-x-0" : "translate-x-full" isRightPanelOpen ? "translate-x-0" : "translate-x-full"
}`} }`}
> >
<div className="h-full overflow-y-auto rounded-md border dark:border-dark-200"> <div className="h-full">{rightPanel}</div>
{rightPanel}
</div>
</div> </div>
)} )}

View File

@@ -1,7 +1,9 @@
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { twMerge } from "tailwind-merge";
import LottieIcon from "~/components/LottieIcon"; import LottieIcon from "~/components/LottieIcon";
import { useIsMobile } from "~/hooks/useMediaQuery";
function classNames(...classes: string[]): string { function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" "); return classes.filter(Boolean).join(" ");
@@ -13,30 +15,41 @@ const Button: React.FC<{
name: string; name: string;
json: object; json: object;
isCollapsed?: boolean; isCollapsed?: boolean;
}> = ({ href, current, name, json, isCollapsed = false }) => { onCloseSideNav?: () => void;
}> = ({ href, current, name, json, isCollapsed = false, onCloseSideNav }) => {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const isMobile = useIsMobile();
const handleMouseEnter = () => { const handleMouseEnter = () => {
setIsHovered(true); setIsHovered(true);
setIndex((index) => index + 1); setIndex((index) => index + 1);
}; };
const handleClick = () => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
};
return ( return (
<Link <Link
href={href} href={href}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onClick={handleClick}
className={classNames( className={classNames(
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 text-neutral-900 hover:bg-light-200 dark:hover:bg-dark-200 dark:hover:text-dark-1000", "group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 text-neutral-900 hover:bg-light-200 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
current current
? "bg-light-200 dark:bg-dark-200 dark:text-dark-1000" ? "bg-light-200 dark:bg-dark-200 dark:text-dark-1000"
: "dark:bg-dark-100 dark:text-dark-900", : "dark:bg-dark-100 dark:text-dark-900",
isCollapsed ? "justify-center" : "gap-x-3", isCollapsed
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
: "gap-x-3",
)} )}
title={isCollapsed ? name : undefined} title={isCollapsed ? name : undefined}
> >
<LottieIcon index={index} json={json} isPlaying={isHovered} /> <LottieIcon index={index} json={json} isPlaying={isHovered} />
{!isCollapsed && name} <span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
</Link> </Link>
); );
}; };

View File

@@ -23,6 +23,7 @@ import { useTheme } from "~/providers/theme";
interface SideNavigationProps { interface SideNavigationProps {
user: UserType; user: UserType;
isLoading: boolean; isLoading: boolean;
onCloseSideNav?: () => void;
} }
interface UserType { interface UserType {
@@ -33,6 +34,7 @@ interface UserType {
export default function SideNavigation({ export default function SideNavigation({
user, user,
isLoading, isLoading,
onCloseSideNav,
}: SideNavigationProps) { }: SideNavigationProps) {
const router = useRouter(); const router = useRouter();
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
@@ -87,12 +89,12 @@ export default function SideNavigation({
<> <>
<nav <nav
className={twMerge( className={twMerge(
"flex h-full flex-col justify-between bg-light-100 pr-3 dark:bg-dark-100", "flex h-full w-64 flex-col justify-between border-r border-light-300 bg-light-100 p-3 dark:border-dark-300 dark:bg-dark-100 md:py-0 md:pl-0",
isCollapsed ? "w-auto" : "w-64", isCollapsed && "md:w-auto",
)} )}
> >
<div> <div>
<div className="flex h-14 items-center justify-between pb-[18px] pt-1.5"> <div className="hidden h-14 items-center justify-between pb-[18px] pt-1.5 md:flex">
{!isCollapsed && ( {!isCollapsed && (
<Link href="/" className="block"> <Link href="/" className="block">
<h1 className="pl-2 text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000"> <h1 className="pl-2 text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
@@ -120,7 +122,7 @@ export default function SideNavigation({
)} )}
</Button> </Button>
</div> </div>
<div className="mx-1 mb-4 w-auto border-b border-light-300 dark:border-dark-400" /> <div className="mx-1 mb-4 hidden w-auto border-b border-light-300 dark:border-dark-400 md:block" />
<WorkspaceMenu isCollapsed={isCollapsed} /> <WorkspaceMenu isCollapsed={isCollapsed} />
<ul role="list" className="space-y-1"> <ul role="list" className="space-y-1">
@@ -132,6 +134,7 @@ export default function SideNavigation({
name={item.name} name={item.name}
json={item.icon} json={item.icon}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav}
/> />
</li> </li>
))} ))}
@@ -144,6 +147,7 @@ export default function SideNavigation({
imageUrl={user.image ?? undefined} imageUrl={user.image ?? undefined}
isLoading={isLoading} isLoading={isLoading}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav}
/> />
</div> </div>
</nav> </nav>

View File

@@ -4,9 +4,11 @@ import { useRouter } from "next/navigation";
import { Menu, Transition } from "@headlessui/react"; import { Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Fragment } from "react"; import { Fragment } from "react";
import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client"; import { authClient } from "@kan/auth/client";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { useTheme } from "~/providers/theme"; import { useTheme } from "~/providers/theme";
import { getAvatarUrl } from "~/utils/helpers"; import { getAvatarUrl } from "~/utils/helpers";
@@ -16,10 +18,7 @@ interface UserMenuProps {
email: string; email: string;
isLoading: boolean; isLoading: boolean;
isCollapsed?: boolean; isCollapsed?: boolean;
} onCloseSideNav?: () => void;
function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
} }
export default function UserMenu({ export default function UserMenu({
@@ -27,27 +26,48 @@ export default function UserMenu({
email, email,
isLoading, isLoading,
isCollapsed = false, isCollapsed = false,
onCloseSideNav,
}: UserMenuProps) { }: UserMenuProps) {
const router = useRouter(); const router = useRouter();
const { themePreference, switchTheme } = useTheme(); const { themePreference, switchTheme } = useTheme();
const { openModal } = useModal(); const { openModal } = useModal();
const isMobile = useIsMobile();
const handleLogout = async () => { const handleLogout = async () => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
await authClient.signOut(); await authClient.signOut();
router.push("/login"); router.push("/login");
}; };
const handleLinkClick = () => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
};
const handleModalOpen = (modalType: string) => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
openModal(modalType);
};
const avatarUrl = imageUrl ? getAvatarUrl(imageUrl) : null; const avatarUrl = imageUrl ? getAvatarUrl(imageUrl) : null;
return ( return (
<Menu as="div" className="relative inline-block w-full text-left"> <Menu as="div" className="relative inline-block w-full text-left">
<div> <div>
{isLoading ? ( {isLoading ? (
<div className={classNames(isCollapsed ? "" : "flex")}> <div className={twMerge(!isCollapsed && "flex")}>
<div className="h-[30px] w-[30px] animate-pulse rounded-full bg-light-200 dark:bg-dark-200" /> <div className="h-[30px] w-[30px] animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
{!isCollapsed && ( <div
<div className="mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200" /> className={twMerge(
)} "mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
isCollapsed && "md:hidden",
)}
/>
</div> </div>
) : ( ) : (
<Menu.Button <Menu.Button
@@ -73,9 +93,14 @@ export default function UserMenu({
</svg> </svg>
</span> </span>
)} )}
{!isCollapsed && ( <span
<span className="mx-2 truncate text-sm">{email}</span> className={twMerge(
)} "mx-2 truncate text-sm",
isCollapsed && "md:hidden",
)}
>
{email}
</span>
</Menu.Button> </Menu.Button>
)} )}
</div> </div>
@@ -90,7 +115,7 @@ export default function UserMenu({
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items <Menu.Items
className={classNames( className={twMerge(
"absolute bottom-[40px] z-10 mt-2 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300", "absolute bottom-[40px] z-10 mt-2 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
isCollapsed ? "left-0 w-48" : "left-0 w-full", isCollapsed ? "left-0 w-48" : "left-0 w-full",
)} )}
@@ -106,7 +131,7 @@ export default function UserMenu({
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400" className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
> >
<span <span
className={classNames( className={twMerge(
themePreference === "system" ? "visible" : "invisible", themePreference === "system" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900", "mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)} )}
@@ -120,7 +145,7 @@ export default function UserMenu({
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400" className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
> >
<span <span
className={classNames( className={twMerge(
themePreference === "dark" ? "visible" : "invisible", themePreference === "dark" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900", "mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)} )}
@@ -134,7 +159,7 @@ export default function UserMenu({
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400" className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
> >
<span <span
className={classNames( className={twMerge(
themePreference === "light" ? "visible" : "invisible", themePreference === "light" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900", "mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)} )}
@@ -149,6 +174,7 @@ export default function UserMenu({
href="mailto:support@kan.bn" href="mailto:support@kan.bn"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
onClick={handleLinkClick}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400" className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
> >
{t`Support`} {t`Support`}
@@ -159,6 +185,7 @@ export default function UserMenu({
href="https://docs.kan.bn" href="https://docs.kan.bn"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
onClick={handleLinkClick}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400" className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
> >
{t`Documentation`} {t`Documentation`}
@@ -166,7 +193,7 @@ export default function UserMenu({
</Menu.Item> </Menu.Item>
<Menu.Item> <Menu.Item>
<button <button
onClick={() => openModal("NEW_FEEDBACK")} onClick={() => handleModalOpen("NEW_FEEDBACK")}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400" className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
> >
{t`Feedback`} {t`Feedback`}

View File

@@ -20,17 +20,20 @@ export default function WorkspaceMenu({
<Menu as="div" className="relative inline-block w-full pb-3 text-left"> <Menu as="div" className="relative inline-block w-full pb-3 text-left">
<div> <div>
{isLoading ? ( {isLoading ? (
<div className={twMerge("mb-1", isCollapsed && "flex p-1.5")}> <div className={twMerge("mb-1", isCollapsed && "md:flex md:p-1.5")}>
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" /> <div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
{!isCollapsed && ( <div
<div className="ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200" /> className={twMerge(
)} "ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
isCollapsed && "hidden md:block",
)}
/>
</div> </div>
) : ( ) : (
<Menu.Button <Menu.Button
className={twMerge( className={twMerge(
"mb-1 flex h-[34px] w-full items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200", "mb-1 flex h-[34px] w-full items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
isCollapsed && "mb-1.5 h-9 p-1", isCollapsed && "md:mb-1.5 md:h-9 md:p-1",
)} )}
title={isCollapsed ? workspace.name : undefined} title={isCollapsed ? workspace.name : undefined}
> >
@@ -39,17 +42,23 @@ export default function WorkspaceMenu({
{workspace.name.charAt(0).toUpperCase()} {workspace.name.charAt(0).toUpperCase()}
</span> </span>
</span> </span>
{!isCollapsed && ( <span
<> className={twMerge(
<span className="ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000"> "ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000",
{workspace.name} isCollapsed && "md:hidden",
</span> )}
{workspace.plan === "pro" && ( >
<span className="ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700"> {workspace.name}
Pro </span>
</span> {workspace.plan === "pro" && (
<span
className={twMerge(
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
isCollapsed && "hidden md:inline-flex",
)} )}
</> >
Pro
</span>
)} )}
</Menu.Button> </Menu.Button>
)} )}

View File

@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
setMatches(media.matches);
const listener = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}, [query]);
return matches;
}
export function useIsMobile(): boolean {
return useMediaQuery("(max-width: 767px)");
}

View File

@@ -191,7 +191,7 @@ export default function CardPage() {
<div className="flex h-full flex-1 flex-row"> <div className="flex h-full flex-1 flex-row">
<div className="m-auto flex h-full w-full max-w-[800px] flex-col overflow-hidden"> <div className="m-auto flex h-full w-full max-w-[800px] flex-col overflow-hidden">
<div className="h-full max-h-[calc(100dvh-3rem)] overflow-y-auto p-6 md:max-h-[calc(100dvh-4rem)] md:p-8"> <div className="h-full max-h-[calc(100dvh-3rem)] overflow-y-auto p-6 md:max-h-[calc(100dvh-4rem)] md:p-8">
<div className="mb-8 mt-6 flex w-full items-center justify-between"> <div className="mb-8 flex w-full items-center justify-between md:mt-6">
{!card && isLoading && ( {!card && isLoading && (
<div className="flex space-x-2"> <div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" /> <div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />