mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
Merge pull request #8 from OHTU-German-learning-website/master
merge new changes
This commit is contained in:
@@ -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;
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user