Skip to content

Commit 2c4558c

Browse files
committed
feat: Add interactive AI hints and SEO meta tags
Introduces Gemini AI integration for generating personalized hints during coding challenges. Enhances user experience by providing context-aware assistance. Additionally, implements comprehensive SEO meta tags in `index.html` to improve discoverability and social sharing. This includes descriptions, keywords, author information, and Open Graph/Twitter card configurations. Updates `ReadingSection` to format code snippets correctly and `ChapterSection` to manage AI hint states. The `geminiService` is refactored for clarity and improved prompt engineering.
1 parent d0b345f commit 2c4558c

File tree

5 files changed

+128
-74
lines changed

5 files changed

+128
-74
lines changed

components/ChapterSection.tsx

Lines changed: 31 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
21
import React, { useState } from 'react';
32
import { ChapterData, PageData } from '../types';
43
import CodePlayground from './CodePlayground';
54
import PreviewBox from './PreviewBox';
65
import ReadingSection from './ReadingSection';
6+
import { getGeminiExplanation } from '../services/geminiService';
77

88
interface ChapterSectionProps {
99
data: ChapterData;
@@ -22,9 +22,8 @@ const ChapterSection: React.FC<ChapterSectionProps> = ({
2222
const [completedPages, setCompletedPages] = useState<Record<string, boolean>>({});
2323
const [userCodes, setUserCodes] = useState<Record<string, string>>({});
2424
const [errors, setErrors] = useState<Record<string, string | null>>({});
25-
26-
// Stores which hint index (1-based) the user is currently on for a specific page
27-
const [hintSteps, setHintSteps] = useState<Record<string, number>>({});
25+
const [hints, setHints] = useState<Record<string, string>>({});
26+
const [loadingHints, setLoadingHints] = useState<Record<string, boolean>>({});
2827

2928
const handleRun = (page: PageData, code: string) => {
3029
if (!page.challenge) return;
@@ -45,30 +44,27 @@ const ChapterSection: React.FC<ChapterSectionProps> = ({
4544
}
4645
};
4746

48-
const revealHint = (pageId: string) => {
49-
setHintSteps(prev => {
50-
const current = prev[pageId] || 0;
51-
return { ...prev, [pageId]: current + 1 };
52-
});
47+
const askWizard = async (page: PageData) => {
48+
if (!page.challenge) return;
49+
50+
setLoadingHints(prev => ({ ...prev, [page.id]: true }));
51+
const code = userCodes[page.id] || "empty";
52+
const help = await getGeminiExplanation(data.title + ": " + page.challenge.subtitle, code);
53+
setHints(prev => ({ ...prev, [page.id]: help }));
54+
setLoadingHints(prev => ({ ...prev, [page.id]: false }));
5355
};
5456

5557
return (
5658
<div id={`chapter-${data.id}`} className="w-full">
59+
{/* Chapter Title Slide (Optional, integrated into first page or just distinct) */}
60+
5761
{data.pages.map((page) => {
58-
59-
// Hint Logic
60-
const currentHintStep = hintSteps[page.id] || 0;
61-
const maxHints = page.challenge?.hints?.length || 0;
62-
const activeHintText = currentHintStep > 0 && page.challenge?.hints
63-
? page.challenge.hints[Math.min(currentHintStep, maxHints) - 1]
64-
: null;
65-
6662
// Wrapper for snap behavior
6763
return (
68-
<div key={page.id} className="min-h-screen w-full snap-start snap-always flex items-center justify-center p-4 md:p-8 relative">
64+
<div key={page.id} className="min-h-screen w-full snap-start flex items-center justify-center p-4 md:p-8 relative">
6965

7066
{/* Chapter Indicator Watermark */}
71-
<div className={`absolute top-4 left-4 md:top-8 md:left-8 px-4 py-1 rounded-full border border-black/20 font-bold text-sm opacity-50 ${data.color} z-20`}>
67+
<div className={`absolute top-4 left-4 md:top-8 md:left-8 px-4 py-1 rounded-full border border-black/20 font-bold text-sm opacity-50 ${data.color}`}>
7268
CH.{data.id} - {data.title}
7369
</div>
7470

@@ -79,8 +75,8 @@ const ChapterSection: React.FC<ChapterSectionProps> = ({
7975
<div className="w-full max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-8 items-center h-full max-h-[90vh]">
8076

8177
{/* Left: Challenge Code */}
82-
<div className="flex flex-col gap-6 h-full justify-center relative z-10 w-full">
83-
<div className="bg-white p-6 sketchy-border border-2 relative shadow-lg">
78+
<div className="flex flex-col gap-6 h-full justify-center">
79+
<div className="bg-white p-6 sketchy-border border-2 relative z-10 shadow-lg">
8480
<div className="absolute -top-3 -left-2 bg-black text-white px-3 py-1 text-xs font-bold rounded transform -rotate-3 shadow-md">
8581
CHALLENGE
8682
</div>
@@ -99,35 +95,21 @@ const ChapterSection: React.FC<ChapterSectionProps> = ({
9995
error={errors[page.id] || null}
10096
/>
10197

102-
{/* Hints System (Hardcoded) */}
103-
{!completedPages[page.id] && page.challenge?.hints && (
104-
<div className="w-full">
105-
{currentHintStep === 0 ? (
98+
{/* Hints */}
99+
{!completedPages[page.id] && (
100+
<div className="flex justify-start pl-2">
101+
{!hints[page.id] ? (
106102
<button
107-
onClick={() => revealHint(page.id)}
108-
className="w-full py-3 rounded-xl border-2 border-purple-200 bg-purple-50 hover:bg-purple-100 text-purple-700 font-bold transition-all flex items-center justify-center gap-2 shadow-sm group"
103+
onClick={() => askWizard(page)}
104+
disabled={loadingHints[page.id]}
105+
className="text-sm font-bold text-purple-600 hover:text-purple-800 transition-all flex items-center gap-2"
109106
>
110-
<span className="text-xl group-hover:scale-110 transition-transform">🧙‍♂️</span>
111-
Ask the Wizard for a Hint
107+
<span className="text-lg">🧙‍♂️</span>
108+
{loadingHints[page.id] ? "Consulting the orbs..." : "Need a hint?"}
112109
</button>
113110
) : (
114-
<div className="w-full bg-purple-50 p-4 rounded-xl border-2 border-purple-200 text-purple-900 shadow-sm animate-in fade-in slide-in-from-bottom-2 relative">
115-
<div className="flex justify-between items-start mb-2">
116-
<div className="font-bold text-xs text-purple-400 uppercase">
117-
Wizard's Wisdom ({Math.min(currentHintStep, maxHints)}/{maxHints})
118-
</div>
119-
{currentHintStep < maxHints && (
120-
<button
121-
onClick={() => revealHint(page.id)}
122-
className="text-xs font-bold text-purple-600 hover:text-purple-800 underline bg-purple-100 px-2 py-1 rounded"
123-
>
124-
Next Hint →
125-
</button>
126-
)}
127-
</div>
128-
<div className="font-hand text-lg leading-tight">
129-
{activeHintText}
130-
</div>
111+
<div className="bg-purple-50 p-3 rounded-lg border border-purple-200 text-purple-900 text-sm max-w-md animate-in fade-in slide-in-from-bottom-2">
112+
<strong>Wizard says:</strong> {hints[page.id]}
131113
</div>
132114
)}
133115
</div>
@@ -145,8 +127,10 @@ const ChapterSection: React.FC<ChapterSectionProps> = ({
145127
</div>
146128
);
147129
})}
130+
131+
{/* Optional: Next Chapter Button Page if needed, or just rely on scroll */}
148132
</div>
149133
);
150134
};
151135

152-
export default ChapterSection;
136+
export default ChapterSection;

components/ReadingSection.tsx

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,29 @@ interface ReadingSectionProps {
1616
data: PageData;
1717
}
1818

19+
// Utility to clean up template literal whitespace
20+
const stripIndent = (str: string) => {
21+
if (!str) return '';
22+
const lines = str.split('\n');
23+
// Find the minimum indentation of non-empty lines
24+
let minIndent = Infinity;
25+
for (const line of lines) {
26+
if (line.trim().length > 0) {
27+
const indent = line.search(/\S/);
28+
if (indent !== -1 && indent < minIndent) {
29+
minIndent = indent;
30+
}
31+
}
32+
}
33+
if (minIndent === Infinity) return str.trim();
34+
35+
// Remove that indentation from each line
36+
return lines.map(line => {
37+
if (line.trim().length === 0) return '';
38+
return line.slice(minIndent);
39+
}).join('\n').trim();
40+
};
41+
1942
const ReadingSection: React.FC<ReadingSectionProps> = ({ data }) => {
2043

2144
const renderIllustration = () => {
@@ -32,13 +55,14 @@ const ReadingSection: React.FC<ReadingSectionProps> = ({ data }) => {
3255
};
3356

3457
const hasCode = !!data.codeSnippet;
58+
const formattedCode = data.codeSnippet ? stripIndent(data.codeSnippet) : '';
3559

3660
return (
3761
<div className="w-full max-w-6xl mx-auto flex flex-col justify-center h-full max-h-[90vh]">
3862
<div className="sketchy-border bg-[#fffdf5] p-8 md:p-12 lg:p-16 relative shadow-[12px_12px_0px_rgba(0,0,0,0.1)] flex flex-col lg:flex-row gap-10 items-center h-full max-h-[80vh]">
3963

4064
{/* Text Content */}
41-
<div className={`${hasCode ? 'flex-1' : 'flex-[0.8]'} space-y-6 overflow-y-auto max-h-full pr-4 scroll-smooth custom-scrollbar flex flex-col justify-center`}>
65+
<div className={`${hasCode ? 'flex-[0.42]' : 'flex-[0.5]'} space-y-6 overflow-y-auto max-h-full pr-4 scroll-smooth custom-scrollbar flex flex-col justify-center`}>
4266
<div className="flex items-center gap-4 mb-2">
4367
<span className="w-10 h-10 bg-black text-white rounded-full flex items-center justify-center text-lg font-bold shadow-md flex-shrink-0">
4468
{data.id.split('-')[1]}
@@ -54,27 +78,27 @@ const ReadingSection: React.FC<ReadingSectionProps> = ({ data }) => {
5478
</div>
5579

5680
{/* Visuals / Code Side */}
57-
<div className={`flex-1 w-full flex flex-col items-center justify-center gap-8 h-full ${!hasCode ? 'bg-white/50 rounded-xl border-2 border-dashed border-black/10 p-8' : ''}`}>
81+
<div className={`${hasCode ? 'flex-[0.58]' : 'flex-[0.5]'} w-full flex flex-col items-center gap-8 h-full justify-center`}>
5882

5983
{data.illustration && (
60-
<div className={`${hasCode ? 'w-40 h-40 md:w-56 md:h-56' : 'w-64 h-64 md:w-96 md:h-96'} animate-float transition-all duration-500`}>
84+
<div className={`${hasCode ? 'w-32 h-32 md:w-40 md:h-40' : 'w-64 h-64 md:w-80 md:h-80'} animate-float transition-all duration-500`}>
6185
{renderIllustration()}
6286
</div>
6387
)}
6488

65-
{data.codeSnippet && (
66-
<div className="w-full max-w-lg relative group">
89+
{hasCode && (
90+
<div className="w-full max-w-xl relative group">
6791
{/* Tape effect */}
6892
<div className="absolute -top-3 left-1/2 -translate-x-1/2 w-20 h-6 bg-yellow-200 opacity-90 transform -rotate-1 shadow-sm z-10"></div>
6993

70-
<div className="bg-[#282c34] p-6 rounded-xl border-2 border-gray-800 shadow-xl overflow-hidden">
94+
<div className="bg-[#282c34] p-6 rounded-xl border-2 border-gray-800 shadow-xl overflow-hidden text-left">
7195
<div className="flex gap-1.5 mb-4 opacity-50">
7296
<div className="w-2.5 h-2.5 rounded-full bg-red-500"></div>
7397
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500"></div>
7498
<div className="w-2.5 h-2.5 rounded-full bg-green-500"></div>
7599
</div>
76-
<div className="overflow-x-auto">
77-
<CodeHighlight code={data.codeSnippet} />
100+
<div className="overflow-x-auto custom-scrollbar">
101+
<CodeHighlight code={formattedCode} />
78102
</div>
79103
</div>
80104
</div>

constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ Object.defineProperty(data, 'count', {
120120
<p>Vue uses a <b>Virtual DOM</b>—a lightweight sketch of your UI. It's just a tree of JavaScript objects called <b>VNodes</b>.</p>
121121
`,
122122
codeSnippet: `// Virtual Node (Cheap)
123+
123124
const vnode = {
124125
tag: 'div',
125126
children: 'Hello Vue'

index.html

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,43 @@
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6-
<title>Source Book: Mini Vue</title>
6+
<title>Source Book: Mini Vue - Interactive Source Code Journey</title>
7+
8+
<!-- SEO Meta Tags -->
9+
<meta name="description" content="An interactive, hand-drawn journey through the core concepts of Vue.js source code. Learn Reactivity, Virtual DOM, and Compilers by completing the missing logic." />
10+
<meta name="keywords" content="Vue.js, Source Code, Tutorial, Interactive, Learning, JavaScript, Frontend, Framework, Reactivity, VDOM, Vapor Mode" />
11+
<meta name="author" content="CoderSerio" />
12+
<meta name="robots" content="index, follow" />
13+
14+
<!-- Open Graph / Facebook -->
15+
<meta property="og:type" content="website" />
16+
<meta property="og:url" content="https://vue-source-book.vercel.app/" />
17+
<meta property="og:title" content="Source Book: Mini Vue" />
18+
<meta property="og:description" content="Master Vue.js internals through an interactive, hand-drawn coding adventure." />
19+
<meta property="og:image" content="https://vuejs.org/images/logo.png" /> <!-- Placeholder for actual OG image -->
20+
21+
<!-- Twitter -->
22+
<meta property="twitter:card" content="summary_large_image" />
23+
<meta property="twitter:url" content="https://vue-source-book.vercel.app/" />
24+
<meta property="twitter:title" content="Source Book: Mini Vue" />
25+
<meta property="twitter:description" content="Master Vue.js internals through an interactive, hand-drawn coding adventure." />
26+
<meta property="twitter:image" content="https://vuejs.org/images/logo.png" />
27+
28+
<!-- Structured Data (JSON-LD) -->
29+
<script type="application/ld+json">
30+
{
31+
"@context": "https://schema.org",
32+
"@type": "Course",
33+
"name": "Source Book: Mini Vue",
34+
"description": "An interactive course to learn Vue.js internal architecture.",
35+
"provider": {
36+
"@type": "Person",
37+
"name": "CoderSerio",
38+
"sameAs": "https://github.com/CoderSerio"
39+
}
40+
}
41+
</script>
42+
743
<script src="https://cdn.tailwindcss.com"></script>
844
<link rel="preconnect" href="https://fonts.googleapis.com">
945
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -73,6 +109,12 @@
73109
.snap-always {
74110
scroll-snap-stop: always;
75111
}
112+
113+
/* Custom formatting for code blocks */
114+
.formatted-code {
115+
white-space: pre; /* Respect indentation */
116+
tab-size: 2;
117+
}
76118
</style>
77119
<script type="importmap">
78120
{

services/geminiService.ts

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,31 @@
11
import { GoogleGenAI } from "@google/genai";
22

3+
// Initialize the client with the API key from the environment
34
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
45

5-
export async function getGeminiExplanation(context: string, code: string): Promise<string> {
6+
export const getGeminiExplanation = async (topic: string, userCode: string): Promise<string> => {
67
try {
8+
const prompt = `
9+
You are a helpful coding tutor for a Vue.js internal implementation course.
10+
The student is stuck on a challenge about: "${topic}".
11+
12+
Here is their current code:
13+
\`\`\`javascript
14+
${userCode}
15+
\`\`\`
16+
17+
Please provide a short, helpful hint (max 2 sentences) to guide them towards the correct implementation without giving away the full answer directly.
18+
Focus on the logic or syntax they might be missing.
19+
`;
20+
721
const response = await ai.models.generateContent({
822
model: 'gemini-2.5-flash',
9-
contents: `
10-
You are a friendly and helpful coding tutor wizard.
11-
The student is working on a challenge about Vue.js internals.
12-
13-
Context: ${context}
14-
Student's Code:
15-
${code}
16-
17-
The student is stuck and needs a hint.
18-
Provide a short, encouraging hint (max 2 sentences).
19-
Do not give the direct answer code, just a conceptual nudge or a small syntax tip if they are close.
20-
Adopt the persona of a wise wizard.
21-
`,
23+
contents: prompt,
2224
});
23-
return response.text || "The spirits are silent.";
25+
26+
return response.text || "I couldn't generate a hint right now. Try reviewing the previous reading section!";
2427
} catch (error) {
25-
console.error("Gemini API Error:", error);
26-
return "The wizard is currently unavailable.";
28+
console.error("Error fetching hint from Gemini:", error);
29+
return "The wizard is currently offline (API Error). Check your internet or API key.";
2730
}
28-
}
31+
};

0 commit comments

Comments
 (0)