Merge pull request #8 from OHTU-German-learning-website/master

merge new changes
This commit is contained in:
erzabytyci
2026-05-08 14:15:39 +03:00
committed by GitHub
19 changed files with 1243 additions and 30 deletions
@@ -0,0 +1,59 @@
BEGIN;
-- Memory game exercise metadata
CREATE TABLE IF NOT EXISTS memory_game_exercises (
id BIGINT PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
exercise_id BIGINT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL,
UNIQUE (exercise_id)
);
CREATE TRIGGER updated_at
BEFORE UPDATE ON memory_game_exercises
FOR EACH ROW EXECUTE FUNCTION updated_at();
-- Memory game pairs: one left item and one matching right item per pair.
CREATE TABLE IF NOT EXISTS memory_game_pairs (
id BIGINT PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
memory_game_exercise_id BIGINT NOT NULL REFERENCES memory_game_exercises(id) ON DELETE CASCADE,
left_item TEXT NOT NULL,
right_item TEXT NOT NULL,
pair_order INTEGER NOT NULL,
UNIQUE (memory_game_exercise_id, pair_order),
UNIQUE (memory_game_exercise_id, right_item)
);
CREATE TRIGGER updated_at
BEFORE UPDATE ON memory_game_pairs
FOR EACH ROW EXECUTE FUNCTION updated_at();
CREATE INDEX IF NOT EXISTS memory_game_pairs_exercise_order_idx
ON memory_game_pairs (memory_game_exercise_id, pair_order);
-- Student answers for memory games.
CREATE TABLE IF NOT EXISTS memory_game_user_answers (
id BIGINT PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
memory_game_exercise_id BIGINT NOT NULL REFERENCES memory_game_exercises(id) ON DELETE CASCADE,
attempts JSONB NOT NULL,
score INTEGER NOT NULL,
completed BOOLEAN NOT NULL DEFAULT false,
UNIQUE (user_id, memory_game_exercise_id)
);
CREATE TRIGGER updated_at
BEFORE UPDATE ON memory_game_user_answers
FOR EACH ROW EXECUTE FUNCTION updated_at();
CREATE INDEX IF NOT EXISTS memory_game_user_answers_user_exercise_idx
ON memory_game_user_answers (user_id, memory_game_exercise_id);
COMMIT;
+67
View File
@@ -563,6 +563,73 @@ ALTER TABLE public.html_pages
ADD CONSTRAINT html_pages_grammar_topic_id_fkey
FOREIGN KEY (grammar_topic_id) REFERENCES grammar_topics(id);
CREATE TABLE public.memory_game_exercises (
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
exercise_id bigint UNIQUE NOT NULL,
created_by bigint NOT NULL,
title text NOT NULL,
description text NOT NULL
);
ALTER TABLE public.memory_game_exercises
ADD CONSTRAINT memory_game_exercises_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE public.memory_game_exercises
ADD CONSTRAINT memory_game_exercises_exercise_id_fkey
FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE;
CREATE TABLE public.memory_game_pairs (
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
memory_game_exercise_id bigint NOT NULL,
left_item text NOT NULL,
right_item text NOT NULL,
pair_order integer NOT NULL
);
CREATE INDEX memory_game_pairs_exercise_order_idx ON public.memory_game_pairs USING btree (memory_game_exercise_id, pair_order);
ALTER TABLE public.memory_game_pairs
ADD CONSTRAINT memory_game_pairs_memory_game_exercise_id_pair_order_key
UNIQUE (memory_game_exercise_id, pair_order);
ALTER TABLE public.memory_game_pairs
ADD CONSTRAINT memory_game_pairs_memory_game_exercise_id_right_item_key
UNIQUE (memory_game_exercise_id, right_item);
ALTER TABLE public.memory_game_pairs
ADD CONSTRAINT memory_game_pairs_memory_game_exercise_id_fkey
FOREIGN KEY (memory_game_exercise_id) REFERENCES memory_game_exercises(id) ON DELETE CASCADE;
CREATE TABLE public.memory_game_user_answers (
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
user_id bigint NOT NULL,
memory_game_exercise_id bigint NOT NULL,
attempts jsonb NOT NULL,
score integer NOT NULL,
completed boolean NOT NULL DEFAULT false
);
CREATE INDEX memory_game_user_answers_user_exercise_idx ON public.memory_game_user_answers USING btree (user_id, memory_game_exercise_id);
ALTER TABLE public.memory_game_user_answers
ADD CONSTRAINT memory_game_user_answers_user_id_memory_game_exercise_id_key
UNIQUE (user_id, memory_game_exercise_id);
ALTER TABLE public.memory_game_user_answers
ADD CONSTRAINT memory_game_user_answers_memory_game_exercise_id_fkey
FOREIGN KEY (memory_game_exercise_id) REFERENCES memory_game_exercises(id) ON DELETE CASCADE;
ALTER TABLE public.memory_game_user_answers
ADD CONSTRAINT memory_game_user_answers_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
CREATE TABLE public.multichoice_content (
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
multichoice_exercise_id bigint NOT NULL,
@@ -0,0 +1,5 @@
import CreateMemoryGame from "@/app/admin/create-exercise/memory-game/page";
export default function EditMemoryGamePage() {
return <CreateMemoryGame />;
}
@@ -0,0 +1,165 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams, useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Column, Row } from "@/components/ui/layout/container";
import { useRequest } from "@/shared/hooks/useRequest";
const emptyPair = { left_item: "", right_item: "" };
export default function CreateMemoryGame() {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [pairs, setPairs] = useState([emptyPair]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [generalError, setGeneralError] = useState("");
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const routeGameId = params?.memory_game_id;
const queryGameId = searchParams.get("id");
const gameId = routeGameId || queryGameId;
const isEditing = Boolean(gameId);
const makeRequest = useRequest();
useEffect(() => {
if (!isEditing) return;
const loadGame = async () => {
setIsLoading(true);
try {
const response = await fetch(
`/api/admin/exercises/memory-game/${gameId}`
);
const data = await response.json();
if (!response.ok) {
throw new Error(data?.error || "Fehler beim Laden des Spiels");
}
setTitle(data.title || "");
setDescription(data.description || "");
setPairs(
data.pairs?.map((pair) => ({
left_item: pair.left_item,
right_item: pair.right_item,
})) || [emptyPair]
);
} catch (error) {
console.error(error);
setGeneralError("Fehler beim Laden des Spiels");
} finally {
setIsLoading(false);
}
};
loadGame();
}, [gameId, isEditing]);
const handlePairChange = (index, field, value) => {
setPairs((current) => {
const next = [...current];
next[index] = { ...next[index], [field]: value };
return next;
});
};
const addPair = () => setPairs((current) => [...current, emptyPair]);
const removePair = (index) =>
setPairs((current) => current.filter((_, i) => i !== index));
const handleSubmit = async () => {
setIsSubmitting(true);
setGeneralError("");
try {
const method = isEditing ? "PUT" : "POST";
const response = await makeRequest(
isEditing
? `/admin/exercises/memory-game/${gameId}`
: "/admin/exercises/memory-game",
{ title, description, pairs },
{ method }
);
const responseData = response?.data;
if (!responseData || responseData.error) {
throw new Error(
responseData?.error || "Fehler beim Speichern des Spiels"
);
}
const nextPath = isEditing
? "/grammar/exercises/memory-game"
: `/grammar/exercises/memory-game/${responseData.id}`;
router.push(nextPath);
} catch (error) {
console.error(error);
setGeneralError(error.message || "Fehler beim Speichern des Spiels");
} finally {
setIsSubmitting(false);
}
};
return (
<Column gap="md" width="100%">
<h2>{isEditing ? "Memory Game bearbeiten" : "Memory Game erstellen"}</h2>
{generalError && <p className="error">{generalError}</p>}
<label>
Titel
<input value={title} onChange={(e) => setTitle(e.target.value)} />
</label>
<label>
Beschreibung
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<Column gap="sm">
<h3>Paarweise Elemente</h3>
{pairs.map((pair, index) => (
<Row key={index} gap="sm" align="center">
<input
type="text"
placeholder="Linkes Element"
value={pair.left_item}
onChange={(e) =>
handlePairChange(index, "left_item", e.target.value)
}
/>
<input
type="text"
placeholder="Rechtes Element"
value={pair.right_item}
onChange={(e) =>
handlePairChange(index, "right_item", e.target.value)
}
/>
<Button
variant="outline"
size="sm"
onClick={() => removePair(index)}
>
Entfernen
</Button>
</Row>
))}
<Button variant="outline" size="sm" onClick={addPair}>
Paar hinzufügen
</Button>
</Column>
<Row gap="md">
<Button
variant="outline"
onClick={() => router.push("/admin/create-exercise/memory-game")}
>
Abbrechen
</Button>
<Button
variant="secondary"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? "Speichern..." : "Speichern"}
</Button>
</Row>
</Column>
);
}
@@ -25,6 +25,9 @@ export default function CreateExercise() {
<LinkButton href="/admin/create-exercise/dnd-match">
Zuordnungsübung
</LinkButton>
<LinkButton href="/admin/create-exercise/memory-game">
Memory Game
</LinkButton>
</Column>
);
}
@@ -150,7 +150,14 @@ export default function CreateGlossaryEntry() {
<p className="error">{formErrors.word_definition}</p>
)}
</Column>
<Row justify={"space-between"} gap="md" mt={"xl"} mb={"xl"}>
<Row
justify={"space-between"}
align="center"
wrap="wrap"
gap="md"
mt={"xl"}
mb={"xl"}
>
<Button variant="outline" onClick={handleCancel}>
Abbrechen
</Button>
@@ -0,0 +1,164 @@
import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
export const GET = withAuth(
async (request, { params }) => {
const { id } = await params;
if (!id || Number.isNaN(Number(id))) {
return NextResponse.json(
{ error: "Ungültige Übungs-ID." },
{ status: 400 }
);
}
const { rows: exerciseRows } = await DB.pool(
`SELECT id, title, description
FROM memory_game_exercises
WHERE id = $1`,
[id]
);
if (exerciseRows.length === 0) {
return NextResponse.json(
{ error: "Übung nicht gefunden." },
{ status: 404 }
);
}
const { rows: pairs } = await DB.pool(
`SELECT id, left_item, right_item, pair_order
FROM memory_game_pairs
WHERE memory_game_exercise_id = $1
ORDER BY pair_order ASC`,
[id]
);
return NextResponse.json({ ...exerciseRows[0], pairs });
},
{ requireAuth: true, requireAdmin: true }
);
export const PUT = withAuth(
async (request, { params }) => {
const { id } = await params;
const body = await request.json();
const { title, description, pairs } = body;
if (
!id ||
Number.isNaN(Number(id)) ||
!title ||
!description ||
!Array.isArray(pairs) ||
pairs.length === 0
) {
return NextResponse.json(
{ error: "Ungültige Eingabe." },
{ status: 400 }
);
}
const trimmedPairs = pairs.map((pair) => ({
left_item: String(pair.left_item || "").trim(),
right_item: String(pair.right_item || "").trim(),
}));
if (trimmedPairs.some((pair) => !pair.left_item || !pair.right_item)) {
return NextResponse.json(
{ error: "Alle Paare müssen ausgefüllt sein." },
{ status: 422 }
);
}
try {
await DB.transaction(async (tx) => {
const { rows } = await tx.query(
`SELECT exercise_id FROM memory_game_exercises WHERE id = $1`,
[id]
);
if (rows.length === 0) {
throw new Error("NOT_FOUND");
}
await tx.query(
`UPDATE memory_game_exercises
SET title = $1, description = $2, updated_at = NOW()
WHERE id = $3`,
[title, description, id]
);
await tx.query(
`DELETE FROM memory_game_pairs WHERE memory_game_exercise_id = $1`,
[id]
);
for (let i = 0; i < trimmedPairs.length; i += 1) {
const pair = trimmedPairs[i];
await tx.query(
`INSERT INTO memory_game_pairs
(memory_game_exercise_id, left_item, right_item, pair_order)
VALUES ($1, $2, $3, $4)`,
[id, pair.left_item, pair.right_item, i + 1]
);
}
});
return NextResponse.json({ success: true });
} catch (error) {
if (error.message === "NOT_FOUND") {
return NextResponse.json(
{ error: "Übung nicht gefunden." },
{ status: 404 }
);
}
console.error("Error updating memory game exercise:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
},
{ requireAuth: true, requireAdmin: true }
);
export const DELETE = withAuth(
async (request, { params }) => {
const { id } = await params;
if (!id || Number.isNaN(Number(id))) {
return NextResponse.json(
{ error: "Ungültige Übungs-ID." },
{ status: 400 }
);
}
try {
const { rows } = await DB.pool(
`SELECT exercise_id FROM memory_game_exercises WHERE id = $1`,
[id]
);
if (rows.length === 0) {
return NextResponse.json(
{ error: "Übung nicht gefunden." },
{ status: 404 }
);
}
await DB.pool(`DELETE FROM exercises WHERE id = $1`, [
rows[0].exercise_id,
]);
return new NextResponse(null, { status: 204 });
} catch (error) {
console.error("Error deleting memory game exercise:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
},
{ requireAuth: true, requireAdmin: true }
);
@@ -0,0 +1,82 @@
import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
export const POST = withAuth(
async (request) => {
try {
const body = await request.json();
const { title, description, pairs } = body;
const createdBy = request.user.id;
if (
!title ||
!description ||
!Array.isArray(pairs) ||
pairs.length === 0
) {
return NextResponse.json(
{ error: "Titel, Beschreibung und Paare sind erforderlich." },
{ status: 400 }
);
}
const trimmedPairs = pairs.map((pair) => ({
left_item: String(pair.left_item || "").trim(),
right_item: String(pair.right_item || "").trim(),
}));
if (trimmedPairs.some((pair) => !pair.left_item || !pair.right_item)) {
return NextResponse.json(
{
error:
"Alle Paare müssen sowohl ein links als auch ein rechts Element haben.",
},
{ status: 422 }
);
}
const result = await DB.transaction(async (tx) => {
const exerciseResult = await tx.query(
`INSERT INTO exercises (created_by, category)
VALUES ($1, 'memory_game')
RETURNING id`,
[createdBy]
);
const exerciseId = exerciseResult.rows[0].id;
const memoryResult = await tx.query(
`INSERT INTO memory_game_exercises
(exercise_id, created_by, title, description)
VALUES ($1, $2, $3, $4)
RETURNING id`,
[exerciseId, createdBy, title, description]
);
const memoryGameId = memoryResult.rows[0].id;
for (let i = 0; i < trimmedPairs.length; i += 1) {
const pair = trimmedPairs[i];
await tx.query(
`INSERT INTO memory_game_pairs
(memory_game_exercise_id, left_item, right_item, pair_order)
VALUES ($1, $2, $3, $4)`,
[memoryGameId, pair.left_item, pair.right_item, i + 1]
);
}
return memoryGameId;
});
return NextResponse.json({ id: result }, { status: 201 });
} catch (error) {
console.error("Error creating memory game exercise:", error);
return NextResponse.json(
{ error: "Fehler beim Erstellen der Übung." },
{ status: 500 }
);
}
},
{ requireAuth: true, requireAdmin: true }
);
@@ -0,0 +1,82 @@
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
export const POST = withAuth(async (request, { params }) => {
const { id } = await params;
const body = await request.json();
const { matches } = body;
const userId = request.user?.id;
if (!id || Number.isNaN(Number(id)) || !Array.isArray(matches)) {
return new Response(JSON.stringify({ error: "Ungültige Anfrage." }), {
status: 400,
});
}
try {
const { rows: pairs } = await DB.pool(
`SELECT left_item, right_item
FROM memory_game_pairs
WHERE memory_game_exercise_id = $1
ORDER BY pair_order ASC`,
[id]
);
if (pairs.length === 0) {
return new Response(JSON.stringify({ error: "Übung nicht gefunden." }), {
status: 404,
});
}
const expectedSet = new Set(
pairs.map((pair) => `${pair.left_item}|||${pair.right_item}`)
);
const correctCount = matches.reduce((count, match) => {
if (
match &&
typeof match.left_item === "string" &&
typeof match.right_item === "string" &&
expectedSet.has(`${match.left_item}|||${match.right_item}`)
) {
return count + 1;
}
return count;
}, 0);
const score = Math.round((correctCount / pairs.length) * 100);
await DB.pool(
`INSERT INTO memory_game_user_answers
(user_id, memory_game_exercise_id, attempts, score, completed, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (user_id, memory_game_exercise_id)
DO UPDATE SET
attempts = EXCLUDED.attempts,
score = EXCLUDED.score,
completed = EXCLUDED.completed,
updated_at = NOW()`,
[
userId,
id,
JSON.stringify(matches),
score,
correctCount === pairs.length,
]
);
return new Response(
JSON.stringify({
total: pairs.length,
correctCount,
score,
perfect: correctCount === pairs.length,
}),
{ status: 201 }
);
} catch (error) {
console.error("Error evaluating memory game answers:", error);
return new Response(JSON.stringify({ error: "Interner Serverfehler." }), {
status: 500,
});
}
});
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
export async function GET(request, { params }) {
const { id } = await params;
if (!id || Number.isNaN(Number(id))) {
return NextResponse.json(
{ error: "Ungültige Übungs-ID." },
{ status: 400 }
);
}
try {
const { rows: exerciseRows } = await DB.pool(
`SELECT
me.id AS memory_game_exercise_id,
e.id AS exercise_id,
me.title,
me.description,
e.created_at
FROM memory_game_exercises me
JOIN exercises e ON me.exercise_id = e.id
WHERE me.id = $1`,
[id]
);
if (exerciseRows.length === 0) {
return NextResponse.json(
{ error: "Übung nicht gefunden." },
{ status: 404 }
);
}
const { rows: pairs } = await DB.pool(
`SELECT id, left_item, right_item, pair_order
FROM memory_game_pairs
WHERE memory_game_exercise_id = $1
ORDER BY pair_order ASC`,
[id]
);
return NextResponse.json({ ...exerciseRows[0], pairs });
} catch (error) {
console.error("Error fetching memory game exercise:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
export async function GET() {
try {
const { rows: exercises } = await DB.pool(
`SELECT
me.id AS memory_game_exercise_id,
e.id AS exercise_id,
me.title,
me.description,
e.created_at
FROM memory_game_exercises me
JOIN exercises e ON me.exercise_id = e.id
ORDER BY e.created_at DESC`
);
return NextResponse.json(exercises);
} catch (error) {
console.error("Error fetching memory game exercises:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import styles from "../page.module.css";
export default function Credits() {
return (
<div className="container">
<section className={styles.creditsSection}>
<h1>Credits</h1>
<div className={styles.creditsList}>
<p className={styles.creditsItem}>
Diese Webseiten entstanden in einem Autorenkollektiv der
Universitäten Helsinki, Oulu und Turku unter der Leitung von{" "}
<a href="mailto:michi@dlc.fi" className={styles.creditsLink}>
Dr. Michael Möbius
</a>
.
</p>
<br></br>
<p className={styles.creditsItem}>
Die Entwicklung wurde gefördert durch die{" "}
<a
href="https://foundationhollo.fi/"
target="_blank"
rel="noreferrer"
className={styles.creditsLink}
>
Stiftung Erkki J. Hollo
</a>
.
</p>
<p className={styles.creditsItem}>
Programmierung durch IT-Studierende der Universität Helsinki
</p>
<p className={styles.creditsItem}>
Technischer Support und Hosting: Universität Helsinki
</p>
</div>
</section>
</div>
);
}
@@ -0,0 +1,121 @@
"use client";
import { useState, useCallback } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import useQuery from "@/shared/hooks/useQuery";
import { Column, Container, Row } from "@/components/ui/layout/container";
import { Button } from "@/components/ui/button";
import { MemoryGame } from "@/components/ui/memory-game/MemoryGame";
export default function MemoryGamePlayerPage() {
const { memory_game_id } = useParams();
const [result, setResult] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const {
data: exercise,
isLoading,
error,
refetch,
} = useQuery(`/exercises/memory-game/${memory_game_id}`);
const handleComplete = useCallback(
async (pairs) => {
setIsSubmitting(true);
setResult(null);
try {
const response = await fetch(
`/api/exercises/memory-game/${memory_game_id}/answers`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ matches: pairs }),
}
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Fehler beim Absenden der Ergebnisse.");
}
setResult(data);
} catch (submitError) {
console.error(submitError);
setResult({ error: submitError.message });
} finally {
setIsSubmitting(false);
}
},
[memory_game_id]
);
if (isLoading) {
return (
<Container display="flex" justify="center" align="center" h="200px">
Lädt...
</Container>
);
}
if (error) {
return (
<Container p="md" bg="var(--tertiary1)" mb="md" br="md">
Fehler: {error.message || error.error}
</Container>
);
}
if (!exercise) {
return (
<Container p="md" bg="var(--tertiary1)" mb="md" br="md">
Übung wurde nicht gefunden.
</Container>
);
}
return (
<Container maxW="1000px" m="0 auto" p="md">
<Container mb="lg">
<h1>{exercise.title}</h1>
<p>{exercise.description}</p>
</Container>
<MemoryGame
pairs={exercise.pairs || []}
onComplete={handleComplete}
onReset={() => setResult(null)}
/>
{isSubmitting && <p>Ergebnisse werden gespeichert...</p>}
{result && (
<Container mt="lg" p="md" bg="var(--bg2)" br="md">
{result.error ? (
<p>Fehler: {result.error}</p>
) : (
<>
<p>
Richtig: {result.correctCount}/{result.total}
</p>
<p>Punktzahl: {result.score}%</p>
{result.perfect && <p>Perfekt! Gut gemacht.</p>}
</>
)}
</Container>
)}
<Row gap="md" mt="lg">
<Link href="/grammar/exercises/memory-game">
<Button variant="outline">Zurück zur Liste</Button>
</Link>
<Button type="button" onClick={() => refetch()}>
Übung neu laden
</Button>
</Row>
</Container>
);
}
@@ -0,0 +1,117 @@
"use client";
import Link from "next/link";
import { Container, Column, Row } from "@/components/ui/layout/container";
import useQuery from "@/shared/hooks/useQuery";
import { Button } from "@/components/ui/button";
export default function MemoryGameExercisesPage() {
const {
data: exercises,
isLoading,
error,
} = useQuery("/exercises/memory-game");
if (isLoading) {
return (
<Container display="flex" justify="center" align="center" h="200px">
Lädt...
</Container>
);
}
if (error) {
return (
<Container p="md" bg="var(--tertiary1)" mb="md" br="md">
Fehler: {error.message || error.error}
</Container>
);
}
if (!exercises || exercises.length === 0) {
return (
<Container maxW="800px" m="0 auto" p="md">
<Container mb="lg">
<h1>Memory Game</h1>
</Container>
<Container color="var(--fg4)">
Zurzeit sind keine Memory Games verfügbar.
</Container>
<Container mt="lg">
<Link href="/grammar/exercises">Zurück zu allen Übungen</Link>
</Container>
</Container>
);
}
return (
<Container maxW="1000px" m="0 auto" p="md">
<Container mb="lg">
<h1>Memory Game</h1>
</Container>
<Container
display="grid"
gap="md"
templateColumns={{ base: "1fr", md: "1fr 1fr", lg: "1fr 1fr 1fr" }}
mb="lg"
>
{exercises.map((exercise) => (
<Row
key={exercise.memory_game_exercise_id}
p="md"
b={`1px solid var(--bg3)`}
br="md"
justify="space-between"
align="center"
boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)"
bg="var(--bg2)"
gap="md"
>
<Column flex="1">
<Container
mb="sm"
fontWeight="600"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
<Link
href={`/grammar/exercises/memory-game/${exercise.memory_game_exercise_id}`}
style={{ fontSize: "var(--font-lg)" }}
>
{exercise.title.length > 30
? `${exercise.title.substring(0, 30)}...`
: exercise.title}
</Link>
</Container>
<Container fontSize="sm" color="var(--fg4)">
Erstellt: {new Date(exercise.created_at).toLocaleDateString()}
</Container>
</Column>
<Column gap="sm" align="flex-end">
<Link
href={`/grammar/exercises/memory-game/${exercise.memory_game_exercise_id}`}
>
<Button type="button" variant="secondary" size="sm">
Spielen
</Button>
</Link>
<Link
href={`/admin/create-exercise/memory-game/${exercise.memory_game_exercise_id}/edit`}
>
<Button type="button" variant="outline" size="sm">
Bearbeiten
</Button>
</Link>
</Column>
</Row>
))}
</Container>
<Container mt="lg">
<Link href="/grammar/exercises">Zurück zu allen Übungen</Link>
</Container>
</Container>
);
}
@@ -41,6 +41,12 @@ const exerciseTypes = [
link: "/grammar/exercises/dnd-match",
image: "🔗",
},
{
title: "Memory Game",
description: "Üben mit Memory-Paaren",
link: "/grammar/exercises/memory-game",
image: "🧠",
},
// Add other exercise types here
];
+3 -29
View File
@@ -49,35 +49,9 @@ export default function Home() {
<div className="container">
<section className={styles.creditsSection}>
<div className={styles.creditsList}>
<p className={styles.creditsItem}>
Diese Webseiten entstanden in einem Autorenkollektiv der
Universitäten Helsinki, Oulu und Turku unter der Leitung von{" "}
<a href="mailto:michi@dlc.fi" className={styles.creditsLink}>
Dr. Michael Möbius
</a>
.
</p>
<br></br>
<p className={styles.creditsItem}>
Die Entwicklung wurde gefördert durch die{" "}
<a
href="https://foundationhollo.fi/"
target="_blank"
rel="noreferrer"
className={styles.creditsLink}
>
Stiftung Erkki J. Hollo
</a>
.
</p>
<p className={styles.creditsItem}>
Programmierung durch IT-Studierende der Universität Helsinki
</p>
<p className={styles.creditsItem}>
Technischer Support und Hosting: Universität Helsinki
</p>
</div>
<LinkButton href="/credits" size="md">
Credits
</LinkButton>
</section>
</div>
</div>
+1
View File
@@ -60,6 +60,7 @@
padding: 1.75rem 2rem;
border-radius: 24px;
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.08);
text-align: center;
}
.creditsTitle {
@@ -0,0 +1,162 @@
"use client";
import { useEffect, useMemo, useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import "./memory-game.css";
function shuffle(items) {
return [...items].sort(() => Math.random() - 0.5);
}
export function MemoryGame({ pairs, onComplete, onReset }) {
const [cards, setCards] = useState([]);
const [flipped, setFlipped] = useState(new Set());
const [selectedIds, setSelectedIds] = useState([]);
const [matchedPairs, setMatchedPairs] = useState([]);
const [feedback, setFeedback] = useState("");
const completionSentRef = useRef(false);
const lastFlippedRef = useRef(null);
const deck = useMemo(() => {
return shuffle(
pairs.flatMap((pair, index) => [
{
id: `left-${index}`,
pairIndex: index,
side: "left",
text: pair.left_item,
},
{
id: `right-${index}`,
pairIndex: index,
side: "right",
text: pair.right_item,
},
])
);
}, [pairs]);
useEffect(() => {
setCards(deck);
setFlipped(new Set());
setSelectedIds([]);
setMatchedPairs([]);
setFeedback("");
completionSentRef.current = false;
lastFlippedRef.current = null;
}, [deck]);
const handleCardClick = (card) => {
if (
flipped.has(card.id) ||
matchedPairs.includes(card.pairIndex) ||
selectedIds.length === 2
) {
return;
}
const newFlipped = new Set(flipped);
newFlipped.add(card.id);
setFlipped(newFlipped);
setSelectedIds((prev) => [...prev, card.id]);
if (lastFlippedRef.current === null) {
lastFlippedRef.current = card;
setFeedback("");
return;
}
const firstCard = lastFlippedRef.current;
if (
firstCard.pairIndex === card.pairIndex &&
firstCard.side !== card.side
) {
setMatchedPairs((prev) => [...prev, firstCard.pairIndex]);
setSelectedIds([]);
setFeedback("Richtig! Weiter so.");
lastFlippedRef.current = null;
return;
}
setFeedback("Nicht korrekt. Versuch es noch einmal.");
setTimeout(() => {
setFlipped((prev) => {
const next = new Set(prev);
next.delete(firstCard.id);
next.delete(card.id);
return next;
});
setSelectedIds([]);
lastFlippedRef.current = null;
}, 1200);
};
useEffect(() => {
if (
matchedPairs.length === pairs.length &&
pairs.length > 0 &&
!completionSentRef.current
) {
completionSentRef.current = true;
onComplete?.(
pairs.map((pair) => ({
left_item: pair.left_item,
right_item: pair.right_item,
}))
);
setFeedback("Alle Paare gefunden!");
}
}, [matchedPairs, onComplete, pairs]);
const resetGame = () => {
setCards(shuffle(deck));
setFlipped(new Set());
setSelectedIds([]);
setMatchedPairs([]);
setFeedback("");
completionSentRef.current = false;
lastFlippedRef.current = null;
onReset?.();
};
return (
<div className="memory-game-shell">
<div className="memory-game-header">
<div>{pairs.length} Paare</div>
<Button variant="outline" size="sm" onClick={resetGame}>
Neu starten
</Button>
</div>
<div className="memory-game-grid">
{cards.map((card) => {
const isFlipped = flipped.has(card.id);
const isMatched = matchedPairs.includes(card.pairIndex);
return (
<button
key={card.id}
type="button"
className={`memory-card ${isFlipped ? "flipped" : ""} ${
isMatched ? "matched" : ""
}`}
onClick={() => handleCardClick(card)}
disabled={
isMatched ||
selectedIds.includes(card.id) ||
selectedIds.length === 2
}
>
{isFlipped || isMatched ? (
<span className="memory-card__text">{card.text}</span>
) : (
<span className="memory-card__back">?</span>
)}
</button>
);
})}
</div>
<div className="memory-game-footer">{feedback}</div>
</div>
);
}
@@ -0,0 +1,79 @@
.memory-game-shell {
display: flex;
flex-direction: column;
gap: 1rem;
}
.memory-game-header,
.memory-game-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.memory-game-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 12px;
}
.memory-card {
aspect-ratio: 1;
padding: 1rem;
border-radius: 8px;
border: 2px solid var(--bg3);
background: linear-gradient(135deg, var(--bg2) 0%, var(--bg3) 100%);
color: var(--fg1);
font-weight: 600;
text-align: center;
cursor: pointer;
transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
min-height: 100px;
}
.memory-card:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.memory-card:active:not(:disabled) {
transform: translateY(0);
}
.memory-card:disabled {
cursor: default;
}
.memory-card.flipped {
background: linear-gradient(135deg, var(--accent2) 0%, var(--accent) 100%);
border-color: var(--accent);
color: var(--fg1);
}
.memory-card.matched {
background: linear-gradient(135deg, var(--success) 0%, #2d7d4f 100%);
border-color: var(--success);
color: var(--bg1);
cursor: default;
}
.memory-card__text {
display: block;
margin: 0 auto;
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
font-size: 0.95rem;
line-height: 1.3;
}
.memory-card__back {
display: block;
font-size: 2rem;
font-weight: bold;
color: var(--fg3);
}