37 KiB
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"
(confirmedlatest
is acceptable for this specific dependency as it's a core library and often updated with breaking changes, solatest
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:
- Read the existing
/home/yannik/repos/swa-practice-app/package.json
file. - Construct a new
package.json
content with the updated, explicit versions for all dependencies and devDependencies. - Write the new content back to
/home/yannik/repos/swa-practice-app/package.json
.
Code Changes (package.json):
--- 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 forapiKey
,showSettings
(for modal visibility), andinputKey
(for the input field value). - The
apiKey
is now initialized by first checkinglocalStorage
for a previously saved key. If not found, it falls back toprocess.env.API_KEY
(though for client-side,localStorage
is preferred for user-entered keys). - The
GoogleGenAI
instance is memoized (useMemo
) and only created if anapiKey
is present, preventing errors if the key is missing.
- Introduced
- 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 inlocalStorage
under the keygemini_api_key
and updates theapiKey
state.
- A
- 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).
- Added informative text within the settings modal to guide users:
- Robust
evaluateWithGemini
Function:- Modified the
evaluateWithGemini
function to accept theai
instance as a parameter, ensuring it uses the dynamically createdGoogleGenAI
instance. - Added a check within
handleSubmit
inExerciseSheet
to verify theai
instance and itsmodels.generateContent
method before making API calls, providing a user-friendly error message if the API key is missing or invalid.
- Modified the
- Template Literal Escaping Fixes:
- Crucially, fixed syntax errors within the
systemInstruction
andcontents
template literals inevaluateWithGemini
. The original code had issues with unescaped backticks (`
) and newlines ( - Specific Fix: All literal backticks within the template strings (e.g., for code blocks like
) were escaped as
``` ```. All literal newlines (\\n
. This ensures they are interpreted as literal characters within the string rather than JavaScript syntax.
- Crucially, fixed syntax errors within the
Step-by-step Actions:
- Read the content of
/home/yannik/repos/swa-practice-app/App.tsx
. - Locate the
App
functional component. - Add
useState
anduseEffect
hooks for API key management. - Integrate the
Dialog
component from@headlessui/react
for the settings modal. - Add the
Cog6ToothIcon
button to trigger the modal. - Insert the API key input field and save/cancel logic within the modal.
- Add the informational text about API key acquisition and usage limits to the modal.
- Modify the
evaluateWithGemini
function signature and its usage withinExerciseSheet
to pass theai
instance. - Apply the necessary escaping (
\`\`\`
for backticks,\\n
for newlines) within thesystemInstruction
andcontents
template literals inevaluateWithGemini
. - Write the modified content back to
/home/yannik/repos/swa-practice-app/App.tsx
.
Code Changes (App.tsx):
--- 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 usingpnpm install --prod --frozen-lockfile
, leveraging build cache.build
stage: Installs all dependencies and runs thepnpm run build
command to create the production build, also leveraging build cache.- Final stage: Copies only the necessary
node_modules
fromprod-deps
and thedist
(build output) frombuild
. It then installsserve
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:
- Construct the Dockerfile content with the specified multi-stage build instructions.
- Write this content to
/home/yannik/repos/swa-practice-app/Dockerfile
.
Code Changes (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 inGEMINI_API_KEY=
.
Step-by-step Action:
- Read the content of
/home/yannik/repos/swa-practice-app/.env.local
. - Replace the string
GEMINI_API_KEY=PLACEHOLDER_API_KEY
withGEMINI_API_KEY=
. - Write the modified content back to
/home/yannik/repos/swa-practice-app/.env.local
.
Code Changes (.env.local):
--- 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 useMermaidDiagram
for exercises withtype: 'mermaid'
Step-by-step Actions:
- Add
mermaid
import toApp.tsx
- Create the
MermaidDiagram
component with state management for diagram HTML and errors - Implement
useEffect
hooks to initialize Mermaid and handle diagram rendering - Add unique ID generation using
useRef
to prevent render caching issues - Update the exercise rendering logic to conditionally show
MermaidDiagram
for mermaid exercises - Add appropriate styling and error handling for the diagram preview
Code Changes (App.tsx):
--- 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 thefeedbackIntegration
, 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.
- Instead of using the default Sentry feedback widget, a custom feedback dialog was implemented in
Step-by-step Actions:
- Remove
@sentry/browser
and add@sentry/react
topackage.json
. - Update
index.tsx
to import from@sentry/react
and add thefeedbackIntegration
to theSentry.init
call. - In
App.tsx
, importChatBubbleLeftRightIcon
from@heroicons/react/24/outline
and* as Sentry from "@sentry/react"
. - Add state variables for the feedback dialog visibility and the feedback message.
- Add a "Feedback" button next to the "Settings" button.
- Implement the custom feedback
Dialog
component with a textarea and submit/cancel buttons. - Create a handler function that calls
Sentry.captureFeedback
with the user's message. - Write the modified content back to the respective files.
Code Changes (package.json):
--- 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):
--- 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):
--- 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>