Skip to content

Commit 5dcefb1

Browse files
committed
Chapter 4: Full custom artifact implementations
Replace placeholder flashcard and study-plan artifacts with complete interactive implementations: Flashcard artifact: - FlashcardViewer with quiz navigation and score tracking - Answer selection with correct/incorrect feedback - Explanation display after each answer - Server handler with Zod schema validation Study Plan artifact: - StudyPlanViewer with expandable week sections - Task completion toggle with persistence - Progress bar with percentage calculation - Server handler for plan generation and updates Both artifacts now have: - Full onStreamPart handlers for real-time streaming - Actions (copy to clipboard) - Toolbar buttons for modifications
1 parent 2dac250 commit 5dcefb1

File tree

4 files changed

+630
-83
lines changed

4 files changed

+630
-83
lines changed

artifacts/flashcard/client.tsx

Lines changed: 209 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,223 @@
11
"use client";
22

3+
import { useState } from "react";
4+
import { toast } from "sonner";
35
import { Artifact } from "@/components/create-artifact";
6+
import { DocumentSkeleton } from "@/components/document-skeleton";
7+
import { CopyIcon, RefreshCwIcon } from "@/components/icons";
8+
import { Button } from "@/components/ui/button";
9+
import { cn } from "@/lib/utils";
10+
import type { FlashcardData } from "./server";
411

5-
// Placeholder flashcard artifact - full implementation coming in Chapter 4
6-
export const flashcardArtifact = new Artifact<"flashcard">({
7-
kind: "flashcard",
8-
description: "Interactive quiz flashcards",
9-
onStreamPart: ({ streamPart, setArtifact }) => {
10-
if (streamPart.type === "data-flashcardDelta") {
11-
setArtifact((draftArtifact) => ({
12-
...draftArtifact,
13-
content: streamPart.data,
14-
isVisible: true,
15-
status: "streaming",
16-
}));
12+
type FlashcardMetadata = Record<string, never>;
13+
14+
function FlashcardViewer({
15+
content,
16+
isLoading,
17+
}: {
18+
content: string;
19+
isLoading: boolean;
20+
}) {
21+
const [currentIndex, setCurrentIndex] = useState(0);
22+
const [selectedAnswer, setSelectedAnswer] = useState<number | null>(null);
23+
const [showExplanation, setShowExplanation] = useState(false);
24+
const [score, setScore] = useState({ correct: 0, total: 0 });
25+
26+
console.log(
27+
`[FlashcardViewer] Render - isLoading: ${isLoading}, content length: ${content?.length || 0}`
28+
);
29+
30+
if (isLoading || !content) {
31+
console.log(
32+
`[FlashcardViewer] Showing skeleton (isLoading=${isLoading}, hasContent=${!!content})`
33+
);
34+
return <DocumentSkeleton artifactKind="flashcard" />;
35+
}
36+
37+
let data: FlashcardData;
38+
try {
39+
data = JSON.parse(content);
40+
console.log(
41+
`[FlashcardViewer] Parsed ${data.questions?.length || 0} questions for topic: ${data.topic}`
42+
);
43+
} catch (error) {
44+
console.error("[FlashcardViewer] JSON parse error:", error);
45+
return (
46+
<div className="flex h-full items-center justify-center p-8">
47+
<p className="text-muted-foreground">Invalid flashcard data</p>
48+
</div>
49+
);
50+
}
51+
52+
const currentQuestion = data.questions[currentIndex];
53+
const isLastQuestion = currentIndex === data.questions.length - 1;
54+
const isAnswered = selectedAnswer !== null;
55+
56+
const handleSelectAnswer = (index: number) => {
57+
if (isAnswered) {
58+
return;
1759
}
18-
},
19-
content: ({ content, status }) => {
20-
if (status === "streaming" || !content) {
21-
return (
22-
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
23-
<div className="text-muted-foreground">Generating quiz...</div>
24-
<div className="h-2 w-48 animate-pulse rounded bg-muted-foreground/20" />
25-
</div>
26-
);
60+
setSelectedAnswer(index);
61+
setShowExplanation(true);
62+
setScore((prev) => ({
63+
correct: prev.correct + (index === currentQuestion.correctAnswer ? 1 : 0),
64+
total: prev.total + 1,
65+
}));
66+
};
67+
68+
const handleNext = () => {
69+
if (isLastQuestion) {
70+
// Reset quiz
71+
setCurrentIndex(0);
72+
setSelectedAnswer(null);
73+
setShowExplanation(false);
74+
setScore({ correct: 0, total: 0 });
75+
} else {
76+
setCurrentIndex((prev) => prev + 1);
77+
setSelectedAnswer(null);
78+
setShowExplanation(false);
2779
}
80+
};
81+
82+
const optionLabels = ["A", "B", "C", "D"];
2883

29-
// Parse and display basic quiz data
30-
try {
31-
const data = JSON.parse(content);
32-
return (
33-
<div className="flex h-full flex-col gap-4 overflow-y-auto p-8">
34-
<h2 className="font-bold text-xl">{data.topic}</h2>
35-
<p className="text-muted-foreground">
36-
{data.questions?.length || 0} questions generated
84+
return (
85+
<div className="flex h-full flex-col p-6 md:p-10">
86+
{/* Header */}
87+
<div className="mb-6 flex items-center justify-between">
88+
<div>
89+
<h2 className="font-semibold text-lg">{data.topic}</h2>
90+
<p className="text-muted-foreground text-sm">
91+
Question {currentIndex + 1} of {data.questions.length}
3792
</p>
38-
<div className="rounded-lg border bg-muted/50 p-4">
39-
<p className="text-muted-foreground text-sm">
40-
Full flashcard UI coming in Chapter 4
41-
</p>
42-
</div>
4393
</div>
44-
);
45-
} catch {
46-
return (
47-
<div className="flex h-full items-center justify-center p-8">
48-
<p className="text-muted-foreground">Loading flashcard data...</p>
94+
<div className="rounded-full bg-primary/10 px-3 py-1 font-medium text-sm">
95+
Score: {score.correct}/{score.total}
96+
</div>
97+
</div>
98+
99+
{/* Question */}
100+
<div className="mb-6 rounded-lg border bg-card p-6">
101+
<p className="font-medium text-lg">{currentQuestion.question}</p>
102+
</div>
103+
104+
{/* Options */}
105+
<div className="mb-6 grid gap-3">
106+
{currentQuestion.options.map((option, index) => {
107+
const isCorrect = index === currentQuestion.correctAnswer;
108+
const isSelected = selectedAnswer === index;
109+
110+
return (
111+
<button
112+
className={cn(
113+
"flex items-center gap-3 rounded-lg border p-4 text-left transition-all",
114+
!isAnswered && "hover:border-primary hover:bg-accent",
115+
isAnswered &&
116+
isCorrect &&
117+
"border-green-500 bg-green-50 dark:bg-green-950",
118+
isAnswered &&
119+
isSelected &&
120+
!isCorrect &&
121+
"border-red-500 bg-red-50 dark:bg-red-950",
122+
isAnswered && !isSelected && !isCorrect && "opacity-50"
123+
)}
124+
disabled={isAnswered}
125+
key={`option-${currentIndex}-${option}`}
126+
onClick={() => handleSelectAnswer(index)}
127+
type="button"
128+
>
129+
<span
130+
className={cn(
131+
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full border font-medium",
132+
isAnswered &&
133+
isCorrect &&
134+
"border-green-500 bg-green-500 text-white",
135+
isAnswered &&
136+
isSelected &&
137+
!isCorrect &&
138+
"border-red-500 bg-red-500 text-white"
139+
)}
140+
>
141+
{optionLabels[index]}
142+
</span>
143+
<span>{option}</span>
144+
</button>
145+
);
146+
})}
147+
</div>
148+
149+
{/* Explanation */}
150+
{showExplanation && (
151+
<div className="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950">
152+
<p className="font-medium text-blue-900 text-sm dark:text-blue-100">
153+
{selectedAnswer === currentQuestion.correctAnswer
154+
? "Correct!"
155+
: `Incorrect. The correct answer is ${optionLabels[currentQuestion.correctAnswer]}.`}
156+
</p>
157+
<p className="mt-2 text-blue-800 text-sm dark:text-blue-200">
158+
{currentQuestion.explanation}
159+
</p>
160+
</div>
161+
)}
162+
163+
{/* Navigation */}
164+
{isAnswered && (
165+
<div className="mt-auto">
166+
<Button className="w-full" onClick={handleNext}>
167+
{isLastQuestion ? "Restart Quiz" : "Next Question"}
168+
</Button>
49169
</div>
170+
)}
171+
</div>
172+
);
173+
}
174+
175+
export const flashcardArtifact = new Artifact<"flashcard", FlashcardMetadata>({
176+
kind: "flashcard",
177+
description: "Interactive flashcard quiz for testing knowledge.",
178+
onStreamPart: ({ streamPart, setArtifact }) => {
179+
console.log(`[FlashcardArtifact] onStreamPart: ${streamPart.type}`);
180+
if (streamPart.type === "data-flashcardDelta") {
181+
const contentLength = (streamPart.data as string)?.length || 0;
182+
console.log(
183+
`[FlashcardArtifact] Setting content: ${contentLength} chars`
50184
);
185+
setArtifact((draft) => ({
186+
...draft,
187+
content: streamPart.data,
188+
isVisible: true,
189+
status: "streaming",
190+
}));
51191
}
52192
},
53-
actions: [],
54-
toolbar: [],
193+
content: ({ content, isLoading }) => (
194+
<FlashcardViewer content={content} isLoading={isLoading} />
195+
),
196+
actions: [
197+
{
198+
icon: <CopyIcon size={18} />,
199+
description: "Copy quiz data",
200+
onClick: ({ content }) => {
201+
navigator.clipboard.writeText(content);
202+
toast.success("Quiz data copied to clipboard!");
203+
},
204+
},
205+
],
206+
toolbar: [
207+
{
208+
icon: <RefreshCwIcon size={18} />,
209+
description: "Generate new questions",
210+
onClick: ({ sendMessage }) => {
211+
sendMessage({
212+
role: "user",
213+
parts: [
214+
{
215+
type: "text",
216+
text: "Generate different questions on the same topic.",
217+
},
218+
],
219+
});
220+
},
221+
},
222+
],
55223
});

artifacts/flashcard/server.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { generateObject } from "ai";
2+
import { z } from "zod";
3+
import { myProvider } from "@/lib/ai/providers";
4+
import { createDocumentHandler } from "@/lib/artifacts/server";
5+
6+
const flashcardSchema = z.object({
7+
topic: z.string(),
8+
questions: z.array(
9+
z.object({
10+
question: z.string(),
11+
options: z.array(z.string()).length(4),
12+
correctAnswer: z.number().min(0).max(3),
13+
explanation: z.string(),
14+
})
15+
),
16+
});
17+
18+
export type FlashcardData = z.infer<typeof flashcardSchema>;
19+
20+
export const flashcardDocumentHandler = createDocumentHandler<"flashcard">({
21+
kind: "flashcard",
22+
onCreateDocument: async ({ title, dataStream }) => {
23+
const { object } = await generateObject({
24+
model: myProvider.languageModel("artifact-model"),
25+
schema: flashcardSchema,
26+
prompt: `Create a quiz with 5 multiple choice questions about: "${title}"
27+
28+
Each question should:
29+
- Test understanding, not just memorization
30+
- Have 4 options (A, B, C, D)
31+
- Have a clear correct answer
32+
- Include a brief explanation of why the answer is correct
33+
34+
Return the quiz as structured JSON.`,
35+
});
36+
37+
const content = JSON.stringify(object, null, 2);
38+
39+
// Stream the content as a single delta
40+
dataStream.write({
41+
type: "data-flashcardDelta",
42+
data: content,
43+
transient: true,
44+
});
45+
46+
return content;
47+
},
48+
onUpdateDocument: async ({ document, description, dataStream }) => {
49+
const currentData = JSON.parse(document.content || "{}") as FlashcardData;
50+
51+
const { object } = await generateObject({
52+
model: myProvider.languageModel("artifact-model"),
53+
schema: flashcardSchema,
54+
prompt: `Update this quiz based on the request: "${description}"
55+
56+
Current quiz:
57+
${JSON.stringify(currentData, null, 2)}
58+
59+
Make the requested changes while maintaining the quiz structure.`,
60+
});
61+
62+
const content = JSON.stringify(object, null, 2);
63+
64+
dataStream.write({
65+
type: "data-flashcardDelta",
66+
data: content,
67+
transient: true,
68+
});
69+
70+
return content;
71+
},
72+
});

0 commit comments

Comments
 (0)