feat: initial commit

This commit is contained in:
2025-07-05 00:07:04 +02:00
commit 2668db0f18
14 changed files with 3679 additions and 0 deletions

564
App.tsx Normal file
View File

@ -0,0 +1,564 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Dialog } from "@headlessui/react";
import { Cog6ToothIcon } from "@heroicons/react/24/outline";
import { Exercise, CheckResult } from './types';
import { exercises as structExercises } from './data/exercises';
import { programmingExercises } from './data/programming_exercises';
import { GoogleGenAI, GenerateContentResponse } from "@google/genai";
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import * as Diff from 'diff';
import Editor from 'react-simple-code-editor';
import Prism from 'prismjs/components/prism-core';
import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-c';
// --- GEMINI API SETUP & HELPERS ---
// (ai instance will be created in App component)
const extractJson = (text: string): any => {
let jsonString = text.trim();
const fenceRegex = /^```(?:json)?\s*\n?(.*?)\n?\s*```$/s;
const match = jsonString.match(fenceRegex);
if (match && match[1]) {
jsonString = match[1].trim();
}
const lastBracket = jsonString.lastIndexOf('}');
if (lastBracket === -1) {
throw new Error("No closing brace '}' found in API response.");
}
let openBraceCount = 0;
let firstBracket = -1;
for (let i = lastBracket; i >= 0; i--) {
if (jsonString[i] === '}') {
openBraceCount++;
} else if (jsonString[i] === '{') {
openBraceCount--;
}
if (openBraceCount === 0) {
firstBracket = i;
break;
}
}
if (firstBracket === -1) {
throw new Error("Could not find matching opening brace '{' in API response.");
}
const finalJsonString = jsonString.substring(firstBracket, lastBracket + 1);
return JSON.parse(finalJsonString);
};
const evaluateWithGemini = async (
ai: GoogleGenAI,
userCode: string,
solutionCode: string,
prompt: string
): Promise<{ correct: boolean, explanation: string }> => {
const systemInstruction = `You are an expert C programming tutor. Your task is to evaluate a user's code against a model solution for a specific exercise.
The user's code might be functionally equivalent even if it looks different. Check for semantic correctness. For example, the order of members in a struct definition does not matter. Variable names can be different as long as they are used consistently and correctly. For programming tasks, focus on whether the user's code fulfills all requirements of the prompt.
The exercise part is: "${prompt}"
Analyze the user's C code and compare it to the provided solution.
Respond ONLY with a single JSON object in the format:
{
"correct": <boolean>,
"explanation": "<string>"
}
- "correct": true if the user's code is a valid and correct solution, false otherwise.
- "explanation": If correct, provide a brief confirmation. If incorrect, provide a clear, concise explanation of what is wrong. Use markdown for formatting, especially for code blocks. **Your entire explanation must be in German.**`;
const contents = `Here is the user's code:\n\`\`\`c\n${userCode}\n\`\`\`\n\nHere is the model solution for reference:\n\`\`\`c\n${solutionCode}\n\`\`\`\n\nPlease evaluate the user's code and provide your response in the specified JSON format.`;
const response: GenerateContentResponse = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: contents,
config: {
systemInstruction: systemInstruction,
responseMimeType: "application/json",
},
});
try {
const jsonResponse = extractJson(response.text);
if (typeof jsonResponse.correct !== 'boolean' || typeof jsonResponse.explanation !== 'string') {
throw new Error("Invalid JSON structure from API.");
}
return jsonResponse;
} catch (e) {
console.error("Failed to parse Gemini response:", e);
console.error("Raw response text:", response.text);
throw new Error(`Could not parse the evaluation response.`);
}
}
// --- UI ICONS ---
const CheckIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
);
const CrossIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
const WarningIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
);
// --- SUB-COMPONENTS ---
const CodeEditor: React.FC<{
value: string;
onChange: (value: string) => void;
placeholder?: string;
}> = ({ value, onChange, placeholder }) => {
return (
<div className="bg-slate-900 rounded-lg border-2 border-slate-700 focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 overflow-hidden">
<Editor
value={value}
onValueChange={onChange}
highlight={code => Prism.highlight(code, Prism.languages.c, 'c')}
padding={12}
placeholder={placeholder}
textareaClassName="code-editor-textarea"
className="caret-white"
style={{
fontFamily: '"Roboto Mono", monospace',
fontSize: 14,
lineHeight: 1.5,
minHeight: '12rem',
color: '#f8f8f2',
}}
/>
</div>
);
};
interface CodeDiffViewerProps {
oldCode: string;
newCode: string;
}
const CodeDiffViewer: React.FC<CodeDiffViewerProps> = ({ oldCode, newCode }) => {
const differences = Diff.diffLines(oldCode.trim(), newCode.trim());
return (
<pre className="bg-slate-800 text-slate-200 p-3 rounded-md font-mono text-xs max-h-64 overflow-auto">
<code>
{differences.map((part, index) => {
const className = part.added
? 'bg-green-500/20'
: part.removed
? 'bg-red-500/20'
: '';
const prefix = part.added ? '+' : part.removed ? '-' : ' ';
const lines = part.value.replace(/\n$/, '').split('\n');
return (
<span key={index} className={`block ${className}`}>
{lines.map((line, i) => (
<div key={i} className="flex">
<span className={`w-5 flex-shrink-0 text-left pl-1 ${part.added ? 'text-green-400' : part.removed ? 'text-red-400' : 'text-slate-500'}`}>{prefix}</span>
<span className="flex-grow whitespace-pre-wrap">{line}</span>
</div>
))}
</span>
);
})}
</code>
</pre>
);
};
interface ExerciseSheetProps {
exercise: Exercise;
onNext: () => void;
ai: GoogleGenAI | null;
}
const ExerciseSheet: React.FC<ExerciseSheetProps> = ({ exercise, onNext, ai }) => {
const [inputs, setInputs] = useState<Record<string, string>>({});
const [results, setResults] = useState<CheckResult[] | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setInputs({});
setResults(null);
setIsLoading(false);
}, [exercise]);
const handleInputChange = (partId: string, value: string) => {
setInputs(prev => ({ ...prev, [partId]: value }));
};
const handleSubmit = async () => {
setIsLoading(true);
setResults(null);
const partsToEvaluate = exercise.parts.map(part => ({
...part,
userInput: inputs[part.id] || '',
}));
const evaluationPromises = partsToEvaluate.map(part => {
if (!part.userInput.trim()) {
return Promise.resolve({
partId: part.id,
isCorrect: false,
explanation: "Keine Eingabe gemacht."
});
}
if (!ai || typeof ai.models !== "object" || !ai.models.generateContent) {
return Promise.resolve({
partId: part.id,
isCorrect: false,
explanation: "API-Key fehlt oder ungültig, oder Gemini SDK nicht korrekt geladen.",
error: "Gemini API nicht verfügbar"
});
}
return evaluateWithGemini(ai, part.userInput, part.solution, part.prompt)
.then(evalResult => ({
partId: part.id,
isCorrect: evalResult.correct,
explanation: evalResult.explanation,
}))
.catch(e => ({
partId: part.id,
isCorrect: false,
explanation: '',
error: e instanceof Error ? e.message : 'An unknown error occurred.'
}));
});
const finalResults = await Promise.all(evaluationPromises);
setResults(finalResults);
setIsLoading(false);
};
const handleReset = () => {
setInputs({});
setResults(null);
setIsLoading(false);
}
const handleNextExercise = () => {
onNext();
}
const hasEvaluationErrors = results?.some(r => r.error);
return (
<div className="bg-white rounded-lg shadow-xl p-8 md:p-12 font-sans w-full relative">
{isLoading && (
<div className="absolute inset-0 bg-white/80 backdrop-blur-sm flex flex-col items-center justify-center z-20 rounded-lg">
<svg className="animate-spin h-12 w-12 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="mt-4 font-serif text-lg text-slate-700">Evaluating your code with Gemini...</p>
</div>
)}
<header className="border-b pb-4 mb-6">
<h1 className="font-serif text-2xl md:text-3xl font-bold text-slate-800">{exercise.title}</h1>
<p className="text-slate-500">Prüfung Programmieren - C</p>
</header>
{!results ? (
// --- EXERCISE VIEW ---
<div className="space-y-8">
{exercise.parts.map(part => (
<div key={part.id} className="flex flex-col md:flex-row gap-4">
<div className="w-full md:w-2/5">
<h2 className="font-serif font-bold text-lg text-slate-700">Teil {part.id}) ({part.points}P)</h2>
<div className="mt-2 prose prose-slate prose-sm max-w-none prose-th:text-slate-900 prose-td:text-slate-800">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{part.prompt}</ReactMarkdown>
</div>
</div>
<div className="w-full md:w-3/5">
<CodeEditor
value={inputs[part.id] || ''}
onChange={(value) => handleInputChange(part.id, value)}
placeholder={`// Code für Teil ${part.id}) hier eingeben...`}
/>
</div>
</div>
))}
<div className="flex justify-end pt-6">
<button onClick={handleSubmit} disabled={isLoading} className="px-8 py-3 bg-blue-600 text-white font-bold rounded-lg hover:bg-blue-700 transition-transform transform hover:scale-105 shadow-lg disabled:bg-slate-400 disabled:scale-100 disabled:cursor-not-allowed">
Abgeben & Prüfen
</button>
</div>
</div>
) : (
// --- RESULT VIEW ---
<div className="space-y-6">
<h2 className="font-serif text-2xl font-bold text-slate-800 border-b pb-2">Auswertung</h2>
{results.map(result => {
const part = exercise.parts.find(p => p.id === result.partId)!;
const userInput = inputs[part.id] || '';
if (result.error) {
return (
<div key={result.partId} className="p-4 rounded-lg border border-amber-500 bg-amber-50">
<div className="flex items-center gap-3 mb-2">
<WarningIcon className="w-8 h-8 text-amber-600 bg-amber-100 rounded-full p-1" />
<h3 className="font-serif font-bold text-xl text-amber-800">Teil {part.id}) - Evaluation Failed</h3>
</div>
<div className="pl-11">
<p className="text-sm text-amber-700 mb-4">Error: {result.error}</p>
<h4 className="font-bold text-sm mb-1 text-slate-600">Ihre Eingabe:</h4>
<pre className="bg-slate-100 text-slate-800 p-3 rounded-md font-mono text-xs max-h-48 overflow-auto">{userInput || '(Keine Eingabe)'}</pre>
</div>
</div>
);
}
return (
<div key={result.partId} className={`p-4 rounded-lg border ${result.isCorrect ? 'border-green-500 bg-green-50' : 'border-red-500 bg-red-50'}`}>
<div className="flex items-center gap-3 mb-2 flex-wrap">
{result.isCorrect ? <CheckIcon className="w-6 h-6 text-green-600" /> : <CrossIcon className="w-6 h-6 text-red-600" />}
<h3 className="font-serif font-bold text-xl text-slate-700">Teil {part.id})</h3>
<span className={`px-3 py-1 text-xs font-bold rounded-full ${result.isCorrect ? 'bg-green-200 text-green-800' : 'bg-red-200 text-red-800'}`}>
{result.isCorrect ? "Korrekt" : "Fehlerhaft"}
</span>
</div>
<div className="prose prose-slate prose-sm max-w-none pl-9 prose-th:text-slate-900 prose-td:text-slate-800">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{result.explanation}</ReactMarkdown>
</div>
{!result.isCorrect && userInput.trim() && (
<div className="mt-4 pl-9">
<h4 className="font-bold text-sm mb-2 text-slate-600">Vergleich Ihrer Eingabe mit der Musterlösung:</h4>
<div className="flex gap-2 text-xs mb-2 items-center">
<span className="flex items-center gap-1.5 px-2 py-0.5 bg-red-500/20 text-red-300 rounded"><span className="font-mono bg-red-900/50 text-red-200 rounded-full w-4 h-4 text-center leading-4">-</span> Ihre Eingabe</span>
<span className="flex items-center gap-1.5 px-2 py-0.5 bg-green-500/20 text-green-300 rounded"><span className="font-mono bg-green-900/50 text-green-200 rounded-full w-4 h-4 text-center leading-4">+</span> Korrekte Lösung</span>
</div>
<CodeDiffViewer oldCode={userInput} newCode={part.solution} />
</div>
)}
</div>
);
})}
{hasEvaluationErrors && (
<div className="mt-6 p-4 rounded-md bg-yellow-100 border border-yellow-300 text-center">
<p className="text-yellow-800 font-medium">Bei einigen Teilen ist ein Fehler bei der Auswertung aufgetreten. Sie können es erneut versuchen.</p>
</div>
)}
<div className="mt-8 pt-6 border-t">
<div className="prose prose-slate prose-sm max-w-none prose-th:text-slate-900 prose-td:text-slate-800">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{exercise.explanation}</ReactMarkdown>
</div>
</div>
<div className="flex justify-between items-center pt-6 flex-wrap gap-4">
<button onClick={handleReset} className="px-6 py-2 bg-slate-200 text-slate-800 font-bold rounded-lg hover:bg-slate-300 transition">
Nochmal versuchen
</button>
{hasEvaluationErrors && (
<button onClick={handleSubmit} disabled={isLoading} className="px-8 py-3 bg-amber-500 text-white font-bold rounded-lg hover:bg-amber-600 transition-transform transform hover:scale-105 shadow-lg disabled:bg-slate-400 disabled:scale-100">
Erneut auswerten
</button>
)}
<button onClick={handleNextExercise} className="px-8 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 transition-transform transform hover:scale-105 shadow-lg">
Nächste Aufgabe
</button>
</div>
</div>
)}
</div>
);
};
// --- MAIN APP COMPONENT ---
type Category = 'structs' | 'programming';
const exerciseData: Record<Category, Exercise[]> = {
structs: structExercises,
programming: programmingExercises
};
export default function App() {
const [exerciseSets, setExerciseSets] = useState<Record<Category, Exercise[]>>({structs: [], programming: []});
const [category, setCategory] = useState<Category>('structs');
const [currentIndex, setCurrentIndex] = useState(0);
// --- API KEY STATE ---
const [apiKey, setApiKey] = useState<string>('');
const [showSettings, setShowSettings] = useState(false);
const [inputKey, setInputKey] = useState('');
// On mount, load API key from localStorage or env
useEffect(() => {
const stored = localStorage.getItem('gemini_api_key');
if (stored) {
setApiKey(stored);
} else if (process.env.API_KEY) {
setApiKey(process.env.API_KEY);
}
}, []);
// Gemini API instance (re-created if key changes)
const ai = useMemo(() => apiKey ? new GoogleGenAI({ apiKey }) : null, [apiKey]);
// Save API key to localStorage and state
const handleSaveKey = () => {
localStorage.setItem('gemini_api_key', inputKey);
setApiKey(inputKey);
setShowSettings(false);
};
// UI logic
useEffect(() => {
if (showSettings) setInputKey(apiKey || '');
}, [showSettings, apiKey]);
useEffect(() => {
const shuffledStructs = [...exerciseData.structs].sort(() => Math.random() - 0.5);
const shuffledProgramming = [...exerciseData.programming].sort(() => Math.random() - 0.5);
setExerciseSets({
structs: shuffledStructs,
programming: shuffledProgramming
});
}, []);
const handleCategoryChange = (newCategory: Category) => {
if (newCategory !== category) {
setCategory(newCategory);
setCurrentIndex(0);
}
}
const exercisesForCategory = useMemo(() => exerciseSets[category], [exerciseSets, category]);
const handleNext = useCallback(() => {
setCurrentIndex(prev => (prev + 1) % (exercisesForCategory.length || 1));
}, [exercisesForCategory.length]);
const currentExercise = useMemo(() => exercisesForCategory[currentIndex], [exercisesForCategory, currentIndex]);
if (exercisesForCategory.length === 0 || !currentExercise) {
return <div className="flex items-center justify-center h-screen text-xl font-serif">Lade Übungen...</div>;
}
const getTabClass = (tabCategory: Category) => {
const isActive = category === tabCategory;
return `px-4 py-3 text-sm md:text-base font-medium transition-all duration-200 border-b-2 focus:outline-none ${
isActive
? 'border-blue-600 text-blue-600'
: 'border-transparent text-slate-500 hover:text-slate-800 hover:border-slate-300'
}`;
}
const getExerciseButtonTitle = (ex: Exercise) => {
const parts = ex.title.split(/ - |: /);
return (parts.pop() || ex.title).trim();
}
return (
<div className="min-h-screen flex flex-col p-4 md:p-8">
{/* Settings Button */}
<button
className="fixed top-4 right-4 z-50 bg-white rounded-full p-2 shadow hover:bg-slate-100 transition"
aria-label="API Key Settings"
onClick={() => setShowSettings(true)}
>
<Cog6ToothIcon className="w-7 h-7 text-slate-600" />
</button>
{/* Settings Modal */}
<Dialog open={showSettings} onClose={() => setShowSettings(false)} className="relative z-50">
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full">
<Dialog.Title className="font-bold text-lg mb-4 flex items-center gap-2">
<Cog6ToothIcon className="w-6 h-6 text-blue-600" />
Gemini API Key
</Dialog.Title>
<div className="mb-4">
<input
type="text"
className="w-full border rounded px-3 py-2 text-slate-800"
value={inputKey}
onChange={e => setInputKey(e.target.value)}
placeholder="Enter Gemini API Key"
/>
</div>
<div className="flex justify-end gap-2">
<button
className="px-4 py-2 rounded bg-slate-200 text-slate-700 hover:bg-slate-300"
onClick={() => setShowSettings(false)}
>
Cancel
</button>
<button
className="px-4 py-2 rounded bg-blue-600 text-white font-bold hover:bg-blue-700"
onClick={handleSaveKey}
disabled={!inputKey.trim()}
>
Save
</button>
</div>
<div className="mt-4 text-xs text-slate-500">
Your API key is stored in your browser only.
</div>
</Dialog.Panel>
</div>
</Dialog>
<header className="mb-6 flex-shrink-0">
<nav className="flex space-x-1 border-b border-slate-200">
<button onClick={() => handleCategoryChange('structs')} className={getTabClass('structs')}>
Structs
</button>
<button onClick={() => handleCategoryChange('programming')} className={getTabClass('programming')}>
Programmieren
</button>
</nav>
</header>
<div className="flex flex-col md:flex-row gap-8 flex-grow min-h-0">
<aside className="w-full md:w-64 flex-shrink-0">
<h2 className="font-serif text-xl font-bold text-slate-700 mb-4 capitalize">{category} Übungen</h2>
<nav className="space-y-2">
{exercisesForCategory.map((ex, index) => (
<button
key={ex.id}
onClick={() => setCurrentIndex(index)}
className={`w-full text-left px-4 py-2 rounded-md transition duration-150 text-sm ${
ex.id === currentExercise.id
? 'bg-blue-600 text-white font-bold shadow'
: 'bg-white text-slate-700 hover:bg-slate-200'
}`}
>
{getExerciseButtonTitle(ex)}
</button>
))}
</nav>
</aside>
<main className="flex-grow flex items-start justify-center min-w-0">
<div className="w-full max-w-4xl">
<ExerciseSheet exercise={currentExercise} onNext={handleNext} ai={ai} />
</div>
</main>
</div>
</div>
);
}