Initial commit

This commit is contained in:
Ashikagi
2026-03-23 20:49:30 +01:00
commit f5338ea3b2
82 changed files with 11979 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
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 GapBarChart from './GapBarChart';
import RadarChart from './RadarChart';
import StrengthList from './StrengthList';
import TrainingPlanPanel from './TrainingPlanPanel';
import TrendChart from './TrendChart';
import WeaknessPriorityList from './WeaknessPriorityList';
export default function AnalysisTab({
assessments,
selectedPatType,
onPatTypeChange,
patTypes,
user
}) {
const patTypeOptions = Object.keys(patTypes || {});
useEffect(() => {
if (!selectedPatType && patTypeOptions.length) {
onPatTypeChange(patTypeOptions[0]);
}
}, [onPatTypeChange, patTypeOptions, selectedPatType]);
const analysis = useMemo(
() =>
buildAnalysis(assessments, selectedPatType, {
windowSize: 5,
missingStrategy: 'zero'
}),
[assessments, selectedPatType]
);
const selectedPatAssessments = assessments.filter((assessment) => assessment.patType === selectedPatType);
const hasAssessments = assessments.length > 0;
const {
activePlan,
historyPlans,
loading,
saving,
error,
saveGeneratedPlan,
updateSessionState,
updateSessionPartial,
loadPlans
} = useTrainingPlans({
user,
patType: selectedPatType
});
const handleSavePlan = ({ durationWeeks, sessionsPerWeek, sessions }) =>
saveGeneratedPlan({
analysis,
durationWeeks,
sessionsPerWeek,
sessions
});
return (
<div className="min-h-screen bg-gradient-to-br from-cyan-50 via-white to-emerald-50 dark:from-gray-950 dark:via-gray-950 dark:to-gray-900 p-6 text-gray-900 dark:text-gray-100">
<div className="max-w-7xl mx-auto space-y-6">
<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-sm text-gray-600 dark:text-gray-400 mt-1">
Priorisierung nach Gap × Faktor × Konstanz basierend auf den letzten 5 Assessments.
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{patTypeOptions.map((patType) => {
const isActive = patType === selectedPatType;
return (
<button
key={patType}
type="button"
onClick={() => onPatTypeChange(patType)}
className={`px-4 py-2 rounded-lg border text-sm font-semibold transition ${
isActive
? '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'
}`}
>
{patType}
</button>
);
})}
</div>
<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}
</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
</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
</span>
)}
</div>
</section>
{!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>
</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>
</section>
) : (
<>
<section className="grid lg:grid-cols-2 gap-4">
<div className="h-full">
<GapBarChart metrics={analysis.exerciseMetrics} />
</div>
<div className="h-full">
<RadarChart radarSeries={analysis.radarSeries} />
</div>
<div className="lg:col-span-2">
<TrendChart trendSeries={analysis.trendSeries} />
</div>
</section>
<section className="grid lg:grid-cols-2 gap-4">
<WeaknessPriorityList weaknesses={analysis.topWeaknesses} />
<StrengthList strengths={analysis.topStrengths} />
</section>
<TrainingPlanPanel
patType={selectedPatType}
analysis={analysis}
activePlan={activePlan}
historyPlans={historyPlans}
loading={loading}
saving={saving}
error={error}
onSavePlan={handleSavePlan}
onUpdateSessionState={updateSessionState}
onUpdateSessionPartial={updateSessionPartial}
onReload={loadPlans}
/>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import React from 'react';
const truncate = (value, maxLength = 26) =>
value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value;
export default function GapBarChart({ metrics = [] }) {
const data = [...metrics]
.sort((left, right) => right.priorityScore - left.priorityScore)
.slice(0, 8);
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.
</div>
);
}
const width = 760;
const leftPad = 220;
const rightPad = 24;
const rowHeight = 38;
const topPad = 20;
const bottomPad = 24;
const height = topPad + bottomPad + rowHeight * data.length;
const maxScore = Math.max(...data.map((item) => item.priorityScore), 0.01);
const chartWidth = width - leftPad - rightPad;
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">
{data.map((item, index) => {
const y = topPad + index * rowHeight;
const barWidth = (item.priorityScore / maxScore) * chartWidth;
return (
<g key={item.name}>
<text
x={10}
y={y + 20}
className="fill-gray-700 dark:fill-gray-200"
style={{ fontSize: 12, fontWeight: 500 }}
>
{truncate(item.name)}
</text>
<rect
x={leftPad}
y={y + 8}
width={chartWidth}
height={16}
rx={8}
className="fill-gray-200 dark:fill-gray-800"
/>
<rect
x={leftPad}
y={y + 8}
width={Math.max(3, barWidth)}
height={16}
rx={8}
className="fill-rose-500"
/>
<text
x={leftPad + Math.max(8, barWidth) + 8}
y={y + 20}
className="fill-gray-700 dark:fill-gray-300"
style={{ fontSize: 11, fontWeight: 600 }}
>
{item.priorityScore.toFixed(2)}
</text>
</g>
);
})}
</svg>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import React from 'react';
const polarToCartesian = (centerX, centerY, radius, angle) => ({
x: centerX + radius * Math.cos(angle),
y: centerY + radius * Math.sin(angle)
});
const truncate = (value, maxLength = 16) => (value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value);
export default function RadarChart({ radarSeries = [] }) {
const data = radarSeries.slice(0, 6);
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 Radarprofil verfügbar.
</div>
);
}
const width = 420;
const height = 320;
const centerX = width / 2;
const centerY = height / 2;
const radius = 105;
const angleStep = (Math.PI * 2) / data.length;
const toPoint = (ratio, idx) => {
const angle = -Math.PI / 2 + idx * angleStep;
return polarToCartesian(centerX, centerY, radius * (Math.max(0, Math.min(1.2, ratio)) / 1.2), angle);
};
const polygon = data
.map((item, idx) => {
const point = toPoint(item.ratio, idx);
return `${point.x},${point.y}`;
})
.join(' ');
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">Radar-Profil (Ist/Soll)</h3>
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto max-w-[420px] mx-auto" role="img" aria-label="Radarprofil">
{[0.25, 0.5, 0.75, 1].map((level) => (
<polygon
key={level}
points={data
.map((_, idx) => {
const angle = -Math.PI / 2 + idx * angleStep;
const point = polarToCartesian(centerX, centerY, radius * level, angle);
return `${point.x},${point.y}`;
})
.join(' ')}
fill="none"
className="stroke-gray-300 dark:stroke-gray-700"
strokeWidth="1"
/>
))}
{data.map((item, idx) => {
const angle = -Math.PI / 2 + idx * angleStep;
const outerPoint = polarToCartesian(centerX, centerY, radius + 16, angle);
const axisEnd = polarToCartesian(centerX, centerY, radius, angle);
return (
<g key={item.name}>
<line
x1={centerX}
y1={centerY}
x2={axisEnd.x}
y2={axisEnd.y}
className="stroke-gray-300 dark:stroke-gray-700"
strokeWidth="1"
/>
<text
x={outerPoint.x}
y={outerPoint.y}
textAnchor="middle"
className="fill-gray-600 dark:fill-gray-300"
style={{ fontSize: 10 }}
>
{truncate(item.name)}
</text>
</g>
);
})}
<polygon points={polygon} className="fill-sky-300/40 dark:fill-sky-500/30 stroke-sky-600 dark:stroke-sky-400" strokeWidth="2" />
</svg>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
const scoreToPercent = (score) => Math.round(Math.max(0, Math.min(1.5, score)) * 100);
export default function StrengthList({ strengths = [] }) {
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.
</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>
<div className="space-y-3">
{strengths.map((item, index) => (
<div
key={item.name}
className="rounded-xl border border-emerald-100 dark:border-emerald-900/60 bg-emerald-50/60 dark:bg-emerald-950/20 p-3"
>
<div className="flex items-center justify-between gap-3">
<p className="font-semibold text-gray-900 dark:text-gray-100">
{index + 1}. {item.name}
</p>
<span className="text-xs px-2 py-1 rounded-full bg-emerald-200/70 dark:bg-emerald-800/60 text-emerald-800 dark:text-emerald-200 font-semibold">
{scoreToPercent(item.strengthScore)}
</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>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,358 @@
import React, { useMemo, useState } from 'react';
import { generateTrainingPlan } from '../../utils/trainingPlanGenerator';
const formatDateTime = (value) => {
if (!value) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(parsed);
};
const taskWithUpdatedIntensity = (task, intensity) => {
if (task.type !== 'main') return task;
const repetitionsByIntensity = {
hoch: 24,
mittel: 18,
leicht: 12
};
return {
...task,
intensity,
repetitions: repetitionsByIntensity[intensity] || 18
};
};
const statusButtonClass = (active) =>
active
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700';
export default function TrainingPlanPanel({
patType,
analysis,
activePlan,
historyPlans,
loading,
saving,
error,
onSavePlan,
onUpdateSessionState,
onUpdateSessionPartial,
onReload
}) {
const [durationWeeks, setDurationWeeks] = useState(4);
const [sessionsPerWeek, setSessionsPerWeek] = useState(3);
const [draftSessions, setDraftSessions] = useState([]);
const [uiMessage, setUiMessage] = useState('');
const draftSummary = useMemo(() => {
const total = draftSessions.length;
const high = draftSessions.filter((session) => session.intensity === 'hoch').length;
const medium = draftSessions.filter((session) => session.intensity === 'mittel').length;
const low = draftSessions.filter((session) => session.intensity === 'leicht').length;
return { total, high, medium, low };
}, [draftSessions]);
const handleGenerateDraft = () => {
const generated = generateTrainingPlan({
topWeaknesses: analysis?.topWeaknesses || [],
topStrengths: analysis?.topStrengths || [],
durationWeeks,
sessionsPerWeek,
sessionMix: '1-main-2-secondary'
});
setDraftSessions(generated.sessions);
setUiMessage('Neuer Entwurf erstellt. Du kannst jetzt Intensität und Übungen je Session anpassen.');
};
const updateDraftSession = (index, patch) => {
setDraftSessions((previous) =>
previous.map((session, currentIndex) => {
if (currentIndex !== index) return session;
const next = {
...session,
...patch
};
if (Object.prototype.hasOwnProperty.call(patch, 'intensity')) {
next.tasks = next.tasks.map((task) => taskWithUpdatedIntensity(task, patch.intensity));
}
return next;
})
);
};
const handleSaveDraft = async () => {
if (!draftSessions.length) {
setUiMessage('Erstelle zuerst einen Entwurf.');
return;
}
const result = await onSavePlan({
analysis,
durationWeeks,
sessionsPerWeek,
sessions: draftSessions
});
if (!result?.ok) {
setUiMessage(result?.error?.message || 'Trainingsplan konnte nicht gespeichert werden.');
return;
}
setUiMessage('Trainingsplan gespeichert. Der vorherige aktive Plan wurde archiviert.');
setDraftSessions([]);
};
const renderSessionEditor = (session, index) => {
const secondaryAsText = session.secondaryExercises.join(', ');
return (
<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}
</p>
<span className="text-xs text-sky-700 dark:text-sky-300">{session.intensity}</span>
</div>
<div className="grid md:grid-cols-3 gap-2 text-sm">
<input
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"
/>
<input
value={secondaryAsText}
onChange={(event) => {
const secondaryExercises = event.target.value
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.slice(0, 2);
updateDraftSession(index, {
secondaryExercises:
secondaryExercises.length === 2
? secondaryExercises
: [...secondaryExercises, 'Positionsspiel Basis'].slice(0, 2)
});
}}
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"
/>
<select
value={session.intensity}
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>
</select>
</div>
</div>
);
};
return (
<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>
<p className="text-sm text-gray-600 dark:text-gray-400">
1 aktiver Plan + Historie, Session-Status: offen / erledigt / übersprungen.
</p>
</div>
<button
onClick={onReload}
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
</button>
</div>
{error && (
<div className="mb-3 rounded-lg border border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/30 p-3 text-sm text-red-700 dark:text-red-200">
{error}
</div>
)}
<div className="grid md:grid-cols-4 gap-3 mb-4">
<label className="text-sm text-gray-700 dark:text-gray-200">
Dauer
<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>
</select>
</label>
<label className="text-sm text-gray-700 dark:text-gray-200">
Einheiten/Woche
<select
value={sessionsPerWeek}
onChange={(event) => setSessionsPerWeek(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</option>
<option value={3}>3</option>
<option value={4}>4</option>
</select>
</label>
<button
onClick={handleGenerateDraft}
type="button"
className="md:self-end px-4 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700"
>
Plan (neu) generieren
</button>
<button
onClick={handleSaveDraft}
type="button"
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'}
</button>
</div>
{uiMessage && (
<div className="mb-4 rounded-lg border border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-900/30 p-3 text-sm text-indigo-700 dark:text-indigo-200">
{uiMessage}
</div>
)}
{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>
<p className="text-xs text-gray-500 dark:text-gray-400">
Sessions: {draftSummary.total} · hoch {draftSummary.high} · mittel {draftSummary.medium} · leicht {draftSummary.low}
</p>
</div>
<div className="max-h-80 overflow-y-auto space-y-2 pr-1">{draftSessions.map(renderSessionEditor)}</div>
</div>
)}
<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>
{loading ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Lädt</p>
) : !activePlan ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Noch kein aktiver Trainingsplan vorhanden.</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
</p>
<div className="max-h-80 overflow-y-auto space-y-2 pr-1">
{activePlan.sessions.map((session) => (
<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}
</p>
<div className="flex gap-1">
{['open', 'done', 'skipped'].map((state) => (
<button
key={state}
type="button"
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'}
</button>
))}
</div>
</div>
<input
defaultValue={session.mainExercise}
onBlur={(event) =>
onUpdateSessionPartial(session.id, {
mainExercise: event.target.value
})
}
className="w-full mb-2 px-3 py-2 text-sm rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
/>
<input
defaultValue={session.secondaryExercises.join(', ')}
onBlur={(event) =>
onUpdateSessionPartial(session.id, {
secondaryExercises: event.target.value
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.slice(0, 2)
})
}
className="w-full mb-2 px-3 py-2 text-sm rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
/>
<textarea
defaultValue={session.notes || ''}
onBlur={(event) => onUpdateSessionPartial(session.id, { notes: event.target.value })}
placeholder="Notiz"
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}
/>
</div>
))}
</div>
</>
)}
</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>
{!historyPlans.length ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Keine archivierten Pläne vorhanden.</p>
) : (
<div className="space-y-2 max-h-80 overflow-y-auto pr-1">
{historyPlans.map((plan) => (
<div key={plan.id} className="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-3">
<p className="text-sm font-semibold text-gray-800 dark:text-gray-100">
{formatDateTime(plan.generatedAt)}
</p>
<p className="text-xs text-gray-600 dark:text-gray-300">
{plan.durationWeeks} Wochen · {plan.sessionsPerWeek} Einheiten/Woche · {plan.sessions.length} Sessions
</p>
</div>
))}
</div>
)}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,59 @@
import React from 'react';
export default function TrendChart({ trendSeries = [] }) {
if (trendSeries.length < 2) {
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">
Für den Trend werden mindestens 2 Assessments benötigt.
</div>
);
}
const width = 760;
const height = 280;
const left = 50;
const right = 20;
const top = 16;
const bottom = 52;
const values = trendSeries.map((entry) => entry.totalPoints);
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const range = maxValue - minValue || 1;
const innerWidth = width - left - right;
const innerHeight = height - top - bottom;
const points = trendSeries.map((entry, index) => {
const x = left + (index / (trendSeries.length - 1)) * innerWidth;
const normalized = (entry.totalPoints - minValue) / range;
const y = top + innerHeight - normalized * innerHeight;
return { ...entry, x, y };
});
const path = points.map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x},${point.y}`).join(' ');
return (
<div className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">Trend (Gesamtpunkte)</h3>
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" role="img" aria-label="Trenddiagramm">
<line x1={left} y1={top} x2={left} y2={top + innerHeight} className="stroke-gray-300 dark:stroke-gray-700" strokeWidth="1" />
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} className="stroke-gray-300 dark:stroke-gray-700" strokeWidth="1" />
<path d={path} fill="none" className="stroke-indigo-500" strokeWidth="3" strokeLinecap="round" />
{points.map((point) => (
<g key={point.assessmentId}>
<circle cx={point.x} cy={point.y} r="4" className="fill-indigo-500" />
<text x={point.x} y={point.y - 10} textAnchor="middle" className="fill-gray-700 dark:fill-gray-200" style={{ fontSize: 11, fontWeight: 600 }}>
{point.totalPoints}
</text>
<text x={point.x} y={height - 18} textAnchor="middle" className="fill-gray-500 dark:fill-gray-400" style={{ fontSize: 10 }}>
{point.label}
</text>
</g>
))}
</svg>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
const scoreToPercent = (score) => Math.round(Math.max(0, Math.min(1.5, score)) * 100);
export default function WeaknessPriorityList({ weaknesses = [] }) {
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.
</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>
<div className="space-y-3">
{weaknesses.map((item, index) => (
<div
key={item.name}
className="rounded-xl border border-rose-100 dark:border-rose-900/60 bg-rose-50/60 dark:bg-rose-950/20 p-3"
>
<div className="flex items-center justify-between gap-3">
<p className="font-semibold text-gray-900 dark:text-gray-100">
{index + 1}. {item.name}
</p>
<span className="text-xs px-2 py-1 rounded-full bg-rose-200/70 dark:bg-rose-800/60 text-rose-800 dark:text-rose-200 font-semibold">
{scoreToPercent(item.priorityScore)}
</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>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import React, { useState } from 'react'
import { ArrowLeft } from 'lucide-react'
import { supabase } from '../lib/supabaseClient'
export default function AuthPanel({ onAuth, themeToggle, onBack }) {
const [mode, setMode] = useState('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (event) => {
event.preventDefault()
if (!supabase) return
setLoading(true)
setError('')
setMessage('')
try {
if (mode === 'login') {
const { data, error: signInError } = await supabase.auth.signInWithPassword({
email,
password
})
if (signInError) {
setError(signInError.message)
} else {
setMessage('Erfolgreich angemeldet')
onAuth?.(data.session)
}
} else {
const { data, error: signUpError } = await supabase.auth.signUp({
email,
password
})
if (signUpError) {
setError(signUpError.message)
} else if (!data.session) {
setMessage('Registrierung abgeschlossen. Prüfe dein Postfach zur Bestätigung.')
} else {
onAuth?.(data.session)
}
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-sky-100 via-blue-50 to-emerald-100 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 flex items-center justify-center p-6 text-gray-900 dark:text-gray-100">
<div className="absolute top-4 left-4 flex items-center gap-3">
{onBack && (
<button
type="button"
onClick={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
</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-gray-600 dark:text-gray-300 mt-2">
Melde dich mit deinem PAT-Test Konto an, um deine Bewertungen sicher zu verwalten.
</p>
</div>
<form onSubmit={handleSubmit} className="px-8 py-6 space-y-4">
<div className="flex items-center gap-2 text-sm font-medium">
<button
type="button"
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
</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
</button>
</div>
<label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">E-Mail</span>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-800 dark:text-gray-100"
placeholder="you@example.com"
/>
</label>
<label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Passwort</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-800 dark:text-gray-100"
placeholder="••••••••"
/>
</label>
{error && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-200 text-sm">
{error}
</div>
)}
{message && (
<div className="p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-200 text-sm">
{message}
</div>
)}
<button
type="submit"
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'}
</button>
</form>
</div>
</div>
)
}

259
src/components/Landing.jsx Normal file
View File

@@ -0,0 +1,259 @@
import React from 'react'
import {
ArrowRight,
CheckCircle2,
FileDown,
ShieldCheck,
Smartphone,
Sparkles,
TrendingUp,
Users
} from 'lucide-react'
import PublicLayout from './public/PublicLayout'
const features = [
{
title: 'Smarte Auswertungen',
body: 'Automatische Berechnungen für jede PAT-Kategorie mit klaren Kennzahlen, Trends und Fortschrittsbildern.',
icon: Sparkles
},
{
title: 'Sichere Speicherung',
body: 'Nutzergebundene Datenhaltung, finale Abschlüsse und kontrollierte Freigaben für Trainer, Verein und Staff.',
icon: ShieldCheck
},
{
title: 'Team-ready',
body: 'Bewertungen erfassen, öffnen, teilen und wieder sperren, ohne Excel-Chaos oder doppelte Listen.',
icon: Users
},
{
title: 'Performance-Insights',
body: 'Schwächen erkennen, Entwicklung verfolgen und Training gezielt auf die kritischen Übungen ausrichten.',
icon: TrendingUp
},
{
title: 'Mobile Erfassung',
body: 'Direkte Eingabe am Tisch mit klaren Grenzen pro Übung und sofortiger Berechnung der Punkte.',
icon: Smartphone
},
{
title: 'Export & Reports',
body: 'Saubere PDF-Ausgabe, öffentliche Freigabelinks und strukturierte Ergebnisansichten für Athleten und Staff.',
icon: FileDown
}
]
const steps = [
{
title: 'Vorlage wählen',
body: 'PAT Start, PAT 1, PAT 2 oder PAT 3 auswählen und die Bewertung sofort anlegen.'
},
{
title: 'Werte erfassen',
body: 'Einzelwerte direkt eingeben, automatisch begrenzen lassen und live berechnen.'
},
{
title: 'Abschließen & teilen',
body: 'Bewertung final abschließen, als PDF exportieren oder per Link freigeben.'
}
]
const stats = [
{ value: '100%', label: 'Digitaler Ablauf' },
{ value: '0€', label: 'Kostenlos starten' },
{ value: 'SSL', label: 'Verschlüsselte Übertragung' },
{ value: '24/7', label: 'Jederzeit verfügbar' }
]
const scrollToSection = (sectionId) => {
if (typeof document === 'undefined') return
const section = document.getElementById(sectionId)
if (!section) return
section.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
export default function Landing({ onGetStarted, onNavigate, themeToggle }) {
return (
<PublicLayout
themeToggle={themeToggle}
onGetStarted={onGetStarted}
onNavigate={onNavigate}
currentPath="/"
navLinks={[
{ label: 'Features', onClick: () => scrollToSection('features') },
{ label: 'So funktioniert es', onClick: () => scrollToSection('how-it-works') },
{ label: 'Statistiken', onClick: () => scrollToSection('stats') }
]}
>
<section className="public-hero">
<div>
<span className="public-hero__badge">
<Sparkles className="h-4 w-4" aria-hidden="true" />
Jetzt live verfügbar
</span>
<h1 className="public-hero__title">
Professionelle
<span className="public-hero__title-accent"> PAT-Test Verwaltung</span>
</h1>
<p className="public-hero__copy">
Erfasse, analysiere und verfolge Pool Ability Tests digital. Von der Eingabe bis zum Report läuft alles in
einer klaren Oberfläche für Trainer, Vereine und Spieler.
</p>
<div className="public-hero__actions">
<button type="button" className="public-site__button" onClick={onGetStarted}>
Kostenlos testen
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
className="public-site__button--secondary"
onClick={() => scrollToSection('features')}
>
Mehr erfahren
</button>
</div>
<div className="public-hero__meta">
<span className="public-chip">
<CheckCircle2 className="h-4 w-4 text-blue-300" aria-hidden="true" />
PAT Start bis PAT 3
</span>
<span className="public-chip">
<CheckCircle2 className="h-4 w-4 text-blue-300" aria-hidden="true" />
Teilen, PDF und Finalisierung
</span>
</div>
</div>
<div className="public-preview__panel">
<div className="public-preview__head">
<strong>Live Übersicht</strong>
<span className="public-preview__status">Online</span>
</div>
<div className="public-preview__grid">
<div className="public-preview__card">
<div className="public-preview__label">PAT 1 · Übung 1</div>
<div className="public-preview__value public-preview__value--accent">4 / 4</div>
<div className="public-progress">
<span style={{ width: '100%' }} />
</div>
</div>
<div className="public-preview__card">
<div className="public-preview__label">Gesamtpunktzahl</div>
<div className="public-preview__value public-preview__value--success">87%</div>
<div className="public-progress">
<span style={{ width: '87%' }} />
</div>
</div>
</div>
<div className="public-preview__meta">
<div className="public-preview__meta-card">
<div className="public-preview__label">Aktive Bewertung</div>
<strong>Stefan · PAT 1</strong>
</div>
<div className="public-preview__meta-card">
<div className="public-preview__label">Status</div>
<strong>Freigabe aktiv</strong>
</div>
<div className="public-preview__meta-card">
<div className="public-preview__label">Workflow</div>
<strong>Erfassen · Finalisieren</strong>
</div>
<div className="public-preview__meta-card">
<div className="public-preview__label">Auswertung</div>
<strong>PDF & Analyse</strong>
</div>
</div>
</div>
</section>
<section className="public-section" id="features">
<div className="public-section__header">
<span className="public-section__tag">Funktionen</span>
<h2 className="public-section__title">Alles, was du für PAT-Tests brauchst</h2>
<p className="public-section__copy">
Von der digitalen Erfassung bis zur teamweiten Auswertung. Die neue Startseite orientiert sich an deiner
Vorlage, sitzt aber sauber im bestehenden Produktstil.
</p>
</div>
<div className="public-features">
{features.map(({ title, body, icon: Icon }) => (
<article key={title} className="public-card">
<span className="public-card__icon">
<Icon className="h-5 w-5" aria-hidden="true" />
</span>
<h3 className="public-card__title">{title}</h3>
<p className="public-card__copy">{body}</p>
</article>
))}
</div>
</section>
<section className="public-section" id="how-it-works">
<div className="public-section__header">
<span className="public-section__tag">Prozess</span>
<h2 className="public-section__title">So läuft PAT mit uns</h2>
<p className="public-section__copy">Von der Testvorlage bis zum Report bleibt der Ablauf kurz, klar und sicher.</p>
</div>
<div className="public-steps">
{steps.map((step, index) => (
<article key={step.title} className="public-card public-step">
<span className="public-step__number">{index + 1}</span>
<h3 className="public-step__title">{step.title}</h3>
<p className="public-step__copy">{step.body}</p>
</article>
))}
</div>
</section>
<section className="public-section" id="stats">
<div className="public-section__header">
<span className="public-section__tag">Statistiken</span>
<h2 className="public-section__title">Klare Vorteile für den Alltag am Tisch</h2>
<p className="public-section__copy">
Weniger Listenpflege, weniger Medienbrüche und deutlich schneller von der Eingabe zur Auswertung.
</p>
</div>
<div className="public-stats">
{stats.map((item) => (
<article key={item.label} className="public-stats__card">
<div className="public-stats__value">{item.value}</div>
<div className="public-stats__label">{item.label}</div>
</article>
))}
</div>
</section>
<section className="public-section">
<div className="public-cta-panel">
<div>
<span className="public-section__tag">Bereit?</span>
<h2 className="public-cta-panel__title">Starte deine nächste PAT-Bewertung ohne Tabellenchaos.</h2>
<p className="public-cta-panel__copy">
Login öffnen, Bewertung anlegen, Werte eintragen und Ergebnisse direkt sauber speichern oder teilen.
</p>
</div>
<div className="public-cta-panel__actions">
<button type="button" className="public-site__button" onClick={onGetStarted}>
Jetzt kostenlos ausprobieren
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</button>
<button type="button" className="public-site__button--secondary" onClick={() => onNavigate('/kontakt')}>
Kontakt öffnen
</button>
</div>
</div>
</section>
</PublicLayout>
)
}

View File

@@ -0,0 +1,131 @@
import React, { useMemo } from 'react'
import { Activity, BarChart3, Clock, TrendingUp } from 'lucide-react'
import { calculateTotal } from '../utils/patCalculations'
const formatDate = (value) => {
if (!value) return '—'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(parsed)
}
export default function LiveOverview({ assessments, loading = false, error }) {
const stats = useMemo(() => {
if (loading || !assessments) {
return {
total: 0,
average: 0,
latest: null,
best: null
}
}
if (!assessments?.length) {
return {
total: 0,
average: 0,
latest: null,
best: null
}
}
const scored = assessments.map((entry) => ({
...entry,
totalPoints: Math.round(calculateTotal(entry.exercises || []))
}))
const average =
scored.reduce((sum, item) => sum + (item.totalPoints || 0), 0) / (scored.length || 1)
const latest = scored[0]
const best = scored.reduce((top, current) =>
!top || (current.totalPoints || 0) > (top.totalPoints || 0) ? current : top,
null)
return {
total: scored.length,
average: Math.round(average),
latest,
best
}
}, [assessments])
return (
<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>
</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'}
</span>
</div>
{error && (
<div className="mb-3 rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/30 p-3 text-sm text-red-700 dark:text-red-200">
{error}
</div>
)}
{loading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((key) => (
<div key={key} className="h-28 rounded-2xl border border-gray-200/60 dark:border-gray-800 bg-gray-100 dark:bg-gray-800 animate-pulse" />
))}
</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.
</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>
<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>
</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>
<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>
</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>
<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'}
</p>
<p className="text-xs text-indigo-700/80 dark:text-indigo-200/70">
{formatDate(stats.latest?.datum)} · {stats.latest?.patType || 'PAT'}
</p>
</div>
<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>
<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">
{stats.best?.totalPoints ?? 0}
</p>
<p className="text-xs text-amber-700/80 dark:text-amber-200/70">
{stats.best?.name || stats.best?.patType || '—'} · {stats.best?.patType || 'PAT'}
</p>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { normalizeExerciseValue } from '../../utils/exerciseInputRules';
const inputClassName = (disabled) =>
`px-3 py-2 border border-gray-300 dark:border-gray-700 rounded text-center bg-white dark:bg-gray-900 ${
disabled ? 'opacity-70 cursor-not-allowed bg-gray-100 dark:bg-gray-800' : ''
}`;
const ExerciseInput = ({ exercise, exIndex, onChange, disabled = false }) => {
const handleInputChange = (valueIndex, subType = null) => (event) => {
onChange(exIndex, valueIndex, normalizeExerciseValue(exercise, event.target.value, subType), subType);
};
if (exercise.subA && exercise.subB) {
const [subLabelA = 'a)', subLabelB = 'b)'] = exercise.subLabels || [];
return (
<>
<div className="grid grid-cols-5 gap-2 mb-2">
<div></div>
<div className="font-semibold">{subLabelA}</div>
{exercise.subA.map((val, i) => (
<input
key={`a-${i}`}
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={val}
onChange={handleInputChange(i, 'a')}
disabled={disabled}
className={inputClassName(disabled)}
/>
))}
</div>
<div className="grid grid-cols-5 gap-2 mb-2">
<div></div>
<div className="font-semibold">{subLabelB}</div>
{exercise.subB.map((val, i) => (
<input
key={`b-${i}`}
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={val}
onChange={handleInputChange(i, 'b')}
disabled={disabled}
className={inputClassName(disabled)}
/>
))}
</div>
</>
);
}
const gridCols =
exercise.values.length === 4 ? 'grid-cols-6' : exercise.values.length === 5 ? 'grid-cols-7' : 'grid-cols-5';
return (
<div className={`grid ${gridCols} gap-2 mb-2`}>
<div></div>
<div></div>
{exercise.values.map((val, i) => (
<input
key={i}
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={val}
onChange={handleInputChange(i)}
disabled={disabled}
className={inputClassName(disabled)}
/>
))}
</div>
);
};
export default ExerciseInput;

View File

@@ -0,0 +1,227 @@
import React from 'react';
import { CheckCircle2, FileDown, Lock, Save, Share2 } from 'lucide-react';
import ExerciseInput from './ExerciseInput';
import SaveDialog from './SaveDialog';
import { downloadAssessmentPdf } from '../../utils/pdfExport';
const PatDetail = ({
currentAssessment,
hasUnsavedChanges,
onBack,
onSave,
onAssessmentChange,
onUpdateExercise,
onMarkDirty,
showSaveDialog,
onSaveAndExit,
onDiscardAndExit,
onCancelExit,
getPatTypeColor,
getAchievement,
calculateTotal,
isReadOnly = false,
canFinalize = false,
finalizeDisabledReason = '',
onFinalize,
isShareActive = false,
canShare = false,
shareDisabledReason = '',
onShare,
onRevokeShare
}) => {
if (!currentAssessment) return null;
const totalPoints = calculateTotal();
const achievement = getAchievement(currentAssessment?.patType, totalPoints);
const handleDownloadPdf = async () => {
await downloadAssessmentPdf({
assessment: currentAssessment,
totalPoints,
achievement
});
};
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-950 p-6 text-gray-900 dark:text-gray-100">
<div className="max-w-5xl mx-auto">
<SaveDialog
show={showSaveDialog}
onSave={onSaveAndExit}
onDiscard={onDiscardAndExit}
onCancel={onCancelExit}
/>
<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-center mb-6">
<div className="flex items-center gap-4">
<button
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
</button>
{hasUnsavedChanges && (
<span className="text-orange-500 dark:text-orange-300 text-sm font-semibold">
Ungespeicherte Änderungen
</span>
)}
<span
className={`px-4 py-2 rounded-full text-sm font-semibold ${getPatTypeColor(
currentAssessment?.patType
)}`}
>
{currentAssessment?.patType}
</span>
{isShareActive && (
<span className="px-4 py-2 rounded-full text-sm font-semibold bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200">
Freigabe aktiv
</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
</span>
)}
</div>
<div className="flex gap-2">
{!isReadOnly && (
<>
<button
onClick={onSave}
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
</button>
<button
onClick={onFinalize}
disabled={!canFinalize}
title={finalizeDisabledReason || 'Test final abschließen'}
className={`px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm ${
canFinalize
? 'bg-emerald-700 text-white hover:bg-emerald-800'
: 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
}`}
>
<CheckCircle2 className="w-4 h-4" aria-hidden="true" />
Final Abschließen
</button>
</>
)}
<button
onClick={handleDownloadPdf}
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
</button>
<button
onClick={onShare}
disabled={!canShare}
title={shareDisabledReason || 'Test als Link teilen'}
className={`px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm ${
canShare
? 'bg-sky-600 text-white hover:bg-sky-700'
: 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
}`}
>
<Share2 className="w-4 h-4" aria-hidden="true" />
Teilen
</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
</button>
)}
</div>
</div>
{isReadOnly && (
<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
</div>
<p className="mt-1 text-sm">
Diese Bewertung wurde final abgeschlossen und kann nicht mehr bearbeitet werden.
</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></div>
<input
type="date"
value={currentAssessment?.datum || ''}
disabled={isReadOnly}
onChange={(e) => {
onAssessmentChange({ ...currentAssessment, datum: e.target.value });
onMarkDirty();
}}
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' : ''
}`}
/>
<input
type="text"
value={currentAssessment?.name || ''}
disabled={isReadOnly}
onChange={(e) => {
onAssessmentChange({ ...currentAssessment, name: e.target.value });
onMarkDirty();
}}
placeholder="Name"
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' : ''
}`}
/>
</div>
{currentAssessment?.exercises.map((exercise, exIndex) => (
<div key={exIndex} className="mb-6 border-b pb-4 border-gray-200 dark:border-gray-800">
<div className="font-bold text-lg mb-2 text-gray-700 dark:text-gray-200">{exercise.name}</div>
<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-bold text-center">{exercise.durchschnitt.toFixed(2)}</div>
</div>
<div className="grid grid-cols-5 gap-2 mb-1">
<div>Faktor</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></div>
<div className="text-center font-bold text-green-600 dark:text-green-300">{exercise.points.toFixed(0)}</div>
</div>
</div>
))}
<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-4xl font-bold text-green-600 dark:text-green-300">{totalPoints.toFixed(0)}</span>
</div>
<div className={`mt-4 px-6 py-4 rounded-lg ${achievement.color} ${achievement.text} text-center`}>
<p className="text-2xl font-bold">{achievement.name}</p>
{achievement.subtitle && <p className="text-lg mt-2">{achievement.subtitle}</p>}
</div>
</div>
</div>
</div>
</div>
);
};
export default PatDetail;

View File

@@ -0,0 +1,36 @@
import React from 'react';
const SaveDialog = ({ show, onSave, onDiscard, onCancel }) => {
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>
<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
</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
</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
</button>
</div>
</div>
</div>
);
};
export default SaveDialog;

View File

@@ -0,0 +1,526 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowUpDown,
Calendar,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Plus,
Trash2,
User
} from 'lucide-react';
const CALENDAR_DAY_LABELS = ['M', 'D', 'M', 'D', 'F', 'S', 'S'];
const toIsoDate = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const parseIsoDate = (value) => {
if (!value || !value.includes('-')) return null;
const [year, month, day] = value.split('-').map(Number);
return new Date(year, month - 1, day);
};
const getCalendarDays = (monthDate) => {
const startOfMonth = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
const offset = (startOfMonth.getDay() + 6) % 7;
const calendarStart = new Date(startOfMonth.getFullYear(), startOfMonth.getMonth(), 1 - offset);
return Array.from({ length: 42 }, (_, index) => {
const day = new Date(calendarStart.getFullYear(), calendarStart.getMonth(), calendarStart.getDate() + index);
return {
key: toIsoDate(day),
date: day,
isoValue: toIsoDate(day),
isCurrentMonth: day.getMonth() === monthDate.getMonth()
};
});
};
const PatList = ({
patTypes,
assessments,
overview,
assessmentShareTokens = {},
onCreate,
onEdit,
onDelete,
getPatTypeColor,
getAchievement
}) => {
const datePickerRef = useRef(null);
const [showCreateMenu, setShowCreateMenu] = useState(false);
const [activeDatePicker, setActiveDatePicker] = useState(null);
const [sortConfig, setSortConfig] = useState({ key: 'datum', direction: 'desc' });
const [filters, setFilters] = useState({ name: '', datum: '' });
const [visibleMonth, setVisibleMonth] = useState(() => {
const today = new Date();
return new Date(today.getFullYear(), today.getMonth(), 1);
});
const patTypeOptions = Object.keys(patTypes || {});
const openAssessments = assessments.filter((assessment) => !assessment.isFinalized);
const finalizedAssessments = assessments.filter((assessment) => assessment.isFinalized);
const getResultValue = (assessment) =>
assessment.exercises.reduce((sum, ex) => sum + (ex.points || 0), 0);
const normalize = (value) => String(value || '').toLowerCase().trim();
const formatDateForFilter = (value) => {
if (!value || !value.includes('-')) return '';
const [year, month, day] = value.split('-');
return `${day}.${month}.${year}`;
};
const formatDateForRow = (value) => {
if (!value || !value.includes('-')) return value || '';
const [year, month, day] = value.split('-');
return `${year}.${month}.${day}`;
};
const monthLabel = useMemo(
() =>
new Intl.DateTimeFormat('de-DE', {
month: 'long',
year: 'numeric'
}).format(visibleMonth),
[visibleMonth]
);
const calendarDays = useMemo(() => getCalendarDays(visibleMonth), [visibleMonth]);
useEffect(() => {
if (!activeDatePicker) return undefined;
const handlePointerDown = (event) => {
if (datePickerRef.current && !datePickerRef.current.contains(event.target)) {
setActiveDatePicker(null);
}
};
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setActiveDatePicker(null);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [activeDatePicker]);
const matchesFilters = (assessment) => {
const matchesName = !filters.name || normalize(assessment.name).includes(normalize(filters.name));
const matchesDate = !filters.datum || normalize(assessment.datum).includes(normalize(filters.datum));
return matchesName && matchesDate;
};
const getSortValue = (assessment, key) => {
if (key === 'name') return (assessment.name || '').toLowerCase();
if (key === 'datum') return assessment.datum || '';
if (key === 'result') return getResultValue(assessment);
return '';
};
const sortAssessments = (entries) =>
[...entries].sort((left, right) => {
const leftValue = getSortValue(left, sortConfig.key);
const rightValue = getSortValue(right, sortConfig.key);
if (leftValue < rightValue) return sortConfig.direction === 'asc' ? -1 : 1;
if (leftValue > rightValue) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
const sortedOpenAssessments = useMemo(
() => sortAssessments(openAssessments.filter(matchesFilters)),
[openAssessments, sortConfig, filters]
);
const sortedFinalizedAssessments = useMemo(
() => sortAssessments(finalizedAssessments.filter(matchesFilters)),
[finalizedAssessments, sortConfig, filters]
);
const handleSort = (key) => {
setSortConfig((current) =>
current.key === key
? { key, direction: current.direction === 'asc' ? 'desc' : 'asc' }
: { key, direction: key === 'datum' ? 'desc' : 'asc' }
);
};
const renderSortIcon = (key) => {
if (sortConfig.key !== key) {
return <ArrowUpDown className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />;
}
return sortConfig.direction === 'asc' ? (
<ChevronUp className="w-4 h-4" aria-hidden="true" />
) : (
<ChevronDown className="w-4 h-4" aria-hidden="true" />
);
};
const handleFilterChange = (key, value) => {
setFilters((current) => ({
...current,
[key]: value
}));
};
const syncVisibleMonth = (value) => {
const parsedDate = parseIsoDate(value);
const baseDate = parsedDate || new Date();
setVisibleMonth(new Date(baseDate.getFullYear(), baseDate.getMonth(), 1));
};
const toggleDatePicker = (tableKey) => {
setActiveDatePicker((current) => {
if (current !== tableKey) {
syncVisibleMonth(filters.datum);
return tableKey;
}
return null;
});
};
const selectDate = (value) => {
handleFilterChange('datum', value);
syncVisibleMonth(value);
setActiveDatePicker(null);
};
const clearDate = () => {
handleFilterChange('datum', '');
syncVisibleMonth('');
setActiveDatePicker(null);
};
const selectToday = () => {
selectDate(toIsoDate(new Date()));
};
const renderAssessmentTable = (tableKey, title, entries, totalEntries, emptyMessage) => {
const isDatePickerOpen = activeDatePicker === tableKey;
return (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-100 dark:border-gray-800 overflow-hidden">
<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}
{entries.length !== totalEntries ? ` von ${totalEntries}` : ''}
</p>
</div>
{totalEntries === 0 ? (
<div className="px-6 py-8 text-sm text-gray-500 dark:text-gray-400">{emptyMessage}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-800 text-left">
<tr>
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => handleSort('name')}
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
>
Name
{renderSortIcon('name')}
</button>
<input
type="text"
value={filters.name}
onChange={(event) => handleFilterChange('name', event.target.value)}
aria-label="Nach Name filtern"
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>
</th>
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => handleSort('datum')}
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
>
Datum
{renderSortIcon('datum')}
</button>
<div ref={isDatePickerOpen ? datePickerRef : null} className="relative">
<button
type="button"
onClick={() => toggleDatePicker(tableKey)}
aria-label="Nach Datum filtern"
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>
<Calendar className="h-4 w-4 text-gray-500 dark:text-gray-400" aria-hidden="true" />
</button>
{isDatePickerOpen && (
<div className="absolute left-0 top-full z-20 mt-2 w-80 rounded-xl border border-gray-200 bg-white p-3 shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="mb-3 flex items-center justify-between">
<button
type="button"
onClick={() =>
setVisibleMonth(
(current) => new Date(current.getFullYear(), current.getMonth() - 1, 1)
)
}
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"
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</button>
<span className="text-sm font-semibold capitalize text-gray-800 dark:text-gray-100">
{monthLabel}
</span>
<button
type="button"
onClick={() =>
setVisibleMonth(
(current) => new Date(current.getFullYear(), current.getMonth() + 1, 1)
)
}
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"
>
<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) => (
<span key={`${label}-${index}`}>{label}</span>
))}
</div>
<div className="mt-2 grid grid-cols-7 gap-1">
{calendarDays.map((day) => {
const isSelected = day.isoValue === filters.datum;
return (
<button
key={day.key}
type="button"
onClick={() => selectDate(day.isoValue)}
className={`flex h-10 items-center justify-center rounded-lg text-sm transition ${
isSelected
? 'bg-indigo-600 text-white'
: day.isCurrentMonth
? 'text-gray-800 hover:bg-gray-100 dark:text-gray-100 dark:hover:bg-gray-800'
: 'text-gray-400 hover:bg-gray-100 dark:text-gray-600 dark:hover:bg-gray-800'
}`}
>
{day.date.getDate()}
</button>
);
})}
</div>
<div className="mt-3 flex items-center justify-between border-t border-gray-200 pt-3 dark:border-gray-700">
<button
type="button"
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
</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
</button>
</div>
</div>
)}
</div>
</div>
</th>
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => handleSort('result')}
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
>
Ergebnis
{renderSortIcon('result')}
</button>
</div>
</th>
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200 text-right"></th>
</tr>
</thead>
<tbody>
{entries.length === 0 ? (
<tr className="border-t border-gray-200 dark:border-gray-800">
<td colSpan="4" className="px-6 py-8 text-sm text-gray-500 dark:text-gray-400">
{emptyMessage}
</td>
</tr>
) : entries.map((assessment) => {
const shareToken = assessmentShareTokens[assessment.id];
const hasShare = Boolean(shareToken);
const totalPoints = getResultValue(assessment);
const achievement = getAchievement(assessment.patType, totalPoints);
return (
<tr
key={assessment.id}
className="border-t border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/40"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2 text-sm">
<span className="font-semibold text-gray-900 dark:text-gray-100">{assessment.name}</span>
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${getPatTypeColor(
assessment.patType
)}`}
>
{assessment.patType}
</span>
{hasShare && (
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200">
Freigabe aktiv
</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
</span>
)}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300 whitespace-nowrap">
<div className="inline-flex items-center gap-2">
<Calendar className="w-4 h-4" aria-hidden="true" />
{formatDateForRow(assessment.datum)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
<span className="text-xl font-bold text-green-600 dark:text-green-300">
{totalPoints.toFixed(0)}
</span>
<span className={`inline-flex px-2 py-1 rounded text-xs font-semibold ${achievement.color} ${achievement.text}`}>
{achievement.name}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => onEdit(assessment)}
className="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700 transition text-sm"
>
Öffnen
</button>
<button
onClick={(e) => {
e.stopPropagation();
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"
>
<Trash2 className="w-5 h-5" aria-hidden="true" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
};
return (
<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="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
</button>
{showCreateMenu && (
<div className="absolute right-0 top-full mt-2 w-48 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg z-10 overflow-hidden">
{patTypeOptions.map((patType) => (
<button
key={patType}
type="button"
onClick={() => {
setShowCreateMenu(false);
onCreate(patType);
}}
className="w-full px-4 py-3 text-left text-sm font-medium text-gray-800 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 transition"
>
{patType}
</button>
))}
</div>
)}
</div>
</div>
</div>
{overview && <div className="mt-6">{overview}</div>}
{assessments.length > 0 && (
<div className="space-y-6">
{renderAssessmentTable(
'open',
'Noch offene Bewertungen',
sortedOpenAssessments,
openAssessments.length,
'Keine offenen Bewertungen mit den aktuellen Suchfiltern gefunden'
)}
{renderAssessmentTable(
'finalized',
'Abgeschlossene Bewertungen',
sortedFinalizedAssessments,
finalizedAssessments.length,
'Keine abgeschlossenen Bewertungen mit den aktuellen Suchfiltern gefunden'
)}
</div>
)}
{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>
</div>
)}
</div>
</div>
);
};
export default PatList;

View File

@@ -0,0 +1,317 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import PatList from './PatList/PatList';
import PatDetail from './PatDetail/PatDetail';
import AnalysisTab from './Analysis/AnalysisTab';
import { patTypes } from '../data/patTypes';
import { useAssessments } from '../hooks/useAssessments';
import { getAchievement, getPatTypeColor } from '../utils/patCalculations';
import LiveOverview from './LiveOverview';
import { buildAnalysis } from '../utils/analysisEngine';
import { generateTrainingPlan } from '../utils/trainingPlanGenerator';
import { saveTrainingPlanWithSessions } from '../lib/trainingPlanService';
import {
createAssessmentShareLink,
listAssessmentShares,
revokeAssessmentShare
} from '../lib/assessmentShareService';
import { getFinalizeDisabledReason } from '../utils/assessmentState';
const copyToClipboard = async (value) => {
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) return false;
await navigator.clipboard.writeText(value);
return true;
};
const isLocalhostShare = () =>
typeof window !== 'undefined' &&
['localhost', '127.0.0.1'].includes(window.location.hostname);
export default function PatTestManager({
user,
activeTab = 'assessments',
onEditingStateChange
}) {
const visiblePatTypes = useMemo(() => patTypes || {}, []);
const patTypeOptions = Object.keys(visiblePatTypes);
const [showList, setShowList] = useState(true);
const [selectedPatType, setSelectedPatType] = useState(() => patTypeOptions[0] || '');
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [assessmentShareTokens, setAssessmentShareTokens] = useState({});
useEffect(() => {
onEditingStateChange?.(!showList);
}, [onEditingStateChange, showList]);
useEffect(() => {
if (!patTypeOptions.length) return;
if (!selectedPatType || !patTypeOptions.includes(selectedPatType)) {
setSelectedPatType(patTypeOptions[0]);
}
}, [patTypeOptions, selectedPatType]);
const regenerateTrainingPlan = useCallback(async ({ savedAssessment, assessments: nextAssessments }) => {
if (!savedAssessment?.patType || !nextAssessments?.length) return;
const analysis = buildAnalysis(nextAssessments, savedAssessment.patType, {
windowSize: 5,
missingStrategy: 'zero'
});
if (!analysis.windowedAssessments.length) return;
const generated = generateTrainingPlan({
topWeaknesses: analysis.topWeaknesses,
topStrengths: analysis.topStrengths,
durationWeeks: 4,
sessionsPerWeek: 3,
sessionMix: '1-main-2-secondary'
});
const result = await saveTrainingPlanWithSessions({
patType: savedAssessment.patType,
analysisSnapshot: analysis,
durationWeeks: 4,
sessionsPerWeek: 3,
sessions: generated.sessions
});
if (!result.ok) {
console.warn('Automatische Plan-Neugenerierung fehlgeschlagen:', result.error?.message || result.error);
}
}, []);
const {
assessments,
currentAssessment,
setCurrentAssessment,
hasUnsavedChanges,
setHasUnsavedChanges,
createNewAssessment,
openAssessment,
handleSave,
finalizeAssessment,
deleteAssessment,
updateExerciseValue,
calculateTotal
} = useAssessments(visiblePatTypes, user, {
onAfterSave: regenerateTrainingPlan
});
const visibleAssessments = assessments;
const visibleAssessmentIds = useMemo(
() => visibleAssessments.map((assessment) => assessment.id),
[visibleAssessments]
);
const isCurrentAssessmentPersisted = useMemo(
() => assessments.some((assessment) => assessment.id === currentAssessment?.id),
[assessments, currentAssessment?.id]
);
const isCurrentAssessmentShareActive = Boolean(
currentAssessment?.id && assessmentShareTokens[currentAssessment.id]
);
const isCurrentAssessmentReadOnly = Boolean(currentAssessment?.isFinalized);
useEffect(() => {
let ignore = false;
const loadAssessmentShares = async () => {
if (!user?.id || !visibleAssessmentIds.length) {
setAssessmentShareTokens({});
return;
}
try {
const shareTokens = await listAssessmentShares(visibleAssessmentIds);
if (!ignore) {
setAssessmentShareTokens(shareTokens);
}
} catch (error) {
console.warn('Freigaben konnten nicht geladen werden:', error);
if (!ignore) {
setAssessmentShareTokens({});
}
}
};
loadAssessmentShares();
return () => {
ignore = true;
};
}, [user?.id, visibleAssessmentIds]);
const handleBackToList = () => {
if (hasUnsavedChanges && !isCurrentAssessmentReadOnly) {
setShowSaveDialog(true);
} else {
setShowList(true);
}
};
const executeSave = async () => {
const result = await handleSave();
if (result?.ok) {
alert('Erfolgreich gespeichert!');
}
return result;
};
const handleFinalize = useCallback(async () => {
const result = await finalizeAssessment();
if (result?.ok) {
alert('Bewertung wurde final abgeschlossen und ist jetzt nur noch lesbar.');
}
}, [finalizeAssessment]);
const handleSaveAndExit = async () => {
if (!currentAssessment?.name || !currentAssessment?.datum) {
alert('Bitte Name und Datum ausfüllen bevor Sie speichern');
return;
}
const result = await executeSave();
if (result?.ok) {
setShowSaveDialog(false);
setShowList(true);
}
};
const handleDiscardAndExit = () => {
setShowSaveDialog(false);
setHasUnsavedChanges(false);
setShowList(true);
};
const handleCancelExit = () => {
setShowSaveDialog(false);
};
const handleShare = useCallback(async (assessment) => {
try {
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.'
: '';
setAssessmentShareTokens((prev) => ({
...prev,
[assessment.id]: token
}));
try {
const copied = await copyToClipboard(url);
if (copied) {
alert(`Freigabelink für "${label}" wurde in die Zwischenablage kopiert.${localhostHint}`);
return;
}
} catch (clipboardError) {
console.warn('Kopieren in die Zwischenablage fehlgeschlagen:', clipboardError);
}
window.prompt(`Freigabelink für "${label}"${localhostHint}`, url);
} catch (error) {
alert(`Freigabelink konnte nicht erstellt werden: ${error.message || error}`);
}
}, []);
const handleRevokeShare = useCallback(async (assessment) => {
const label = assessment?.name || assessment?.patType || 'dieser Test';
try {
await revokeAssessmentShare(assessment.id);
setAssessmentShareTokens((prev) => {
const next = { ...prev };
delete next[assessment.id];
return next;
});
alert(`Freigabe für "${label}" wurde aufgehoben.`);
} catch (error) {
alert(`Freigabe konnte nicht aufgehoben werden: ${error.message || error}`);
}
}, []);
const handleShareFromDetail = useCallback(() => {
if (!currentAssessment) return;
if (!isCurrentAssessmentPersisted) {
alert('Bitte den Test zuerst speichern, bevor du ihn teilen kannst.');
return;
}
if (hasUnsavedChanges) {
alert('Bitte erst speichern, damit der aktuelle Stand geteilt wird.');
return;
}
handleShare(currentAssessment);
}, [currentAssessment, handleShare, hasUnsavedChanges, isCurrentAssessmentPersisted]);
const handleRevokeShareFromDetail = useCallback(() => {
if (!currentAssessment || !isCurrentAssessmentShareActive) return;
handleRevokeShare(currentAssessment);
}, [currentAssessment, handleRevokeShare, isCurrentAssessmentShareActive]);
const shareDisabledReason = !isCurrentAssessmentPersisted
? 'Bitte zuerst speichern'
: hasUnsavedChanges
? 'Bitte erst speichern, damit der aktuelle Stand geteilt wird'
: '';
const finalizeDisabledReason = getFinalizeDisabledReason(currentAssessment);
if (!showList) {
return (
<PatDetail
currentAssessment={currentAssessment}
hasUnsavedChanges={hasUnsavedChanges}
onBack={handleBackToList}
onSave={executeSave}
onAssessmentChange={setCurrentAssessment}
onUpdateExercise={updateExerciseValue}
onMarkDirty={() => setHasUnsavedChanges(true)}
showSaveDialog={showSaveDialog}
onSaveAndExit={handleSaveAndExit}
onDiscardAndExit={handleDiscardAndExit}
onCancelExit={handleCancelExit}
getPatTypeColor={getPatTypeColor}
getAchievement={getAchievement}
calculateTotal={calculateTotal}
isReadOnly={isCurrentAssessmentReadOnly}
canFinalize={!finalizeDisabledReason}
finalizeDisabledReason={finalizeDisabledReason}
onFinalize={handleFinalize}
isShareActive={isCurrentAssessmentShareActive}
canShare={isCurrentAssessmentPersisted && !hasUnsavedChanges}
shareDisabledReason={shareDisabledReason}
onShare={handleShareFromDetail}
onRevokeShare={handleRevokeShareFromDetail}
/>
);
}
if (activeTab === 'analysis') {
return (
<AnalysisTab
assessments={visibleAssessments}
selectedPatType={selectedPatType}
onPatTypeChange={setSelectedPatType}
patTypes={visiblePatTypes}
user={user}
/>
);
}
return (
<PatList
patTypes={visiblePatTypes}
assessments={visibleAssessments}
overview={<LiveOverview assessments={visibleAssessments} />}
assessmentShareTokens={assessmentShareTokens}
selectedPatType={selectedPatType}
onPatTypeChange={setSelectedPatType}
onCreate={(patType) => {
createNewAssessment(patType);
setShowList(false);
}}
onEdit={(assessment) => {
openAssessment(assessment);
setShowList(false);
}}
onDelete={deleteAssessment}
getPatTypeColor={getPatTypeColor}
getAchievement={getAchievement}
/>
);
}

View File

@@ -0,0 +1,76 @@
import React, { useEffect, useState } from 'react'
import SharedAssessmentView from './SharedAssessmentView'
import { getSharedAssessment } from '../lib/assessmentShareService'
export default function SharedAssessmentPage({ shareToken, themeToggle }) {
const [assessment, setAssessment] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
let ignore = false
const loadAssessment = async () => {
if (!shareToken) {
setError('Freigabelink fehlt.')
setLoading(false)
return
}
setLoading(true)
setError('')
try {
const sharedAssessment = await getSharedAssessment(shareToken)
if (ignore) return
if (!sharedAssessment) {
setAssessment(null)
setError('Dieser Freigabelink ist ungültig oder nicht mehr verfügbar.')
return
}
setAssessment(sharedAssessment)
} catch (err) {
if (ignore) return
setAssessment(null)
setError(err.message || 'Geteilter Test konnte nicht geladen werden.')
} finally {
if (!ignore) {
setLoading(false)
}
}
}
loadAssessment()
return () => {
ignore = true
}
}, [shareToken])
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
<div className="absolute top-4 right-4 z-20">{themeToggle}</div>
{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...
</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-gray-600 dark:text-gray-300 mt-3">{error}</p>
</div>
</div>
) : (
<SharedAssessmentView assessment={assessment} />
)}
</div>
)
}

View File

@@ -0,0 +1,137 @@
import React from 'react'
import { calculateTotal, getAchievement, getPatTypeColor } from '../utils/patCalculations'
const formatDate = (value) => {
if (!value) return '—'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(parsed)
}
const formatValue = (value) => {
if (value === '' || value === null || typeof value === 'undefined') return '—'
return value
}
const ValueRow = ({ label, values = [] }) => (
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-10 text-sm font-semibold text-gray-600 dark:text-gray-300">{label}</span>
{values.map((value, index) => (
<span
key={`${label}-${index}`}
className="min-w-12 rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-center text-sm font-medium text-gray-800 dark:text-gray-100"
>
{formatValue(value)}
</span>
))}
</div>
)
export default function SharedAssessmentView({ assessment }) {
if (!assessment) return null
const totalPoints = calculateTotal(assessment.exercises || [])
const achievement = getAchievement(assessment.patType, totalPoints)
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-950 p-6 text-gray-900 dark:text-gray-100">
<div className="max-w-5xl mx-auto">
<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>
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100 mt-2">
{assessment.name || 'PAT Test'}
</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.
</p>
</div>
<span
className={`px-4 py-2 rounded-full text-sm font-semibold ${getPatTypeColor(assessment.patType)}`}
>
{assessment.patType}
</span>
</div>
<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="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="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="mt-1 text-lg font-semibold">{achievement.name}</p>
</div>
</div>
{(assessment.exercises || []).map((exercise, index) => (
<div key={`${exercise.name}-${index}`} className="mb-6 border-b pb-4 border-gray-200 dark:border-gray-800">
<div className="flex items-start justify-between gap-3 mb-3 flex-wrap">
<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}
</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-xl font-bold text-green-700 dark:text-green-200">
{(exercise.points || 0).toFixed(0)}
</p>
</div>
</div>
<div className="space-y-3">
{exercise.subA && exercise.subB ? (
<>
<ValueRow label={exercise.subLabels?.[0] || 'a)'} values={exercise.subA} />
<ValueRow label={exercise.subLabels?.[1] || 'b)'} values={exercise.subB} />
</>
) : (
<ValueRow label="Werte" 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-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-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-lg font-semibold">{exercise.faktor}</p>
</div>
</div>
</div>
))}
<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-4xl font-bold text-green-600 dark:text-green-300">{totalPoints.toFixed(0)}</span>
</div>
<div className={`mt-4 px-6 py-4 rounded-lg ${achievement.color} ${achievement.text} text-center`}>
<p className="text-2xl font-bold">{achievement.name}</p>
{achievement.subtitle && <p className="text-lg mt-2">{achievement.subtitle}</p>}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,211 @@
import React from 'react'
import { ArrowRight, FileText, Mail, ShieldCheck } from 'lucide-react'
import PublicLayout from './PublicLayout'
const pageContent = {
'/datenschutz': {
tag: 'Datenschutz',
title: 'Datenschutzhinweise für PAT Manager',
intro:
'Diese Seite ist als saubere öffentliche Datenschutzansicht angelegt. Inhaltlich solltest du sie vor dem Livegang noch mit deinen finalen Verantwortlichen, Kontaktdaten und juristisch geprüften Angaben vervollständigen.',
cards: [
{
title: 'Verantwortliche Stelle',
body:
'Trage hier den Vereinsnamen, die verantwortliche Person oder das Unternehmen ein, das PAT Manager betreibt. Ergänze außerdem Anschrift und eine echte Kontaktmöglichkeit.'
},
{
title: 'Welche Daten verarbeitet werden',
body:
'Je nach Nutzung können Anmeldedaten, Bewertungsdaten, Freigaben, PDF-Exporte und technische Zugriffsdaten verarbeitet werden. Bei geteilten Bewertungen sehen anonyme Nutzer nur die explizit freigegebene Ansicht.'
},
{
title: 'Zweck der Verarbeitung',
body:
'Die Verarbeitung dient der Durchführung von PAT-Bewertungen, der Auswertung von Leistungsdaten, der Teamorganisation sowie dem sicheren Speichern und Teilen einzelner Testergebnisse.'
},
{
title: 'Speicherung und Dienstleister',
body:
'Die Anwendung nutzt Supabase als technische Datenbasis. Prüfe vor dem produktiven Einsatz die Auftragsverarbeitung, Speicherregion und die tatsächlich eingesetzten Zusatzdienste.'
},
{
title: 'Rechte der betroffenen Personen',
items: [
'Auskunft über gespeicherte Daten',
'Berichtigung unrichtiger Daten',
'Löschung oder Einschränkung der Verarbeitung',
'Widerspruch gegen einzelne Verarbeitungen',
'Beschwerde bei der zuständigen Aufsichtsbehörde'
]
},
{
title: 'Wichtiger Hinweis',
wide: true,
notice:
'Diese Datenschutzseite ist eine strukturierte Vorlage. Vor der Veröffentlichung müssen echte Kontaktdaten, Rechtsgrundlagen, Speicherfristen und Dienstleisterangaben ergänzt und geprüft werden.'
}
]
},
'/impressum': {
tag: 'Impressum',
title: 'Impressum für PAT Manager',
intro:
'Die Seite ist technisch fertig eingebunden, damit deine Footer- und Navigationslinks nicht mehr ins Leere laufen. Die konkreten Pflichtangaben musst du jetzt noch mit deinen echten Daten ergänzen.',
cards: [
{
title: 'Angaben gemäß § 5 TMG',
body:
'Ergänze hier Betreibername, Rechtsform, vertretungsberechtigte Person sowie die vollständige ladungsfähige Anschrift.'
},
{
title: 'Kontakt',
body:
'Trage hier eine echte E-Mail-Adresse, optional Telefonnummer und weitere Kommunikationswege ein, unter denen Nutzer dich rechtssicher erreichen können.'
},
{
title: 'Verantwortlich für den Inhalt',
body:
'Wenn erforderlich, ergänze die verantwortliche Person nach § 18 Abs. 2 MStV mit Name und Anschrift.'
},
{
title: 'Haftungs- und Linkhinweise',
body:
'Wenn du externe Dienste, Inhalte oder Verlinkungen nutzt, prüfe bitte deine endgültigen Texte zu Haftung, Urheberrecht und externen Verweisen.'
},
{
title: 'Wichtiger Hinweis',
wide: true,
notice:
'Dieses Impressum enthält absichtlich keine erfundenen Unternehmensdaten. Bitte ersetze die Platzhalter vor Veröffentlichung durch deine echten Pflichtangaben.'
}
]
},
'/kontakt': {
tag: 'Kontakt',
title: 'Kontakt und Rückfragen',
intro:
'Hier ist eine öffentliche Kontaktseite im Stil der neuen Startseite eingebunden. Wenn du reale Ansprechpartner und Kanäle ergänzt, kannst du sie direkt produktiv nutzen.',
cards: [
{
title: 'Produkt & Demo',
body:
'Nutze diesen Bereich für Anfragen zu PAT Manager, Demos, Vereinsnutzung oder Einführung im Trainingsalltag.'
},
{
title: 'Technischer Support',
body:
'Hier kannst du später Support-Zeiten, E-Mail-Adresse, Reaktionszeiten oder einen Helpdesk-Link ergänzen.'
},
{
title: 'Kooperationen',
body:
'Platz für Kontakte zu Vereinen, Trainern, Leistungszentren oder Partnern, die das System einsetzen oder testen möchten.'
},
{
title: 'Empfohlene nächste Ergänzungen',
items: [
'Echte Support-E-Mail oder Kontaktformular verknüpfen',
'Antwortzeiten und Zuständigkeiten ergänzen',
'Optional Telefonnummer oder Vereinsanschrift angeben'
]
},
{
title: 'Schnellzugriff',
wide: true,
action: true,
body:
'Wenn du direkt in die Anwendung wechseln willst, kannst du von hier aus sofort den Login öffnen und weiterarbeiten.'
}
]
}
}
export default function PublicInfoPage({ path, onGetStarted, onNavigate, themeToggle }) {
const content = pageContent[path] || pageContent['/kontakt']
return (
<PublicLayout
themeToggle={themeToggle}
onGetStarted={onGetStarted}
onNavigate={onNavigate}
currentPath={path}
navLinks={[
{ label: 'Startseite', onClick: () => onNavigate('/') },
{ label: 'Datenschutz', onClick: () => onNavigate('/datenschutz'), active: path === '/datenschutz' },
{ label: 'Impressum', onClick: () => onNavigate('/impressum'), active: path === '/impressum' },
{ label: 'Kontakt', onClick: () => onNavigate('/kontakt'), active: path === '/kontakt' }
]}
>
<section className="public-page">
<div className="public-page__hero">
<span className="public-section__tag">
{path === '/kontakt' ? <Mail className="h-4 w-4" aria-hidden="true" /> : <FileText className="h-4 w-4" aria-hidden="true" />}
{content.tag}
</span>
<h1 className="public-page__title">{content.title}</h1>
<p className="public-page__copy">{content.intro}</p>
</div>
<div className="public-page__grid">
{content.cards.map((card) => (
<article
key={card.title}
className={`public-page__card${card.wide ? ' public-page__card--wide' : ''}`}
>
<h2 className="public-page__card-title">{card.title}</h2>
{card.body && <p className="public-page__card-copy">{card.body}</p>}
{card.items && (
<ul className="public-page__list">
{card.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
{card.notice && <div className="public-page__notice">{card.notice}</div>}
{card.action && (
<div className="public-hero__actions">
<button type="button" className="public-site__button" onClick={onGetStarted}>
Login öffnen
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</button>
<button type="button" className="public-site__button--secondary" onClick={() => onNavigate('/')}>
Zur Startseite
</button>
</div>
)}
</article>
))}
</div>
<section className="public-section">
<div className="public-cta-panel">
<div>
<span className="public-section__tag">
<ShieldCheck className="h-4 w-4" aria-hidden="true" />
Öffentliche Seiten fertig
</span>
<h2 className="public-cta-panel__title">Die verlinkten Info-Seiten sind jetzt Bestandteil der App.</h2>
<p className="public-cta-panel__copy">
Ergänze jetzt noch deine echten Vereins-, Kontakt- und Rechtstexte. Danach laufen Navigation und Footer
sauber ohne Platzhalterlinks.
</p>
</div>
<div className="public-cta-panel__actions">
<button type="button" className="public-site__button" onClick={() => onNavigate('/')}>
Zur Startseite
</button>
<button type="button" className="public-site__button--secondary" onClick={onGetStarted}>
App öffnen
</button>
</div>
</div>
</section>
</section>
</PublicLayout>
)
}

View File

@@ -0,0 +1,82 @@
import React from 'react'
import { ArrowRight, Target } from 'lucide-react'
import './publicSite.css'
const footerPages = [
{ label: 'Datenschutz', path: '/datenschutz' },
{ label: 'Impressum', path: '/impressum' },
{ label: 'Kontakt', path: '/kontakt' }
]
export default function PublicLayout({
children,
themeToggle,
onGetStarted,
onNavigate,
navLinks = [],
currentPath = '/'
}) {
return (
<div className="public-site">
<div className="public-site__backdrop" />
<div className="public-site__grid" />
<header className="public-site__nav">
<button type="button" className="public-site__brand" onClick={() => onNavigate('/')}>
<span className="public-site__brand-mark">
<Target className="h-5 w-5" aria-hidden="true" />
</span>
<span className="public-site__brand-copy">
<span className="public-site__brand-title">PAT Manager</span>
<span className="public-site__brand-subtitle">Digitale Leistungsdiagnostik für Billard</span>
</span>
</button>
<nav className="public-site__nav-links" aria-label="Öffentliche Navigation">
{navLinks.map((link) => (
<button
key={link.label}
type="button"
className={`public-site__nav-link${link.active ? ' is-active' : ''}`}
onClick={link.onClick}
>
{link.label}
</button>
))}
</nav>
<div className="public-site__nav-actions">
{themeToggle && <div className="public-site__theme-toggle">{themeToggle}</div>}
<button type="button" className="public-site__cta public-site__cta--nav" onClick={onGetStarted}>
Anmelden
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</header>
<main className="public-site__main">{children}</main>
<footer className="public-site__footer">
<div className="public-site__footer-inner">
<div className="public-site__footer-links">
<button type="button" className="public-site__footer-link" onClick={onGetStarted}>
Anmelden
</button>
{footerPages.map((page) => (
<button
key={page.path}
type="button"
className={`public-site__footer-link${currentPath === page.path ? ' is-active' : ''}`}
onClick={() => onNavigate(page.path)}
>
{page.label}
</button>
))}
</div>
<p className="public-site__footer-meta">PAT Manager · Öffentliche Seiten</p>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,772 @@
.public-site {
--public-bg: #07131f;
--public-bg-strong: #0b1726;
--public-surface: rgba(11, 23, 38, 0.78);
--public-surface-soft: rgba(15, 27, 45, 0.62);
--public-border: rgba(148, 163, 184, 0.16);
--public-text: #f8fafc;
--public-muted: #9db0c8;
--public-accent: #60a5fa;
--public-accent-strong: #2563eb;
--public-success: #3b82f6;
--public-warm: #f59e0b;
position: relative;
min-height: 100vh;
color: var(--public-text);
background:
radial-gradient(circle at top left, rgba(59, 130, 246, 0.16), transparent 28%),
radial-gradient(circle at top right, rgba(96, 165, 250, 0.18), transparent 22%),
radial-gradient(circle at 50% 100%, rgba(245, 158, 11, 0.08), transparent 30%),
linear-gradient(180deg, var(--public-bg) 0%, var(--public-bg-strong) 48%, var(--public-bg) 100%);
overflow: hidden;
}
.public-site__backdrop,
.public-site__grid {
pointer-events: none;
position: absolute;
inset: 0;
}
.public-site__backdrop {
background:
radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.12), transparent 20%),
radial-gradient(circle at 82% 22%, rgba(96, 165, 250, 0.14), transparent 18%);
}
.public-site__grid {
background-image:
linear-gradient(rgba(148, 163, 184, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.04) 1px, transparent 1px);
background-size: 52px 52px;
mask-image: linear-gradient(180deg, transparent, black 18%, black 82%, transparent);
}
.public-site__nav {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
padding: 1.2rem 5vw;
background: rgba(7, 19, 31, 0.84);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
.public-site__brand {
display: inline-flex;
align-items: center;
gap: 0.85rem;
background: none;
border: 0;
color: var(--public-text);
font: inherit;
cursor: pointer;
padding: 0;
}
.public-site__brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.8rem;
height: 2.8rem;
border-radius: 1rem;
background: linear-gradient(135deg, rgba(96, 165, 250, 0.96), rgba(37, 99, 235, 0.92));
color: #eff6ff;
box-shadow: 0 16px 40px rgba(14, 165, 233, 0.18);
}
.public-site__brand-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1.1;
}
.public-site__brand-title {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.public-site__brand-subtitle {
font-size: 0.8rem;
color: var(--public-muted);
}
.public-site__nav-links,
.public-site__footer-links {
display: flex;
align-items: center;
gap: 0.85rem;
flex-wrap: wrap;
}
.public-site__nav-link,
.public-site__footer-link,
.public-site__text-action {
background: none;
border: 0;
color: var(--public-muted);
cursor: pointer;
font: inherit;
font-size: 0.95rem;
font-weight: 500;
padding: 0.2rem 0;
transition: color 0.2s ease;
}
.public-site__nav-link:hover,
.public-site__footer-link:hover,
.public-site__text-action:hover,
.public-site__nav-link.is-active,
.public-site__footer-link.is-active {
color: var(--public-text);
}
.public-site__nav-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.public-site__theme-toggle {
display: inline-flex;
}
.public-site__cta,
.public-site__button,
.public-site__button--secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.55rem;
border-radius: 0.95rem;
padding: 0.95rem 1.4rem;
font-size: 0.95rem;
font-weight: 700;
text-decoration: none;
transition: transform 0.22s ease, box-shadow 0.22s ease, background 0.22s ease, border-color 0.22s ease;
cursor: pointer;
}
.public-site__cta,
.public-site__button {
color: #eff6ff;
background: linear-gradient(135deg, var(--public-accent), var(--public-accent-strong));
border: 0;
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22);
}
.public-site__cta:hover,
.public-site__button:hover {
transform: translateY(-2px);
box-shadow: 0 18px 38px rgba(37, 99, 235, 0.28);
}
.public-site__cta--nav {
min-height: 42px;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
gap: 0.5rem;
line-height: 1.25rem;
box-shadow: none;
}
.public-site__cta--nav:hover {
box-shadow: none;
}
.public-site__button--secondary {
color: var(--public-text);
background: rgba(15, 27, 45, 0.55);
border: 1px solid var(--public-border);
}
.public-site__button--secondary:hover {
transform: translateY(-2px);
border-color: rgba(56, 189, 248, 0.32);
background: rgba(15, 27, 45, 0.82);
}
.public-site__main {
position: relative;
z-index: 1;
padding: 0 5vw 4rem;
}
.public-hero,
.public-section,
.public-page {
max-width: 1280px;
margin: 0 auto;
}
.public-hero {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
gap: 3rem;
align-items: center;
padding: 4.8rem 0 4.5rem;
}
.public-hero__badge,
.public-section__tag {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.5rem 0.95rem;
border-radius: 999px;
border: 1px solid rgba(96, 165, 250, 0.22);
background: rgba(96, 165, 250, 0.1);
color: #dbeafe;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.public-hero__title {
margin: 1.35rem 0 1.1rem;
font-size: clamp(2.85rem, 6vw, 5rem);
line-height: 0.98;
letter-spacing: -0.04em;
}
.public-hero__title-accent {
display: block;
color: transparent;
background: linear-gradient(135deg, #dbeafe 0%, #60a5fa 45%, #2563eb 100%);
background-clip: text;
-webkit-background-clip: text;
}
.public-hero__copy {
margin: 0;
max-width: 40rem;
color: var(--public-muted);
font-size: 1.1rem;
line-height: 1.8;
}
.public-hero__actions {
display: flex;
flex-wrap: wrap;
gap: 0.95rem;
margin-top: 2rem;
}
.public-hero__meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.65rem;
}
.public-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: 999px;
border: 1px solid var(--public-border);
background: rgba(15, 27, 45, 0.62);
padding: 0.7rem 1rem;
color: var(--public-muted);
font-size: 0.9rem;
}
.public-hero__panel,
.public-card,
.public-page__card,
.public-stats__card,
.public-preview__panel {
border: 1px solid var(--public-border);
background: var(--public-surface);
backdrop-filter: blur(18px);
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.24);
}
.public-preview__panel {
padding: 1.6rem;
border-radius: 1.8rem;
transform: perspective(1000px) rotateY(-6deg) rotateX(4deg);
transition: transform 0.3s ease;
}
.public-preview__panel:hover {
transform: perspective(1000px) rotateY(-2deg) rotateX(1deg);
}
.public-preview__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.public-preview__status {
color: #bfdbfe;
font-size: 0.85rem;
font-weight: 600;
}
.public-preview__status::before {
content: '';
display: inline-block;
width: 0.55rem;
height: 0.55rem;
margin-right: 0.45rem;
border-radius: 999px;
background: #60a5fa;
box-shadow: 0 0 0 6px rgba(96, 165, 250, 0.16);
}
.public-preview__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-top: 1.2rem;
}
.public-preview__card {
border-radius: 1.25rem;
padding: 1.15rem;
background: rgba(7, 19, 31, 0.56);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.public-preview__label {
color: var(--public-muted);
font-size: 0.8rem;
}
.public-preview__value {
margin-top: 0.55rem;
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.03em;
}
.public-preview__value--accent {
color: #93c5fd;
}
.public-preview__value--success {
color: #60a5fa;
}
.public-progress {
height: 0.5rem;
margin-top: 0.85rem;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.public-progress > span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--public-accent), var(--public-accent-strong));
}
.public-preview__meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
margin-top: 1rem;
}
.public-preview__meta-card {
padding: 1rem;
border-radius: 1rem;
background: rgba(3, 9, 18, 0.42);
border: 1px solid rgba(148, 163, 184, 0.08);
}
.public-section {
margin-bottom: 4.5rem;
}
.public-section__header {
max-width: 48rem;
margin: 0 auto 2.5rem;
text-align: center;
}
.public-section__title {
margin: 1rem 0 0.75rem;
font-size: clamp(2rem, 4vw, 3.25rem);
line-height: 1.08;
letter-spacing: -0.03em;
}
.public-section__copy {
margin: 0;
color: var(--public-muted);
font-size: 1.05rem;
line-height: 1.75;
}
.public-features,
.public-steps,
.public-page__grid {
display: grid;
gap: 1.2rem;
}
.public-features {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.public-card {
border-radius: 1.5rem;
padding: 1.5rem;
}
.public-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 1rem;
background: linear-gradient(135deg, rgba(96, 165, 250, 0.2), rgba(37, 99, 235, 0.16));
color: #dbeafe;
margin-bottom: 1rem;
}
.public-card__title {
margin: 0 0 0.75rem;
font-size: 1.2rem;
}
.public-card__copy {
margin: 0;
color: var(--public-muted);
line-height: 1.75;
}
.public-steps {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.public-step {
position: relative;
padding: 1.6rem;
border-radius: 1.5rem;
}
.public-step__number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.25rem;
height: 3.25rem;
border-radius: 999px;
background: rgba(96, 165, 250, 0.12);
border: 1px solid rgba(96, 165, 250, 0.32);
color: #dbeafe;
font-weight: 700;
margin-bottom: 1rem;
}
.public-step__title {
margin: 0 0 0.6rem;
font-size: 1.15rem;
}
.public-step__copy {
margin: 0;
color: var(--public-muted);
line-height: 1.7;
}
.public-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
.public-stats__card {
border-radius: 1.5rem;
padding: 1.5rem;
text-align: center;
}
.public-stats__value {
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 800;
letter-spacing: -0.04em;
}
.public-stats__label {
margin-top: 0.5rem;
color: var(--public-muted);
}
.public-cta-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
padding: 2rem;
border-radius: 1.9rem;
background: linear-gradient(135deg, rgba(6, 18, 31, 0.94), rgba(11, 23, 38, 0.78));
border: 1px solid var(--public-border);
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.24);
}
.public-cta-panel__title {
margin: 0.75rem 0 0.55rem;
font-size: clamp(1.8rem, 4vw, 2.6rem);
line-height: 1.1;
}
.public-cta-panel__copy {
margin: 0;
color: var(--public-muted);
line-height: 1.75;
}
.public-cta-panel__actions {
display: flex;
flex-wrap: wrap;
gap: 0.9rem;
}
.public-page {
padding: 3.25rem 0 4rem;
}
.public-page__hero {
max-width: 54rem;
margin-bottom: 2rem;
}
.public-page__title {
margin: 1rem 0 0.8rem;
font-size: clamp(2.25rem, 5vw, 4rem);
line-height: 1.02;
letter-spacing: -0.04em;
}
.public-page__copy {
margin: 0;
color: var(--public-muted);
font-size: 1.03rem;
line-height: 1.8;
}
.public-page__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.public-page__card {
border-radius: 1.5rem;
padding: 1.5rem;
}
.public-page__card--wide {
grid-column: 1 / -1;
}
.public-page__card-title {
margin: 0 0 0.8rem;
font-size: 1.15rem;
}
.public-page__card-copy,
.public-page__list {
margin: 0;
color: var(--public-muted);
line-height: 1.75;
}
.public-page__list {
padding-left: 1.1rem;
}
.public-page__list li + li {
margin-top: 0.45rem;
}
.public-page__notice {
margin-top: 1rem;
padding: 1rem 1.1rem;
border-radius: 1rem;
border: 1px solid rgba(245, 158, 11, 0.18);
background: rgba(245, 158, 11, 0.08);
color: #fcd34d;
line-height: 1.7;
}
.public-site__footer {
position: relative;
z-index: 1;
max-width: 1280px;
margin: 0 auto;
padding: 0 5vw 2.8rem;
}
.public-site__footer-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.2rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(148, 163, 184, 0.12);
}
.public-site__footer-meta {
color: var(--public-muted);
font-size: 0.92rem;
}
html:not(.dark) .public-site {
--public-bg: #eef4ff;
--public-bg-strong: #f8fbff;
--public-surface: rgba(255, 255, 255, 0.88);
--public-surface-soft: rgba(255, 255, 255, 0.74);
--public-border: rgba(37, 99, 235, 0.14);
--public-text: #0f172a;
--public-muted: #475569;
background:
radial-gradient(circle at top left, rgba(96, 165, 250, 0.2), transparent 28%),
radial-gradient(circle at top right, rgba(191, 219, 254, 0.44), transparent 24%),
radial-gradient(circle at 50% 100%, rgba(245, 158, 11, 0.12), transparent 30%),
linear-gradient(180deg, var(--public-bg) 0%, var(--public-bg-strong) 48%, #edf5ff 100%);
}
html:not(.dark) .public-site__backdrop {
background:
radial-gradient(circle at 18% 18%, rgba(96, 165, 250, 0.14), transparent 22%),
radial-gradient(circle at 82% 22%, rgba(59, 130, 246, 0.12), transparent 18%);
}
html:not(.dark) .public-site__grid {
background-image:
linear-gradient(rgba(37, 99, 235, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(37, 99, 235, 0.05) 1px, transparent 1px);
}
html:not(.dark) .public-site__nav {
background: rgba(255, 255, 255, 0.84);
border-bottom: 1px solid rgba(37, 99, 235, 0.12);
}
html:not(.dark) .public-site__brand-mark {
box-shadow: 0 16px 40px rgba(37, 99, 235, 0.16);
}
html:not(.dark) .public-site__button--secondary {
background: rgba(255, 255, 255, 0.84);
border-color: rgba(37, 99, 235, 0.16);
}
html:not(.dark) .public-site__button--secondary:hover {
border-color: rgba(37, 99, 235, 0.28);
background: rgba(255, 255, 255, 0.96);
}
html:not(.dark) .public-hero__badge,
html:not(.dark) .public-section__tag {
color: #1d4ed8;
background: rgba(96, 165, 250, 0.12);
}
html:not(.dark) .public-chip {
background: rgba(255, 255, 255, 0.78);
}
html:not(.dark) .public-preview__panel,
html:not(.dark) .public-card,
html:not(.dark) .public-page__card,
html:not(.dark) .public-stats__card {
box-shadow: 0 24px 52px rgba(37, 99, 235, 0.12);
}
html:not(.dark) .public-preview__card {
background: rgba(255, 255, 255, 0.84);
border-color: rgba(37, 99, 235, 0.12);
}
html:not(.dark) .public-progress {
background: rgba(37, 99, 235, 0.1);
}
html:not(.dark) .public-preview__meta-card {
background: rgba(244, 247, 255, 0.9);
border-color: rgba(37, 99, 235, 0.12);
}
html:not(.dark) .public-cta-panel {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(239, 246, 255, 0.92));
box-shadow: 0 24px 52px rgba(37, 99, 235, 0.12);
}
html:not(.dark) .public-site__footer-inner {
border-top: 1px solid rgba(37, 99, 235, 0.12);
}
@media (max-width: 1100px) {
.public-hero {
grid-template-columns: 1fr;
}
.public-preview__panel {
transform: none;
}
.public-features,
.public-steps,
.public-stats,
.public-page__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.public-cta-panel {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 780px) {
.public-site__nav {
align-items: flex-start;
flex-direction: column;
}
.public-site__nav-links,
.public-site__nav-actions,
.public-site__footer-inner {
width: 100%;
}
.public-site__footer-inner {
flex-direction: column;
align-items: flex-start;
}
.public-preview__grid,
.public-preview__meta,
.public-features,
.public-steps,
.public-stats,
.public-page__grid {
grid-template-columns: 1fr;
}
.public-hero__title {
font-size: clamp(2.4rem, 12vw, 4rem);
}
}