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 = () => {
setIsRightPanelOpen(!isRightPanelOpen);
if (!isRightPanelOpen) {
@@ -98,9 +102,9 @@ export default function Dashboard({
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#1c1c1c"};
}
`}</style>
<div className="relative flex h-screen flex-col bg-light-100 px-3 py-3 dark:bg-dark-100">
{/* Mobile Navigation Controls - positioned absolutely over content */}
<div className="absolute left-8 top-8 z-50 flex items-center gap-2 md:hidden">
<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 Header */}
<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
ref={sideNavButtonRef}
onClick={toggleSideNav}
@@ -118,10 +122,8 @@ export default function Dashboard({
/>
)}
</button>
</div>
{/* {hasRightPanel && (
<div className="absolute right-8 top-8 z-50 md:hidden">
{hasRightPanel && (
<button
ref={rightPanelButtonRef}
onClick={toggleRightPanel}
@@ -139,21 +141,22 @@ export default function Dashboard({
/>
)}
</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
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
user={{ email: session?.user.email, image: session?.user.image }}
isLoading={sessionLoading}
onCloseSideNav={closeSideNav}
/>
</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="h-full w-full overflow-y-auto">{children}</div>
@@ -161,13 +164,11 @@ export default function Dashboard({
{hasRightPanel && rightPanel && (
<div
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"
}`}
>
<div className="h-full overflow-y-auto rounded-md border dark:border-dark-200">
{rightPanel}
</div>
<div className="h-full">{rightPanel}</div>
</div>
)}

View File

@@ -1,7 +1,9 @@
import Link from "next/link";
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import LottieIcon from "~/components/LottieIcon";
import { useIsMobile } from "~/hooks/useMediaQuery";
function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
@@ -13,30 +15,41 @@ const Button: React.FC<{
name: string;
json: object;
isCollapsed?: boolean;
}> = ({ href, current, name, json, isCollapsed = false }) => {
onCloseSideNav?: () => void;
}> = ({ href, current, name, json, isCollapsed = false, onCloseSideNav }) => {
const [isHovered, setIsHovered] = useState(false);
const [index, setIndex] = useState(0);
const isMobile = useIsMobile();
const handleMouseEnter = () => {
setIsHovered(true);
setIndex((index) => index + 1);
};
const handleClick = () => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
};
return (
<Link
href={href}
onMouseEnter={handleMouseEnter}
onClick={handleClick}
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",
current
? "bg-light-200 dark:bg-dark-200 dark:text-dark-1000"
: "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}
>
<LottieIcon index={index} json={json} isPlaying={isHovered} />
{!isCollapsed && name}
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
</Link>
);
};

View File

@@ -23,6 +23,7 @@ import { useTheme } from "~/providers/theme";
interface SideNavigationProps {
user: UserType;
isLoading: boolean;
onCloseSideNav?: () => void;
}
interface UserType {
@@ -33,6 +34,7 @@ interface UserType {
export default function SideNavigation({
user,
isLoading,
onCloseSideNav,
}: SideNavigationProps) {
const router = useRouter();
const [isCollapsed, setIsCollapsed] = useState(false);
@@ -87,12 +89,12 @@ export default function SideNavigation({
<>
<nav
className={twMerge(
"flex h-full flex-col justify-between bg-light-100 pr-3 dark:bg-dark-100",
isCollapsed ? "w-auto" : "w-64",
"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 && "md:w-auto",
)}
>
<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 && (
<Link href="/" className="block">
<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>
</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} />
<ul role="list" className="space-y-1">
@@ -132,6 +134,7 @@ export default function SideNavigation({
name={item.name}
json={item.icon}
isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav}
/>
</li>
))}
@@ -144,6 +147,7 @@ export default function SideNavigation({
imageUrl={user.image ?? undefined}
isLoading={isLoading}
isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav}
/>
</div>
</nav>

View File

@@ -4,9 +4,11 @@ import { useRouter } from "next/navigation";
import { Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment } from "react";
import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { useModal } from "~/providers/modal";
import { useTheme } from "~/providers/theme";
import { getAvatarUrl } from "~/utils/helpers";
@@ -16,10 +18,7 @@ interface UserMenuProps {
email: string;
isLoading: boolean;
isCollapsed?: boolean;
}
function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
onCloseSideNav?: () => void;
}
export default function UserMenu({
@@ -27,27 +26,48 @@ export default function UserMenu({
email,
isLoading,
isCollapsed = false,
onCloseSideNav,
}: UserMenuProps) {
const router = useRouter();
const { themePreference, switchTheme } = useTheme();
const { openModal } = useModal();
const isMobile = useIsMobile();
const handleLogout = async () => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
await authClient.signOut();
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;
return (
<Menu as="div" className="relative inline-block w-full text-left">
<div>
{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" />
{!isCollapsed && (
<div className="mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
)}
<div
className={twMerge(
"mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
isCollapsed && "md:hidden",
)}
/>
</div>
) : (
<Menu.Button
@@ -73,9 +93,14 @@ export default function UserMenu({
</svg>
</span>
)}
{!isCollapsed && (
<span className="mx-2 truncate text-sm">{email}</span>
)}
<span
className={twMerge(
"mx-2 truncate text-sm",
isCollapsed && "md:hidden",
)}
>
{email}
</span>
</Menu.Button>
)}
</div>
@@ -90,7 +115,7 @@ export default function UserMenu({
leaveTo="transform opacity-0 scale-95"
>
<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",
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"
>
<span
className={classNames(
className={twMerge(
themePreference === "system" ? "visible" : "invisible",
"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"
>
<span
className={classNames(
className={twMerge(
themePreference === "dark" ? "visible" : "invisible",
"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"
>
<span
className={classNames(
className={twMerge(
themePreference === "light" ? "visible" : "invisible",
"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"
target="_blank"
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"
>
{t`Support`}
@@ -159,6 +185,7 @@ export default function UserMenu({
href="https://docs.kan.bn"
target="_blank"
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"
>
{t`Documentation`}
@@ -166,7 +193,7 @@ export default function UserMenu({
</Menu.Item>
<Menu.Item>
<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"
>
{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">
<div>
{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" />
{!isCollapsed && (
<div className="ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
)}
<div
className={twMerge(
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
isCollapsed && "hidden md:block",
)}
/>
</div>
) : (
<Menu.Button
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",
isCollapsed && "mb-1.5 h-9 p-1",
isCollapsed && "md:mb-1.5 md:h-9 md:p-1",
)}
title={isCollapsed ? workspace.name : undefined}
>
@@ -39,17 +42,23 @@ export default function WorkspaceMenu({
{workspace.name.charAt(0).toUpperCase()}
</span>
</span>
{!isCollapsed && (
<>
<span className="ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000">
{workspace.name}
</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">
Pro
</span>
<span
className={twMerge(
"ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000",
isCollapsed && "md:hidden",
)}
>
{workspace.name}
</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>
)}

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="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="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 && (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />