在Day20中,我們建置了VibePulse的單一技能詳細頁面/skills/[id],並透過Next.js的ServerComponent(RSC)與ClientComponent混合架構實作了流暢的Tabs切換。
今天(Day21),我們將正式填補詳細頁中的第二個核心頁籤——學習筆記(StudyNotes)模組。
身為開發者或學習者,記錄技術細節、程式碼範例與踩坑經驗是提升技能熟練度的關鍵。今天我們將實作一個支援Markdown語法、程式碼語法高亮(SyntaxHighlighting)、雙欄即時預覽(Split-screenPreview)以及全套CRUDAPI的學習筆記系統!
1.系統架構與資料庫關聯(PrismaModelExtension)
為了支援單一技能擁有多篇筆記,我們首先需要擴充prisma/schema.prisma中的資料模型
2.實戰步驟1:擴充PrismaSchema與Migration
開啟@prisma/schema.prisma,請AI為我們加入Note模型:
// prisma/schema.prisma
model Skill {
id String @id @default(uuid())
title String
category Category
proficiency Int @default(0)
tags String[] @default([])
status Status @default(LEARNING)
notesCount Int @default(0)
lastPracticedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 1:N 關聯
notes Note[]
@@index([category])
@@index([status])
@@map("skills")
}
model Note {
id String @id @default(uuid())
skillId String
title String
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 關聯外鍵
skill Skill @relation(fields: [skillId], references: [id], onDelete: Cascade)
@@index([skillId])
@@map("notes")
}
在Terminal執行Migration更新資料庫:
npx prisma migrate dev --name add_note_model
3.實戰步驟2:安裝Markdown與語法高亮相關套件
在Terminal執行:
npm install react-markdown remark-gfm rehype-highlight highlight.js
react-markdown:將Markdown字串安全地渲染為HTML節點。
remark-gfm:支援GitHubFlavoredMarkdown(表格、Tasklist、刪除線等)。
rehype-highlight+highlight.js:自動為程式碼區塊(CodeBlocks)進行語法高亮上色。
4.實戰步驟3:撰寫NotesAPIRoutes(app/api/skills/[id]/notes/route.ts)
我們需要建立支援讀取與新增筆記的API端點,並在新增/刪除筆記時自動更新Skill的notesCount數量。
// app/api/skills/[id]/notes/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
const createNoteSchema = z.object({
title: z.string().min(1, '請輸入筆記標題').max(100),
content: z.string().min(1, '筆記內容不能為空'),
});
// GET: 取得特定技能的所有筆記
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const notes = await prisma.note.findMany({
where: { skillId: params.id },
orderBy: { updatedAt: 'desc' },
});
return NextResponse.json({ success: true, data: notes });
} catch (error) {
return NextResponse.json({ success: false, error: '無法讀取筆記資料' }, { status: 500 });
}
}
// POST: 新增一篇筆記
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const body = await request.json();
const validation = createNoteSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json({ success: false, error: '資料驗證失敗' }, { status: 400 });
}
const { title, content } = validation.data;
// 使用 Transaction 同時建立 Note 並更新 Skill 的 notesCount
const [newNote] = await prisma.$transaction([
prisma.note.create({
data: {
skillId: params.id,
title,
content,
},
}),
prisma.skill.update({
where: { id: params.id },
data: { notesCount: { increment: 1 } },
}),
]);
return NextResponse.json({ success: true, data: newNote }, { status: 201 });
} catch (error) {
return NextResponse.json({ success: false, error: '建立筆記失敗' }, { status: 500 });
}
}
5.實戰步驟4:建立Markdown編輯與預覽組件(components/notes/NoteEditor.tsx)
這個組件提供編輯(Edit)與即時預覽(Preview)雙模式切換,並引導使用者寫出結構化的學習筆記。
// components/notes/NoteEditor.tsx
'use client';
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/tokyo-night-dark.css'; // 導入深色程式碼主題
import { Eye, Edit3, Save } from 'lucide-react';
interface NoteEditorProps {
initialTitle?: string;
initialContent?: string;
onSave: (data: { title: string; content: string }) => void;
isSubmitting?: boolean;
}
export function NoteEditor({
initialTitle = '',
initialContent = '',
onSave,
isSubmitting = false,
}: NoteEditorProps) {
const [title, setTitle] = useState(initialTitle);
const [content, setContent] = useState(initialContent);
const [mode, setMode] = useState<'write' | 'preview' | 'split'>('split');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !content.trim()) return;
onSave({ title, content });
};
return (
<form onSubmit={handleSubmit} className="space-y-4 rounded-xl border border-slate-800 bg-slate-900/60 p-6">
{/* 工具列與模式切換 */}
<div className="flex flex-wrap items-center justify-between gap-4 pb-4 border-b border-slate-800">
<input
type="text"
placeholder="筆記標題..."
value={title}
onChange={(e) => setTitle(e.target.value)}
className="flex-1 bg-transparent text-lg font-bold text-slate-100 placeholder-slate-500 focus:outline-none"
/>
<div className="flex items-center gap-2">
<div className="flex items-center rounded-lg bg-slate-800 p-1 text-xs">
<button
type="button"
onClick={() => setMode('write')}
className={`px-3 py-1.5 rounded-md transition ${mode === 'write' ? 'bg-cyan-500 text-white' : 'text-slate-400 hover:text-slate-200'}`}
>
<Edit3 className="inline h-3.5 w-3.5 mr-1" /> 撰寫
</button>
<button
type="button"
onClick={() => setMode('preview')}
className={`px-3 py-1.5 rounded-md transition ${mode === 'preview' ? 'bg-cyan-500 text-white' : 'text-slate-400 hover:text-slate-200'}`}
>
<Eye className="inline h-3.5 w-3.5 mr-1" /> 預覽
</button>
</div>
<button
type="submit"
disabled={isSubmitting || !title.trim() || !content.trim()}
className="flex items-center gap-1.5 px-4 py-2 text-sm font-semibold text-white bg-cyan-600 rounded-lg hover:bg-cyan-500 disabled:opacity-50 transition"
>
<Save className="h-4 w-4" /> 儲存筆記
</button>
</div>
</div>
{/* 編輯區與預覽區 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 min-h-[300px]">
{(mode === 'write' || mode === 'split') && (
<textarea
placeholder="使用 Markdown 記錄你的學習心得與 Code 範例..."
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full h-full min-h-[300px] p-4 bg-slate-950 border border-slate-800 rounded-lg text-slate-200 text-sm font-mono focus:outline-none focus:border-cyan-500/50 resize-y"
/>
)}
{(mode === 'preview' || mode === 'split') && (
<div className="prose prose-invert max-w-none p-4 bg-slate-950/50 border border-slate-800/80 rounded-lg overflow-y-auto text-sm text-slate-300">
{content.trim() ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
>
{content}
</ReactMarkdown>
) : (
<span className="text-slate-600 italic">尚無預覽內容...</span>
)}
</div>
)}
</div>
</form>
);
}
今天我們成功替VibePulse的單一技能頁面升級了完整Markdown筆記功能:
PrismaModel擴充:設計了Skill與Note的1:N關聯,並使用資料庫Transaction自動更新統計計數。
Markdown渲染與語法高亮:整合react-markdown與highlight.js,讓程式碼與筆記呈現專業極致的排版質感。
即時雙欄預覽:提供撰寫/預覽/雙欄split動態切換,大幅提升學習筆記撰寫體驗!