一、路由组
在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. 图片配置域名
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
最终效果如下: