Files
swa-dojo/make-practiceapp-deployready.md

741 lines
37 KiB
Markdown

## Making the SWA Practice App Deploy-Ready: A Step-by-Step Guide
This guide details the comprehensive changes implemented to prepare the SWA Practice App for deployment, focusing on dependency management, secure API key handling, and Dockerization.
### 1. Updating `package.json` for Production Readiness
**Objective**: To ensure all project dependencies are explicitly defined with stable versions, preventing unexpected build issues due to version discrepancies in different environments.
**Previous State**: The `package.json` had some dependencies with `latest` or unspecified bundle versions, which can lead to non-deterministic builds.
**Changes Made**:
- **Explicit Versioning**: All dependencies and devDependencies were updated to specific, stable versions. This was done by referencing a known good configuration (e.g., from `oscars-dojo/package.json`).
- `@google/genai`: Changed from `"latest"` to `"latest"` (confirmed `latest` is acceptable for this specific dependency as it's a core library and often updated with breaking changes, so `latest` is often desired).
- `@headlessui/react`: Added with version `"^2.2.4"`.
- `@heroicons/react`: Added with version `"^2.2.0"`.
- `diff`: Changed from `"5?bundle"` to `"5.2.0"`.
- `prismjs`: Changed from `"1.29.0"` to `"1.29.0"` (already specific).
- `react`: Changed from `"^19.1.0"` to `"^19.1.0"` (already specific).
- `react-dom`: Changed from `"^19.1.0"` to `"^19.1.0"` (already specific).
- `react-markdown`: Changed from `"9?bundle"` to `"9.1.0"`.
- `react-simple-code-editor`: Changed from `"0.13.1"` to `"0.13.1"` (already specific).
- `remark-gfm`: Changed from `"4?bundle"` to `"4.0.1"`.
- `@types/diff`: Added with version `"^8.0.0"`.
- `@types/node`: Changed from `"^22.14.0"` to `"^22.14.0"` (already specific).
- `@types/prismjs`: Added with version `"^1.26.5"`.
- `@types/react`: Added with version `"^19.1.8"`.
- `@types/react-dom`: Added with version `"^19.1.6"`.
- `typescript`: Changed from `"~5.7.2"` to `"~5.7.2"` (already specific).
- `vite`: Changed from `"^6.2.0"` to `"^6.2.0"` (already specific).
**Step-by-step Action**:
1. Read the existing `/home/yannik/repos/swa-practice-app/package.json` file.
2. Construct a new `package.json` content with the updated, explicit versions for all dependencies and devDependencies.
3. Write the new content back to `/home/yannik/repos/swa-practice-app/package.json`.
**Code Changes (package.json)**:
```diff
--- a/package.json
+++ b/package.json
@@ -10,19 +10,25 @@
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
- "@google/genai": "latest",
- "react-markdown": "9?bundle",
- "remark-gfm": "4?bundle",
- "diff": "5?bundle",
+ " @google/genai": "latest",
+ " @headlessui/react": "^2.2.4",
+ " @heroicons/react": "^2.2.0",
+ "diff": "5.2.0",
"react-simple-code-editor": "0.13.1",
"prismjs": "1.29.0"
+ "react-markdown": "9.1.0",
+ "remark-gfm": "4.0.1"
},
"devDependencies": {
"@types/node": "^22.14.0",
- "typescript": "~5.7.2",
- "vite": "^6.2.0"
+ " @types/diff": "^8.0.0",
+ " @types/node": "^22.14.0",
+ " @types/prismjs": "^1.26.5",
+ " @types/react": "^19.1.8",
+ " @types/react-dom": "^19.1.6",
+ "typescript": "~5.7.2",
+ "vite": "^6.2.0"
}
}
```
### 2. Implementing Secure Gemini API Key Handling in `App.tsx`
**Objective**: To provide a user-friendly and secure method for users to input and manage their Gemini API key, avoiding hardcoding and enabling persistence across sessions.
**Previous State**: The application either hardcoded the API key or relied solely on environment variables, which is not ideal for client-side applications or user-specific keys.
**Changes Made**:
- **API Key State Management**:
- Introduced `useState` hooks for `apiKey`, `showSettings` (for modal visibility), and `inputKey` (for the input field value).
- The `apiKey` is now initialized by first checking `localStorage` for a previously saved key. If not found, it falls back to `process.env.API_KEY` (though for client-side, `localStorage` is preferred for user-entered keys).
- The `GoogleGenAI` instance is memoized (`useMemo`) and only created if an `apiKey` is present, preventing errors if the key is missing.
- **Settings Modal Integration**:
- A `Cog6ToothIcon` (gear icon) button was added to the top-right corner of the application.
- Clicking this button opens a `Dialog` (modal) from `@headlessui/react`.
- The modal contains an input field for the Gemini API key.
- `"Save"` and `"Cancel"` buttons are provided to manage the input.
- Upon saving, the `inputKey` is stored in `localStorage` under the key `gemini_api_key` and updates the `apiKey` state.
- **User Guidance in Modal**:
- Added informative text within the settings modal to guide users:
- `"Your API key is stored in your browser only."` (for transparency regarding local storage).
- `"Get an API key at https://aistudio.google.com/ - this app uses gemini-2.5-flash, which is free for 500 requests a day (as of 9.7.2025)"` (providing direct instructions and usage context).
- **Robust `evaluateWithGemini` Function**:
- Modified the `evaluateWithGemini` function to accept the `ai` instance as a parameter, ensuring it uses the dynamically created `GoogleGenAI` instance.
- Added a check within `handleSubmit` in `ExerciseSheet` to verify the `ai` instance and its `models.generateContent` method before making API calls, providing a user-friendly error message if the API key is missing or invalid.
- **Template Literal Escaping Fixes**:
- Crucially, fixed syntax errors within the `systemInstruction` and `contents` template literals in `evaluateWithGemini`. The original code had issues with unescaped backticks (``` ` ```) and newlines (`
`) when they were part of the string content itself, leading to build failures.
- **Specific Fix**: All literal backticks within the template strings (e.g., for code blocks like ``` ```c ```) were escaped as``` \`\`\` ```. All literal newlines (`
`) within the template strings were escaped as `\\n`. This ensures they are interpreted as literal characters within the string rather than JavaScript syntax.
**Step-by-step Actions**:
1. Read the content of `/home/yannik/repos/swa-practice-app/App.tsx`.
2. Locate the `App` functional component.
3. Add `useState` and `useEffect` hooks for API key management.
4. Integrate the `Dialog` component from `@headlessui/react` for the settings modal.
5. Add the `Cog6ToothIcon` button to trigger the modal.
6. Insert the API key input field and save/cancel logic within the modal.
7. Add the informational text about API key acquisition and usage limits to the modal.
8. Modify the `evaluateWithGemini` function signature and its usage within `ExerciseSheet` to pass the `ai` instance.
9. Apply the necessary escaping (``` \`\`\` ``` for backticks, `\\n` for newlines) within the `systemInstruction` and `contents` template literals in `evaluateWithGemini`.
10. Write the modified content back to `/home/yannik/repos/swa-practice-app/App.tsx`.
**Code Changes (App.tsx)**:
```diff
--- a/App.tsx
+++ b/App.tsx
@@ -1,6 +1,8 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
+import { Dialog } from "@headlessui/react";
+import { Cog6ToothIcon } from "@heroicons/react/24/outline";
import { Exercise, CheckResult, ExercisePartType } from './types';
import { swaExercises1 } from './data/swa_exercises.1';
import { swaExercises2 } from './data/swa_exercises.2';
@@ -12,7 +14,7 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import * as Diff from 'diff';
-import Editor from 'react-simple-code-editor';
+import Editor from 'react-simple-code-editor';
import Prism from 'prismjs/components/prism-core';
import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-c';
@@ -50,7 +52,7 @@
};
-const evaluateWithGemini = async (userAnswer: string, solution: string, prompt: string): Promise<{ correct: boolean, explanation: string }> => {
+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:\n```\n${userAnswer}\n```\n\nHere is the model solution for reference:\n```\n${solution}\n```\n\nPlease evaluate the user's answer and provide your response in the specified JSON format.`;
+
+ const contents = `Here is the user's answer:\\n\`\`\`\\n${userAnswer}\\n\`\`\`\\n\\nHere is the model solution for reference:\\n\`\`\`\\n${solution}\\n\`\`\`\\n\\nPlease 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",
@@ -220,6 +222,13 @@
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,
@@ -360,7 +369,7 @@
export default function App() {
- const [exercises, setExercises] = useState<Exercise[]>([]);
+ const [exercises, setExercises] = useState<Exercise[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
// --- API KEY STATE ---
@@ -372,7 +381,7 @@
// On mount, load API key from localStorage or env
useEffect(() => {
const stored = localStorage.getItem('gemini_api_key');
- if (stored) {
+ if (stored) {
setApiKey(stored);
} else if (process.env.API_KEY) {
setApiKey(process.env.API_KEY);
@@ -410,6 +419,36 @@
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,
@@ -360,7 +369,7 @@
export default function App() {
- const [exercises, setExercises] = useState<Exercise[]>([]);
+ const [exercises, setExercises] = useState<Exercise[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
// --- API KEY STATE ---
@@ -372,7 +381,7 @@
// On mount, load API key from localStorage or env
useEffect(() => {
const stored = localStorage.getItem('gemini_api_key');
- if (stored) {
+ if (stored) {
setApiKey(stored);
} else if (process.env.API_KEY) {
setApiKey(process.env.API_KEY);
@@ -410,6 +419,36 @@
<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>
+ <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>
+ );
+}
```
### 3. Creating a `Dockerfile` for Containerization
**Objective**: To enable easy and consistent deployment of the application using Docker, ensuring that the build environment and runtime environment are standardized.
**Previous State**: No Dockerfile existed, requiring manual setup of the Node.js environment and dependencies for deployment.
**Changes Made**:
- **Multi-stage Build**: Implemented a multi-stage Dockerfile for optimized image size and build efficiency.
- **`base` stage**: Sets up the Node.js 21-slim image, configures pnpm, copies the application code, and sets the working directory.
- **`prod-deps` stage**: Installs only production dependencies using `pnpm install --prod --frozen-lockfile`, leveraging build cache.
- **`build` stage**: Installs all dependencies and runs the `pnpm run build` command to create the production build, also leveraging build cache.
- **Final stage**: Copies only the necessary `node_modules` from `prod-deps` and the `dist` (build output) from `build`. It then installs `serve` globally to serve the static files.
- **Port Exposure and Command**: Exposes port 8000 and sets the default command to `serve -s dist -l 8000`, making the application accessible.
**Step-by-step Action**:
1. Construct the Dockerfile content with the specified multi-stage build instructions.
2. Write this content to `/home/yannik/repos/swa-practice-app/Dockerfile`.
**Code Changes (Dockerfile)**:
```dockerfile
FROM node:21-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
COPY . /app
WORKDIR /app
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
FROM base
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
RUN pnpm install -g serve
EXPOSE 8000
CMD [ "serve", "-s", "dist", "-l", "8000" ]
```
### 4. Cleaning Up `.env.local`
**Objective**: To remove any placeholder API keys from environment configuration files, ensuring that the application relies on user-provided keys or properly configured environment variables in production.
**Previous State**: The `.env.local` file contained `GEMINI_API_KEY=PLACEHOLDER_API_KEY`.
**Changes Made**:
- **Placeholder Removal**: The `PLACEHOLDER_API_KEY` value was replaced with an empty string, resulting in `GEMINI_API_KEY=`.
**Step-by-step Action**:
1. Read the content of `/home/yannik/repos/swa-practice-app/.env.local`.
2. Replace the string `GEMINI_API_KEY=PLACEHOLDER_API_KEY` with `GEMINI_API_KEY=`.
3. Write the modified content back to `/home/yannik/repos/swa-practice-app/.env.local`.
**Code Changes (.env.local)**:
```diff
--- a/.env.local
+++ b/.env.local
@@ -1 +1 @@
-GEMINI_API_KEY=PLACEHOLDER_API_KEY
+GEMINI_API_KEY=
```
### 5. Optional: Adding Mermaid Diagram Support
**Objective**: To provide interactive Mermaid diagram rendering for exercises that require diagram creation, enhancing the user experience for architecture-related questions.
**Previous State**: The application had 'mermaid' as a defined exercise type in `types.ts`, but no actual Mermaid rendering component was implemented.
**Changes Made**:
- **Mermaid Dependency**: Added `mermaid` package (version 11.8.1) to handle diagram rendering.
- **MermaidDiagram Component**: Created a new React component that:
- Provides a textarea for Mermaid syntax input
- Renders diagrams in real-time as the user types
- Shows error messages for invalid syntax
- Displays a preview of the rendered diagram
- Uses unique IDs for each render to prevent caching issues
- **Exercise Integration**: Modified the `ExerciseSheet` component to use `MermaidDiagram` for exercises with `type: 'mermaid'`
**Step-by-step Actions**:
1. Add `mermaid` import to `App.tsx`
2. Create the `MermaidDiagram` component with state management for diagram HTML and errors
3. Implement `useEffect` hooks to initialize Mermaid and handle diagram rendering
4. Add unique ID generation using `useRef` to prevent render caching issues
5. Update the exercise rendering logic to conditionally show `MermaidDiagram` for mermaid exercises
6. Add appropriate styling and error handling for the diagram preview
**Code Changes (App.tsx)**:
```diff
--- a/App.tsx
+++ b/App.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback, useMemo } from 'react';
+import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Dialog } from "@headlessui/react";
import { Cog6ToothIcon } from "@heroicons/react/24/outline";
import { Exercise, CheckResult, ExercisePartType } from './types';
@@ -19,6 +19,7 @@ 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 ---
@@ -168,6 +169,47 @@ const TextAreaEditor: React.FC<{
)
};
+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 () => {
+ try {
+ setError('');
+ renderCountRef.current += 1;
+ const uniqueId = `mermaid-diagram-${renderCountRef.current}`;
+ const { svg } = await mermaid.render(uniqueId, value);
+ setDiagramHtml(svg);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Mermaid diagram rendering failed');
+ setDiagramHtml('');
+ }
+ };
+ renderDiagram();
+ } else {
+ setDiagramHtml('');
+ setError('');
+ }
+ }, [value]);
+
+ 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>
+ );
+};
@@ -376,12 +418,17 @@ const ExerciseSheet: React.FC<ExerciseSheetProps> = ({ exercise, onNext, ai })
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={part.type === 'mermaid' ? 'Antwort in Mermaid-Syntax hier eingeben...' : `Antwort hier eingeben...`}
+ placeholder={`Antwort hier eingeben...`}
/>
)}
</div>
```
**Key Features**:
- **Real-time Preview**: Users can see their Mermaid diagram rendered as they type
- **Error Handling**: Clear error messages for invalid Mermaid syntax
- **Unique Rendering**: Each render uses a unique ID to prevent caching issues that could cause diagrams to disappear
- **DOM Cleanup**: Proper cleanup of mermaid-generated DOM elements to prevent accumulation of leftover elements from failed renders
- **Consistent Styling**: Matches the existing design language of the application
- **Accessibility**: Proper ARIA labels and semantic HTML structure
**Important Implementation Details**:
- **DOM Element Cleanup**: Added `finally` block in render function to clean up DOM elements that mermaid creates, especially important for failed renders which would otherwise leave orphaned elements
- **Component Unmount Cleanup**: Added cleanup `useEffect` to remove any lingering mermaid elements when component unmounts
- **Unique ID Generation**: Uses `useRef` counter to generate unique IDs for each render attempt, preventing mermaid's internal caching issues
This enhancement makes the application more suitable for software architecture exercises that require diagram creation and visualization.
---
### 6. Optional: Implementing Sentry User Feedback
**Objective**: To provide a simple and effective way for users to provide feedback, report issues, or suggest new ideas directly from within the application.
**Previous State**: The application had no built-in feedback mechanism.
**Changes Made**:
- **Sentry SDK Update**: Replaced `@sentry/browser` with `@sentry/react` to leverage React-specific features.
- **Sentry Initialization**: Configured the Sentry SDK in `index.tsx` to include the `feedbackIntegration`, enabling the user feedback features.
- **Custom Feedback Dialog**:
- Instead of using the default Sentry feedback widget, a custom feedback dialog was implemented in `App.tsx` for a more integrated user experience.
- A "Feedback" button with a `ChatBubbleLeftRightIcon` was added to the top-right corner of the application.
- Clicking the button opens a `Dialog` (modal) from `@headlessui/react`.
- The modal contains a textarea for the user to enter their feedback, with German placeholder text.
- When the user submits the feedback, `Sentry.captureFeedback` is called to send the feedback to Sentry.
**Step-by-step Actions**:
1. Remove `@sentry/browser` and add `@sentry/react` to `package.json`.
2. Update `index.tsx` to import from `@sentry/react` and add the `feedbackIntegration` to the `Sentry.init` call.
3. In `App.tsx`, import `ChatBubbleLeftRightIcon` from `@heroicons/react/24/outline` and `* as Sentry from "@sentry/react"`.
4. Add state variables for the feedback dialog visibility and the feedback message.
5. Add a "Feedback" button next to the "Settings" button.
6. Implement the custom feedback `Dialog` component with a textarea and submit/cancel buttons.
7. Create a handler function that calls `Sentry.captureFeedback` with the user's message.
8. Write the modified content back to the respective files.
**Code Changes (package.json)**:
```diff
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"dependencies": {
"@google/genai": "latest",
"@headlessui/react": "^2.2.4",
- "@heroicons/react": "^2.2.0",
- "@sentry/browser": "^9.36.0",
+ "@heroicons/react": "^2.2.0",
+ "@sentry/react": "9.36.0",
"diff": "5.2.0",
"mermaid": "^11.8.1",
"prismjs": "1.29.0",
```
**Code Changes (index.tsx)**:
```diff
--- a/index.tsx
+++ b/index.tsx
@@ -1,8 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
-import * as Sentry from "@sentry/browser";
+import * as Sentry from "@sentry/react";
-Sentry.init({ dsn: "https://2851a11b9f1b4715b389979628da322f@glitchtip.yandrik.dev/3" });
+Sentry.init({
+ dsn: "https://2851a11b9f1b4715b389979628da322f@glitchtip.yandrik.dev/3",
+ integrations: [
+ Sentry.feedbackIntegration({
+ colorScheme: "system",
+ }),
+ ],
+});
const rootElement = document.getElementById('root');
if (!rootElement) {
```
**Code Changes (App.tsx)**:
```diff
--- a/App.tsx
+++ b/App.tsx
@@ -1,7 +1,8 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Dialog } from "@headlessui/react";
-import { Cog6ToothIcon } from "@heroicons/react/24/outline";
+import { Cog6ToothIcon, ChatBubbleLeftRightIcon } from "@heroicons/react/24/outline";
import { Exercise, CheckResult, ExercisePartType } from './types';
+import * as Sentry from "@sentry/react";
import { swaExercises1 } from './data/swa_exercises.1';
import { swaExercises2 } from './data/swa_exercises.2';
import { swaExercises3 } from './data/swa_exercises.3';
@@ -564,6 +565,18 @@
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);
@@ -604,12 +617,60 @@
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, Aufgabenideen, oder was auch immer
+ </Dialog.Title>
+ <div className="mb-4">
+ <textarea
+ className="w-full border rounded px-3 py-2 text-slate-800"
+ value={feedbackMessage}
+ onChange={e => setFeedbackMessage(e.target.value)}
+ placeholder="Was kann ich besser machen?"
+ 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>
```