dramaling-vocab-learning/docs/03_development/routing.md

12 KiB
Raw Blame History

路由架構指南

🗺️ 路由結構總覽

DramaLing 使用 Next.js 14 App Router採用檔案系統路由。

app/
├── (auth)/                  # 認證相關頁面群組
│   ├── login/
│   ├── signup/
│   └── forgot-password/
├── (dashboard)/             # 需要登入的頁面群組
│   ├── layout.tsx          # 共用 Dashboard Layout
│   ├── page.tsx            # Dashboard 首頁
│   ├── flashcards/
│   ├── decks/
│   ├── progress/
│   └── settings/
├── api/                     # API 路由
├── layout.tsx              # 根 Layout
├── page.tsx                # 首頁
└── not-found.tsx           # 404 頁面

📁 詳細路由規劃

公開路由(無需登入)

路徑 頁面 說明
/ 首頁 Landing page產品介紹
/features 功能介紹 詳細功能說明
/pricing 價格方案 訂閱方案(未來)
/about 關於我們 團隊介紹
/privacy 隱私政策 法律文件
/terms 使用條款 法律文件

認證路由

路徑 頁面 說明
/login 登入 Email/密碼或 Google 登入
/signup 註冊 新用戶註冊
/forgot-password 忘記密碼 密碼重設請求
/reset-password 重設密碼 實際重設密碼
/verify-email 驗證信箱 Email 驗證頁面

受保護路由(需登入)

路徑 頁面 說明
/dashboard 儀表板 用戶主頁,學習統計
/flashcards 詞卡列表 所有詞卡總覽
/flashcards/new 新增詞卡 AI 生成詞卡
/flashcards/[id] 詞卡詳情 單一詞卡檢視/編輯
/decks 卡組列表 詞卡分類管理
/decks/[id] 卡組詳情 特定卡組的詞卡
/learn/[deckId] 學習模式 間隔重複學習
/progress 學習進度 統計與成就
/settings 設定 個人資料與偏好

🔧 路由實作

1. 根 Layout

// app/layout.tsx
import { Inter } from 'next/font/google'
import { Providers } from './providers'
import { Toaster } from '@/components/ui/toaster'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export const metadata = {
  title: 'DramaLing - Learn English with Drama',
  description: 'Master English vocabulary through your favorite TV shows',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body className={inter.className}>
        <Providers>
          {children}
          <Toaster />
        </Providers>
      </body>
    </html>
  )
}

2. 路由群組 Layout

// app/(dashboard)/layout.tsx
import { redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth'
import { Sidebar } from '@/components/layout/Sidebar'
import { Header } from '@/components/layout/Header'

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await getServerSession()

  if (!session) {
    redirect('/login')
  }

  return (
    <div className="flex h-screen">
      <Sidebar />
      <div className="flex-1 flex flex-col">
        <Header user={session.user} />
        <main className="flex-1 overflow-y-auto p-6">
          {children}
        </main>
      </div>
    </div>
  )
}

3. 動態路由

// app/(dashboard)/flashcards/[id]/page.tsx
import { notFound } from 'next/navigation'
import { getFlashcard } from '@/lib/api/flashcards'

interface PageProps {
  params: {
    id: string
  }
}

export default async function FlashcardPage({ params }: PageProps) {
  const flashcard = await getFlashcard(params.id)

  if (!flashcard) {
    notFound()
  }

  return (
    <div>
      <h1>{flashcard.word}</h1>
      <p>{flashcard.translation}</p>
      {/* 詞卡詳情 */}
    </div>
  )
}

// 生成靜態參數(可選)
export async function generateStaticParams() {
  const flashcards = await getPopularFlashcards()
  return flashcards.map((card) => ({
    id: card.id,
  }))
}

4. 平行路由Modal

// app/(dashboard)/@modal/(.)flashcards/[id]/page.tsx
import { Modal } from '@/components/ui/modal'
import FlashcardDetail from '@/components/FlashcardDetail'

export default function FlashcardModal({ params }: { params: { id: string } }) {
  return (
    <Modal>
      <FlashcardDetail id={params.id} />
    </Modal>
  )
}

// app/(dashboard)/layout.tsx
export default function Layout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <>
      {children}
      {modal}
    </>
  )
}

🛡️ 路由保護

1. Middleware 認證檢查

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getToken } from 'next-auth/jwt'

// 需要認證的路由
const protectedRoutes = [
  '/dashboard',
  '/flashcards',
  '/decks',
  '/learn',
  '/progress',
  '/settings',
]

// 已登入用戶不應訪問的路由
const authRoutes = ['/login', '/signup', '/forgot-password']

export async function middleware(request: NextRequest) {
  const token = await getToken({ req: request })
  const { pathname } = request.nextUrl

  // 檢查受保護路由
  const isProtectedRoute = protectedRoutes.some(route =>
    pathname.startsWith(route)
  )

  if (isProtectedRoute && !token) {
    const url = new URL('/login', request.url)
    url.searchParams.set('callbackUrl', pathname)
    return NextResponse.redirect(url)
  }

  // 已登入用戶重定向
  const isAuthRoute = authRoutes.some(route =>
    pathname.startsWith(route)
  )

  if (isAuthRoute && token) {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}

2. 客戶端路由守衛

// components/auth/AuthGuard.tsx
'use client'

import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useStore } from '@/store'

interface AuthGuardProps {
  children: React.ReactNode
  fallback?: React.ReactNode
}

export function AuthGuard({ children, fallback }: AuthGuardProps) {
  const router = useRouter()
  const { isAuthenticated, isLoading, checkAuth } = useStore()

  useEffect(() => {
    checkAuth()
  }, [])

  useEffect(() => {
    if (!isLoading && !isAuthenticated) {
      router.push('/login')
    }
  }, [isAuthenticated, isLoading, router])

  if (isLoading) {
    return fallback || <div>Loading...</div>
  }

  if (!isAuthenticated) {
    return null
  }

  return <>{children}</>
}

🔀 路由導航

1. 程式化導航

// components/FlashcardList.tsx
'use client'

import { useRouter } from 'next/navigation'

export function FlashcardList() {
  const router = useRouter()

  const handleCardClick = (id: string) => {
    router.push(`/flashcards/${id}`)
  }

  const handleCreateNew = () => {
    router.push('/flashcards/new')
  }

  return (
    // ...
  )
}
// components/Navigation.tsx
import Link from 'next/link'

export function Navigation() {
  return (
    <nav>
      <Link href="/dashboard" className="nav-link">
        Dashboard
      </Link>
      <Link
        href="/flashcards"
        prefetch={true}  // 預載入
        className="nav-link"
      >
        Flashcards
      </Link>
      <Link
        href={{
          pathname: '/decks',
          query: { sort: 'recent' },
        }}
        className="nav-link"
      >
        Decks
      </Link>
    </nav>
  )
}

3. 動態麵包屑

// components/Breadcrumbs.tsx
'use client'

import { usePathname } from 'next/navigation'
import Link from 'next/link'

export function Breadcrumbs() {
  const pathname = usePathname()
  const segments = pathname.split('/').filter(Boolean)

  return (
    <nav aria-label="Breadcrumb">
      <ol className="flex space-x-2">
        <li>
          <Link href="/">Home</Link>
        </li>
        {segments.map((segment, index) => {
          const href = `/${segments.slice(0, index + 1).join('/')}`
          const isLast = index === segments.length - 1

          return (
            <li key={segment}>
              <span>/</span>
              {isLast ? (
                <span className="font-semibold">
                  {segment.charAt(0).toUpperCase() + segment.slice(1)}
                </span>
              ) : (
                <Link href={href}>
                  {segment.charAt(0).toUpperCase() + segment.slice(1)}
                </Link>
              )}
            </li>
          )
        })}
      </ol>
    </nav>
  )
}

📱 路由載入狀態

1. Loading UI

// app/(dashboard)/flashcards/loading.tsx
export default function Loading() {
  return (
    <div className="space-y-4">
      <div className="h-8 bg-gray-200 rounded animate-pulse" />
      <div className="grid grid-cols-3 gap-4">
        {[...Array(6)].map((_, i) => (
          <div key={i} className="h-48 bg-gray-200 rounded animate-pulse" />
        ))}
      </div>
    </div>
  )
}

2. Error Boundary

// app/(dashboard)/flashcards/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="flex flex-col items-center justify-center min-h-[400px]">
      <h2 className="text-2xl font-bold mb-4">Something went wrong!</h2>
      <p className="text-gray-600 mb-4">{error.message}</p>
      <button
        onClick={reset}
        className="px-4 py-2 bg-blue-500 text-white rounded"
      >
        Try again
      </button>
    </div>
  )
}

🎯 路由最佳實踐

1. SEO 優化

// app/(dashboard)/flashcards/page.tsx
import { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'Flashcards | DramaLing',
  description: 'Manage your vocabulary flashcards',
  openGraph: {
    title: 'Flashcards | DramaLing',
    description: 'Learn English vocabulary with AI-powered flashcards',
  },
}

// 動態 metadata
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const flashcard = await getFlashcard(params.id)

  return {
    title: `${flashcard.word} | DramaLing`,
    description: flashcard.translation,
  }
}

2. 路由預載入策略

// components/FlashcardGrid.tsx
import Link from 'next/link'

export function FlashcardGrid({ flashcards }: { flashcards: Flashcard[] }) {
  return (
    <div className="grid grid-cols-3 gap-4">
      {flashcards.map((card, index) => (
        <Link
          key={card.id}
          href={`/flashcards/${card.id}`}
          // 只預載入前 6 
          prefetch={index < 6}
        >
          <FlashcardCard card={card} />
        </Link>
      ))}
    </div>
  )
}

3. 查詢參數處理

// app/(dashboard)/flashcards/page.tsx
import { Suspense } from 'react'

interface PageProps {
  searchParams: {
    page?: string
    sort?: 'recent' | 'alphabetical' | 'difficulty'
    filter?: string
  }
}

export default function FlashcardsPage({ searchParams }: PageProps) {
  const page = Number(searchParams.page) || 1
  const sort = searchParams.sort || 'recent'
  const filter = searchParams.filter

  return (
    <Suspense fallback={<Loading />}>
      <FlashcardList
        page={page}
        sort={sort}
        filter={filter}
      />
    </Suspense>
  )
}

🚀 路由性能優化

1. 部分預渲染

// app/(dashboard)/flashcards/page.tsx
export const dynamic = 'force-dynamic'  // 動態渲染
export const revalidate = 60  // ISR60 秒重新驗證

2. 路由分割

// 使用動態導入減少初始載入
const FlashcardEditor = dynamic(
  () => import('@/components/FlashcardEditor'),
  {
    loading: () => <p>Loading editor...</p>,
    ssr: false,  // 客戶端渲染
  }
)

3. 路由快取策略

// app/api/flashcards/route.ts
export async function GET(request: Request) {
  // 設定快取標頭
  return NextResponse.json(data, {
    headers: {
      'Cache-Control': 'public, s-maxage=10, stale-while-revalidate=59',
    },
  })
}