Lernkarten-App aus Unterrichtsmaterialien hinzugefügt

This commit is contained in:
2026-07-15 10:26:52 +02:00
parent 451e0de4ff
commit 786c9c5e24
28 changed files with 4125 additions and 6 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react-lernkarten-schulung</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "react-lernkarten-schulung",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^10.6.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"vite": "^8.1.1"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+530
View File
@@ -0,0 +1,530 @@
#root {
min-height: 100vh;
}
.site-shell {
display: flex;
min-height: 100vh;
flex-direction: column;
}
.site-header {
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid #d8e0ea;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(12px);
}
.header-inner {
display: flex;
width: min(1120px, calc(100% - 40px));
min-height: 80px;
margin: 0 auto;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.site-title {
margin: 2px 0 0;
font-size: 1.35rem;
font-weight: 800;
}
.eyebrow {
margin: 0;
color: #1d4ed8;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.nav-list {
display: flex;
margin: 0;
padding: 0;
gap: 8px;
list-style: none;
}
.nav-link {
display: inline-flex;
min-height: 44px;
padding: 10px 14px;
align-items: center;
border-radius: 8px;
color: #344054;
font-weight: 700;
text-decoration: none;
}
.nav-link:hover {
background: #edf3ff;
color: #1d4ed8;
}
.nav-link-active {
background: #dbe7ff;
color: #163f9c;
}
.page-container {
width: min(1120px, calc(100% - 40px));
margin: 0 auto;
padding: 48px 0 64px;
flex: 1;
}
.site-footer {
padding: 24px 20px;
border-top: 1px solid #d8e0ea;
color: #667085;
text-align: center;
}
.hero,
.page-heading {
margin-bottom: 28px;
}
.hero {
padding: 48px;
border: 1px solid #cbd9ef;
border-radius: 18px;
background:
linear-gradient(
135deg,
rgba(219, 231, 255, 0.95),
rgba(255, 255, 255, 0.98)
);
}
.hero h1,
.page-heading h1,
.detail-card h1,
.centered-content h1 {
margin: 8px 0 14px;
line-height: 1.15;
}
.hero h1 {
max-width: 720px;
font-size: clamp(2.3rem, 6vw, 4.5rem);
}
.hero-text,
.page-heading p {
max-width: 760px;
color: #475467;
font-size: 1.08rem;
line-height: 1.7;
}
.content-card,
.progress-card,
.filter-card,
.detail-card,
.learning-card {
border: 1px solid #d8e0ea;
background: #ffffff;
box-shadow: 0 12px 32px rgba(28, 45, 75, 0.07);
}
.content-card,
.progress-card,
.filter-card,
.detail-card {
margin-bottom: 24px;
padding: 28px;
border-radius: 14px;
}
.content-card h2,
.progress-card h2 {
margin-top: 0;
}
.concept-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 0;
padding-left: 20px;
gap: 12px 24px;
}
.progress-heading-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.progress-heading-row h2 {
margin-bottom: 0;
}
.progress-card p {
color: #475467;
}
.progress-track {
height: 14px;
overflow: hidden;
border-radius: 999px;
background: #e4eaf2;
}
.progress-value {
height: 100%;
border-radius: inherit;
background: #1d4ed8;
transition: width 250ms ease;
}
.filter-card {
display: grid;
grid-template-columns: minmax(0, 1fr) 240px;
gap: 20px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-field label {
font-weight: 800;
}
.form-field input,
.form-field select {
min-height: 46px;
padding: 10px 12px;
border: 1px solid #aeb9c7;
border-radius: 8px;
background: #ffffff;
color: #172033;
}
.form-field input:hover,
.form-field select:hover {
border-color: #60748f;
}
.result-count {
margin: 28px 0 18px;
color: #475467;
font-weight: 700;
}
.card-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
}
.learning-card {
display: flex;
min-height: 275px;
padding: 24px;
flex-direction: column;
border-radius: 14px;
}
.learning-card:hover {
transform: translateY(-2px);
box-shadow: 0 16px 38px rgba(28, 45, 75, 0.12);
}
.learning-card-learned {
border-color: #86c39b;
background: #f4fbf6;
}
.learning-card h2 {
margin: 18px 0 12px;
font-size: 1.25rem;
line-height: 1.4;
}
.learning-card .button {
margin-top: auto;
}
.badge-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.badge {
display: inline-flex;
min-height: 28px;
padding: 5px 9px;
align-items: center;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 800;
text-transform: capitalize;
}
.badge-category {
background: #dbe7ff;
color: #163f9c;
}
.difficulty-leicht {
background: #e2f6e8;
color: #22663a;
}
.difficulty-mittel {
background: #fff2cc;
color: #795900;
}
.difficulty-schwer {
background: #fce2e2;
color: #8c2525;
}
.learning-status {
color: #667085;
font-weight: 700;
}
.learning-status.learned {
color: #267342;
}
.button,
.text-link {
font-weight: 800;
}
.button {
display: inline-flex;
min-height: 44px;
padding: 10px 16px;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
transition:
background 150ms ease,
border-color 150ms ease,
transform 150ms ease;
}
.button:hover:not(:disabled) {
transform: translateY(-1px);
}
.button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.button-primary {
border-color: #183f9c;
background: #1d4ed8;
color: #ffffff;
}
.button-primary:hover {
background: #173fae;
}
.button-secondary {
border-color: #8da0b8;
background: #ffffff;
color: #253858;
}
.button-secondary:hover:not(:disabled) {
background: #edf3ff;
border-color: #1d4ed8;
}
.button-danger {
border-color: #a52b2b;
background: #fff6f6;
color: #902525;
}
.button-danger:hover {
background: #fce3e3;
}
.text-link {
color: #1d4ed8;
}
.empty-message {
padding: 24px;
border: 1px dashed #9eacc0;
border-radius: 12px;
background: #ffffff;
color: #475467;
}
.detail-card {
max-width: 850px;
margin-right: auto;
margin-left: auto;
padding: 36px;
}
.detail-card-learned {
border-color: #86c39b;
}
.detail-top-row {
display: flex;
margin-bottom: 28px;
align-items: center;
justify-content: space-between;
gap: 20px;
color: #667085;
}
.answer-area {
margin: 28px 0;
padding: 24px;
border-radius: 12px;
background: #f3f6fa;
}
.answer-area > p {
color: #475467;
}
.answer-box {
margin-bottom: 20px;
padding: 20px;
border-left: 5px solid #1d4ed8;
border-radius: 8px;
background: #ffffff;
}
.answer-box h2 {
margin-top: 0;
}
.learning-actions {
margin-bottom: 28px;
}
.card-navigation,
.button-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.card-navigation {
justify-content: space-between;
border-top: 1px solid #d8e0ea;
padding-top: 24px;
}
.definition-list {
margin: 0;
}
.definition-list div {
padding: 18px 0;
border-bottom: 1px solid #e2e7ee;
}
.definition-list div:last-child {
border-bottom: 0;
}
.definition-list dt {
margin-bottom: 6px;
font-weight: 800;
}
.definition-list dd {
margin: 0;
color: #475467;
line-height: 1.65;
}
.centered-content {
max-width: 720px;
margin-right: auto;
margin-left: auto;
text-align: center;
}
.centered-content .button-row {
justify-content: center;
}
.error-code {
margin: 0;
color: #1d4ed8;
font-size: 2rem;
font-weight: 900;
}
@media (max-width: 900px) {
.card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.concept-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 700px) {
.header-inner {
width: min(100% - 28px, 1120px);
padding: 16px 0;
align-items: flex-start;
flex-direction: column;
}
.nav-list {
width: 100%;
flex-wrap: wrap;
}
.page-container {
width: min(100% - 28px, 1120px);
padding: 32px 0 48px;
}
.hero {
padding: 30px 24px;
}
.filter-card,
.card-grid,
.concept-list {
grid-template-columns: 1fr;
}
.detail-card {
padding: 24px;
}
.detail-top-row,
.card-navigation {
align-items: stretch;
flex-direction: column;
}
.card-navigation .button {
width: 100%;
}
}
+131
View File
@@ -0,0 +1,131 @@
import { useEffect, useState } from "react";
import {
BrowserRouter,
Route,
Routes,
} from "react-router-dom";
import Header from "./components/Header";
import HomePage from "./pages/HomePage";
import KartenPage from "./pages/KartenPage";
import KartenDetailPage from "./pages/KartenDetailPage";
import ProjektPage from "./pages/ProjektPage";
import NotFoundPage from "./pages/NotFoundPage";
import lernkarten from "./data/lernkarten";
import "./App.css";
const STORAGE_KEY = "learnedCardIds";
function loadLearnedCardIds() {
try {
const savedIds = localStorage.getItem(STORAGE_KEY);
if (!savedIds) {
return [];
}
const parsedIds = JSON.parse(savedIds);
if (!Array.isArray(parsedIds)) {
return [];
}
return parsedIds.filter(
(id) =>
Number.isInteger(id) &&
lernkarten.some((karte) => karte.id === id)
);
} catch {
return [];
}
}
function App() {
const [learnedCardIds, setLearnedCardIds] =
useState(loadLearnedCardIds);
useEffect(() => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(learnedCardIds)
);
}, [learnedCardIds]);
function markAsLearned(id) {
setLearnedCardIds((currentIds) => {
if (currentIds.includes(id)) {
return currentIds;
}
return [...currentIds, id];
});
}
function removeLearnedStatus(id) {
setLearnedCardIds((currentIds) =>
currentIds.filter((cardId) => cardId !== id)
);
}
return (
<BrowserRouter>
<div className="site-shell">
<Header />
<main className="page-container">
<Routes>
<Route
path="/"
element={
<HomePage
learnedCount={learnedCardIds.length}
totalCount={lernkarten.length}
/>
}
/>
<Route
path="/karten"
element={
<KartenPage
lernkarten={lernkarten}
learnedCardIds={learnedCardIds}
/>
}
/>
<Route
path="/karten/:id"
element={
<KartenDetailPage
lernkarten={lernkarten}
learnedCardIds={learnedCardIds}
onMarkAsLearned={markAsLearned}
onRemoveLearnedStatus={removeLearnedStatus}
/>
}
/>
<Route
path="/projekt"
element={<ProjektPage />}
/>
<Route
path="*"
element={<NotFoundPage />}
/>
</Routes>
</main>
<footer className="site-footer">
<p>
React-Lernkarten-App · Schulungsprojekt von
Mathias Ellmann
</p>
</footer>
</div>
</BrowserRouter>
);
}
export default App;
@@ -0,0 +1,38 @@
function Fortschritt({ learnedCount, totalCount }) {
const percentage =
totalCount === 0
? 0
: Math.round((learnedCount / totalCount) * 100);
return (
<section
className="progress-card"
aria-labelledby="progress-heading"
>
<div className="progress-heading-row">
<h2 id="progress-heading">Lernfortschritt</h2>
<strong>{percentage} %</strong>
</div>
<p>
{learnedCount} von {totalCount} Lernkarten gelernt
</p>
<div
className="progress-track"
role="progressbar"
aria-label="Lernfortschritt"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={percentage}
>
<div
className="progress-value"
style={{ width: `${percentage}%` }}
/>
</div>
</section>
);
}
export default Fortschritt;
@@ -0,0 +1,18 @@
import Navigation from "./Navigation";
function Header() {
return (
<header className="site-header">
<div className="header-inner">
<div>
<p className="eyebrow">React-Schulungsprojekt</p>
<p className="site-title">Lernkarten-App</p>
</div>
<Navigation />
</div>
</header>
);
}
export default Header;
@@ -0,0 +1,30 @@
const kategorien = [
"Alle",
"JavaScript",
"DOM",
"React",
"API",
"Routing",
];
function KategorieFilter({ value, onChange }) {
return (
<div className="form-field">
<label htmlFor="category">Kategorie</label>
<select
id="category"
value={value}
onChange={(event) => onChange(event.target.value)}
>
{kategorien.map((kategorie) => (
<option key={kategorie} value={kategorie}>
{kategorie}
</option>
))}
</select>
</div>
);
}
export default KategorieFilter;
@@ -0,0 +1,44 @@
import { Link } from "react-router-dom";
function Lernkarte({ karte, isLearned }) {
return (
<article
className={`learning-card ${
isLearned ? "learning-card-learned" : ""
}`}
>
<div className="badge-row">
<span className="badge badge-category">
{karte.kategorie}
</span>
<span
className={`badge difficulty-${karte.schwierigkeit}`}
>
{karte.schwierigkeit}
</span>
</div>
<h2>{karte.frage}</h2>
<p
className={
isLearned
? "learning-status learned"
: "learning-status"
}
>
{isLearned ? "Bereits gelernt" : "Noch offen"}
</p>
<Link
className="button button-primary"
to={`/karten/${karte.id}`}
>
Details öffnen
</Link>
</article>
);
}
export default Lernkarte;
@@ -0,0 +1,31 @@
import Lernkarte from "./Lernkarte";
function LernkartenListe({
lernkarten,
learnedCardIds,
}) {
if (lernkarten.length === 0) {
return (
<p className="empty-message" role="status">
Zu Ihrer Suche wurden keine Lernkarten gefunden.
</p>
);
}
return (
<section
className="card-grid"
aria-label="Gefundene Lernkarten"
>
{lernkarten.map((karte) => (
<Lernkarte
key={karte.id}
karte={karte}
isLearned={learnedCardIds.includes(karte.id)}
/>
))}
</section>
);
}
export default LernkartenListe;
@@ -0,0 +1,39 @@
import { NavLink } from "react-router-dom";
function getNavLinkClass({ isActive }) {
return isActive
? "nav-link nav-link-active"
: "nav-link";
}
function Navigation() {
return (
<nav aria-label="Hauptnavigation">
<ul className="nav-list">
<li>
<NavLink to="/" className={getNavLinkClass} end>
Start
</NavLink>
</li>
<li>
<NavLink
to="/karten"
className={getNavLinkClass}
>
Lernkarten
</NavLink>
</li>
<li>
<NavLink
to="/projekt"
className={getNavLinkClass}
>
Projekt
</NavLink>
</li>
</ul>
</nav>
);
}
export default Navigation;
@@ -0,0 +1,17 @@
function Suchfeld({ value, onChange }) {
return (
<div className="form-field">
<label htmlFor="search">Lernkarten durchsuchen</label>
<input
id="search"
type="search"
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="Frage oder Antwort eingeben"
/>
</div>
);
}
export default Suchfeld;
+100
View File
@@ -0,0 +1,100 @@
const lernkarten = [
{
id: 1,
frage: "Was ist React?",
antwort:
"React ist eine JavaScript-Bibliothek zur Entwicklung komponentenbasierter Benutzeroberflächen.",
kategorie: "React",
schwierigkeit: "leicht",
},
{
id: 2,
frage: "Was ist eine React-Komponente?",
antwort:
"Eine React-Komponente ist ein wiederverwendbarer UI-Baustein, der JSX zurückgibt.",
kategorie: "React",
schwierigkeit: "leicht",
},
{
id: 3,
frage: "Was sind Props?",
antwort:
"Props sind Daten oder Funktionen, die eine übergeordnete Komponente an eine untergeordnete Komponente übergibt.",
kategorie: "React",
schwierigkeit: "mittel",
},
{
id: 4,
frage: "Was ist State?",
antwort:
"State ist ein veränderbarer Zustand innerhalb einer React-Komponente.",
kategorie: "React",
schwierigkeit: "leicht",
},
{
id: 5,
frage: "Wofür wird useState verwendet?",
antwort:
"useState speichert veränderbare Werte und stellt eine Setter-Funktion zur Aktualisierung bereit.",
kategorie: "React",
schwierigkeit: "mittel",
},
{
id: 6,
frage: "Wofür wird useEffect verwendet?",
antwort:
"useEffect wird für Seiteneffekte wie API-Aufrufe, Timer oder die Synchronisation mit externen Systemen verwendet.",
kategorie: "React",
schwierigkeit: "mittel",
},
{
id: 7,
frage: "Was ist das DOM?",
antwort:
"Das DOM ist die JavaScript-Repräsentation einer HTML-Seite. JavaScript kann darüber Elemente lesen und verändern.",
kategorie: "DOM",
schwierigkeit: "leicht",
},
{
id: 8,
frage: "Was macht map()?",
antwort:
"map() verarbeitet jedes Element eines Arrays und erzeugt daraus ein neues Array.",
kategorie: "JavaScript",
schwierigkeit: "leicht",
},
{
id: 9,
frage: "Was macht filter()?",
antwort:
"filter() erzeugt ein neues Array mit den Elementen, die eine Bedingung erfüllen.",
kategorie: "JavaScript",
schwierigkeit: "leicht",
},
{
id: 10,
frage: "Was ist eine API?",
antwort:
"Eine API ist eine definierte Schnittstelle, über die Anwendungen Daten oder Funktionen austauschen.",
kategorie: "API",
schwierigkeit: "mittel",
},
{
id: 11,
frage: "Was ist clientseitiges Routing?",
antwort:
"Clientseitiges Routing wechselt Ansichten innerhalb einer Single-Page-Anwendung, ohne die vollständige Webseite neu zu laden.",
kategorie: "Routing",
schwierigkeit: "mittel",
},
{
id: 12,
frage: "Was macht useParams()?",
antwort:
"useParams() liest dynamische Parameter aus der aktuellen URL.",
kategorie: "Routing",
schwierigkeit: "schwer",
},
];
export default lernkarten;
+47
View File
@@ -0,0 +1,47 @@
* {
box-sizing: border-box;
}
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #172033;
background: #eef3f8;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
html {
min-width: 320px;
min-height: 100%;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
}
button,
input,
select {
font: inherit;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 3px solid rgba(29, 78, 216, 0.35);
outline-offset: 3px;
}
a {
color: inherit;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>
);
@@ -0,0 +1,56 @@
import { Link } from "react-router-dom";
import Fortschritt from "../components/Fortschritt";
const konzepte = [
"Komponenten",
"Props",
"State",
"Events",
"Listen",
"Filter",
"Routing",
"dynamische URL-Parameter",
"localStorage",
];
function HomePage({ learnedCount, totalCount }) {
return (
<>
<section className="hero">
<p className="eyebrow">Fortsetzungsprojekt</p>
<h1>React-Lernkarten-App</h1>
<p className="hero-text">
Diese Anwendung baut auf der React-To-do-Liste auf.
Bereits bekannte Konzepte werden wiederholt und um
mehrere Seiten, Routing und gespeicherten
Lernfortschritt erweitert.
</p>
<Link
className="button button-primary"
to="/karten"
>
Lernkarten öffnen
</Link>
</section>
<Fortschritt
learnedCount={learnedCount}
totalCount={totalCount}
/>
<section className="content-card">
<h2>Verwendete Konzepte</h2>
<ul className="concept-list">
{konzepte.map((konzept) => (
<li key={konzept}>{konzept}</li>
))}
</ul>
</section>
</>
);
}
export default HomePage;
@@ -0,0 +1,196 @@
import { useEffect, useState } from "react";
import {
Link,
useNavigate,
useParams,
} from "react-router-dom";
function KartenDetailPage({
lernkarten,
learnedCardIds,
onMarkAsLearned,
onRemoveLearnedStatus,
}) {
const { id } = useParams();
const navigate = useNavigate();
const [showAnswer, setShowAnswer] = useState(false);
const cardId = Number(id);
const karte = lernkarten.find(
(item) => item.id === cardId
);
useEffect(() => {
setShowAnswer(false);
}, [id]);
if (!karte) {
return (
<section className="content-card centered-content">
<p className="error-code">Nicht gefunden</p>
<h1>Lernkarte nicht gefunden</h1>
<p>
Für die angegebene Adresse existiert keine
Lernkarte.
</p>
<Link
className="button button-primary"
to="/karten"
>
Zur Lernkartenübersicht
</Link>
</section>
);
}
const isLearned = learnedCardIds.includes(karte.id);
const currentIndex = lernkarten.findIndex(
(item) => item.id === karte.id
);
const previousCard =
currentIndex > 0
? lernkarten[currentIndex - 1]
: null;
const nextCard =
currentIndex < lernkarten.length - 1
? lernkarten[currentIndex + 1]
: null;
function goToPreviousCard() {
if (previousCard) {
navigate(`/karten/${previousCard.id}`);
}
}
function goToNextCard() {
if (nextCard) {
navigate(`/karten/${nextCard.id}`);
}
}
return (
<article
className={`detail-card ${
isLearned ? "detail-card-learned" : ""
}`}
>
<div className="detail-top-row">
<Link className="text-link" to="/karten">
Zurück zur Übersicht
</Link>
<span>
Karte {currentIndex + 1} von {lernkarten.length}
</span>
</div>
<div className="badge-row">
<span className="badge badge-category">
{karte.kategorie}
</span>
<span
className={`badge difficulty-${karte.schwierigkeit}`}
>
{karte.schwierigkeit}
</span>
</div>
<h1>{karte.frage}</h1>
<p
className={
isLearned
? "learning-status learned"
: "learning-status"
}
>
{isLearned
? "Diese Lernkarte ist als gelernt markiert."
: "Diese Lernkarte ist noch offen."}
</p>
<section className="answer-area">
{!showAnswer && (
<p>
Überlegen Sie zunächst selbst und blenden Sie
anschließend die Antwort ein.
</p>
)}
{showAnswer && (
<section
className="answer-box"
aria-labelledby="answer-heading"
>
<h2 id="answer-heading">Antwort</h2>
<p>{karte.antwort}</p>
</section>
)}
<button
className="button button-secondary"
type="button"
onClick={() => setShowAnswer((visible) => !visible)}
aria-expanded={showAnswer}
>
{showAnswer
? "Antwort ausblenden"
: "Antwort anzeigen"}
</button>
</section>
<section className="learning-actions">
{!isLearned ? (
<button
className="button button-primary"
type="button"
onClick={() => onMarkAsLearned(karte.id)}
>
Als gelernt markieren
</button>
) : (
<button
className="button button-danger"
type="button"
onClick={() =>
onRemoveLearnedStatus(karte.id)
}
>
Lernstatus entfernen
</button>
)}
</section>
<nav
className="card-navigation"
aria-label="Zwischen Lernkarten wechseln"
>
<button
className="button button-secondary"
type="button"
onClick={goToPreviousCard}
disabled={!previousCard}
>
Vorherige Karte
</button>
<button
className="button button-secondary"
type="button"
onClick={goToNextCard}
disabled={!nextCard}
>
Nächste Karte
</button>
</nav>
</article>
);
}
export default KartenDetailPage;
@@ -0,0 +1,72 @@
import { useState } from "react";
import Suchfeld from "../components/Suchfeld";
import KategorieFilter from "../components/KategorieFilter";
import Fortschritt from "../components/Fortschritt";
import LernkartenListe from "../components/LernkartenListe";
function KartenPage({
lernkarten,
learnedCardIds,
}) {
const [search, setSearch] = useState("");
const [category, setCategory] = useState("Alle");
const normalizedSearch = search.trim().toLowerCase();
const filteredCards = lernkarten.filter((karte) => {
const matchesSearch =
karte.frage.toLowerCase().includes(normalizedSearch) ||
karte.antwort.toLowerCase().includes(normalizedSearch);
const matchesCategory =
category === "Alle" ||
karte.kategorie === category;
return matchesSearch && matchesCategory;
});
return (
<>
<section className="page-heading">
<p className="eyebrow">Übersicht</p>
<h1>Lernkartenübersicht</h1>
<p>
Durchsuchen Sie die Karten und öffnen Sie eine
Detailseite, um die Antwort zu prüfen.
</p>
</section>
<Fortschritt
learnedCount={learnedCardIds.length}
totalCount={lernkarten.length}
/>
<section
className="filter-card"
aria-label="Lernkarten filtern"
>
<Suchfeld
value={search}
onChange={setSearch}
/>
<KategorieFilter
value={category}
onChange={setCategory}
/>
</section>
<p className="result-count" aria-live="polite">
{filteredCards.length} von {lernkarten.length} Karten
werden angezeigt.
</p>
<LernkartenListe
lernkarten={filteredCards}
learnedCardIds={learnedCardIds}
/>
</>
);
}
export default KartenPage;
@@ -0,0 +1,29 @@
import { Link } from "react-router-dom";
function NotFoundPage() {
return (
<section className="content-card centered-content">
<p className="error-code">404</p>
<h1>Seite nicht gefunden</h1>
<p>
Die aufgerufene Adresse gehört zu keiner Seite dieser
Anwendung.
</p>
<div className="button-row">
<Link className="button button-primary" to="/">
Zur Startseite
</Link>
<Link
className="button button-secondary"
to="/karten"
>
Zu den Lernkarten
</Link>
</div>
</section>
);
}
export default NotFoundPage;
@@ -0,0 +1,84 @@
const routerBegriffe = [
{
begriff: "BrowserRouter",
erklaerung:
"Stellt den Routing-Kontext für die gesamte Anwendung bereit und synchronisiert die Oberfläche mit der Browseradresse.",
},
{
begriff: "Routes",
erklaerung:
"Enthält die möglichen Routen und wählt die zur aktuellen URL passende Route aus.",
},
{
begriff: "Route",
erklaerung:
"Verbindet einen URL-Pfad mit der React-Komponente, die für diesen Pfad angezeigt wird.",
},
{
begriff: "Link und NavLink",
erklaerung:
"Ermöglichen Navigation innerhalb der Anwendung, ohne die gesamte HTML-Seite neu zu laden. NavLink kann zusätzlich den aktiven Navigationszustand darstellen.",
},
{
begriff: "useParams",
erklaerung:
"Liest dynamische Werte aus einer URL, beispielsweise die ID aus /karten/5.",
},
{
begriff: "useNavigate",
erklaerung:
"Ermöglicht programmatische Navigation, zum Beispiel beim Wechsel zur nächsten Lernkarte.",
},
];
function ProjektPage() {
return (
<>
<section className="page-heading">
<p className="eyebrow">Hintergrund</p>
<h1>Über das Projekt</h1>
<p>
Die Lernkarten-App verbindet bekannte
React-Grundlagen mit clientseitigem Routing.
</p>
</section>
<section className="content-card">
<h2>Verbindung zur To-do-App</h2>
<p>
Komponenten, Props, State, Events, map(), filter()
und unveränderliche Aktualisierungen bleiben
erhalten. Neu hinzu kommen mehrere Seiten,
dynamische URLs und eine dauerhafte Speicherung des
Lernstatus.
</p>
</section>
<section className="content-card">
<h2>React Router</h2>
<dl className="definition-list">
{routerBegriffe.map((eintrag) => (
<div key={eintrag.begriff}>
<dt>{eintrag.begriff}</dt>
<dd>{eintrag.erklaerung}</dd>
</div>
))}
</dl>
</section>
<section className="content-card">
<h2>localStorage</h2>
<p>
Der Browser speichert die IDs gelernter Karten
lokal. Dadurch bleibt der Lernfortschritt nach einem
Neuladen erhalten. localStorage ersetzt in diesem
Projekt kein Backend und gilt nur für den jeweils
verwendeten Browser.
</p>
</section>
</>
);
}
export default ProjektPage;
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3005
}
})