Übersetzung Teil 1

This commit is contained in:
Ashikagi
2026-03-23 23:02:51 +01:00
parent cd77f88d96
commit 46d468575a
21 changed files with 1605 additions and 333 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(supabase --version)"
]
}
}

View File

@@ -1,14 +1,21 @@
Offen
nere Landing Page
Rangliste
Übersetzen in gängigge Sprachen Englisch Italienisch Spanisch Portugisisch
Max Punkte bei Bewertung PAT Start, Pat2 und Pat3
Admin Oberfläche (anzahl user mit Mail Adressen/ anelegten Tests / anbindung von Werbung Links und rechts von den Test Übersichten
2fa
Erledigt
Erledigt:
Final Speichern do
Max Punkte bei Bewertung PAT1
User Profil mit Land und Sprach Auswahl
Termninal
vercel deploy
git add .
git commit -m "BenutzerProfil "
git push

View File

@@ -10,6 +10,8 @@ import ProfileTab from './components/ProfileTab'
import { useUserProfile } from './hooks/useUserProfile'
import { getCountryLabel, getCountryOption } from './data/countries'
import CountryFlag from './components/CountryFlag'
import { LanguageProvider } from './i18n/LanguageContext'
import { getTranslator } from './i18n/translations'
const readShareToken = () => {
if (typeof window === 'undefined') return ''
@@ -44,6 +46,9 @@ function App() {
deleteProfileData
} = useUserProfile(session)
const language = profile?.language || 'de'
const t = getTranslator(language)
useEffect(() => {
const root = document.documentElement
if (theme === 'dark') {
@@ -144,64 +149,80 @@ function App() {
type="button"
>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
<span className="text-sm font-medium">{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}</span>
<span className="text-sm font-medium">{theme === 'dark' ? t('nav.theme_light') : t('nav.theme_dark')}</span>
</button>
)
if (shareToken) {
return <SharedAssessmentPage shareToken={shareToken} themeToggle={renderThemeToggle()} />
return (
<LanguageProvider language={language}>
<SharedAssessmentPage shareToken={shareToken} themeToggle={renderThemeToggle()} />
</LanguageProvider>
)
}
if (!supabase) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100 flex items-center justify-center p-6">
<div className="absolute top-4 right-4">{renderThemeToggle()}</div>
<div className="max-w-xl w-full bg-white/90 dark:bg-gray-900/80 border border-dashed border-gray-300 dark:border-gray-700 rounded-xl shadow-sm p-6">
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-2">Supabase noch nicht konfiguriert</h2>
<p className="text-gray-600 dark:text-gray-300">
Lege eine <code>.env</code> an (siehe <code>.env.example</code>) und trage
<code> VITE_SUPABASE_URL </code> sowie <code>VITE_SUPABASE_ANON_KEY</code> ein, um Login und
Registrierung zu aktivieren.
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-3">
Anschließend Vite neu starten, damit die Variablen geladen werden.
</p>
<LanguageProvider language={language}>
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100 flex items-center justify-center p-6">
<div className="absolute top-4 right-4">{renderThemeToggle()}</div>
<div className="max-w-xl w-full bg-white/90 dark:bg-gray-900/80 border border-dashed border-gray-300 dark:border-gray-700 rounded-xl shadow-sm p-6">
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-2">Supabase noch nicht konfiguriert</h2>
<p className="text-gray-600 dark:text-gray-300">
Lege eine <code>.env</code> an (siehe <code>.env.example</code>) und trage
<code> VITE_SUPABASE_URL </code> sowie <code>VITE_SUPABASE_ANON_KEY</code> ein, um Login und
Registrierung zu aktivieren.
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-3">
Anschließend Vite neu starten, damit die Variablen geladen werden.
</p>
</div>
</div>
</div>
</LanguageProvider>
)
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-300">
<div className="absolute top-4 right-4">{renderThemeToggle()}</div>
<span>Auth wird geladen...</span>
</div>
<LanguageProvider language={language}>
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-300">
<div className="absolute top-4 right-4">{renderThemeToggle()}</div>
<span>Auth wird geladen...</span>
</div>
</LanguageProvider>
)
}
if (!session) {
if (showAuth) {
return <AuthPanel onAuth={setSession} themeToggle={renderThemeToggle()} onBack={() => setShowAuth(false)} />
return (
<LanguageProvider language={language}>
<AuthPanel onAuth={setSession} themeToggle={renderThemeToggle()} onBack={() => setShowAuth(false)} />
</LanguageProvider>
)
}
if (publicPath === '/datenschutz' || publicPath === '/impressum' || publicPath === '/kontakt') {
return (
<PublicInfoPage
path={publicPath}
onGetStarted={() => setShowAuth(true)}
onNavigate={navigatePublic}
themeToggle={renderThemeToggle()}
/>
<LanguageProvider language={language}>
<PublicInfoPage
path={publicPath}
onGetStarted={() => setShowAuth(true)}
onNavigate={navigatePublic}
themeToggle={renderThemeToggle()}
/>
</LanguageProvider>
)
}
return (
<Landing
onGetStarted={() => setShowAuth(true)}
onNavigate={navigatePublic}
themeToggle={renderThemeToggle()}
/>
<LanguageProvider language={language}>
<Landing
onGetStarted={() => setShowAuth(true)}
onNavigate={navigatePublic}
themeToggle={renderThemeToggle()}
/>
</LanguageProvider>
)
}
@@ -213,99 +234,101 @@ function App() {
const hasName = Boolean(userDisplayName)
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
<header className="bg-white/80 dark:bg-gray-900/70 backdrop-blur border-b border-gray-200 dark:border-gray-800">
<div className="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between gap-3 flex-wrap">
<div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Angemeldet</p>
<div className="flex items-center gap-2 flex-wrap text-sm font-semibold text-gray-800 dark:text-gray-100">
{countryLabel && (
<CountryFlag
countryCode={selectedCountry?.code || profile?.country}
title={countryLabel}
className="h-4 w-6 rounded-[2px] shadow-sm ring-1 ring-black/10"
/>
)}
<span>{hasName ? userDisplayName : session?.user?.email}</span>
<LanguageProvider language={language}>
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
<header className="bg-white/80 dark:bg-gray-900/70 backdrop-blur border-b border-gray-200 dark:border-gray-800">
<div className="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between gap-3 flex-wrap">
<div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{t('nav.logged_in')}</p>
<div className="flex items-center gap-2 flex-wrap text-sm font-semibold text-gray-800 dark:text-gray-100">
{countryLabel && (
<CountryFlag
countryCode={selectedCountry?.code || profile?.country}
title={countryLabel}
className="h-4 w-6 rounded-[2px] shadow-sm ring-1 ring-black/10"
/>
)}
<span>{hasName ? userDisplayName : session?.user?.email}</span>
</div>
</div>
<nav className="flex items-center gap-2">
<button
type="button"
onClick={() => setActiveTab('assessments')}
className={`px-4 py-2 rounded-lg border text-sm font-semibold transition ${
activeTab === 'assessments'
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 border-gray-900 dark:border-gray-100'
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
{t('nav.assessments')}
</button>
<button
type="button"
onClick={() => setActiveTab('analysis')}
disabled={isEditingAssessment}
className={`px-4 py-2 rounded-lg border text-sm font-semibold transition ${
activeTab === 'analysis'
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 border-gray-900 dark:border-gray-100'
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
} ${isEditingAssessment ? 'opacity-50 cursor-not-allowed' : ''}`}
title={isEditingAssessment ? t('nav.analysis_disabled') : t('nav.analysis_open')}
>
{t('nav.analysis')}
</button>
<button
type="button"
onClick={() => setActiveTab('profile')}
disabled={isEditingAssessment}
className={`px-4 py-2 rounded-lg border text-sm font-semibold transition ${
activeTab === 'profile'
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 border-gray-900 dark:border-gray-100'
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
} ${isEditingAssessment ? 'opacity-50 cursor-not-allowed' : ''}`}
title={isEditingAssessment ? t('nav.profile_disabled') : t('nav.profile_open')}
>
{t('nav.profile')}
</button>
</nav>
<div className="flex items-center gap-3">
{renderThemeToggle()}
<button
onClick={handleSignOut}
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-200 transition"
>
{t('nav.sign_out')}
</button>
</div>
</div>
</header>
<nav className="flex items-center gap-2">
<button
type="button"
onClick={() => setActiveTab('assessments')}
className={`px-4 py-2 rounded-lg border text-sm font-semibold transition ${
activeTab === 'assessments'
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 border-gray-900 dark:border-gray-100'
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
Bewertungen
</button>
{activeTab !== 'profile' && (
<PatTestManager
user={session?.user}
activeTab={activeTab}
onEditingStateChange={setIsEditingAssessment}
testsVisible={Boolean(profile?.testsVisible)}
isProfileLoading={profileLoading}
/>
)}
<button
type="button"
onClick={() => setActiveTab('analysis')}
disabled={isEditingAssessment}
className={`px-4 py-2 rounded-lg border text-sm font-semibold transition ${
activeTab === 'analysis'
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 border-gray-900 dark:border-gray-100'
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
} ${isEditingAssessment ? 'opacity-50 cursor-not-allowed' : ''}`}
title={isEditingAssessment ? 'Analyse ist während der Bearbeitung deaktiviert' : 'Analyse öffnen'}
>
Analyse
</button>
<button
type="button"
onClick={() => setActiveTab('profile')}
disabled={isEditingAssessment}
className={`px-4 py-2 rounded-lg border text-sm font-semibold transition ${
activeTab === 'profile'
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 border-gray-900 dark:border-gray-100'
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
} ${isEditingAssessment ? 'opacity-50 cursor-not-allowed' : ''}`}
title={isEditingAssessment ? 'Profil ist während der Bearbeitung deaktiviert' : 'Profil öffnen'}
>
Profil
</button>
</nav>
<div className="flex items-center gap-3">
{renderThemeToggle()}
<button
onClick={handleSignOut}
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-200 transition"
>
Abmelden
</button>
</div>
</div>
</header>
{activeTab !== 'profile' && (
<PatTestManager
user={session?.user}
activeTab={activeTab}
onEditingStateChange={setIsEditingAssessment}
testsVisible={Boolean(profile?.testsVisible)}
isProfileLoading={profileLoading}
/>
)}
{activeTab === 'profile' && (
<ProfileTab
session={session}
profile={profile}
loading={profileLoading}
error={profileError}
onSave={saveProfile}
onDelete={deleteProfileData}
onSessionChange={setSession}
/>
)}
</div>
{activeTab === 'profile' && (
<ProfileTab
session={session}
profile={profile}
loading={profileLoading}
error={profileError}
onSave={saveProfile}
onDelete={deleteProfileData}
onSessionChange={setSession}
/>
)}
</div>
</LanguageProvider>
)
}

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useMemo } from 'react';
import { Activity, AlertTriangle, Brain, Target } from 'lucide-react';
import { buildAnalysis } from '../../utils/analysisEngine';
import { useTrainingPlans } from '../../hooks/useTrainingPlans';
import { useTranslation } from '../../i18n/LanguageContext';
import GapBarChart from './GapBarChart';
import RadarChart from './RadarChart';
import StrengthList from './StrengthList';
@@ -16,6 +17,7 @@ export default function AnalysisTab({
patTypes,
user
}) {
const t = useTranslation();
const patTypeOptions = Object.keys(patTypes || {});
useEffect(() => {
@@ -65,10 +67,10 @@ export default function AnalysisTab({
<section className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white/90 dark:bg-gray-900/80 backdrop-blur p-5 shadow-sm">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">Analyse</p>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Wo solltest du besser werden?</h1>
<p className="text-xs uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">{t('analysis.title')}</p>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{t('analysis.priority_title')}</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Priorisierung nach Gap × Faktor × Konstanz basierend auf den letzten 5 Assessments.
{t('analysis.priority_desc')}
</p>
</div>
</div>
@@ -96,18 +98,18 @@ export default function AnalysisTab({
<div className="mt-4 flex flex-wrap gap-2 text-xs">
<span className="px-3 py-1 rounded-full border border-cyan-200 dark:border-cyan-800 bg-cyan-50 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-200 font-semibold">
<Activity className="w-3 h-3 inline-block mr-1" />
Assessments: {selectedPatAssessments.length}
{t('analysis.assessments_count', { count: selectedPatAssessments.length })}
</span>
<span className="px-3 py-1 rounded-full border border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-200 font-semibold">
<Target className="w-3 h-3 inline-block mr-1" />
Analysefenster: {analysis.windowedAssessments.length}/5
{t('analysis.window', { count: analysis.windowedAssessments.length })}
</span>
{analysis.isLimitedData && (
<span className="px-3 py-1 rounded-full border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-200 font-semibold">
<AlertTriangle className="w-3 h-3 inline-block mr-1" />
Begrenzte Datenbasis
{t('analysis.limited_data')}
</span>
)}
</div>
@@ -116,13 +118,13 @@ export default function AnalysisTab({
{!hasAssessments ? (
<section className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900/70 p-6 text-center">
<Brain className="w-10 h-10 mx-auto mb-3 text-gray-400 dark:text-gray-500" />
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-1">Noch keine Daten für die Analyse</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">Lege zuerst eine Bewertung im Bereich "Bewertungen" an.</p>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('analysis.no_data')}</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">{t('analysis.no_data_hint')}</p>
</section>
) : selectedPatAssessments.length === 0 ? (
<section className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900/70 p-6 text-center">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-1">Keine Bewertungen für {selectedPatType}</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">Wähle einen anderen PAT-Typ oder erstelle eine neue Bewertung.</p>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('analysis.no_type_data', { patType: selectedPatType })}</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">{t('analysis.no_type_hint')}</p>
</section>
) : (
<>

View File

@@ -1,9 +1,12 @@
import React from 'react';
import { useTranslation } from '../../i18n/LanguageContext';
const truncate = (value, maxLength = 26) =>
value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value;
export default function GapBarChart({ metrics = [] }) {
const t = useTranslation();
const data = [...metrics]
.sort((left, right) => right.priorityScore - left.priorityScore)
.slice(0, 8);
@@ -11,7 +14,7 @@ export default function GapBarChart({ metrics = [] }) {
if (!data.length) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
Kein Gap-Chart verfügbar.
{t('gapchart.no_data')}
</div>
);
}
@@ -29,8 +32,8 @@ export default function GapBarChart({ metrics = [] }) {
return (
<div className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm h-full flex flex-col">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">Gap-Bar-Chart (Priorität)</h3>
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" role="img" aria-label="Gap-Balkendiagramm">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">{t('gapchart.title')}</h3>
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" role="img" aria-label={t('gapchart.aria')}>
{data.map((item, index) => {
const y = topPad + index * rowHeight;
const barWidth = (item.priorityScore / maxScore) * chartWidth;

View File

@@ -1,19 +1,22 @@
import React from 'react';
import { useTranslation } from '../../i18n/LanguageContext';
const scoreToPercent = (score) => Math.round(Math.max(0, Math.min(1.5, score)) * 100);
export default function StrengthList({ strengths = [] }) {
const t = useTranslation();
if (!strengths.length) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
Keine Stärken-Daten verfügbar.
{t('strength.no_data')}
</div>
);
}
return (
<div className="rounded-2xl border border-emerald-200 dark:border-emerald-900 bg-white dark:bg-gray-900 p-5 shadow-sm">
<h3 className="text-lg font-semibold text-emerald-700 dark:text-emerald-300 mb-4">Top-3 Stärken</h3>
<h3 className="text-lg font-semibold text-emerald-700 dark:text-emerald-300 mb-4">{t('strength.title')}</h3>
<div className="space-y-3">
{strengths.map((item, index) => (
@@ -30,9 +33,9 @@ export default function StrengthList({ strengths = [] }) {
</span>
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-300">
<p>Ist: {item.actualMean.toFixed(2)}</p>
<p>Soll: {item.soll.toFixed(2)}</p>
<p>Trend: {item.trendDelta.toFixed(2)}</p>
<p>{t('strength.actual', { score: item.actualMean.toFixed(2) })}</p>
<p>{t('strength.target', { score: item.soll.toFixed(2) })}</p>
<p>{t('strength.trend', { score: item.trendDelta.toFixed(2) })}</p>
</div>
</div>
))}

View File

@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react';
import { generateTrainingPlan } from '../../utils/trainingPlanGenerator';
import { useTranslation } from '../../i18n/LanguageContext';
const formatDateTime = (value) => {
if (!value) return '—';
@@ -48,6 +49,7 @@ export default function TrainingPlanPanel({
onUpdateSessionPartial,
onReload
}) {
const t = useTranslation();
const [durationWeeks, setDurationWeeks] = useState(4);
const [sessionsPerWeek, setSessionsPerWeek] = useState(3);
const [draftSessions, setDraftSessions] = useState([]);
@@ -72,7 +74,7 @@ export default function TrainingPlanPanel({
});
setDraftSessions(generated.sessions);
setUiMessage('Neuer Entwurf erstellt. Du kannst jetzt Intensität und Übungen je Session anpassen.');
setUiMessage(t('training.draft_created'));
};
const updateDraftSession = (index, patch) => {
@@ -96,7 +98,7 @@ export default function TrainingPlanPanel({
const handleSaveDraft = async () => {
if (!draftSessions.length) {
setUiMessage('Erstelle zuerst einen Entwurf.');
setUiMessage(t('training.create_draft_first'));
return;
}
@@ -108,11 +110,11 @@ export default function TrainingPlanPanel({
});
if (!result?.ok) {
setUiMessage(result?.error?.message || 'Trainingsplan konnte nicht gespeichert werden.');
setUiMessage(result?.error?.message || t('training.save_error'));
return;
}
setUiMessage('Trainingsplan gespeichert. Der vorherige aktive Plan wurde archiviert.');
setUiMessage(t('training.save_success'));
setDraftSessions([]);
};
@@ -123,7 +125,7 @@ export default function TrainingPlanPanel({
<div key={`draft-${index}`} className="rounded-xl border border-sky-100 dark:border-sky-900/60 bg-sky-50/60 dark:bg-sky-950/20 p-3">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-sky-800 dark:text-sky-200">
Woche {session.weekNo} · Einheit {session.sessionNo}
{t('training.week_session', { weekNo: session.weekNo, sessionNo: session.sessionNo })}
</p>
<span className="text-xs text-sky-700 dark:text-sky-300">{session.intensity}</span>
</div>
@@ -133,7 +135,7 @@ export default function TrainingPlanPanel({
value={session.mainExercise}
onChange={(event) => updateDraftSession(index, { mainExercise: event.target.value })}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
placeholder="Hauptübung"
placeholder={t('training.main_exercise')}
/>
<input
@@ -153,7 +155,7 @@ export default function TrainingPlanPanel({
});
}}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
placeholder="Nebenübungen, getrennt mit Komma"
placeholder={t('training.side_exercises')}
/>
<select
@@ -161,9 +163,9 @@ export default function TrainingPlanPanel({
onChange={(event) => updateDraftSession(index, { intensity: event.target.value })}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
>
<option value="hoch">hoch</option>
<option value="mittel">mittel</option>
<option value="leicht">leicht</option>
<option value="hoch">{t('training.high')}</option>
<option value="mittel">{t('training.medium')}</option>
<option value="leicht">{t('training.easy')}</option>
</select>
</div>
</div>
@@ -174,9 +176,9 @@ export default function TrainingPlanPanel({
<section className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-5 shadow-sm">
<div className="flex items-start justify-between flex-wrap gap-3 mb-4">
<div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Trainingsplan ({patType})</h3>
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">{t('training.title', { patType })}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
1 aktiver Plan + Historie, Session-Status: offen / erledigt / übersprungen.
{t('training.hint')}
</p>
</div>
@@ -185,7 +187,7 @@ export default function TrainingPlanPanel({
type="button"
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800"
>
Neu laden
{t('training.reload')}
</button>
</div>
@@ -197,20 +199,20 @@ export default function TrainingPlanPanel({
<div className="grid md:grid-cols-4 gap-3 mb-4">
<label className="text-sm text-gray-700 dark:text-gray-200">
Dauer
{t('training.duration')}
<select
value={durationWeeks}
onChange={(event) => setDurationWeeks(Number(event.target.value))}
className="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
>
<option value={2}>2 Wochen</option>
<option value={4}>4 Wochen</option>
<option value={6}>6 Wochen</option>
<option value={2}>{t('training.2weeks')}</option>
<option value={4}>{t('training.4weeks')}</option>
<option value={6}>{t('training.6weeks')}</option>
</select>
</label>
<label className="text-sm text-gray-700 dark:text-gray-200">
Einheiten/Woche
{t('training.sessions_per_week')}
<select
value={sessionsPerWeek}
onChange={(event) => setSessionsPerWeek(Number(event.target.value))}
@@ -227,7 +229,7 @@ export default function TrainingPlanPanel({
type="button"
className="md:self-end px-4 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700"
>
Plan (neu) generieren
{t('training.generate')}
</button>
<button
@@ -236,7 +238,7 @@ export default function TrainingPlanPanel({
disabled={saving || !draftSessions.length}
className="md:self-end px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-60"
>
{saving ? 'Speichert…' : 'Entwurf speichern'}
{saving ? t('training.saving') : t('training.save_draft')}
</button>
</div>
@@ -249,9 +251,9 @@ export default function TrainingPlanPanel({
{draftSessions.length > 0 && (
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Entwurf (teilweise editierbar)</h4>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{t('training.draft_label')}</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
Sessions: {draftSummary.total} · hoch {draftSummary.high} · mittel {draftSummary.medium} · leicht {draftSummary.low}
{t('training.sessions_info', { count: draftSummary.total, high: draftSummary.high, medium: draftSummary.medium, easy: draftSummary.low })}
</p>
</div>
@@ -261,16 +263,16 @@ export default function TrainingPlanPanel({
<div className="grid lg:grid-cols-2 gap-4">
<div className="rounded-xl border border-emerald-100 dark:border-emerald-900/60 p-3 bg-emerald-50/50 dark:bg-emerald-950/20">
<h4 className="font-semibold text-emerald-800 dark:text-emerald-200 mb-2">Aktiver Plan</h4>
<h4 className="font-semibold text-emerald-800 dark:text-emerald-200 mb-2">{t('training.active_plan')}</h4>
{loading ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Lädt</p>
<p className="text-sm text-gray-600 dark:text-gray-300">{t('training.loading')}</p>
) : !activePlan ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Noch kein aktiver Trainingsplan vorhanden.</p>
<p className="text-sm text-gray-600 dark:text-gray-300">{t('training.no_active')}</p>
) : (
<>
<p className="text-xs text-gray-600 dark:text-gray-300 mb-2">
Generiert: {formatDateTime(activePlan.generatedAt)} · {activePlan.durationWeeks} Wochen · {activePlan.sessionsPerWeek} Einheiten/Woche
{t('training.generated', { date: formatDateTime(activePlan.generatedAt), weeks: activePlan.durationWeeks, sessions: activePlan.sessionsPerWeek })}
</p>
<div className="max-h-80 overflow-y-auto space-y-2 pr-1">
@@ -278,7 +280,7 @@ export default function TrainingPlanPanel({
<div key={session.id} className="rounded-lg border border-emerald-200/70 dark:border-emerald-800 bg-white dark:bg-gray-900 p-3">
<div className="flex items-center justify-between gap-2 mb-2">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
W{session.weekNo} · E{session.sessionNo}
{t('training.week_short', { weekNo: session.weekNo, sessionNo: session.sessionNo })}
</p>
<div className="flex gap-1">
{['open', 'done', 'skipped'].map((state) => (
@@ -288,7 +290,7 @@ export default function TrainingPlanPanel({
onClick={() => onUpdateSessionState(session.id, state)}
className={`text-xs px-2 py-1 border rounded ${statusButtonClass(session.state === state)}`}
>
{state === 'open' ? 'offen' : state === 'done' ? 'erledigt' : 'übersprungen'}
{state === 'open' ? t('training.status_open') : state === 'done' ? t('training.status_done') : t('training.status_skipped')}
</button>
))}
</div>
@@ -321,7 +323,7 @@ export default function TrainingPlanPanel({
<textarea
defaultValue={session.notes || ''}
onBlur={(event) => onUpdateSessionPartial(session.id, { notes: event.target.value })}
placeholder="Notiz"
placeholder={t('training.note')}
className="w-full px-3 py-2 text-sm rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
rows={2}
/>
@@ -333,10 +335,10 @@ export default function TrainingPlanPanel({
</div>
<div className="rounded-xl border border-gray-200 dark:border-gray-800 p-3 bg-gray-50 dark:bg-gray-950/40">
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Plan-Historie</h4>
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">{t('training.history')}</h4>
{!historyPlans.length ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Keine archivierten Pläne vorhanden.</p>
<p className="text-sm text-gray-600 dark:text-gray-300">{t('training.no_history')}</p>
) : (
<div className="space-y-2 max-h-80 overflow-y-auto pr-1">
{historyPlans.map((plan) => (

View File

@@ -1,19 +1,22 @@
import React from 'react';
import { useTranslation } from '../../i18n/LanguageContext';
const scoreToPercent = (score) => Math.round(Math.max(0, Math.min(1.5, score)) * 100);
export default function WeaknessPriorityList({ weaknesses = [] }) {
const t = useTranslation();
if (!weaknesses.length) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
Keine Schwächen-Daten verfügbar.
{t('weakness.no_data')}
</div>
);
}
return (
<div className="rounded-2xl border border-rose-200 dark:border-rose-900 bg-white dark:bg-gray-900 p-5 shadow-sm">
<h3 className="text-lg font-semibold text-rose-700 dark:text-rose-300 mb-4">Top-3 Schwächen</h3>
<h3 className="text-lg font-semibold text-rose-700 dark:text-rose-300 mb-4">{t('weakness.title')}</h3>
<div className="space-y-3">
{weaknesses.map((item, index) => (
@@ -30,9 +33,9 @@ export default function WeaknessPriorityList({ weaknesses = [] }) {
</span>
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-300">
<p>Gap: {item.gapScore.toFixed(2)}</p>
<p>Konstanz: {item.consistencyScore.toFixed(2)}</p>
<p>Trend: {item.trendDelta.toFixed(2)}</p>
<p>{t('weakness.gap', { score: item.gapScore.toFixed(2) })}</p>
<p>{t('weakness.consistency', { score: item.consistencyScore.toFixed(2) })}</p>
<p>{t('weakness.trend', { score: item.trendDelta.toFixed(2) })}</p>
</div>
</div>
))}

View File

@@ -1,8 +1,10 @@
import React, { useState } from 'react'
import { ArrowLeft } from 'lucide-react'
import { supabase } from '../lib/supabaseClient'
import { useTranslation } from '../i18n/LanguageContext'
export default function AuthPanel({ onAuth, themeToggle, onBack }) {
const t = useTranslation()
const [mode, setMode] = useState('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@@ -27,7 +29,7 @@ export default function AuthPanel({ onAuth, themeToggle, onBack }) {
if (signInError) {
setError(signInError.message)
} else {
setMessage('Erfolgreich angemeldet')
setMessage(t('auth.login_success'))
onAuth?.(data.session)
}
} else {
@@ -38,7 +40,7 @@ export default function AuthPanel({ onAuth, themeToggle, onBack }) {
if (signUpError) {
setError(signUpError.message)
} else if (!data.session) {
setMessage('Registrierung abgeschlossen. Prüfe dein Postfach zur Bestätigung.')
setMessage(t('auth.register_success'))
} else {
onAuth?.(data.session)
}
@@ -60,17 +62,17 @@ export default function AuthPanel({ onAuth, themeToggle, onBack }) {
className="inline-flex items-center gap-2 rounded-full border border-gray-200/70 bg-white/70 px-3 py-2 text-sm font-medium text-gray-800 shadow-sm backdrop-blur transition hover:-translate-y-[1px] hover:shadow-md dark:border-gray-800 dark:bg-gray-900/70 dark:text-gray-100"
>
<ArrowLeft className="h-4 w-4" />
Zurück
{t('auth.back')}
</button>
)}
</div>
<div className="absolute top-4 right-4">{themeToggle}</div>
<div className="w-full max-w-lg bg-white/90 dark:bg-gray-900/80 backdrop-blur rounded-2xl shadow-xl border border-white/70 dark:border-gray-800">
<div className="px-8 py-6 border-b border-gray-100 dark:border-gray-800">
<p className="text-sm uppercase tracking-wide text-indigo-600 dark:text-indigo-300 font-semibold">PAT Stats</p>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">Anmelden oder registrieren</h1>
<p className="text-sm uppercase tracking-wide text-indigo-600 dark:text-indigo-300 font-semibold">{t('auth.title')}</p>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">{t('auth.subtitle')}</h1>
<p className="text-gray-600 dark:text-gray-300 mt-2">
Melde dich mit deinem PAT-Test Konto an, um deine Bewertungen sicher zu verwalten.
{t('auth.description')}
</p>
</div>
@@ -81,19 +83,19 @@ export default function AuthPanel({ onAuth, themeToggle, onBack }) {
onClick={() => setMode('login')}
className={`px-4 py-2 rounded-full transition ${mode === 'login' ? 'bg-indigo-600 text-white' : 'text-gray-600 dark:text-gray-300 hover:bg-indigo-50 dark:hover:bg-indigo-900/40'}`}
>
Login
{t('auth.login_tab')}
</button>
<button
type="button"
onClick={() => setMode('register')}
className={`px-4 py-2 rounded-full transition ${mode === 'register' ? 'bg-emerald-600 text-white' : 'text-gray-600 dark:text-gray-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/40'}`}
>
Registrieren
{t('auth.register_tab')}
</button>
</div>
<label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">E-Mail</span>
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('auth.email')}</span>
<input
type="email"
value={email}
@@ -105,7 +107,7 @@ export default function AuthPanel({ onAuth, themeToggle, onBack }) {
</label>
<label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Passwort</span>
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('auth.password')}</span>
<input
type="password"
value={password}
@@ -133,7 +135,7 @@ export default function AuthPanel({ onAuth, themeToggle, onBack }) {
disabled={loading}
className="w-full py-3 rounded-lg font-semibold text-white bg-gradient-to-r from-indigo-600 to-emerald-600 shadow-md hover:shadow-lg transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{loading ? 'Wird gesendet...' : mode === 'login' ? 'Einloggen' : 'Konto erstellen'}
{loading ? t('auth.sending') : mode === 'login' ? t('auth.login_btn') : t('auth.register_btn')}
</button>
</form>
</div>

View File

@@ -1,6 +1,7 @@
import React, { useMemo } from 'react'
import { Activity, BarChart3, Clock, TrendingUp } from 'lucide-react'
import { calculateTotal } from '../utils/patCalculations'
import { useTranslation } from '../i18n/LanguageContext'
const formatDate = (value) => {
if (!value) return '—'
@@ -10,6 +11,8 @@ const formatDate = (value) => {
}
export default function LiveOverview({ assessments, loading = false, error }) {
const t = useTranslation()
const stats = useMemo(() => {
if (loading || !assessments) {
return {
@@ -54,12 +57,12 @@ export default function LiveOverview({ assessments, loading = false, error }) {
<div className="bg-white/90 dark:bg-gray-900/80 border border-gray-100 dark:border-gray-800 rounded-2xl shadow-lg p-6 mb-6">
<div className="flex items-center justify-between flex-wrap gap-3 mb-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-600 dark:text-emerald-300">Live Übersicht</p>
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">Aktuelle Statistik</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">Basis: alle Bewertungen, geladen beim Seitenaufruf</p>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-600 dark:text-emerald-300">{t('live.title')}</p>
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">{t('live.stats_title')}</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('live.basis')}</p>
</div>
<span className="rounded-full border border-emerald-200/70 dark:border-emerald-700 px-3 py-1 text-xs font-semibold text-emerald-700 dark:text-emerald-200 bg-emerald-50 dark:bg-emerald-900/40">
{loading ? 'Lädt…' : 'Stand: jetzt'}
{loading ? t('live.loading') : t('live.timestamp')}
</span>
</div>
@@ -77,35 +80,35 @@ export default function LiveOverview({ assessments, loading = false, error }) {
</div>
) : stats.total === 0 ? (
<div className="rounded-xl border border-dashed border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/60 p-4 text-sm text-gray-600 dark:text-gray-300">
Noch keine Bewertungen vorhanden. Lege eine neue Bewertung an, um hier Live-Werte zu sehen.
{t('live.no_data')}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="rounded-2xl border border-emerald-200/70 dark:border-emerald-800 bg-gradient-to-br from-emerald-50 via-white to-emerald-50 dark:from-emerald-900/30 dark:via-gray-900 dark:to-emerald-900/20 p-4 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-200">Bewertungen</p>
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-200">{t('live.count')}</p>
<Activity className="h-4 w-4 text-emerald-600 dark:text-emerald-300" aria-hidden="true" />
</div>
<p className="mt-3 text-3xl font-bold text-emerald-800 dark:text-emerald-100">{stats.total}</p>
<p className="text-xs text-emerald-700/80 dark:text-emerald-200/70">In der PAT Datenbank hinterlegt</p>
<p className="text-xs text-emerald-700/80 dark:text-emerald-200/70">{t('live.count_desc')}</p>
</div>
<div className="rounded-2xl border border-sky-200/70 dark:border-sky-800 bg-gradient-to-br from-sky-50 via-white to-sky-50 dark:from-sky-900/30 dark:via-gray-900 dark:to-sky-900/20 p-4 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-sky-700 dark:text-sky-200">Ø Gesamtpunkte</p>
<p className="text-sm font-semibold text-sky-700 dark:text-sky-200">{t('live.avg_points')}</p>
<TrendingUp className="h-4 w-4 text-sky-600 dark:text-sky-300" aria-hidden="true" />
</div>
<p className="mt-3 text-3xl font-bold text-sky-800 dark:text-sky-100">{stats.average}</p>
<p className="text-xs text-sky-700/80 dark:text-sky-200/70">Durchschnitt aller Assessments</p>
<p className="text-xs text-sky-700/80 dark:text-sky-200/70">{t('live.avg_desc')}</p>
</div>
<div className="rounded-2xl border border-indigo-200/70 dark:border-indigo-800 bg-gradient-to-br from-indigo-50 via-white to-indigo-50 dark:from-indigo-900/30 dark:via-gray-900 dark:to-indigo-900/20 p-4 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-indigo-700 dark:text-indigo-200">Letzte Bewertung</p>
<p className="text-sm font-semibold text-indigo-700 dark:text-indigo-200">{t('live.last')}</p>
<Clock className="h-4 w-4 text-indigo-600 dark:text-indigo-300" aria-hidden="true" />
</div>
<p className="mt-3 text-lg font-semibold text-indigo-900 dark:text-indigo-100">
{stats.latest?.name || stats.latest?.patType || 'Unbenannt'}
{stats.latest?.name || stats.latest?.patType || t('live.unnamed')}
</p>
<p className="text-xs text-indigo-700/80 dark:text-indigo-200/70">
{formatDate(stats.latest?.datum)} · {stats.latest?.patType || 'PAT'}
@@ -114,7 +117,7 @@ export default function LiveOverview({ assessments, loading = false, error }) {
<div className="rounded-2xl border border-amber-200/70 dark:border-amber-800 bg-gradient-to-br from-amber-50 via-white to-amber-50 dark:from-amber-900/30 dark:via-gray-900 dark:to-amber-900/20 p-4 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-amber-700 dark:text-amber-200">Top Ergebnis</p>
<p className="text-sm font-semibold text-amber-700 dark:text-amber-200">{t('live.top')}</p>
<BarChart3 className="h-4 w-4 text-amber-600 dark:text-amber-300" aria-hidden="true" />
</div>
<p className="mt-3 text-3xl font-bold text-amber-800 dark:text-amber-100">

View File

@@ -3,6 +3,7 @@ import { CheckCircle2, FileDown, Lock, Save, Share2 } from 'lucide-react';
import ExerciseInput from './ExerciseInput';
import SaveDialog from './SaveDialog';
import { downloadAssessmentPdf } from '../../utils/pdfExport';
import { useTranslation } from '../../i18n/LanguageContext';
const PatDetail = ({
currentAssessment,
@@ -30,6 +31,8 @@ const PatDetail = ({
onShare,
onRevokeShare
}) => {
const t = useTranslation();
if (!currentAssessment) return null;
const totalPoints = calculateTotal();
@@ -59,11 +62,11 @@ const PatDetail = ({
onClick={onBack}
className="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100 font-medium"
>
Zurück zur Übersicht
{t('detail.back')}
</button>
{hasUnsavedChanges && (
<span className="text-orange-500 dark:text-orange-300 text-sm font-semibold">
Ungespeicherte Änderungen
{t('detail.unsaved')}
</span>
)}
<span
@@ -79,12 +82,12 @@ const PatDetail = ({
? 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
}`}>
{testsVisible ? 'Freigabe aktiv' : 'Freigabe pausiert'}
{testsVisible ? t('detail.share_active') : t('detail.share_paused')}
</span>
)}
{currentAssessment?.isFinalized && (
<span className="px-4 py-2 rounded-full text-sm font-semibold bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200">
Final abgeschlossen
{t('detail.finalized')}
</span>
)}
</div>
@@ -96,7 +99,7 @@ const PatDetail = ({
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition flex items-center gap-2 text-sm"
>
<Save className="w-4 h-4" aria-hidden="true" />
Speichern
{t('detail.save')}
</button>
<button
onClick={onFinalize}
@@ -109,7 +112,7 @@ const PatDetail = ({
}`}
>
<CheckCircle2 className="w-4 h-4" aria-hidden="true" />
Final Abschließen
{t('detail.finalize')}
</button>
</>
)}
@@ -118,7 +121,7 @@ const PatDetail = ({
className="bg-amber-600 text-white px-4 py-2 rounded-lg hover:bg-amber-700 transition flex items-center gap-2 text-sm"
>
<FileDown className="w-4 h-4" aria-hidden="true" />
PDF
{t('detail.pdf')}
</button>
<button
onClick={onShare}
@@ -131,14 +134,14 @@ const PatDetail = ({
}`}
>
<Share2 className="w-4 h-4" aria-hidden="true" />
Teilen
{t('detail.share')}
</button>
{isShareActive && (
<button
onClick={onRevokeShare}
className="border border-rose-300 text-rose-700 dark:border-rose-700 dark:text-rose-300 px-4 py-2 rounded-lg hover:bg-rose-50 dark:hover:bg-rose-950/40 transition text-sm"
>
Freigabe aufheben
{t('detail.unshare')}
</button>
)}
</div>
@@ -148,16 +151,16 @@ const PatDetail = ({
<div className="mb-6 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-emerald-800 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-200">
<div className="flex items-center gap-2 font-semibold">
<Lock className="h-4 w-4" aria-hidden="true" />
Nur Lese-Modus
{t('detail.readonly')}
</div>
<p className="mt-1 text-sm">
Diese Bewertung wurde final abgeschlossen und kann nicht mehr bearbeitet werden.
{t('detail.readonly_hint')}
</p>
</div>
)}
<div className="grid grid-cols-4 gap-2 mb-6 bg-gray-50 dark:bg-gray-800 p-4 rounded">
<div className="font-bold">Datum / Name</div>
<div className="font-bold">{t('detail.date_name')}</div>
<div></div>
<input
type="date"
@@ -179,7 +182,7 @@ const PatDetail = ({
onAssessmentChange({ ...currentAssessment, name: e.target.value });
onMarkDirty();
}}
placeholder="Name"
placeholder={t('detail.name_placeholder')}
className={`px-3 py-2 border border-gray-300 dark:border-gray-700 rounded bg-white dark:bg-gray-900 ${
isReadOnly ? 'opacity-70 cursor-not-allowed bg-gray-100 dark:bg-gray-800' : ''
}`}
@@ -193,19 +196,19 @@ const PatDetail = ({
<ExerciseInput exercise={exercise} exIndex={exIndex} onChange={onUpdateExercise} disabled={isReadOnly} />
<div className="grid grid-cols-5 gap-2 mb-1 bg-blue-50 dark:bg-blue-900/30 p-2 rounded">
<div className="font-semibold">Durchschnitt Soll {exercise.soll}</div>
<div className="font-semibold">Ist</div>
<div className="font-semibold">{t('detail.col_avg_target', { soll: exercise.soll })}</div>
<div className="font-semibold">{t('detail.col_actual')}</div>
<div className="font-bold text-center">{exercise.durchschnitt.toFixed(2)}</div>
</div>
<div className="grid grid-cols-5 gap-2 mb-1">
<div>Faktor</div>
<div>{t('detail.col_factor')}</div>
<div></div>
<div className="text-center font-semibold">{exercise.faktor}</div>
</div>
<div className="grid grid-cols-5 gap-2 bg-green-50 dark:bg-green-900/30 p-2 rounded">
<div className="font-bold">Points</div>
<div className="font-bold">{t('detail.col_points')}</div>
<div></div>
<div className="text-center font-bold text-green-600 dark:text-green-300">{exercise.points.toFixed(0)}</div>
</div>
@@ -214,7 +217,7 @@ const PatDetail = ({
<div className="mt-6 bg-gradient-to-r from-green-100 to-blue-100 dark:from-gray-800 dark:to-gray-700 p-6 rounded-lg">
<div className="flex justify-between items-center mb-4">
<span className="text-2xl font-bold text-gray-800 dark:text-gray-100">Ergebnis</span>
<span className="text-2xl font-bold text-gray-800 dark:text-gray-100">{t('detail.col_result')}</span>
<span className="text-4xl font-bold text-green-600 dark:text-green-300">{totalPoints.toFixed(0)}</span>
</div>

View File

@@ -1,31 +1,34 @@
import React from 'react';
import { useTranslation } from '../../i18n/LanguageContext';
const SaveDialog = ({ show, onSave, onDiscard, onCancel }) => {
const t = useTranslation();
if (!show) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl p-6 max-w-md w-full mx-4 border border-gray-200 dark:border-gray-800">
<h3 className="text-xl font-bold text-gray-800 dark:text-gray-100 mb-4">Ungespeicherte Änderungen</h3>
<p className="text-gray-600 dark:text-gray-300 mb-6">Sie haben ungespeicherte Änderungen. Möchten Sie diese speichern?</p>
<h3 className="text-xl font-bold text-gray-800 dark:text-gray-100 mb-4">{t('savedialog.title')}</h3>
<p className="text-gray-600 dark:text-gray-300 mb-6">{t('savedialog.message')}</p>
<div className="flex gap-3">
<button
onClick={onSave}
className="flex-1 bg-green-600 text-white px-4 py-3 rounded-lg hover:bg-green-700 transition font-semibold"
>
Ja, speichern
{t('savedialog.yes')}
</button>
<button
onClick={onDiscard}
className="flex-1 bg-red-600 text-white px-4 py-3 rounded-lg hover:bg-red-700 transition font-semibold"
>
Nein, verwerfen
{t('savedialog.no')}
</button>
<button
onClick={onCancel}
className="flex-1 bg-gray-500 dark:bg-gray-700 text-white px-4 py-3 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-600 transition font-semibold"
>
Abbrechen
{t('savedialog.cancel')}
</button>
</div>
</div>

View File

@@ -10,8 +10,7 @@ import {
Trash2,
User
} from 'lucide-react';
const CALENDAR_DAY_LABELS = ['M', 'D', 'M', 'D', 'F', 'S', 'S'];
import { useTranslation } from '../../i18n/LanguageContext';
const toIsoDate = (date) => {
const year = date.getFullYear();
@@ -56,6 +55,7 @@ const PatList = ({
getPatTypeColor,
getAchievement
}) => {
const t = useTranslation();
const datePickerRef = useRef(null);
const [showCreateMenu, setShowCreateMenu] = useState(false);
const [activeDatePicker, setActiveDatePicker] = useState(null);
@@ -217,7 +217,7 @@ const PatList = ({
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/70">
<h2 className="text-lg font-bold text-gray-800 dark:text-gray-100">{title}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Anzahl: {entries.length}
{t('patlist.count', { count: entries.length })}
{entries.length !== totalEntries ? ` von ${totalEntries}` : ''}
</p>
</div>
@@ -236,14 +236,14 @@ const PatList = ({
onClick={() => handleSort('name')}
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
>
Name
{t('patlist.col_name')}
{renderSortIcon('name')}
</button>
<input
type="text"
value={filters.name}
onChange={(event) => handleFilterChange('name', event.target.value)}
aria-label="Nach Name filtern"
aria-label={t('patlist.filter_name')}
className="w-40 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-950 px-3 py-2 text-sm font-normal text-gray-700 dark:text-gray-200"
/>
</div>
@@ -255,18 +255,18 @@ const PatList = ({
onClick={() => handleSort('datum')}
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
>
Datum
{t('patlist.col_date')}
{renderSortIcon('datum')}
</button>
<div ref={isDatePickerOpen ? datePickerRef : null} className="relative">
<button
type="button"
onClick={() => toggleDatePicker(tableKey)}
aria-label="Nach Datum filtern"
aria-label={t('patlist.filter_date')}
aria-expanded={isDatePickerOpen}
className="flex w-36 items-center justify-between rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-950 px-3 py-2 text-left text-sm font-normal text-gray-700 dark:text-gray-200"
>
<span>{formatDateForFilter(filters.datum) || 'tt.mm.jjjj'}</span>
<span>{formatDateForFilter(filters.datum) || t('patlist.date_placeholder')}</span>
<Calendar className="h-4 w-4 text-gray-500 dark:text-gray-400" aria-hidden="true" />
</button>
@@ -281,7 +281,7 @@ const PatList = ({
)
}
className="rounded-lg p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label="Vorheriger Monat"
aria-label={t('patlist.prev_month')}
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</button>
@@ -296,14 +296,14 @@ const PatList = ({
)
}
className="rounded-lg p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label="Nächster Monat"
aria-label={t('patlist.next_month')}
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="grid grid-cols-7 gap-1 text-center text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{CALENDAR_DAY_LABELS.map((label, index) => (
{t('patlist.days').split(' ').map((label, index) => (
<span key={`${label}-${index}`}>{label}</span>
))}
</div>
@@ -337,14 +337,14 @@ const PatList = ({
onClick={selectToday}
className="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
Heute
{t('patlist.today')}
</button>
<button
type="button"
onClick={clearDate}
className="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
Löschen
{t('patlist.delete')}
</button>
</div>
</div>
@@ -359,7 +359,7 @@ const PatList = ({
onClick={() => handleSort('result')}
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
>
Ergebnis
{t('patlist.col_result')}
{renderSortIcon('result')}
</button>
</div>
@@ -401,12 +401,12 @@ const PatList = ({
? 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
}`}>
{testsVisible ? 'Freigabe aktiv' : 'Freigabe pausiert'}
{testsVisible ? t('patlist.share_active') : t('patlist.share_paused')}
</span>
)}
{assessment.isFinalized && (
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200">
Final abgeschlossen
{t('patlist.finalized')}
</span>
)}
</div>
@@ -433,7 +433,7 @@ const PatList = ({
onClick={() => onEdit(assessment)}
className="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700 transition text-sm"
>
Öffnen
{t('patlist.open')}
</button>
<button
onClick={(e) => {
@@ -441,7 +441,7 @@ const PatList = ({
onDelete(assessment.id);
}}
className="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 p-2"
title="Löschen"
title={t('patlist.delete')}
>
<Trash2 className="w-5 h-5" aria-hidden="true" />
</button>
@@ -462,15 +462,15 @@ const PatList = ({
<div className="min-h-screen bg-gradient-to-br from-green-50 to-blue-100 dark:from-gray-950 dark:to-gray-900 p-6 text-gray-900 dark:text-gray-100">
<div className="max-w-7xl mx-auto">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 mb-6 border border-gray-100 dark:border-gray-800">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">PAT Test Manager</h1>
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">{t('patlist.title')}</h1>
<div className="relative">
<button
onClick={() => setShowCreateMenu((current) => !current)}
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition flex items-center justify-center gap-2"
>
<Plus className="w-4 h-4" />
Neue Bewertung
{t('patlist.new_assessment')}
</button>
{showCreateMenu && (
@@ -501,17 +501,17 @@ const PatList = ({
<div className="space-y-6">
{renderAssessmentTable(
'open',
'Noch offene Bewertungen',
t('patlist.open_assessments'),
sortedOpenAssessments,
openAssessments.length,
'Keine offenen Bewertungen mit den aktuellen Suchfiltern gefunden'
t('patlist.no_open')
)}
{renderAssessmentTable(
'finalized',
'Abgeschlossene Bewertungen',
t('patlist.closed_assessments'),
sortedFinalizedAssessments,
finalizedAssessments.length,
'Keine abgeschlossenen Bewertungen mit den aktuellen Suchfiltern gefunden'
t('patlist.no_closed')
)}
</div>
)}
@@ -519,8 +519,8 @@ const PatList = ({
{assessments.length === 0 && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-12 text-center border border-gray-100 dark:border-gray-800">
<User className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-700" aria-hidden="true" />
<p className="text-gray-600 dark:text-gray-300 mb-4">Noch keine Bewertungen vorhanden</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Lege oben eine neue Bewertung an und wähle dabei PAT Start, PAT 1, PAT 2 oder PAT 3</p>
<p className="text-gray-600 dark:text-gray-300 mb-4">{t('patlist.no_assessments')}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('patlist.no_assessments_hint')}</p>
</div>
)}
</div>

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../i18n/LanguageContext';
import PatList from './PatList/PatList';
import PatDetail from './PatDetail/PatDetail';
import AnalysisTab from './Analysis/AnalysisTab';
@@ -32,6 +33,7 @@ export default function PatTestManager({
testsVisible = false,
isProfileLoading = false
}) {
const t = useTranslation();
const visiblePatTypes = useMemo(() => patTypes || {}, []);
const patTypeOptions = Object.keys(visiblePatTypes);
const [showList, setShowList] = useState(true);
@@ -151,7 +153,7 @@ export default function PatTestManager({
const executeSave = async () => {
const result = await handleSave();
if (result?.ok) {
alert('Erfolgreich gespeichert!');
alert(t('patmgr.saved'));
}
return result;
};
@@ -159,13 +161,13 @@ export default function PatTestManager({
const handleFinalize = useCallback(async () => {
const result = await finalizeAssessment();
if (result?.ok) {
alert('Bewertung wurde final abgeschlossen und ist jetzt nur noch lesbar.');
alert(t('patmgr.finalized'));
}
}, [finalizeAssessment]);
const handleSaveAndExit = async () => {
if (!currentAssessment?.name || !currentAssessment?.datum) {
alert('Bitte Name und Datum ausfüllen bevor Sie speichern');
alert(t('patmgr.fill_name_date'));
return;
}
@@ -190,7 +192,7 @@ export default function PatTestManager({
const { token, url } = await createAssessmentShareLink(assessment);
const label = assessment?.name || assessment?.patType || 'dieser Test';
const localhostHint = isLocalhostShare()
? '\n\nHinweis: Der Link zeigt aktuell auf localhost und ist nur erreichbar, wenn die App von außen erreichbar ist.'
? `\n\n${t('patmgr.share_localhost_hint')}`
: '';
setAssessmentShareTokens((prev) => ({
...prev,
@@ -200,16 +202,16 @@ export default function PatTestManager({
try {
const copied = await copyToClipboard(url);
if (copied) {
alert(`Freigabelink für "${label}" wurde in die Zwischenablage kopiert.${localhostHint}`);
alert(`${t('patmgr.share_copied', { label })}${localhostHint}`);
return;
}
} catch (clipboardError) {
console.warn('Kopieren in die Zwischenablage fehlgeschlagen:', clipboardError);
}
window.prompt(`Freigabelink für "${label}"${localhostHint}`, url);
window.prompt(`${t('patmgr.share_link_label', { label })}${localhostHint}`, url);
} catch (error) {
alert(`Freigabelink konnte nicht erstellt werden: ${error.message || error}`);
alert(t('patmgr.share_error', { error: error.message || error }));
}
}, []);
const handleRevokeShare = useCallback(async (assessment) => {
@@ -222,31 +224,31 @@ export default function PatTestManager({
delete next[assessment.id];
return next;
});
alert(`Freigabe für "${label}" wurde aufgehoben.`);
alert(t('patmgr.unshare_success', { label }));
} catch (error) {
alert(`Freigabe konnte nicht aufgehoben werden: ${error.message || error}`);
alert(t('patmgr.unshare_error', { error: error.message || error }));
}
}, []);
const handleShareFromDetail = useCallback(() => {
if (!currentAssessment) return;
if (isProfileLoading) {
alert('Profil wird noch geladen. Bitte versuche es gleich noch einmal.');
alert(t('patmgr.profile_loading'));
return;
}
if (!testsVisible) {
alert('Aktiviere im Profil zuerst "Tests sichtbar", bevor du Tests teilst.');
alert(t('patmgr.enable_visible'));
return;
}
if (!isCurrentAssessmentPersisted) {
alert('Bitte den Test zuerst speichern, bevor du ihn teilen kannst.');
alert(t('patmgr.save_before_share'));
return;
}
if (hasUnsavedChanges) {
alert('Bitte erst speichern, damit der aktuelle Stand geteilt wird.');
alert(t('patmgr.save_for_share'));
return;
}
@@ -264,13 +266,13 @@ export default function PatTestManager({
handleRevokeShare(currentAssessment);
}, [currentAssessment, handleRevokeShare, isCurrentAssessmentShareActive]);
const shareDisabledReason = !isCurrentAssessmentPersisted
? 'Bitte zuerst speichern'
? t('patmgr.save_first')
: hasUnsavedChanges
? 'Bitte erst speichern, damit der aktuelle Stand geteilt wird'
? t('patmgr.save_for_share')
: isProfileLoading
? 'Profil wird geladen'
? t('patmgr.profile_loading_btn')
: !testsVisible
? 'Aktiviere im Profil "Tests sichtbar"'
? t('patmgr.enable_visible_btn')
: '';
const finalizeDisabledReason = getFinalizeDisabledReason(currentAssessment);

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { countryOptions, getCountryLabel, getCountryOption } from '../data/countries';
import CountryFlag from './CountryFlag';
import { useTranslation } from '../i18n/LanguageContext';
const emptyStatus = {
message: '',
@@ -17,6 +18,7 @@ export default function ProfileTab({
onDelete,
onSessionChange
}) {
const t = useTranslation();
const [formValues, setFormValues] = useState(profile);
const [status, setStatus] = useState(emptyStatus);
const [saving, setSaving] = useState(false);
@@ -87,14 +89,14 @@ export default function ProfileTab({
setStatus({
message: result.emailUpdateRequested
? 'Profil gespeichert. Die neue Mailadresse muss ggf. noch per E-Mail bestätigt werden.'
: 'Profil gespeichert.',
? t('profile.saved_email')
: t('profile.saved'),
error: ''
});
} else {
setStatus({
message: '',
error: result?.error?.message || 'Profil konnte nicht gespeichert werden.'
error: result?.error?.message || t('profile.error_save')
});
}
@@ -102,9 +104,7 @@ export default function ProfileTab({
};
const handleDelete = async () => {
const confirmed = window.confirm(
'Profil wirklich löschen?\n\nDabei werden alle Tests dieses Benutzers sowie vorhandene Analysepläne dauerhaft entfernt.'
);
const confirmed = window.confirm(t('profile.delete_confirm'));
if (!confirmed) return;
@@ -115,13 +115,13 @@ export default function ProfileTab({
if (result?.ok) {
setStatus({
message: 'Profil und alle Tests wurden gelöscht. Das Konto bleibt bestehen und kann neu eingerichtet werden.',
message: t('profile.deleted'),
error: ''
});
} else {
setStatus({
message: '',
error: result?.error?.message || 'Profil konnte nicht gelöscht werden.'
error: result?.error?.message || t('profile.error_delete')
});
}
@@ -133,44 +133,44 @@ export default function ProfileTab({
<div className="max-w-4xl mx-auto">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 overflow-hidden">
<div className="px-6 py-5 border-b border-gray-200 dark:border-gray-800">
<p className="text-xs uppercase tracking-[0.25em] text-sky-700 dark:text-sky-300">Benutzerprofil</p>
<h1 className="text-3xl font-bold mt-2">Profil verwalten</h1>
<p className="text-xs uppercase tracking-[0.25em] text-sky-700 dark:text-sky-300">{t('profile.title_label')}</p>
<h1 className="text-3xl font-bold mt-2">{t('profile.title')}</h1>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
Name, Vorname, Mailadresse, Land und die Sichtbarkeit deiner Testfreigaben werden hier gepflegt.
{t('profile.description')}
</p>
</div>
{loading ? (
<div className="px-6 py-10 text-sm text-gray-600 dark:text-gray-300">
Profil wird geladen...
{t('profile.loading')}
</div>
) : (
<form onSubmit={handleSubmit} className="px-6 py-6 space-y-6">
<div className="grid md:grid-cols-2 gap-5">
<label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Vorname</span>
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.first_name')}</span>
<input
type="text"
value={formValues.firstName || ''}
onChange={updateField('firstName')}
className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="Max"
placeholder={t('profile.first_name_placeholder')}
/>
</label>
<label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Nachname</span>
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.last_name')}</span>
<input
type="text"
value={formValues.lastName || ''}
onChange={updateField('lastName')}
className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="Mustermann"
placeholder={t('profile.last_name_placeholder')}
/>
</label>
<label className="block md:col-span-2">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Mailadresse</span>
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.email')}</span>
<input
type="email"
value={formValues.email || session?.user?.email || ''}
@@ -180,12 +180,12 @@ export default function ProfileTab({
placeholder="you@example.com"
/>
<span className="mt-2 block text-xs text-gray-500 dark:text-gray-400">
Wenn du die Mailadresse änderst, wird dir eine Bestätigung an die neue Adresse gesendet.
{t('profile.email_hint')}
</span>
</label>
<label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Land</span>
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.country')}</span>
<div ref={countryDropdownRef} className="relative mt-1">
<button
type="button"
@@ -195,11 +195,11 @@ export default function ProfileTab({
<span className="inline-flex items-center gap-3">
<CountryFlag
countryCode={selectedCountry?.code || formValues.country}
title={selectedCountry?.name || getCountryLabel(formValues.country) || 'Land auswählen'}
title={selectedCountry?.name || getCountryLabel(formValues.country) || t('profile.country_none')}
className="h-5 w-7 rounded-[3px] shadow-sm ring-1 ring-black/10"
/>
<span className="font-medium">
{selectedCountry?.name || getCountryLabel(formValues.country) || 'Land auswählen'}
{selectedCountry?.name || getCountryLabel(formValues.country) || t('profile.country_none')}
</span>
</span>
<ChevronDown
@@ -222,10 +222,10 @@ export default function ProfileTab({
<span className="inline-flex items-center gap-3">
<CountryFlag
countryCode=""
title="Kein Land ausgewählt"
title={t('profile.country_none')}
className="h-5 w-7 rounded-[3px] shadow-sm"
/>
<span>Kein Land ausgewählt</span>
<span>{t('profile.country_none')}</span>
</span>
{!formValues.country && <Check className="h-4 w-4" aria-hidden="true" />}
</button>
@@ -278,35 +278,52 @@ export default function ProfileTab({
)}
</div>
<span className="mt-2 block text-xs text-gray-500 dark:text-gray-400">
Die ausgewählte Flagge wird später auch im Kopfbereich neben deiner Mailadresse angezeigt.
{t('profile.country_hint')}
</span>
</label>
<div className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Ranglistenplatz</span>
<div className="mt-1 rounded-lg border border-dashed border-amber-300 bg-amber-50 px-4 py-3 dark:border-amber-900/70 dark:bg-amber-950/30">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
Platzhalter
</span>
<span className="rounded-full bg-white px-3 py-1 text-sm font-semibold text-amber-700 shadow-sm dark:bg-gray-900 dark:text-amber-300">
# -
</span>
</div>
<p className="mt-2 text-xs text-amber-700/80 dark:text-amber-200/80">
Hier kann spater der Ranglistenplatz des Benutzers angezeigt werden.
</p>
<label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.display')}</span>
<select
value={formValues.language || 'de'}
onChange={updateField('language')}
className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-sky-500"
>
<option value="de">Deutsch</option>
<option value="en">Englisch</option>
<option value="it">Italienisch</option>
<option value="es">Spanisch</option>
<option value="pt">Portugiesisch</option>
</select>
<span className="mt-2 block text-xs text-gray-500 dark:text-gray-400">
{t('profile.display_hint')}
</span>
</label>
</div>
<div className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.rank')}</span>
<div className="mt-1 rounded-lg border border-dashed border-amber-300 bg-amber-50 px-4 py-3 dark:border-amber-900/70 dark:bg-amber-950/30">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
{t('profile.rank_placeholder')}
</span>
<span className="rounded-full bg-white px-3 py-1 text-sm font-semibold text-amber-700 shadow-sm dark:bg-gray-900 dark:text-amber-300">
# -
</span>
</div>
<p className="mt-2 text-xs text-amber-700/80 dark:text-amber-200/80">
{t('profile.rank_hint')}
</p>
</div>
</div>
<div className="rounded-xl border border-sky-200 dark:border-sky-900/70 bg-sky-50 dark:bg-sky-950/30 p-5">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Tests sichtbar</h2>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{t('profile.tests_visible_title')}</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300 max-w-2xl">
Ist dieser Schalter aktiv, funktionieren deine Freigabelinks. Wenn du ihn deaktivierst,
bleiben vorhandene Links gespeichert, sind aber nicht mehr öffentlich sichtbar.
{t('profile.tests_visible_desc')}
</p>
</div>
@@ -318,7 +335,7 @@ export default function ProfileTab({
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500"
/>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100">
{formValues.testsVisible ? 'Sichtbar' : 'Nicht sichtbar'}
{formValues.testsVisible ? t('profile.visible') : t('profile.not_visible')}
</span>
</label>
</div>
@@ -338,7 +355,7 @@ export default function ProfileTab({
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="text-sm text-gray-500 dark:text-gray-400">
Konto-ID: <span className="font-mono">{session?.user?.id || '—'}</span>
{t('profile.account_id')}: <span className="font-mono">{session?.user?.id || '—'}</span>
</div>
<div className="flex items-center gap-3 flex-wrap">
@@ -348,7 +365,7 @@ export default function ProfileTab({
disabled={deleting || saving}
className="px-5 py-3 rounded-lg border border-rose-300 bg-white text-rose-700 font-semibold hover:bg-rose-50 transition disabled:opacity-60 disabled:cursor-not-allowed dark:border-rose-800 dark:bg-gray-900 dark:text-rose-300 dark:hover:bg-rose-950/40"
>
{deleting ? 'Löscht...' : 'Profil löschen'}
{deleting ? t('profile.deleting') : t('profile.delete')}
</button>
<button
@@ -356,7 +373,7 @@ export default function ProfileTab({
disabled={saving || deleting}
className="px-5 py-3 rounded-lg bg-sky-600 text-white font-semibold hover:bg-sky-700 transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{saving ? 'Speichert...' : 'Profil speichern'}
{saving ? t('profile.saving') : t('profile.save')}
</button>
</div>
</div>

View File

@@ -1,8 +1,10 @@
import React, { useEffect, useState } from 'react'
import SharedAssessmentView from './SharedAssessmentView'
import { getSharedAssessment } from '../lib/assessmentShareService'
import { useTranslation } from '../i18n/LanguageContext'
export default function SharedAssessmentPage({ shareToken, themeToggle }) {
const t = useTranslation()
const [assessment, setAssessment] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -12,7 +14,7 @@ export default function SharedAssessmentPage({ shareToken, themeToggle }) {
const loadAssessment = async () => {
if (!shareToken) {
setError('Freigabelink fehlt.')
setError(t('shared.missing_token'))
setLoading(false)
return
}
@@ -27,7 +29,7 @@ export default function SharedAssessmentPage({ shareToken, themeToggle }) {
if (!sharedAssessment) {
setAssessment(null)
setError('Dieser Freigabelink ist ungültig, deaktiviert oder aktuell nicht öffentlich sichtbar.')
setError(t('shared.invalid'))
return
}
@@ -35,7 +37,7 @@ export default function SharedAssessmentPage({ shareToken, themeToggle }) {
} catch (err) {
if (ignore) return
setAssessment(null)
setError(err.message || 'Geteilter Test konnte nicht geladen werden.')
setError(err.message || t('shared.load_error'))
} finally {
if (!ignore) {
setLoading(false)
@@ -57,14 +59,14 @@ export default function SharedAssessmentPage({ shareToken, themeToggle }) {
{loading ? (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-6">
Geteilter Test wird geladen...
{t('shared.loading')}
</div>
</div>
) : error ? (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="max-w-xl w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-6">
<p className="text-xs uppercase tracking-[0.2em] text-rose-600 dark:text-rose-300">Freigabe</p>
<h1 className="text-2xl font-bold mt-2">Geteilter Test nicht verfügbar</h1>
<p className="text-xs uppercase tracking-[0.2em] text-rose-600 dark:text-rose-300">{t('shared.label')}</p>
<h1 className="text-2xl font-bold mt-2">{t('shared.not_available')}</h1>
<p className="text-gray-600 dark:text-gray-300 mt-3">{error}</p>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import React from 'react'
import { calculateTotal, getAchievement, getPatTypeColor } from '../utils/patCalculations'
import { useTranslation } from '../i18n/LanguageContext'
const formatDate = (value) => {
if (!value) return '—'
@@ -28,6 +29,8 @@ const ValueRow = ({ label, values = [] }) => (
)
export default function SharedAssessmentView({ assessment }) {
const t = useTranslation()
if (!assessment) return null
const totalPoints = calculateTotal(assessment.exercises || [])
@@ -39,12 +42,12 @@ export default function SharedAssessmentView({ assessment }) {
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 border border-gray-200 dark:border-gray-800">
<div className="flex justify-between items-start gap-4 mb-6 flex-wrap">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-sky-700 dark:text-sky-300">Geteilter Test</p>
<p className="text-xs uppercase tracking-[0.2em] text-sky-700 dark:text-sky-300">{t('shared.title')}</p>
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100 mt-2">
{assessment.name || 'PAT Test'}
{assessment.name || t('shared.default_name')}
</h1>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
Dieser Link zeigt nur diesen einen Test im Nur-Lesen-Modus.
{t('shared.hint')}
</p>
</div>
@@ -57,17 +60,17 @@ export default function SharedAssessmentView({ assessment }) {
<div className="grid md:grid-cols-3 gap-3 mb-6 bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Datum</p>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{t('shared.date')}</p>
<p className="mt-1 text-lg font-semibold">{formatDate(assessment.datum)}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Gesamtpunkte</p>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{t('shared.total_points')}</p>
<p className="mt-1 text-lg font-semibold text-green-600 dark:text-green-300">
{totalPoints.toFixed(0)}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Bewertung</p>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{t('shared.rating')}</p>
<p className="mt-1 text-lg font-semibold">{achievement.name}</p>
</div>
</div>
@@ -78,11 +81,11 @@ export default function SharedAssessmentView({ assessment }) {
<div>
<h2 className="font-bold text-lg text-gray-700 dark:text-gray-200">{exercise.name}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Soll {exercise.soll} · Faktor {exercise.faktor}
{t('shared.target_factor', { soll: exercise.soll, faktor: exercise.faktor })}
</p>
</div>
<div className="rounded-lg bg-green-50 dark:bg-green-900/30 px-4 py-2">
<p className="text-xs uppercase tracking-wide text-green-700 dark:text-green-300">Points</p>
<p className="text-xs uppercase tracking-wide text-green-700 dark:text-green-300">{t('shared.col_points')}</p>
<p className="text-xl font-bold text-green-700 dark:text-green-200">
{(exercise.points || 0).toFixed(0)}
</p>
@@ -96,23 +99,23 @@ export default function SharedAssessmentView({ assessment }) {
<ValueRow label={exercise.subLabels?.[1] || 'b)'} values={exercise.subB} />
</>
) : (
<ValueRow label="Werte" values={exercise.values || []} />
<ValueRow label={t('shared.col_values')} values={exercise.values || []} />
)}
</div>
<div className="grid md:grid-cols-3 gap-3 mt-4">
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/30 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-blue-700 dark:text-blue-300">Durchschnitt</p>
<p className="text-xs uppercase tracking-wide text-blue-700 dark:text-blue-300">{t('shared.col_avg')}</p>
<p className="text-lg font-semibold text-blue-700 dark:text-blue-200">
{(exercise.durchschnitt || 0).toFixed(2)}
</p>
</div>
<div className="rounded-lg bg-gray-50 dark:bg-gray-800 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Soll</p>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{t('shared.col_target')}</p>
<p className="text-lg font-semibold">{exercise.soll}</p>
</div>
<div className="rounded-lg bg-gray-50 dark:bg-gray-800 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Faktor</p>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{t('shared.col_factor')}</p>
<p className="text-lg font-semibold">{exercise.faktor}</p>
</div>
</div>
@@ -121,7 +124,7 @@ export default function SharedAssessmentView({ assessment }) {
<div className="mt-6 bg-gradient-to-r from-green-100 to-blue-100 dark:from-gray-800 dark:to-gray-700 p-6 rounded-lg">
<div className="flex justify-between items-center gap-4 flex-wrap mb-4">
<span className="text-2xl font-bold text-gray-800 dark:text-gray-100">Ergebnis</span>
<span className="text-2xl font-bold text-gray-800 dark:text-gray-100">{t('shared.col_result')}</span>
<span className="text-4xl font-bold text-green-600 dark:text-green-300">{totalPoints.toFixed(0)}</span>
</div>

View File

@@ -7,6 +7,7 @@ const buildDefaultProfile = (email = '') => ({
firstName: '',
lastName: '',
country: '',
language: 'de',
testsVisible: false,
email
});
@@ -15,6 +16,7 @@ const mapFromDb = (row, email = '') => ({
firstName: row?.first_name || '',
lastName: row?.last_name || '',
country: row?.country || '',
language: row?.language || 'de',
testsVisible: Boolean(row?.tests_visible),
email
});
@@ -24,6 +26,7 @@ const mapToDb = (profile, userId) => ({
first_name: profile.firstName.trim(),
last_name: profile.lastName.trim(),
country: profile.country.trim(),
language: profile.language || 'de',
tests_visible: Boolean(profile.testsVisible)
});
@@ -62,7 +65,7 @@ export const useUserProfile = (session) => {
try {
const { data, error: loadError } = await supabase
.from('user_profiles')
.select('first_name, last_name, country, tests_visible')
.select('first_name, last_name, country, language, tests_visible')
.eq('user_id', userId)
.maybeSingle();
@@ -112,7 +115,7 @@ export const useUserProfile = (session) => {
const { data, error: saveError } = await supabase
.from('user_profiles')
.upsert(mapToDb(nextValues, userId), { onConflict: 'user_id' })
.select('first_name, last_name, country, tests_visible')
.select('first_name, last_name, country, language, tests_visible')
.single();
if (saveError) throw saveError;

View File

@@ -0,0 +1,18 @@
import React, { createContext, useContext, useMemo } from 'react';
import { getTranslator } from './translations';
const LanguageContext = createContext(() => (key) => key);
export function LanguageProvider({ language, children }) {
const t = useMemo(() => getTranslator(language), [language]);
return (
<LanguageContext.Provider value={t}>
{children}
</LanguageContext.Provider>
);
}
export function useTranslation() {
return useContext(LanguageContext);
}

1164
src/i18n/translations.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
alter table public.user_profiles
add column if not exists language text not null default 'de';