第十章 路由组与响应式导航设计与实现

一、路由组

nextjs.org/docs/app/bu...

app目录中,嵌套文件夹通常映射到 URL 路径。您可以将文件夹标记为路由组,以防止该文件夹包含在路由的 URL 路径中。这允许您将路线段和项目文件组织到逻辑组中,而不影响 URL 路径结构。

可以通过将文件夹名称括在括号中来创建路由组:(folderName),组名称也可以嵌套,如:(page) (blockAccessAfterLogin) (protected)

在组内创建layout.ts处理一些相同的逻辑,如登录注册页在登录之后就不允许访问了,这个时候可以在(blockAccessAfterLogin)内增加layout.ts统一处理

将前文中的路由分组后的文件目录如下:

scss 复制代码
src/app
└─ (page)/
  └─ (blockAccessAfterLogin)/
    └─ layout.tsx
    └─ login/
      └─ EmailLogin.tsx
      └─ PhoneLogin.tsx
      └─ form.tsx
      └─ page.tsx
    └─ register/
      └─ EmailRegister.tsx
      └─ PhoneRegister.tsx
      └─ form.tsx
      └─ page.tsx
  └─ (protected)/
    └─ dashboard/
      └─ page.tsx
  └─ layout.tsx
  └─ page.tsx
└─ _trpc/
  └─ client.ts
└─ api/
  └─ auth/
    └─ [...nextauth]/
      └─ route.ts
  └─ trpc/
    └─ [trpc]/
      └─ route.ts
└─ favicon.ico
└─ globals.css
└─ layout.tsx

其他文件只需要移动位置即可,重点改造了这三个layout

app的layout代码如下:

ts 复制代码
// src/app/layout.tsx
import type { Metadata } from 'next'
import './globals.css'

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" className="light">
      <body className="min-h-screen font-sans antialiased grainy">
        {children}
      </body>
    </html>
  )
}

(page)的layout代码如下:

ts 复制代码
// src/app/(page)/layout.tsx
import Providers from '@/components/Providers'
import { Toaster } from '@/components/ui/toaster'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  // 如果全局保护路由,这里可以做一些判断
  return (
    <Providers>
      {/* todo 导航 */}
      <main>{children}</main>
      <Toaster />
    </Providers>
  )
}

(blockAccessAfterLogin)的layout代码如下:

ts 复制代码
// src/app/(page)/(blockAccessAfterLogin)/layout.tsx
import { getServerSession } from 'next-auth'
import { redirect } from 'next/navigation'
import { authOptions } from '@/app/api/auth/[...nextauth]/route'
export default async function ProtectedLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await getServerSession(authOptions)
  if (session) {
    redirect('/')
  }
  return <>{children}</>
}

因为在(blockAccessAfterLogin)内增加了layout,统一处理了登录以后不可以访问的逻辑,所以在页面上不需要额外处理

二、导航

1. 添加shadcn组件

bash 复制代码
# 添加shadcn组件
npx shadcn-ui@latest add dropdown-menu

2. 添加页面

添加一个没有拦截可以任意访问的页面About

ts 复制代码
// src/app/(page)/(allow)/about/page.tsx
function AboutPage() {
  return <div>AboutPage</div>
}

export default AboutPage

添加一个受保护的Account页面

ts 复制代码
// src/app/(page)/(protected)/account/page.tsx
function AccountPage() {
  return <div>AccountPage</div>
}

export default AccountPage

Account页面受保护并不是因为其放到protected下,protected只是用于分组,我们补充nextAuth的middle,添加对Account的拦截

ts 复制代码
// src/middleware.ts
export { default } from 'next-auth/middleware'

export const config = { matcher: ['/dashboard/:path*', '/account/:path*'] }

3. 导航组件

样式hidden items-center space-x-4 sm:flex使pc端的导航默认隐藏,只有在屏幕大于sm指定的尺寸时显示,移动端的MobileNav组件也进行了类似的判断

pc端导航组件如下:

ts 复制代码
// src/components/Navbar/index.tsx
import Link from 'next/link'
import MaxWidthWrapper from '../MaxWidthWrapper'
import { getServerSession } from 'next-auth'
import { buttonVariants } from '../ui/button'
import MobileNav from './MobileNav'
import MenuUser from './MenuUser'
import { authOptions } from '@/app/api/auth/[...nextauth]/route'
const Navbar = async () => {
  const session = await getServerSession(authOptions)

  return (
    <nav className="sticky h-14 inset-x-0 top-0 z-30 w-full border-b border-gray-200 bg-white/75 backdrop-blur-lg transition-all">
      <MaxWidthWrapper className="max-w-screen-2xl">
        <div className="flex h-14 items-center justify-between border-b border-zinc-200">
          <div>
            <Link href="/" className="flex z-40 font-semibold">
              <span>首页</span>
            </Link>
          </div>
          <MobileNav isAuth={!!session} />

          <div className="hidden items-center space-x-4 sm:flex">
            <Link
              href="/about"
              className={buttonVariants({
                variant: 'ghost',
                size: 'sm',
              })}
            >
              关于我们
            </Link>
            {!!session && (
              <>
                <Link
                  href="/dashboard"
                  className={buttonVariants({
                    variant: 'ghost',
                    size: 'sm',
                  })}
                >
                  工作台
                </Link>
                <MenuUser userInfo={session} />
              </>
            )}
            {!session && (
              <>
                <Link
                  href="/register"
                  className={buttonVariants({
                    variant: 'ghost',
                    size: 'sm',
                  })}
                >
                  注册
                </Link>
                <Link
                  href="/login"
                  className={buttonVariants({
                    variant: 'ghost',
                    size: 'sm',
                  })}
                >
                  登录
                </Link>
              </>
            )}
          </div>
        </div>
      </MaxWidthWrapper>
    </nav>
  )
}

export default Navbar

pc端使用的用户信息组件如下:

在组件内使用了/images/user.png图片地址,需要在public下添加/images/user.png图片,同时移除public下不使用的资源

图片组件:nextjs.org/docs/app/ap...

ts 复制代码
// src/components/Navbar/MenuUser.tsx
'use client'

import { LogOut, User } from 'lucide-react'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Session } from 'next-auth'
import { useRouter } from 'next/navigation'
import { signOut } from 'next-auth/react'
import Image from 'next/image'
import { useState } from 'react'
interface Iprops {
  userInfo: Session
}

export default function DropdownMenuDemo(props: Iprops) {
  const { userInfo } = props
  const [isLoaded, setIsLoaded] = useState(false)
  const router = useRouter()
  const closeOnCurrent = (href: string) => {
    router.push(href)
  }
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <div className="rounded-full w-10 h-10 flex items-center justify-center border border-zinc-200 relative bg-zinc-50 overflow-hidden cursor-pointer">
          <Image
            sizes="100%"
            fill={true}
            src={
              userInfo?.user?.image ? userInfo.user.image : '/images/user.png'
            }
            alt="avatar"
            priority={true}
            onLoad={() => setIsLoaded(true)}
          />
          {!isLoaded && (
            <div className="absolute w-16 h-16 animate-pulse rounded-full bg-zinc-300 opacity-25"></div>
          )}
        </div>
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-56">
        <DropdownMenuLabel>我的账户</DropdownMenuLabel>
        <DropdownMenuSeparator />
        <DropdownMenuGroup>
          <DropdownMenuItem
            onClick={() => {
              closeOnCurrent('/account')
            }}
            className="cursor-pointer"
          >
            <User className="mr-2 h-4 w-4" />
            <span>账户信息</span>
          </DropdownMenuItem>
        </DropdownMenuGroup>
        <DropdownMenuSeparator />
        <DropdownMenuItem
          className="cursor-pointer"
          onClick={() => {
            signOut()
          }}
        >
          <LogOut className="mr-2 h-4 w-4" />
          <span>退出登录</span>
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

移动端导航组件如下:

ts 复制代码
// src/components/Navbar/MobileNav.tsx
'use client'

import { cn } from '@/lib/utils'
import { ArrowRight, LayoutGrid, LogOut, User } from 'lucide-react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { buttonVariants } from '../ui/button'
import { signOut } from 'next-auth/react'
import CustomMenuIcon from './CustomMenuIcon'
const MobileNav = ({ isAuth }: { isAuth: boolean }) => {
  const [isOpen, setOpen] = useState<boolean>(false)
  const [isClicked, setIsClicked] = useState(false)
  const pathname = usePathname()
  const toggleOpen = () => setOpen((prev) => !prev)
  useEffect(() => {
    setOpen(false)
  }, [pathname])

  const closeOnCurrent = () => {
    setOpen(false)
    setIsClicked(!isClicked)
  }

  return (
    <div className="sm:hidden">
      <CustomMenuIcon close={isOpen} clickMenu={toggleOpen} />
      {isOpen ? (
        <div className="fixed animate-in slide-in-from-top-5 fade-in-20 inset-0 z-0 w-full">
          <ul className="absolute bg-white border-b border-zinc-200 shadow-xl grid w-full gap-3 px-10 pt-20 pb-8">
            <li>
              <Link
                onClick={closeOnCurrent}
                className="flex items-center w-full font-semibold text-green-600"
                href="/about"
              >
                关于我们
                <ArrowRight className="ml-2 h-5 w-5" />
              </Link>
            </li>
            <li className="my-3 h-px w-full bg-gray-300" />
            {!isAuth ? (
              <>
                <li className="flex w-full space-x-4">
                  <Link
                    onClick={closeOnCurrent}
                    className={cn(
                      'font-semibold flex-grow',
                      buttonVariants({
                        variant: 'secondary',
                        size: 'sm',
                      })
                    )}
                    href="/login"
                  >
                    登录
                  </Link>
                  <Link
                    onClick={closeOnCurrent}
                    className={cn(
                      'font-semibold flex-grow',
                      buttonVariants({
                        variant: 'outline',
                        size: 'sm',
                      })
                    )}
                    href="/register"
                  >
                    注册
                  </Link>
                </li>
              </>
            ) : (
              <>
                <li className="flex justify-start items-center gap-2 text-sm font-semibold">
                  <User className="h-4 w-4" />
                  <Link
                    onClick={closeOnCurrent}
                    className="flex items-center w-full font-semibold"
                    href="/account"
                  >
                    账户信息
                  </Link>
                </li>
                <li className="my-3 h-px w-full bg-gray-300" />
                <li className="flex justify-start items-center gap-2 text-sm font-semibold">
                  <LayoutGrid className="h-4 w-4" />
                  <Link
                    onClick={closeOnCurrent}
                    className="flex items-center w-full font-semibold"
                    href="/dashboard"
                  >
                    工作台
                  </Link>
                </li>
                <li className="my-3 h-px w-full bg-gray-300" />
                <li
                  className="flex justify-start items-center gap-2 text-sm font-semibold cursor-pointer"
                  onClick={() => signOut()}
                >
                  <LogOut className="h-4 w-4" /> <span>登出</span>
                </li>
              </>
            )}
          </ul>
        </div>
      ) : null}
    </div>
  )
}

export default MobileNav

移动端导航组件使用的自定义的菜单按钮组件如下:

ts 复制代码
// src/components/Navbar/CustomMenuIcon.tsx
import { cn } from '@/lib/utils'
import { useState } from 'react'

interface Iprops {
  clickMenu: () => void
  close: boolean
  size?: 'sm' | 'md'
}

const IconSize = (size: 'sm' | 'md' | undefined) => {
  switch (size) {
    case 'sm':
      return {
        common: 'w-4',
        wrap: 'w-4 h-4',
        span1close: 'translate-x-0 top-1.5 rotate-45',
        span2second: 'top-1.5 translate-x-2',
        span2close: 'translate-x-4',
        span3second: 'top-3 translate-x-1',
        span3close: 'translate-x-0 top-1.5 -rotate-45',
      }
    default:
      return {
        common: 'w-5',
        wrap: 'w-5 h-5',
        span1close: 'translate-x-0 top-2.5 rotate-45',
        span2second: 'top-2 translate-x-2',
        span2close: 'translate-x-5',
        span3second: 'top-4 translate-x-1',
        span3close: 'translate-x-0 top-2.5 -rotate-45',
      }
  }
}

function CustomMenuIcon({ clickMenu, close, size }: Iprops) {
  const [hovered, setHovered] = useState(false)
  const clickIcon = () => {
    setHovered(false)
    // 执行外部方法
    clickMenu && clickMenu()
  }
  const iconSize = IconSize(size)
  const commonStyle = `block ${iconSize.common} h-0.5 bg-zinc-700 absolute transition-all duration-300 ease-in-out`
  return (
    <div
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      className={cn(
        'cursor-pointer relative overflow-hidden z-50 text-zinc-700',
        iconSize.wrap
      )}
      onClick={clickIcon}
    >
      <span
        className={cn(
          commonStyle,
          'top-0 translate-x-0',
          hovered && !close ? 'translate-x-1' : '',
          close ? iconSize.span1close : ''
        )}
      ></span>
      <span
        className={cn(
          commonStyle,
          iconSize.span2second,
          hovered ? 'translate-x-0' : '',
          close ? iconSize.span2close : ''
        )}
      ></span>
      <span
        className={cn(
          commonStyle,
          iconSize.span3second,
          hovered && !close ? 'translate-x-2.5' : '',
          close ? iconSize.span3close : ''
        )}
      ></span>
    </div>
  )
}

export default CustomMenuIcon

4. 应用导航

导航组件完成后需要在layout使用,我们把导航放到src/app/layout

ts 复制代码
// src/app/(page)/layout.tsx
import Navbar from '@/components/Navbar'
import Providers from '@/components/Providers'
import { Toaster } from '@/components/ui/toaster'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  // 如果全局保护路由,这里可以做一些判断
  return (
    <Providers>
      <Navbar />
      <main>{children}</main>
      <Toaster />
    </Providers>
  )
}

5. 图片配置域名

nextjs.org/docs/messag...

next/image Un-configured Host?

One of your pages that leverages the next/image component, passed a src value that uses a hostname in the URL that isn't defined in the images.remotePatterns in next.config.js.

因为测试图片是从github复制的,其域名是avatars.githubusercontent.com

ts 复制代码
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'avatars.githubusercontent.com',
      },
    ],
  },
}

module.exports = nextConfig

6. 预览

手动添加一个图片地址,最终样式如下:

三、背景样式

给登录和注册增加一个好看的背景色,添加一个ColorfullBg组件,在(blockAccessAfterLogin)layout中使用

ts 复制代码
// src/components/ColorfullBg.tsx
function ColorfullBg() {
  return (
    <div
      aria-hidden="true"
      className="pointer-events-none absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80"
    >
      <div
        style={{
          clipPath:
            'polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)',
        }}
        className="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[300deg] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]"
      />
    </div>
  )
}

export default ColorfullBg

最终效果如下:

相关推荐
Martin -Tang22 分钟前
vite和webpack的区别
前端·webpack·node.js·vite
迷途小码农零零发23 分钟前
解锁微前端的优秀库
前端
王解1 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
我不当帕鲁谁当帕鲁1 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis
那一抹阳光多灿烂1 小时前
工程化实战内功修炼测试题
前端·javascript
放逐者-保持本心,方可放逐2 小时前
微信小程序=》基础=》常见问题=》性能总结
前端·微信小程序·小程序·前端框架
毋若成4 小时前
前端三大组件之CSS,三大选择器,游戏网页仿写
前端·css
红中马喽4 小时前
JS学习日记(webAPI—DOM)
开发语言·前端·javascript·笔记·vscode·学习
Black蜡笔小新5 小时前
网页直播/点播播放器EasyPlayer.js播放器OffscreenCanvas这个特性是否需要特殊的环境和硬件支持
前端·javascript·html
秦jh_6 小时前
【Linux】多线程(概念,控制)
linux·运维·前端