Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
73e3cc0
Initial implementation of feature
Peter2594 Mar 9, 2026
752a59d
Improve feature implementation
Peter2594 Mar 9, 2026
da44aa0
Add documentation for feature
Peter2594 Mar 9, 2026
2da7838
chore: update package.json dependencies
Peter2594 Mar 9, 2026
b970425
feat: update homepage welcome message
Peter2594 Mar 9, 2026
4395ae9
style: modify background colors in global styles
Peter2594 Mar 9, 2026
7937473
docs: add practice note to README
Peter2594 Mar 9, 2026
7e20a42
style: add card-based layout and modern container styling
Peter2594 Mar 9, 2026
e1b66d8
feat: implement AI task decomposition logic and UI button
Peter2594 Mar 9, 2026
cdb3f6a
chore: add pdf-parse, jszip, jest; configure Next.js external packages
Jun 6, 2026
f9218de
feat: add shared TypeScript types for quiz generator
Jun 6, 2026
34d4a2a
feat: add PDF and PPTX text extractors with tests
Jun 6, 2026
4845683
Fix pdf-parse import for v2.4.5 compatibility
Jun 6, 2026
da678c5
feat: add /api/generate route for file parsing and Gemini question ge…
Jun 6, 2026
547ad4a
feat: add UploadZone component with drag-drop and question count sele…
Jun 6, 2026
87105b9
feat: add QuizCard component for displaying a single multiple-choice …
Jun 6, 2026
91705a4
feat: add ResultCard component showing per-question result with expla…
Jun 6, 2026
8b8546b
feat: rewrite page.tsx as quiz generator state machine
Jun 6, 2026
dbee2cd
fix: add AbortController and empty questions validation to page.tsx
Jun 6, 2026
4e1d2d4
feat: switch AI provider to Anthropic Claude, fix pdf-parse v2 API
Jun 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,5 @@ A GitHub repository containing:

* Proper branch structure
* Clear commit history
* A merged feature branch into `dev`
* A merged feature branch into `dev`
This project is a practice for Git workflow.
10 changes: 10 additions & 0 deletions __mocks__/pdf-parse.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class PDFParse {
constructor(options) {
this._buffer = options.data;
}
async getText() {
return { text: `mocked pdf content from ${this._buffer.length} bytes` };
}
}

module.exports = { PDFParse };
62 changes: 62 additions & 0 deletions __tests__/lib/extractors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import JSZip from "jszip";

let extractPptxText: (buf: Buffer) => Promise<string>;
let extractPdfText: (buf: Buffer) => Promise<string>;

beforeAll(async () => {
const mod = await import("@/app/lib/extractors");
extractPptxText = mod.extractPptxText;
extractPdfText = mod.extractPdfText;
});

async function makePptx(slides: string[]): Promise<Buffer> {
const zip = new JSZip();
slides.forEach((text, i) => {
zip.file(
`ppt/slides/slide${i + 1}.xml`,
`<p:sld><p:cSld><p:spTree><p:sp><p:txBody><a:p><a:r><a:t>${text}</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>`
);
});
return zip.generateAsync({ type: "nodebuffer" });
}

describe("extractPptxText", () => {
it("extracts text from a single slide", async () => {
const buf = await makePptx(["Hello World"]);
const result = await extractPptxText(buf);
expect(result).toContain("Hello World");
});

it("extracts and joins text from multiple slides", async () => {
const buf = await makePptx(["Slide One", "Slide Two"]);
const result = await extractPptxText(buf);
expect(result).toContain("Slide One");
expect(result).toContain("Slide Two");
});

it("returns empty string when no slides exist", async () => {
const zip = new JSZip();
const buf = await zip.generateAsync({ type: "nodebuffer" });
const result = await extractPptxText(buf);
expect(result).toBe("");
});

it("handles a:t tags with attributes", async () => {
const zip = new JSZip();
zip.file(
"ppt/slides/slide1.xml",
`<p:sld><a:t xml:space="preserve">Attributed Text</a:t></p:sld>`
);
const buf = await zip.generateAsync({ type: "nodebuffer" });
const result = await extractPptxText(buf);
expect(result).toContain("Attributed Text");
});
});

describe("extractPdfText", () => {
it("calls pdf-parse and returns text", async () => {
const fakeBuffer = Buffer.from("fake");
const result = await extractPdfText(fakeBuffer);
expect(typeof result).toBe("string");
});
});
73 changes: 73 additions & 0 deletions app/api/generate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import Anthropic from "@anthropic-ai/sdk";
import { extractPdfText, extractPptxText } from "@/app/lib/extractors";
import type { Question } from "@/app/types";

const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

export async function POST(req: Request) {
try {
const formData = await req.formData();
const file = formData.get("file") as File | null;
const countStr = formData.get("count") as string | null;

if (!file) {
return NextResponse.json({ error: "請上傳檔案" }, { status: 400 });
}

const count = parseInt(countStr ?? "10", 10);
const name = file.name.toLowerCase();

if (!name.endsWith(".pdf") && !name.endsWith(".pptx")) {
return NextResponse.json({ error: "只支援 PDF 或 PPTX 檔案" }, { status: 400 });
}

if (file.size > 10 * 1024 * 1024) {
return NextResponse.json({ error: "檔案大小不能超過 10MB" }, { status: 400 });
}

const buffer = Buffer.from(await file.arrayBuffer());
const text = name.endsWith(".pdf")
? await extractPdfText(buffer)
: await extractPptxText(buffer);

if (text.trim().length < 100) {
return NextResponse.json({ error: "文件內容不足,無法生成考題" }, { status: 400 });
}

const prompt = `你是出題老師。根據以下內容,生成 ${count} 道繁體中文四選一選擇題。

規則:
- 每題有四個選項 A、B、C、D
- 必須標明正確答案(只填大寫字母 A/B/C/D)
- 提供一段解析說明(2-3 句,說明為何這個答案正確)
- 題目要考驗對內容的理解,不只是表面記憶

請只回傳純 JSON array,不要有任何 Markdown、程式碼區塊或其他文字:
[{"id":1,"question":"...","options":{"A":"...","B":"...","C":"...","D":"..."},"answer":"A","explanation":"..."}]

文件內容:
${text.slice(0, 8000)}`;

const stream = client.messages.stream({
model: "claude-opus-4-8",
max_tokens: 4096,
messages: [{ role: "user", content: prompt }],
});

const response = await stream.finalMessage();
const textBlock = response.content.find((b) => b.type === "text");
if (!textBlock || textBlock.type !== "text") {
throw new Error("No text in response");
}

const raw = textBlock.text.trim();
const jsonStr = raw.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
const questions: Question[] = JSON.parse(jsonStr);

return NextResponse.json({ questions });
} catch (err) {
console.error("Generate error:", err);
return NextResponse.json({ error: "AI 生成失敗,請重試" }, { status: 500 });
}
}
38 changes: 38 additions & 0 deletions app/components/QuizCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"use client";
import type { Question, OptionKey } from "@/app/types";

interface QuizCardProps {
question: Question;
index: number;
selected: OptionKey | null;
onSelect: (key: OptionKey) => void;
}

const OPTIONS: OptionKey[] = ["A", "B", "C", "D"];

export default function QuizCard({ question, index, selected, onSelect }: QuizCardProps) {
return (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<p className="font-semibold text-slate-800 mb-4 leading-relaxed">
<span className="text-indigo-500 font-bold mr-2">Q{index + 1}.</span>
{question.question}
</p>
<div className="space-y-2">
{OPTIONS.map(key => (
<button
key={key}
type="button"
onClick={() => onSelect(key)}
className={`w-full text-left px-4 py-3 rounded-xl border-2 transition-all
${selected === key
? "border-indigo-500 bg-indigo-50 text-indigo-800 font-semibold"
: "border-gray-100 bg-gray-50 text-slate-700 hover:border-indigo-300 hover:bg-indigo-50/50"}`}
>
<span className="font-bold mr-2">{key}.</span>
{question.options[key]}
</button>
))}
</div>
</div>
);
}
39 changes: 39 additions & 0 deletions app/components/ResultCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Question, OptionKey } from "@/app/types";

interface ResultCardProps {
question: Question;
index: number;
selected: OptionKey | null;
}

export default function ResultCard({ question, index, selected }: ResultCardProps) {
const isCorrect = selected === question.answer;

return (
<div className={`rounded-2xl border-2 p-6 transition-all
${isCorrect ? "border-green-200 bg-green-50" : "border-rose-200 bg-rose-50"}`}>
<div className="flex items-start gap-3">
<span className="text-2xl flex-shrink-0">{isCorrect ? "✅" : "❌"}</span>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-800 leading-relaxed">
<span className="text-gray-400 mr-2">Q{index + 1}.</span>
{question.question}
</p>
{!isCorrect && (
<p className="text-rose-600 text-sm mt-1">
你選了 <strong>{selected ?? "(未作答)"}</strong>,
正確答案是 <strong>{question.answer}</strong>
{" — "}{question.options[question.answer]}
</p>
)}
<div className="mt-3 bg-white/70 rounded-xl p-3 border border-amber-200">
<p className="text-sm text-gray-700">
<span className="font-semibold text-amber-600">💡 解析:</span>
{question.explanation}
</p>
</div>
</div>
</div>
</div>
);
}
108 changes: 108 additions & 0 deletions app/components/UploadZone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"use client";
import { useRef, useState } from "react";
import type { DragEvent, ChangeEvent } from "react";
import type { QuestionCount } from "@/app/types";

interface UploadZoneProps {
onGenerate: (file: File, count: QuestionCount) => void;
}

function validateFile(file: File): string {
const name = file.name.toLowerCase();
if (!name.endsWith(".pdf") && !name.endsWith(".pptx")) return "只支援 PDF 或 PPTX 檔案";
if (file.size > 10 * 1024 * 1024) return "檔案大小不能超過 10MB";
return "";
}

export default function UploadZone({ onGenerate }: UploadZoneProps) {
const [file, setFile] = useState<File | null>(null);
const [count, setCount] = useState<QuestionCount>(10);
const [dragOver, setDragOver] = useState(false);
const [error, setError] = useState("");
const inputRef = useRef<HTMLInputElement>(null);

function handleFile(f: File) {
const err = validateFile(f);
if (err) { setError(err); return; }
setError("");
setFile(f);
}

function onDrop(e: DragEvent<HTMLDivElement>) {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
}

function onChange(e: ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (f) handleFile(f);
}

return (
<div className="space-y-6">
<div
onDrop={onDrop}
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onClick={() => inputRef.current?.click()}
className={`border-2 border-dashed rounded-2xl p-12 text-center cursor-pointer transition-all
${dragOver
? "border-indigo-500 bg-indigo-50"
: "border-gray-300 hover:border-indigo-400 hover:bg-gray-50"}`}
>
<input
ref={inputRef}
type="file"
accept=".pdf,.pptx"
className="hidden"
onChange={onChange}
/>
<div className="text-4xl mb-3">📄</div>
{file ? (
<p className="font-semibold text-indigo-700">{file.name}</p>
) : (
<>
<p className="text-gray-600 font-medium">拖曳 PDF / PPTX 到這裡</p>
<p className="text-gray-400 text-sm mt-1">或點擊選擇檔案(最大 10MB)</p>
</>
)}
</div>

{error && (
<p className="text-rose-500 text-sm text-center">{error}</p>
)}

<div>
<p className="text-sm text-gray-500 text-center mb-3">選擇題數</p>
<div className="flex justify-center gap-3">
{([5, 10, 20] as QuestionCount[]).map(n => (
<button
key={n}
type="button"
onClick={() => setCount(n)}
className={`px-6 py-2 rounded-xl font-semibold transition-all
${count === n
? "bg-indigo-600 text-white shadow-md"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}
>
{n} 題
</button>
))}
</div>
</div>

<button
type="button"
onClick={() => file && onGenerate(file, count)}
disabled={!file}
className="w-full py-4 bg-gradient-to-r from-indigo-600 to-purple-600 text-white
font-bold rounded-xl hover:opacity-90 active:scale-95 transition-all shadow-lg
disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100"
>
開始生成考題
</button>
</div>
);
}
2 changes: 1 addition & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@import "tailwindcss";

:root {
--background: #ffffff;
--background: #f0f0f0;
--foreground: #171717;
}

Expand Down
27 changes: 27 additions & 0 deletions app/lib/extractors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import JSZip from "jszip";
import { PDFParse } from "pdf-parse";

export async function extractPdfText(buffer: Buffer): Promise<string> {
const parser = new PDFParse({ data: buffer });
const result = await parser.getText();
return result.text;
}

export async function extractPptxText(buffer: Buffer): Promise<string> {
const zip = await JSZip.loadAsync(buffer);
const slideKeys = Object.keys(zip.files)
.filter(name => /^ppt\/slides\/slide\d+\.xml$/.test(name))
.sort();

const texts: string[] = [];
for (const key of slideKeys) {
const xml = await zip.files[key].async("string");
const matches = xml.match(/<a:t[^>]*>([^<]*)<\/a:t>/g) ?? [];
const slideText = matches
.map(m => m.replace(/<[^>]+>/g, ""))
.join(" ")
.trim();
if (slideText) texts.push(slideText);
}
return texts.join("\n");
}
Loading