735 lines
34 KiB
TypeScript
735 lines
34 KiB
TypeScript
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import { Dialog } from "@headlessui/react";
|
|
import { Cog6ToothIcon, ChatBubbleLeftRightIcon } from "@heroicons/react/24/outline";
|
|
import * as Sentry from "@sentry/react";
|
|
import { Exercise, CheckResult, ExercisePartType } from './types';
|
|
import { swaExercises1 } from './data/swa_exercises.1';
|
|
import { swaExercises2 } from './data/swa_exercises.2';
|
|
import { swaExercises3 } from './data/swa_exercises.3';
|
|
import { swaExercises4 } from './data/swa_exercises.4';
|
|
import { swaExercises5 } from './data/swa_exercises.5';
|
|
import { swaExercises6 } from './data/swa_exercises.6';
|
|
import { swaExercises7 } from './data/swa_exercises.7';
|
|
import { swaExercisesExamples } from './data/swa_exercises.examples';
|
|
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';
|
|
import mermaid from 'mermaid';
|
|
|
|
|
|
// --- GEMINI API SETUP & HELPERS ---
|
|
// (ai instance will be created in App component)
|
|
|
|
const extractJson = (text: string): any => {
|
|
let jsonString = text.trim();
|
|
|
|
// Remove BOM and other invisible characters
|
|
jsonString = jsonString.replace(/^\uFEFF/, ''); // Remove BOM
|
|
jsonString = jsonString.replace(/^[\u200B-\u200D\uFEFF]/g, ''); // Remove zero-width characters
|
|
|
|
const fenceRegex = /^```(?:json)?\s*\n?(.*?)\n?\s*```$/s;
|
|
const match = jsonString.match(fenceRegex);
|
|
if (match && match[1]) {
|
|
jsonString = match[1].trim();
|
|
}
|
|
|
|
// Try to parse directly first (most common case)
|
|
try {
|
|
return JSON.parse(jsonString);
|
|
} catch (directParseError) {
|
|
// If direct parsing fails, try the bracket extraction method
|
|
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);
|
|
try {
|
|
return JSON.parse(finalJsonString);
|
|
} catch (finalParseError) {
|
|
throw new Error(`JSON parsing failed: ${finalParseError.message}. Raw text: ${jsonString.substring(0, 100)}...`);
|
|
}
|
|
}
|
|
};
|
|
|
|
|
|
const evaluateWithGemini = async (
|
|
ai: GoogleGenAI,
|
|
userAnswer: string,
|
|
solution: string,
|
|
prompt: string
|
|
): Promise<{ correct: boolean, explanation: string }> => {
|
|
const systemInstruction = `You are an expert Software Architecture (SWA) tutor. Your task is to evaluate a user's answer against a model solution for a specific exercise. The user's answer might be phrased differently but contain the correct key concepts. Focus on semantic correctness, not just literal string matching. For Mermaid diagrams, check if the user's diagram correctly represents the required structures and relationships.\n\nThe exercise is: "${prompt}"\n\nAnalyze the user's answer and compare it to the provided model solution.\n\nRespond ONLY with a single JSON object in the format:\n{\n "correct": <boolean>,\n "explanation": "<string>"\n}\n\n- "correct": true if the user's answer is a valid and correct solution, false otherwise.\n- "explanation": If correct, provide a brief confirmation and elaborate on why the answer is good. If incorrect, provide a clear, concise explanation of what is wrong or missing. Use markdown for formatting. **Your entire explanation must be in German.**`;
|
|
|
|
const contents = `Here is the user's answer:
|
|
\`\`\`
|
|
${userAnswer}
|
|
\`\`\`
|
|
|
|
Here is the model solution for reference:
|
|
\`\`\`
|
|
${solution}
|
|
\`\`\`
|
|
|
|
Please evaluate the user's answer and provide your response in the specified JSON format.`;
|
|
|
|
const response: GenerateContentResponse = await ai.models.generateContent({
|
|
model: "gemini-2.5-flash-preview-04-17",
|
|
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>
|
|
);
|
|
};
|
|
|
|
const TextAreaEditor: React.FC<{
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
}> = ({ value, onChange, placeholder }) => {
|
|
return (
|
|
<textarea
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className="w-full h-48 p-3 bg-slate-50 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 font-sans text-slate-800"
|
|
aria-label="Answer text area"
|
|
/>
|
|
)
|
|
};
|
|
|
|
const MermaidDiagram: React.FC<{ value: string; onChange: (value: string) => void; placeholder?: string }> = ({ value, onChange, placeholder }) => {
|
|
const [diagramHtml, setDiagramHtml] = useState<string>('');
|
|
const [error, setError] = useState<string>('');
|
|
const renderCountRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
mermaid.initialize({
|
|
startOnLoad: false,
|
|
theme: 'default',
|
|
securityLevel: 'loose'
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (value.trim()) {
|
|
const renderDiagram = async () => {
|
|
renderCountRef.current += 1;
|
|
const uniqueId = `mermaid-diagram-${renderCountRef.current}`;
|
|
|
|
try {
|
|
setError('');
|
|
setDiagramHtml('');
|
|
const { svg } = await mermaid.render(uniqueId, value);
|
|
setDiagramHtml(svg);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Mermaid diagram rendering failed');
|
|
setDiagramHtml('');
|
|
} finally {
|
|
// Clean up the DOM element that mermaid might have created
|
|
const element = document.getElementById(uniqueId);
|
|
if (element && element.parentNode) {
|
|
element.parentNode.removeChild(element);
|
|
}
|
|
}
|
|
};
|
|
renderDiagram();
|
|
} else {
|
|
setDiagramHtml('');
|
|
setError('');
|
|
}
|
|
}, [value]);
|
|
|
|
// Cleanup function to remove any lingering mermaid elements
|
|
useEffect(() => {
|
|
return () => {
|
|
// Clean up any mermaid elements that might be left in the DOM
|
|
const mermaidElements = document.querySelectorAll('[id^="mermaid-diagram-"]');
|
|
mermaidElements.forEach(element => {
|
|
if (element.parentNode) {
|
|
element.parentNode.removeChild(element);
|
|
}
|
|
});
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<textarea
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className="w-full h-48 p-3 bg-slate-50 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 font-mono text-slate-800"
|
|
aria-label="Mermaid diagram input"
|
|
/>
|
|
{error && (
|
|
<div className="p-3 bg-red-50 border-2 border-red-200 rounded-lg">
|
|
<p className="text-red-700 text-sm font-medium">Mermaid Error:</p>
|
|
<p className="text-red-600 text-sm">{error}</p>
|
|
</div>
|
|
)}
|
|
{diagramHtml && (
|
|
<div className="p-4 bg-white border-2 border-slate-200 rounded-lg">
|
|
<p className="text-slate-600 text-sm mb-2 font-medium">Preview:</p>
|
|
<div
|
|
className="mermaid-preview"
|
|
dangerouslySetInnerHTML={{ __html: diagramHtml }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</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 isCodeExercise = (type: ExercisePartType) => !['text', 'mermaid'].includes(type);
|
|
|
|
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. Bitte setze deinen API-Key über den Einstellungen-Button oben rechts.",
|
|
error: "Gemini API nicht verfügbar. Bitte setze deinen API-Key über den Einstellungen-Button oben rechts."
|
|
});
|
|
}
|
|
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 answer 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">Softwarearchitektur Übung</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">Frage {part.id.split('-').pop()}) ({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">
|
|
{isCodeExercise(part.type) ? (
|
|
<CodeEditor
|
|
value={inputs[part.id] || ''}
|
|
onChange={(value) => handleInputChange(part.id, value)}
|
|
placeholder={`// Code für Teil ${part.id}) hier eingeben...`}
|
|
/>
|
|
) : part.type === 'mermaid' ? (
|
|
<MermaidDiagram
|
|
value={inputs[part.id] || ''}
|
|
onChange={(value) => handleInputChange(part.id, value)}
|
|
placeholder="Mermaid-Diagramm hier eingeben..."
|
|
/>
|
|
) : (
|
|
<TextAreaEditor
|
|
value={inputs[part.id] || ''}
|
|
onChange={(value) => handleInputChange(part.id, value)}
|
|
placeholder={`Antwort 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">Frage {part.id.split('-').pop()}) - 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">Frage {part.id.split('-').pop()})</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 ---
|
|
const allSwaExercises: Exercise[] = [
|
|
...swaExercises1,
|
|
...swaExercises2,
|
|
...swaExercises3,
|
|
...swaExercises4,
|
|
...swaExercises5,
|
|
...swaExercises6,
|
|
...swaExercises6,
|
|
...swaExercises7,
|
|
...swaExercisesExamples
|
|
];
|
|
|
|
|
|
export default function App() {
|
|
const [exercises, setExercises] = useState<Exercise[]>([]);
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const [showFeedback, setShowFeedback] = useState(false);
|
|
const [feedbackMessage, setFeedbackMessage] = useState('');
|
|
|
|
const handleFeedbackSubmit = () => {
|
|
Sentry.captureFeedback({
|
|
message: feedbackMessage,
|
|
});
|
|
setFeedbackMessage('');
|
|
setShowFeedback(false);
|
|
};
|
|
|
|
// --- 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 shuffled = [...allSwaExercises].sort(() => Math.random() - 0.5);
|
|
setExercises(shuffled);
|
|
}, []);
|
|
|
|
const handleNext = useCallback(() => {
|
|
setCurrentIndex(prev => (prev + 1) % (exercises.length || 1));
|
|
}, [exercises.length]);
|
|
|
|
const currentExercise = useMemo(() => exercises[currentIndex], [exercises, currentIndex]);
|
|
|
|
if (exercises.length === 0 || !currentExercise) {
|
|
return <div className="flex items-center justify-center h-screen text-xl font-serif">Lade Übungen...</div>;
|
|
}
|
|
|
|
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">
|
|
{/* Feedback Button */}
|
|
<button
|
|
className="fixed top-4 right-16 z-50 bg-white rounded-full p-2 shadow hover:bg-slate-100 transition"
|
|
aria-label="Feedback"
|
|
onClick={() => setShowFeedback(true)}
|
|
>
|
|
<ChatBubbleLeftRightIcon className="w-7 h-7 text-slate-600" />
|
|
</button>
|
|
{/* 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>
|
|
<div className="mt-2 text-xs text-slate-500">
|
|
Get an API key at <a href="https://aistudio.google.com/" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://aistudio.google.com/</a> - this app uses gemini-2.5-flash, which is free for 500 requests a day (as of 9.7.2025)
|
|
</div>
|
|
</Dialog.Panel>
|
|
</div>
|
|
</Dialog>
|
|
{/* Feedback Modal */}
|
|
<Dialog open={showFeedback} onClose={() => setShowFeedback(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">
|
|
<ChatBubbleLeftRightIcon className="w-6 h-6 text-blue-600" />
|
|
Feedback?
|
|
</Dialog.Title>
|
|
<div className="mb-4">
|
|
<p>
|
|
Hi :)<br/>Hast du Feedback, Aufgabenideen, oder gab es einen Bug? Sag mir gern bescheid, und ich schau, was ich tun kann - yandrik
|
|
</p>
|
|
<textarea
|
|
className="w-full border rounded px-3 py-2 text-slate-800"
|
|
value={feedbackMessage}
|
|
onChange={e => setFeedbackMessage(e.target.value)}
|
|
placeholder="Ich finde, dass..."
|
|
rows={5}
|
|
/>
|
|
</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={() => setShowFeedback(false)}
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
className="px-4 py-2 rounded bg-blue-600 text-white font-bold hover:bg-blue-700"
|
|
onClick={handleFeedbackSubmit}
|
|
disabled={!feedbackMessage.trim()}
|
|
>
|
|
Senden
|
|
</button>
|
|
</div>
|
|
</Dialog.Panel>
|
|
</div>
|
|
</Dialog>
|
|
<header className="mb-6 flex-shrink-0">
|
|
<h1 className="text-3xl font-serif font-bold text-slate-800">SWA Trainer</h1>
|
|
<p className="text-slate-500">Interaktive Übungen zur Vorbereitung auf die SWA-Klausur</p>
|
|
</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">Themengebiete</h2>
|
|
<nav className="space-y-2">
|
|
{exercises.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 key={currentExercise.id} exercise={currentExercise} onNext={handleNext} ai={ai} />
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|