Merge pull request #531 from erzabytyci/user-tiers

feat: add time stamp, update: test's advices, fix: gap exercise, improve: drag und drop
This commit is contained in:
erzabytyci
2026-06-08 02:57:15 +03:00
committed by GitHub
47 changed files with 1052 additions and 94 deletions
@@ -0,0 +1,2 @@
ALTER TABLE dnd_exercises
ADD COLUMN IF NOT EXISTS description TEXT;
@@ -0,0 +1,204 @@
UPDATE form_parts
SET advice_en = $$If you achieved a high average score in group A, the following exercise types and
learning strategies may be most helpful for you:
Make frequent use of exercise types that help you improve your memory:
• Group and sort words/sentences
• Find associations that help you remember structures, e.g. create mind maps
with words from a topic or communication situation, or memorise the vowels
of strong verbs using mnemonics (Gitarre – sitzen – saß – gesessen)!
• Put new words into context to help you remember them better!
• Use images, sounds or actions to help you remember new expressions better!
• Work in a structured way; follow the learning path of the exercises (“Next
exercise / Continue”)!
• Revise older material frequently!$$
WHERE form_id = (SELECT id FROM forms WHERE public_id = 'learning_type')
AND step_label = 'A';
UPDATE form_parts
SET advice_de = $$Wenn du in der Gruppe A einen hohen Mittelwert erreicht hast, dann helfen dir
vielleicht folgende Übungsformen und Lernstrategien am besten:
Verwende häufig solche Übungstypen, die dir helfen, dein Gedächtnis zu
verbessern:
Wörter/Sätze gruppieren und sortieren
Finde Assoziationen, mit denen du dir Strukturen merken kannst, z. B. erstelle
Mindmaps mit Wörtern eines Themas/einer Kommunikationssituation oder
merke dir die Vokale der starken Verben mit Merkwörtern (Gitarre – sitzen –
saß – gesessen)!
neue Wörter in einen Zusammenhang bringen, um sie sich besser zu merken
Verwende Bilder, Töne oder Handlungen, um dir neue Ausdrücke besser zu
merken!
Arbeite strukturiert, folge dem Lernpfad der Übungen („Nächste Übung /
Weiter“)!
Wiederhole häufig älteres Material!$$
WHERE form_id = (SELECT id FROM forms WHERE public_id = 'learning_type')
AND step_label = 'A';
UPDATE form_parts
SET advice_en = $$If you have achieved a high average score in Group B, the following exercise types
and learning strategies may be most helpful:
• Revise the structures regularly!
• Use plenty of communication scenarios from the app and look for others
where you can use the German language in all its forms (writing, reading,
speaking, listening)!
• Do exercises where you have to write something – special tip: first write your
answers by hand on a piece of paper! Then type them into the app.
• If you don’t understand something, use reference materials. You’ll find these
in the links on the left-hand side.$$
WHERE form_id = (SELECT id FROM forms WHERE public_id = 'learning_type')
AND step_label = 'B';
UPDATE form_parts
SET advice_de = $$Wenn du in der Gruppe B einen hohen Mittelwert erreicht hast, dann helfen dir
vielleicht folgende Übungsformen und Lernstrategien am besten:
Wiederhole die Strukturen regelmäßig!
Verwende viele Kommunikationssituationen aus der App und suche dir
weitere, wo du die deutsche Sprache in allen Formen (Schreiben, Lesen,
Sprechen, Hören) anwenden kannst!
Mache Übungen, bei denen du etwas schreiben musst – Spezialtipp:
Schreibe deine Antworten zuerst mit der Hand auf einen Zettel! Danach tippst
du sie in die App.
Wenn du etwas nicht verstehst, benutze Hilfsmittel zum Nachschlagen. Die
findest du am linken Rand in den Links.$$
WHERE form_id = (SELECT id FROM forms WHERE public_id = 'learning_type')
AND step_label = 'B';
UPDATE form_parts
SET advice_en = $$If you achieved a high average score in Group C, the following exercise types and
learning strategies may be most helpful for you:
• Try to guess unfamiliar things from the context first or use your own
languages to work out unfamiliar structures! Often, it’s not a problem if you
don’t quite understand a detail.
• When producing language (speaking/writing), simply try to replace such
difficult structures with simpler ones that you know for sure.
If you achieved a low score in Group C, then try in future to be more confident and
less anxious when using the German language. Mistakes aren’t a problem; you can
learn from them!$$
WHERE form_id = (SELECT id FROM forms WHERE public_id = 'learning_type')
AND step_label = 'C';
UPDATE form_parts
SET advice_de = $$Wenn du in der Gruppe C einen hohen Mittelwert erreicht hast, dann helfen dir
vielleicht folgende Übungsformen und Lernstrategien am besten:
Versuche, unbekannte Dinge zuerst aus dem Kontext zu erraten oder nutze
deine eigenen Sprachen zur Erschließung unbekannter Strukturen! Oft ist es
auch kein Problem, wenn man ein Detail nicht ganz versteht.
Bei der Sprachproduktion (Sprechen/Schreiben) versuchst du einfach, solche
schwierigen Strukturen durch einfachere zu ersetzen, die du sicher kennst.
Wenn du in der Gruppe C einen niedrigen Wert erreicht hast, dann versuche in
Zukunft, mutiger, mit weniger Angst mit der deutschen Sprache umzugehen. Fehler
sind kein Problem, man kann aus ihnen lernen!$$
WHERE form_id = (SELECT id FROM forms WHERE public_id = 'learning_type')
AND step_label = 'C';
UPDATE form_parts
SET advice_en = $$If you achieved a high average score in Group D, then consider the following
learning strategies:
• Draw up a study plan, write it down and work on these points!
• Set yourself specific goals in your study plan that you can actually achieve,
e.g. In April, I want to learn to talk confidently and correctly about my work.
• Once you’ve mastered a point, tick it off or mark it as ‘done’! You can come
back to it later and repeat the structure, so you don’t forget it again.
• Use some of the app’s exercises to check whether you’ve achieved your
goals! You can also use the ‘Brauchen Sie Hilfe?’ page for this. Write a short
text and send it along with your email address. We’ll then send you feedback!
• As well as the app, use material you’re familiar with for your studies – whether
from school or your own resources!
If you’ve scored low in Group D, try to find lots of situations where people speak
German. Make use of social media or video platforms for this.$$
WHERE form_id = (SELECT id FROM forms WHERE public_id = 'learning_type')
AND step_label = 'D';
UPDATE form_parts
SET advice_de = $$Wenn du in der Gruppe D einen hohen Mittelwert erreicht hast, dann denke an
folgende Lernstrategien:
Mach dir einen Lernplan, schreibe ihn auf und arbeite an diesen Punkten!
Stelle dir in deinem Lernplan konkrete Ziele, die du auch wirklich erreichen
kannst, z. B. Im April möchte ich lernen, sicher und korrekt über meine Arbeit
zu erzählen.
Wenn du einen Punkt sicher beherrschst, markiere ihn mit einem Haken oder
kennzeichne ihn als „erledigt“! Du kannst später darauf zurückkommen und
die Struktur wiederholen, damit du sie nicht wieder vergisst.
Überprüfe mit einigen Übungen der App, ob du deine Ziele erreicht hast! Dazu
kannst du auch die Seite „Brauchen Sie Hilfe?“ benutzen. Schreibe einen
kleinen Text und sende ihn mit deiner E-Mail-Adresse. Dann bekommst du
von uns Feedback!
Nutze zum Lernen neben der App auch dir bekanntes Material, aus der
Schule oder eigenes!
Wenn du in der Gruppe D einen niedrigen Wert erreicht hast, dann versuche, viele
Situationen zu finden, wo Menschen auf Deutsch sprechen. Nutze dazu auch die
Sozialen Medien oder Videoplattformen.$$
WHERE form_id = (SELECT id FROM forms WHERE public_id = 'learning_type')
AND step_label = 'D';
UPDATE form_parts
SET advice_en = $$If you have achieved a high average score in Group E, keep the following in mind
whilst learning:
• Keep a learning diary and write down your achieved goals and your feelings!
• When choosing topics, however, make sure you don’t do exercises that are
too easy! Ideally, the exercise should start slightly above your level. Then you
can feel how it gets easier and easier for you as you practise. Enjoy that
feeling!
• Talk to other learners about learning German! Why not use your social media
for this too!
If you have achieved a low score in Group E, then consider the following questions:
• Try to exchange ideas with other learners!
• Be more confident about speaking and writing, even if the structures aren’t
very familiar!
• Also use the ‘Brauchen Sie Hilfe?’ page if you have any questions. Don’t
forget to include your email address so we can reply to you.$$
WHERE form_id = (SELECT id FROM forms WHERE public_id = 'learning_type')
AND step_label = 'E';
UPDATE form_parts
SET advice_de = $$Wenn du in der Gruppe E einen hohen Mittelwert erreicht hast, dann denke beim
Lernen an folgende Dinge:
Führe ein Lerntagebuch und schreibe darin deine erreichten Ziele und deine
Gefühle auf!
Achte bei der Auswahl der Themen aber darauf, dass du nicht zu einfache
Übungen machst! Optimal ist es, wenn die Übung etwas über deinem Niveau
beginnt. Dann kannst du spüren, wie sie während des Übens für dich immer
leichter wird. Genieße das Gefühl!
Sprich mit anderen Lernenden über das Deutschlernen! Nutze auch deine
Sozialen Medien dazu!
Wenn du in der Gruppe E einen niedrigen Wert erreicht hast, dann denke an
folgende Fragen:
Versuche, dich mit anderen Lernen auszutauschen!
Hab mehr Mut zum Sprechen und Schreiben, auch wenn die Strukturen nicht
sehr bekannt sind!
Benutze auch die Seite „Brauchen Sie Hilfe?“, wenn du Fragen hast. Vergiss
nicht deine E-Mail-Adresse, damit wir dir antworten können.$$
WHERE form_id = (SELECT id FROM forms WHERE public_id = 'learning_type')
AND step_label = 'E';
UPDATE form_parts
SET advice_en = $$If you have achieved a high average score in Group F, then bear the following in
mind whilst studying:
• Find out as much as you can from a variety of sources about life and culture
in German-speaking countries!
• Don’t forget about literature either! It offers both entertainment and language
learning!
If you achieved a low average score in Group F, then bear the following in mind
when studying:
Learning is a social process, especially when it comes to learning a foreign
language. Seek out contact with real people who speak German as their mother
tongue and speak to them in German. Ask them to correct you!$$
WHERE form_id = (SELECT id FROM forms WHERE public_id = 'learning_type')
AND step_label = 'F';
UPDATE form_parts
SET advice_de = $$Wenn du in der Gruppe F einen hohen Mittelwert erreicht hast, dann denke beim
Lernen an folgende Dinge:
Informiere dich aus möglichst vielen Quellen über Leben und Kultur der
deutschsprachigen Länder!
Vergiss auch nicht die schöne Literatur! Sie bietet Unterhaltung und
Sprachenlernen!
Wenn du in der Gruppe F einen niedrigen Mittelwert erreicht hast, dann denke beim
Lernen vor allem daran:
Lernen ist ein sozialer Prozess, besonders Fremdsprachenlernen. Suche den
Kontakt mit realen Personen, die Deutsch als Muttersprache sprechen und sprich mit
ihnen auf Deutsch. Bitte darum, dass sie dich korrigieren!$$
WHERE form_id = (SELECT id FROM forms WHERE public_id = 'learning_type')
AND step_label = 'F';
@@ -0,0 +1,50 @@
ALTER TABLE exercises
ADD COLUMN IF NOT EXISTS updated_by bigint;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'exercises_updated_by_fkey'
) THEN
ALTER TABLE exercises
ADD CONSTRAINT exercises_updated_by_fkey
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
UPDATE exercises
SET updated_by = created_by
WHERE updated_by IS NULL;
ALTER TABLE html_pages
ADD COLUMN IF NOT EXISTS updated_at timestamp with time zone NOT NULL DEFAULT now();
ALTER TABLE html_pages
ADD COLUMN IF NOT EXISTS updated_by bigint;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'html_pages_updated_by_fkey'
) THEN
ALTER TABLE html_pages
ADD CONSTRAINT html_pages_updated_by_fkey
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
DROP TRIGGER IF EXISTS update_exercises_updated_at ON exercises;
CREATE TRIGGER update_exercises_updated_at
BEFORE UPDATE ON exercises
FOR EACH ROW
EXECUTE FUNCTION updated_at();
DROP TRIGGER IF EXISTS update_html_pages_updated_at ON html_pages;
CREATE TRIGGER update_html_pages_updated_at
BEFORE UPDATE ON html_pages
FOR EACH ROW
EXECUTE FUNCTION updated_at();
@@ -0,0 +1,35 @@
-- Backfill updated_by for existing exercises where we can infer the creator
-- from child exercise tables.
UPDATE exercises
SET updated_by = created_by
WHERE updated_by IS NULL
AND created_by IS NOT NULL;
UPDATE exercises e
SET updated_by = de.created_by
FROM dnd_exercises de
WHERE e.id = de.exercise_id
AND e.updated_by IS NULL
AND de.created_by IS NOT NULL;
UPDATE exercises e
SET updated_by = dme.created_by
FROM dnd_match_exercises dme
WHERE e.id = dme.exercise_id
AND e.updated_by IS NULL
AND dme.created_by IS NOT NULL;
UPDATE exercises e
SET updated_by = mge.created_by
FROM memory_game_exercises mge
WHERE e.id = mge.exercise_id
AND e.updated_by IS NULL
AND mge.created_by IS NOT NULL;
UPDATE exercises e
SET updated_by = jse.created_by
FROM jumbled_sentence_exercises jse
WHERE e.id = jse.exercise_id
AND e.updated_by IS NULL
AND jse.created_by IS NOT NULL;
@@ -0,0 +1,15 @@
ALTER TABLE glossary_entries
ADD COLUMN IF NOT EXISTS updated_by bigint;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'glossary_entries_updated_by_fkey'
) THEN
ALTER TABLE glossary_entries
ADD CONSTRAINT glossary_entries_updated_by_fkey
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
+25 -4
View File
@@ -140,9 +140,12 @@ CREATE TABLE public.exercises (
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
category text,
created_by bigint
created_by bigint,
updated_by bigint
);
CREATE TRIGGER update_exercises_updated_at BEFORE UPDATE ON public.exercises FOR EACH ROW EXECUTE FUNCTION updated_at();
ALTER TABLE public.click_to_exercises
ADD CONSTRAINT click_to_exercises_exercise_id_fkey
FOREIGN KEY (exercise_id) REFERENCES exercises(id);
@@ -165,7 +168,8 @@ CREATE TABLE public.dnd_exercises (
updated_at timestamp with time zone NOT NULL DEFAULT now(),
created_by bigint NOT NULL,
exercise_id bigint NOT NULL,
title text NOT NULL
title text NOT NULL,
description text
);
ALTER TABLE public.dnd_exercises
@@ -338,6 +342,10 @@ ALTER TABLE public.exercises_to_grammar_items
ADD CONSTRAINT exercises_to_grammar_items_grammar_item_id_fkey
FOREIGN KEY (grammar_item_id) REFERENCES grammar_items(id);
ALTER TABLE public.exercises
ADD CONSTRAINT exercises_updated_by_fkey
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL;
CREATE SEQUENCE public.feedbacks_id_seq;
CREATE TABLE public.feedbacks (
@@ -558,11 +566,16 @@ CREATE TABLE public.glossary_entries (
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
word text NOT NULL,
word_definition text NOT NULL
word_definition text NOT NULL,
updated_by bigint
);
CREATE TRIGGER update_glossary_entries_updated_at BEFORE UPDATE ON public.glossary_entries FOR EACH ROW EXECUTE FUNCTION updated_at();
ALTER TABLE public.glossary_entries
ADD CONSTRAINT glossary_entries_updated_by_fkey
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL;
CREATE SEQUENCE public.grammar_topics_id_seq;
CREATE TABLE public.grammar_topics (
@@ -581,13 +594,21 @@ CREATE TABLE public.html_pages (
slug text,
page_group text,
grammar_topic_id integer,
description text
description text,
updated_at timestamp with time zone NOT NULL DEFAULT now(),
updated_by bigint
);
CREATE TRIGGER update_html_pages_updated_at BEFORE UPDATE ON public.html_pages FOR EACH ROW EXECUTE FUNCTION updated_at();
ALTER TABLE public.html_pages
ADD CONSTRAINT html_pages_grammar_topic_id_fkey
FOREIGN KEY (grammar_topic_id) REFERENCES grammar_topics(id);
ALTER TABLE public.html_pages
ADD CONSTRAINT html_pages_updated_by_fkey
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE public.jumbled_sentence_exercises
ADD CONSTRAINT jumbled_sentence_exercises_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE;
@@ -11,6 +11,7 @@ import WordSelectionExercise from "@/components/ui/click/click.js";
import Editor from "@/components/ui/editor";
import { htmlToPlainText } from "@/shared/utils/normalizeEditorText";
import { withBasePath } from "@/shared/utils/basePath";
import AdminLastModified from "@/components/ui/admin-last-modified";
function escapeHtml(value) {
return String(value || "")
@@ -175,6 +176,12 @@ export default function CreateExercise() {
? "Wortauswahl-Übung bearbeiten"
: "Wortauswahl-Übung erstellen"}
</h1>
{isEditMode && (
<AdminLastModified
updatedAt={exerciseData?.last_modified_at}
updatedBy={exerciseData?.last_modified_by}
/>
)}
{submitted ? (
successMessage() // Show only the success message if submitted
) : !previewMode ? (
@@ -7,6 +7,7 @@ import { Row } from "@/components/ui/layout/container";
import useQuery from "@/shared/hooks/useQuery";
import "./admin.css";
import { withBasePath } from "@/shared/utils/basePath";
import AdminLastModified from "@/components/ui/admin-last-modified";
const EMPTY_PAIR = () => ({ leftItem: "", rightItem: "" });
@@ -133,6 +134,12 @@ export default function DndMatchAdminPage() {
? "Zuordnungs-Übung bearbeiten"
: "Neue Zuordnungs-Übung erstellen"}
</h1>
{isEditMode && (
<AdminLastModified
updatedAt={exerciseData?.last_modified_at}
updatedBy={exerciseData?.last_modified_by}
/>
)}
{error && (
<p className="error-message" role="alert">
@@ -5,6 +5,11 @@
gap: 20px;
}
.dragdrop-admin-title {
white-space: nowrap;
font-size: clamp(1.35rem, 2vw, 2rem);
}
.admin-content {
flex: 1;
padding: 5px;
@@ -38,6 +43,12 @@
background-color: var(--bg2);
}
.dragdrop-description-input {
min-height: 14rem;
max-width: 42rem;
resize: vertical;
}
.form-input::placeholder {
color: var(--fg4);
font-style: italic;
@@ -61,4 +72,4 @@
.form-purple {
background-color: var(--purple);
}
}
@@ -10,6 +10,7 @@ import { useParams, useRouter } from "next/navigation";
import useQuery from "@/shared/hooks/useQuery";
import PreviewDragDrop from "@/components/ui/dragdrop/dragdrop_preview";
import { withBasePath } from "@/shared/utils/basePath";
import AdminLastModified from "@/components/ui/admin-last-modified";
export default function DragdropAdminPage() {
const { dnd_id } = useParams();
@@ -17,6 +18,7 @@ export default function DragdropAdminPage() {
const [numberOfFields, setNumberOfFields] = useState(null);
const [inputFields, setInputFields] = useState([]);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [generalError, setGeneralError] = useState("");
const [showPreview, setShowPreview] = useState(false);
@@ -60,6 +62,7 @@ export default function DragdropAdminPage() {
if (!isEditMode || !exerciseData) return;
setTitle(exerciseData.title || "");
setDescription(exerciseData.description || "");
setInputFields(
Array.isArray(exerciseData.fields) ? exerciseData.fields : []
);
@@ -92,6 +95,7 @@ export default function DragdropAdminPage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
description: description.trim(),
fields: inputFields,
}),
}
@@ -104,6 +108,7 @@ export default function DragdropAdminPage() {
} else {
const res = await makeRequest("/admin/exercises/dragdrop", {
title: title.trim(),
description: description.trim(),
fields: inputFields,
});
@@ -150,11 +155,17 @@ export default function DragdropAdminPage() {
return (
<div className={styles.page}>
<div className="admin-container">
<h1>
<h1 className="dragdrop-admin-title">
{isEditMode
? "Eine Sortieren/Gruppieren-Übung bearbeiten"
: "Eine Sortieren/Gruppieren-Übung erstellen"}
</h1>
{isEditMode && (
<AdminLastModified
updatedAt={exerciseData?.last_modified_at}
updatedBy={exerciseData?.last_modified_by}
/>
)}
{generalError && (
<p className="error" role="alert">
{generalError}
@@ -185,6 +196,17 @@ export default function DragdropAdminPage() {
placeholder="Titel eingeben"
/>
</div>
<div className="form-group">
<label htmlFor="description">Beschreibung/Anweisung:</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="form-input dragdrop-description-input"
placeholder="Kurze Anleitung für die Lernenden"
rows={8}
/>
</div>
<div className="form-group">
<label htmlFor="fieldCount">Anzahl der Felder</label>
<Dropdown
@@ -8,6 +8,7 @@ import Editor from "@/components/ui/editor";
import { useRequest } from "@/shared/hooks/useRequest";
import useQuery from "@/shared/hooks/useQuery";
import { htmlToPlainText } from "@/shared/utils/normalizeEditorText";
import AdminLastModified from "@/components/ui/admin-last-modified";
import "./fillinthegap.css";
function tokenizeText(text) {
@@ -381,6 +382,12 @@ export default function CreateFillInTheGapExercisePage() {
? "Lückentext-Übung bearbeiten"
: "Lückentext-Übung erstellen"}
</h1>
{isEditMode && (
<AdminLastModified
updatedAt={exerciseData?.last_modified_at}
updatedBy={exerciseData?.last_modified_by}
/>
)}
<Container className="fitg-block">
<label htmlFor="fitg-title">Titel</label>
@@ -7,6 +7,7 @@ import { useRouter, useParams } from "next/navigation";
import useQuery from "@/shared/hooks/useQuery";
import "../../../multichoice/multichoice.css";
import { withBasePath } from "@/shared/utils/basePath";
import AdminLastModified from "@/components/ui/admin-last-modified";
export default function EditFreeFormExercisePage() {
const router = useRouter();
@@ -171,6 +172,10 @@ export default function EditFreeFormExercisePage() {
return (
<Column gap="md">
<h2>Freitextübung bearbeiten</h2>
<AdminLastModified
updatedAt={data?.last_modified_at}
updatedBy={data?.last_modified_by}
/>
<p>Geladene Fragen: {questions.length}</p>
{generalError && (
@@ -8,6 +8,7 @@ import { jumbledSentenceExerciseSchema } from "@/shared/schemas/jumbled-sentence
import { withBasePath } from "@/shared/utils/basePath";
import useQuery from "@/shared/hooks/useQuery";
import styles from "./jumbled-sentence.module.css";
import AdminLastModified from "@/components/ui/admin-last-modified";
function toElementPerLine(value) {
if (typeof value !== "string") return "";
@@ -318,6 +319,12 @@ export default function CreateJumbledSentenceExercise() {
<h2>
{isEditMode ? "Satzmix-Übung bearbeiten" : "Satzmix-Übung erstellen"}
</h2>
{isEditMode && (
<AdminLastModified
updatedAt={data?.exercise?.last_modified_at}
updatedBy={data?.exercise?.last_modified_by}
/>
)}
<label className={styles.fieldLabel}>
Titel:
<input
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { Column, Row } from "@/components/ui/layout/container";
import { useRequest } from "@/shared/hooks/useRequest";
import { withBasePath } from "@/shared/utils/basePath";
import AdminLastModified from "@/components/ui/admin-last-modified";
const emptyPair = { left_item: "", right_item: "" };
@@ -15,6 +16,8 @@ export default function CreateMemoryGame() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [generalError, setGeneralError] = useState("");
const [lastModifiedAt, setLastModifiedAt] = useState(null);
const [lastModifiedBy, setLastModifiedBy] = useState(null);
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
@@ -45,6 +48,8 @@ export default function CreateMemoryGame() {
right_item: pair.right_item,
})) || [emptyPair]
);
setLastModifiedAt(data.last_modified_at || null);
setLastModifiedBy(data.last_modified_by || null);
} catch (error) {
console.error(error);
setGeneralError("Fehler beim Laden des Spiels");
@@ -101,6 +106,12 @@ export default function CreateMemoryGame() {
return (
<Column gap="md" width="100%">
<h2>{isEditing ? "Memory Game bearbeiten" : "Memory Game erstellen"}</h2>
{isEditing && (
<AdminLastModified
updatedAt={lastModifiedAt}
updatedBy={lastModifiedBy}
/>
)}
{generalError && <p className="error">{generalError}</p>}
<label>
Titel
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import useQuery from "@/shared/hooks/useQuery";
import "./multichoice.css";
import { withBasePath } from "@/shared/utils/basePath";
import AdminLastModified from "@/components/ui/admin-last-modified";
export default function CreateMultichoicePage() {
const router = useRouter();
@@ -221,6 +222,12 @@ export default function CreateMultichoicePage() {
? "Multiple Choice Übung bearbeiten"
: "Multiple Choice Übung erstellen"}
</h1>
{isEditMode && (
<AdminLastModified
updatedAt={exerciseData?.last_modified_at}
updatedBy={exerciseData?.last_modified_by}
/>
)}
{error && <div className="error-message">{error}</div>}
@@ -7,10 +7,13 @@ import Editor from "@/components/ui/editor";
import layout from "@/shared/styles/layout.module.css";
import { useRouter, useSearchParams } from "next/navigation";
import { withBasePath } from "@/shared/utils/basePath";
import AdminLastModified from "@/components/ui/admin-last-modified";
export default function EditorSection({
initialContent,
initialDescription,
initialUpdatedAt,
initialUpdatedBy,
type,
slug,
title,
@@ -98,6 +101,10 @@ export default function EditorSection({
return (
<Column className={layout.viewContent}>
<AdminLastModified
updatedAt={initialUpdatedAt}
updatedBy={initialUpdatedBy}
/>
<Row gap="1rem">
<Button
onClick={() => {
@@ -61,6 +61,8 @@ export default async function Edit({ params, searchParams }) {
<EditorSection
initialContent={pageContent.content}
initialDescription={pageContent.description ?? ""}
initialUpdatedAt={pageContent.updated_at ?? null}
initialUpdatedBy={pageContent.updated_by_username ?? null}
type={type}
slug={decodedSlug}
title={pageContent.title}
@@ -86,6 +88,8 @@ export default async function Edit({ params, searchParams }) {
<EditorSection
initialContent=""
initialDescription=""
initialUpdatedAt={null}
initialUpdatedBy={null}
type={type}
slug={decodedSlug}
title={chapter?.title || decodedSlug}
@@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { zodErrorToFormErrors } from "@/shared/schemas/schema-utils";
import Editor from "@/components/ui/editor";
import { withBasePath } from "@/shared/utils/basePath";
import AdminLastModified from "@/components/ui/admin-last-modified";
const defaultFormErrors = {
word: "",
@@ -20,6 +21,8 @@ export default function CreateGlossaryEntry() {
const [formErrors, setFormErrors] = useState(defaultFormErrors);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoadingEntry, setIsLoadingEntry] = useState(false);
const [lastModifiedAt, setLastModifiedAt] = useState(null);
const [lastModifiedBy, setLastModifiedBy] = useState(null);
const makeRequest = useRequest();
const router = useRouter();
const searchParams = useSearchParams();
@@ -41,6 +44,8 @@ export default function CreateGlossaryEntry() {
const entry = await response.json();
setWord(entry.word);
setWordDefinition(entry.word_definition);
setLastModifiedAt(entry.updated_at || null);
setLastModifiedBy(entry.last_modified_by || null);
} catch (error) {
console.error("Error loading glossary entry:", error);
setGeneralError("Glossar-Eintrag konnte nicht geladen werden.");
@@ -117,6 +122,12 @@ export default function CreateGlossaryEntry() {
<h2>
{isEditing ? "Glossar-Eintrag bearbeiten" : "Glossar-Eintrag erstellen"}
</h2>
{isEditing && (
<AdminLastModified
updatedAt={lastModifiedAt}
updatedBy={lastModifiedBy}
/>
)}
<p>
{isEditing
? "Aktualisieren Sie den ausgewählten Glossar-Eintrag."
@@ -52,9 +52,22 @@ export const GET = withAuth(
const { click_id } = await params;
const result = await DB.pool(
`SELECT id, title, category, target_words, all_words, source_html, created_at, updated_at
FROM click_exercises
WHERE id = $1`,
`SELECT
ce.id,
ce.title,
ce.category,
ce.target_words,
ce.all_words,
ce.source_html,
ce.created_at,
ce.updated_at,
e.updated_at AS last_modified_at,
COALESCE(NULLIF(u.username, ''), u.email) AS last_modified_by
FROM click_exercises ce
LEFT JOIN click_to_exercises cte ON cte.click_id = ce.id
LEFT JOIN exercises e ON e.id = cte.exercise_id
LEFT JOIN users u ON u.id = COALESCE(e.updated_by, e.created_by)
WHERE ce.id = $1`,
[click_id]
);
@@ -149,6 +162,15 @@ export const PUT = withAuth(
]
);
await DB.pool(
`UPDATE exercises e
SET updated_by = $1
FROM click_to_exercises cte
WHERE cte.exercise_id = e.id
AND cte.click_id = $2`,
[request.user?.id ?? null, click_id]
);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error updating click exercise:", error);
@@ -64,6 +64,17 @@ export const POST = withAuth(
VALUES ($1, $2, $3, $4, $5) returning id`,
[title, targetCategory, targetWords, allWords, sanitizedSourceHtml]
);
await DB.pool(
`UPDATE exercises e
SET created_by = $1,
updated_by = $1
FROM click_to_exercises cte
WHERE cte.exercise_id = e.id
AND cte.click_id = $2`,
[request.user?.id ?? null, id.rows[0].id]
);
return Response.json({ id: id.rows[0].id }, { status: 201 });
},
{
@@ -9,9 +9,16 @@ export const GET = withAuth(
const { match_id } = await params;
const { rows: exerciseRows } = await DB.pool(
`SELECT id, title, description
FROM dnd_match_exercises
WHERE id = $1`,
`SELECT
dme.id,
dme.title,
dme.description,
e.updated_at AS last_modified_at,
COALESCE(NULLIF(u.username, ''), u.email) AS last_modified_by
FROM dnd_match_exercises dme
JOIN exercises e ON e.id = dme.exercise_id
LEFT JOIN users u ON u.id = COALESCE(e.updated_by, e.created_by)
WHERE dme.id = $1`,
[match_id]
);
@@ -75,6 +82,17 @@ export const PUT = withAuth(
[title, description, match_id]
);
await tx.query(
`UPDATE exercises
SET updated_by = $1
WHERE id = (
SELECT exercise_id
FROM dnd_match_exercises
WHERE id = $2
)`,
[request.user?.id ?? null, match_id]
);
await tx.query(
`DELETE FROM dnd_match_pairs WHERE dnd_match_exercise_id = $1`,
[match_id]
@@ -24,8 +24,8 @@ export const POST = withAuth(
const result = await DB.transaction(async (tx) => {
const exerciseRes = await tx.query(
`INSERT INTO exercises (created_by, category)
VALUES ($1, 'dnd_match')
`INSERT INTO exercises (created_by, updated_by, category)
VALUES ($1, $1, 'dnd_match')
RETURNING id`,
[createdBy]
);
@@ -11,7 +11,7 @@ const parseWords = (content) => [
),
];
const validateFields = ({ title, fields }) => {
const validateFields = ({ title, description, fields }) => {
if (!title || !Array.isArray(fields) || fields.length === 0) {
return { error: "Titel und Felder sind erforderlich.", status: 400 };
}
@@ -23,6 +23,13 @@ const validateFields = ({ title, fields }) => {
};
}
if (description && description.length > 1000) {
return {
error: "Die Beschreibung darf maximal 1000 Zeichen lang sein.",
status: 422,
};
}
for (const field of fields) {
if (
!field?.category ||
@@ -89,9 +96,16 @@ export const GET = withAuth(
const { dnd_id } = await params;
const exerciseRes = await DB.pool(
`SELECT id, title
FROM dnd_exercises
WHERE id = $1`,
`SELECT
de.id,
de.title,
de.description,
e.updated_at AS last_modified_at,
COALESCE(NULLIF(u.username, ''), u.email) AS last_modified_by
FROM dnd_exercises de
JOIN exercises e ON e.id = de.exercise_id
LEFT JOIN users u ON u.id = COALESCE(e.updated_by, e.created_by)
WHERE de.id = $1`,
[dnd_id]
);
@@ -126,6 +140,9 @@ export const GET = withAuth(
return NextResponse.json({
id: exerciseRes.rows[0].id,
title: exerciseRes.rows[0].title,
description: exerciseRes.rows[0].description || "",
last_modified_at: exerciseRes.rows[0].last_modified_at,
last_modified_by: exerciseRes.rows[0].last_modified_by,
fields,
});
} catch (error) {
@@ -149,9 +166,10 @@ export const PUT = withAuth(
const body = await request.json();
const payload = body.body ?? body;
const title = payload.title?.trim();
const description = String(payload.description || "").trim();
const fields = payload.fields;
const validationError = validateFields({ title, fields });
const validationError = validateFields({ title, description, fields });
if (validationError) {
return NextResponse.json(
{ error: validationError.error },
@@ -174,9 +192,21 @@ export const PUT = withAuth(
await tx.query(
`UPDATE dnd_exercises
SET title = $1,
description = $2,
updated_at = NOW()
WHERE id = $2`,
[title, dnd_id]
WHERE id = $3`,
[title, description || null, dnd_id]
);
await tx.query(
`UPDATE exercises
SET updated_by = $1
WHERE id = (
SELECT exercise_id
FROM dnd_exercises
WHERE id = $2
)`,
[request.user?.id ?? null, dnd_id]
);
await tx.query(
@@ -55,6 +55,7 @@ export const POST = withAuth(
const rawBody = await request.json();
const body = rawBody.body ?? rawBody;
const title = body.title;
const description = String(body.description || "").trim();
const fields = body.fields;
const created_by = request.user?.id;
@@ -70,8 +71,8 @@ export const POST = withAuth(
// 1. Insert into exercises
const exRes = await DB.pool(
`INSERT INTO exercises (created_by, category)
VALUES ($1, $2)
`INSERT INTO exercises (created_by, updated_by, category)
VALUES ($1, $1, $2)
RETURNING id`,
[created_by, exerciseCategory]
);
@@ -79,10 +80,10 @@ export const POST = withAuth(
// 2. Insert into dnd_exercises
const dndRes = await DB.pool(
`INSERT INTO dnd_exercises (created_by, exercise_id, title)
VALUES ($1, $2, $3)
`INSERT INTO dnd_exercises (created_by, exercise_id, title, description)
VALUES ($1, $2, $3, $4)
RETURNING id`,
[created_by, exercise_id, title]
[created_by, exercise_id, title, description || null]
);
const dnd_id = dndRes.rows[0].id;
// Process fields
@@ -21,9 +21,18 @@ export const GET = withAuth(
const { exercise_id } = await params;
const { rows: exerciseRows } = await DB.pool(
`SELECT id, exercise_id, title, source_text, source_html
FROM fill_gap_exercises
WHERE id = $1`,
`SELECT
fge.id,
fge.exercise_id,
fge.title,
fge.source_text,
fge.source_html,
e.updated_at AS last_modified_at,
COALESCE(NULLIF(u.username, ''), u.email) AS last_modified_by
FROM fill_gap_exercises fge
JOIN exercises e ON e.id = fge.exercise_id
LEFT JOIN users u ON u.id = COALESCE(e.updated_by, e.created_by)
WHERE fge.id = $1`,
[exercise_id]
);
@@ -65,6 +74,8 @@ export const GET = withAuth(
return NextResponse.json({
...exerciseRows[0],
last_modified_at: exerciseRows[0].last_modified_at,
last_modified_by: exerciseRows[0].last_modified_by,
gaps,
});
} catch (error) {
@@ -123,6 +134,17 @@ export const PUT = withAuth(
[title, normalizedText, sourceHtml, exercise_id]
);
await tx.query(
`UPDATE exercises
SET updated_by = $1
WHERE id = (
SELECT exercise_id
FROM fill_gap_exercises
WHERE id = $2
)`,
[request.user?.id ?? null, exercise_id]
);
await tx.query(
`DELETE FROM fill_gap_gaps
WHERE fill_gap_exercise_id = $1`,
@@ -25,11 +25,17 @@ export const POST = withAuth(
);
const sourceHtml = sanitizeHtml(textHtml || "");
const userId = request.user?.id;
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const created = await DB.transaction(async (tx) => {
const exerciseResult = await tx.query(
`INSERT INTO exercises (created_at, updated_at, category)
VALUES (NOW(), NOW(), 'fillinthegap')
RETURNING id`
`INSERT INTO exercises (created_at, updated_at, created_by, updated_by, category)
VALUES (NOW(), NOW(), $1, $1, 'fillinthegap')
RETURNING id`,
[userId]
);
const exerciseId = exerciseResult.rows[0].id;
@@ -10,11 +10,15 @@ export const GET = withAuth(
const { rows: exerciseRows } = await DB.pool(
`
SELECT
id,
title,
exercise_id
FROM free_form_exercises
WHERE id = $1
ffe.id,
ffe.title,
ffe.exercise_id,
e.updated_at AS last_modified_at,
COALESCE(NULLIF(u.username, ''), u.email) AS last_modified_by
FROM free_form_exercises ffe
JOIN exercises e ON e.id = ffe.exercise_id
LEFT JOIN users u ON u.id = COALESCE(e.updated_by, e.created_by)
WHERE ffe.id = $1
`,
[exercise_id]
);
@@ -128,6 +132,17 @@ export const PUT = withAuth(
[title, exercise_id]
);
await tx.query(
`UPDATE exercises
SET updated_by = $1
WHERE id = (
SELECT exercise_id
FROM free_form_exercises
WHERE id = $2
)`,
[request.user?.id ?? null, exercise_id]
);
await tx.query(
`
DELETE FROM free_form_answers
@@ -25,10 +25,18 @@ export const POST = withAuth(
);
}
const userId = req.user?.id;
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const exerciseId = await DB.transaction(async (tx) => {
const exercise = await tx.query(`
INSERT INTO exercises (created_at, updated_at, category) VALUES (NOW(), NOW(), 'freeform') RETURNING id
`);
const exercise = await tx.query(
`INSERT INTO exercises (created_at, updated_at, created_by, updated_by, category)
VALUES (NOW(), NOW(), $1, $1, 'freeform')
RETURNING id`,
[userId]
);
const exerciseId = exercise.rows[0].id;
const freeFormExercise = await tx.query(
@@ -7,7 +7,15 @@ export const GET = withAuth(async (request, { params }) => {
const { id } = await params;
try {
const exRes = await DB.pool(
`SELECT id, title FROM jumbled_sentence_exercises WHERE id = $1`,
`SELECT
jse.id,
jse.title,
e.updated_at AS last_modified_at,
COALESCE(NULLIF(u.username, ''), u.email) AS last_modified_by
FROM jumbled_sentence_exercises jse
JOIN exercises e ON e.id = jse.exercise_id
LEFT JOIN users u ON u.id = COALESCE(e.updated_by, e.created_by)
WHERE jse.id = $1`,
[id]
);
if (!exRes.rows.length) {
@@ -55,6 +63,16 @@ export const PUT = withAuth(
`UPDATE jumbled_sentence_exercises SET title = $1, updated_at = NOW() WHERE id = $2`,
[title, id]
);
await client.query(
`UPDATE exercises
SET updated_by = $1
WHERE id = (
SELECT exercise_id
FROM jumbled_sentence_exercises
WHERE id = $2
)`,
[request.user?.id ?? null, id]
);
await client.query(
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
[id]
@@ -23,7 +23,7 @@ export const POST = withAuth(
try {
const result = await DB.transaction(async (client) => {
const exerciseRes = await client.query(
`INSERT INTO exercises (created_at, updated_at, category, created_by) VALUES (NOW(), NOW(), $1, $2) RETURNING id`,
`INSERT INTO exercises (created_at, updated_at, category, created_by, updated_by) VALUES (NOW(), NOW(), $1, $2, $2) RETURNING id`,
["jumbled-sentence", created_by]
);
const exercise_id = exerciseRes.rows[0].id;
@@ -14,9 +14,16 @@ export const GET = withAuth(
}
const { rows: exerciseRows } = await DB.pool(
`SELECT id, title, description
FROM memory_game_exercises
WHERE id = $1`,
`SELECT
mge.id,
mge.title,
mge.description,
e.updated_at AS last_modified_at,
COALESCE(NULLIF(u.username, ''), u.email) AS last_modified_by
FROM memory_game_exercises mge
JOIN exercises e ON e.id = mge.exercise_id
LEFT JOIN users u ON u.id = COALESCE(e.updated_by, e.created_by)
WHERE mge.id = $1`,
[id]
);
@@ -90,6 +97,17 @@ export const PUT = withAuth(
[title, description, id]
);
await tx.query(
`UPDATE exercises
SET updated_by = $1
WHERE id = (
SELECT exercise_id
FROM memory_game_exercises
WHERE id = $2
)`,
[request.user?.id ?? null, id]
);
await tx.query(
`DELETE FROM memory_game_pairs WHERE memory_game_exercise_id = $1`,
[id]
@@ -38,8 +38,8 @@ export const POST = withAuth(
const result = await DB.transaction(async (tx) => {
const exerciseResult = await tx.query(
`INSERT INTO exercises (created_by, category)
VALUES ($1, 'memory_game')
`INSERT INTO exercises (created_by, updated_by, category)
VALUES ($1, $1, 'memory_game')
RETURNING id`,
[createdBy]
);
@@ -77,7 +77,14 @@ export const GET = withAuth(
const { exercise_id } = await params;
const exerciseResult = await DB.pool(
"SELECT * FROM multichoice_exercises WHERE id = $1",
`SELECT
me.*,
e.updated_at AS last_modified_at,
COALESCE(NULLIF(u.username, ''), u.email) AS last_modified_by
FROM multichoice_exercises me
JOIN exercises e ON e.id = me.exercise_id
LEFT JOIN users u ON u.id = COALESCE(e.updated_by, e.created_by)
WHERE me.id = $1`,
[exercise_id]
);
const exercise = exerciseResult.rows[0];
@@ -120,6 +127,8 @@ export const GET = withAuth(
return NextResponse.json({
...exercise,
last_modified_at: exercise.last_modified_at,
last_modified_by: exercise.last_modified_by,
content: contentWithOptions,
});
} catch (error) {
@@ -183,6 +192,17 @@ export const PUT = withAuth(
[title, exercise_id]
);
await tx.query(
`UPDATE exercises
SET updated_by = $1
WHERE id = (
SELECT exercise_id
FROM multichoice_exercises
WHERE id = $2
)`,
[request.user?.id ?? null, exercise_id]
);
await tx.query(
"DELETE FROM multichoice_content WHERE multichoice_exercise_id = $1",
[exercise_id]
@@ -28,13 +28,17 @@ export const POST = withAuth(
);
}
const created_by = 1;
const created_by = request.user?.id;
if (!created_by) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// 1. Insert into parent 'exercises' table
// FIX: Removed 'description' column. Only saving creator and category.
const exResult = await DB.pool(
`INSERT INTO exercises (created_by, category)
VALUES ($1, 'multichoice')
`INSERT INTO exercises (created_by, updated_by, category)
VALUES ($1, $1, 'multichoice')
RETURNING id`,
[created_by]
);
+27 -12
View File
@@ -12,11 +12,11 @@ export const POST = withAuth(
const entryId = await DB.transaction(async (tx) => {
const result = await tx.query(
`
INSERT INTO glossary_entries (word, word_definition)
VALUES ($1, $2)
INSERT INTO glossary_entries (word, word_definition, updated_by)
VALUES ($1, $2, $3)
RETURNING id
`,
[word, word_definition]
[word, word_definition, req.user?.id ?? null]
);
return result.rows[0].id;
});
@@ -36,9 +36,16 @@ export const GET = withAuth(
if (id) {
const result = await DB.pool(
`SELECT id, word, word_definition, created_at, updated_at
FROM glossary_entries
WHERE id = $1`,
`SELECT
ge.id,
ge.word,
ge.word_definition,
ge.created_at,
ge.updated_at,
COALESCE(NULLIF(u.username, ''), u.email) AS last_modified_by
FROM glossary_entries ge
LEFT JOIN users u ON u.id = ge.updated_by
WHERE ge.id = $1`,
[id]
);
@@ -53,8 +60,15 @@ export const GET = withAuth(
}
const entries = await DB.pool(
`SELECT id, word, word_definition, created_at, updated_at
FROM glossary_entries
`SELECT
ge.id,
ge.word,
ge.word_definition,
ge.created_at,
ge.updated_at,
COALESCE(NULLIF(u.username, ''), u.email) AS last_modified_by
FROM glossary_entries ge
LEFT JOIN users u ON u.id = ge.updated_by
ORDER BY created_at DESC`
);
@@ -86,10 +100,11 @@ export const PATCH = withAuth(
`UPDATE glossary_entries
SET word = $1,
word_definition = $2,
updated_at = NOW()
WHERE id = $3
RETURNING id, word, word_definition, created_at, updated_at`,
[word, word_definition, id]
updated_at = NOW(),
updated_by = $3
WHERE id = $4
RETURNING id, word, word_definition, created_at, updated_at, updated_by`,
[word, word_definition, req.user?.id ?? null, id]
);
if (result.rowCount === 0) {
@@ -35,8 +35,8 @@ export const PATCH = withAuth(
try {
const { type, slug } = await params;
const query = `UPDATE html_pages SET content = '' WHERE slug = $1 AND page_group = $2 RETURNING *`;
const result = await DB.pool(query, [slug, type]);
const query = `UPDATE html_pages SET content = '', updated_by = $3 WHERE slug = $1 AND page_group = $2 RETURNING *`;
const result = await DB.pool(query, [slug, type, req.user?.id ?? null]);
if (result.rowCount === 0)
return NextResponse.json({ error: "Not found" }, { status: 404 });
@@ -113,12 +113,13 @@ export const PUT = withAuth(
});
}
newData.updated_by = req.user?.id ?? null;
let updated = await setPageData(type, slug, newData);
if (!updated && type === "grammar") {
const insertQuery = `
INSERT INTO html_pages (title, description, content, slug, page_group)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO html_pages (title, description, content, slug, page_group, updated_by)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT DO NOTHING
`;
@@ -128,6 +129,7 @@ export const PUT = withAuth(
newData.content ?? "",
newData.slug,
type,
req.user?.id ?? null,
]);
updated = await setPageData(type, slug, newData);
@@ -166,13 +168,14 @@ export const POST = withAuth(
});
}
const { title, description } = await req.json();
const query = `INSERT INTO html_pages (title, description, content, slug, page_group) VALUES ($1, $2, $3, $4, $5)`;
const query = `INSERT INTO html_pages (title, description, content, slug, page_group, updated_by) VALUES ($1, $2, $3, $4, $5, $6)`;
const result = await DB.pool(query, [
title,
normalizePageDescription(description),
"",
slug,
type,
req.user?.id ?? null,
]);
if (result.rowCount === 0)
+11 -2
View File
@@ -120,8 +120,17 @@ export const POST = withAuth(
}
await DB.pool(
"INSERT INTO html_pages (title, description, content, slug, page_group, page_order, grammar_topic_id) VALUES ($1, $2, $3, $4, $5, $6, $7)",
[trimmedTitle, pageDescription, "", slug, type, pageOrder, grammarTopicId]
"INSERT INTO html_pages (title, description, content, slug, page_group, page_order, grammar_topic_id, updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
[
trimmedTitle,
pageDescription,
"",
slug,
type,
pageOrder,
grammarTopicId,
req.user?.id ?? null,
]
);
return NextResponse.json({ slug }, { status: 200 });
@@ -12,7 +12,7 @@ export const GET = withAuth(async (request, { params }) => {
// Fetch exercise details
const exercise = await DB.pool(
`
SELECT dnd.id, dnd.title, dnd.created_at
SELECT dnd.id, dnd.title, dnd.description, dnd.created_at
FROM dnd_exercises dnd
WHERE dnd.id = $1
`,
@@ -55,6 +55,7 @@ export const GET = withAuth(async (request, { params }) => {
return Response.json({
exercise: exercise.rows[0],
title: exercise.rows[0].title,
description: exercise.rows[0].description || "",
categories: categories.rows,
words: words.rows,
});
@@ -26,16 +26,46 @@ export const PUT = withAuth(
const { answer } = await request.json();
logger.info(`Submitting answer: ${user_id}, ${question_id}, ${answer}`);
await DB.pool(
`
await DB.transaction(async (tx) => {
const updated = await tx.query(
`
UPDATE user_question_answers
SET answer = $3,
user_id = COALESCE($4, user_id),
updated_at = NOW()
WHERE answerer_id = $1 AND part_question_id = $2
RETURNING id
`,
[answerer_id, question_id, answer, user_id]
);
if (updated.rows.length === 0) {
await tx.query(
`
INSERT INTO user_question_answers (user_id, part_question_id, answer, answerer_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, part_question_id) DO UPDATE
SET answer = $3
RETURNING *
`,
[user_id, question_id, answer, answerer_id]
);
[user_id, question_id, answer, answerer_id]
);
return;
}
if (updated.rows.length > 1) {
await tx.query(
`
DELETE FROM user_question_answers
WHERE answerer_id = $1
AND part_question_id = $2
AND id <> (
SELECT MAX(id)
FROM user_question_answers
WHERE answerer_id = $1 AND part_question_id = $2
)
`,
[answerer_id, question_id]
);
}
});
return NextResponse.json({ success: true });
},
@@ -9,12 +9,20 @@ export const GET = withAuth(
const answerer_id = user?.id ?? request.headers.get("x-session-id");
const averages = await DB.pool(
`
SELECT AVG(answer)::numeric(10, 2) as part_average, pq.form_part_id
FROM user_question_answers uqa
JOIN part_questions pq ON uqa.part_question_id = pq.id
WITH latest_answers AS (
SELECT DISTINCT ON (part_question_id)
part_question_id,
answer
FROM user_question_answers
WHERE answerer_id = $1
ORDER BY part_question_id, updated_at DESC, id DESC
)
SELECT AVG(la.answer)::numeric(10, 2) as part_average, pq.form_part_id
FROM latest_answers la
JOIN part_questions pq ON la.part_question_id = pq.id
JOIN form_parts fp ON pq.form_part_id = fp.id
JOIN forms f ON fp.form_id = f.id
WHERE uqa.answerer_id = $1 AND f.public_id = $2
WHERE f.public_id = $2
GROUP BY pq.form_part_id
`,
[answerer_id, public_id]
@@ -15,8 +15,14 @@ export const GET = withAuth(
WITH questions AS
(SELECT pq.id, pq.title_en, pq.title_de, pq.form_part_id, COALESCE(uqa.answer, 0) as answer
FROM part_questions pq
LEFT JOIN user_question_answers uqa
ON pq.id = uqa.part_question_id AND uqa.answerer_id = $1
LEFT JOIN LATERAL (
SELECT answer
FROM user_question_answers
WHERE part_question_id = pq.id
AND answerer_id = $1
ORDER BY updated_at DESC, id DESC
LIMIT 1
) uqa ON true
),
parts as
(SELECT fp.id,
@@ -39,6 +39,7 @@ export default function Dragdrop({}) {
<div className={styles.page}>
<div className="exercise-container">
<h1>{exercise.title}</h1>
{!!exercise.description && <p>{exercise.description}</p>}
<DndProvider backend={HTML5Backend}>
<Area exerciseID={dnd_id} />
</DndProvider>
@@ -94,7 +94,9 @@ function buildHtmlWithGapPlaceholders(sourceHtml, gapsByTokenIndex) {
fragment.appendChild(doc.createTextNode(chunk));
}
tokenIndex += 1;
if (tokenParts.word) {
tokenIndex += 1;
}
}
textNode.parentNode?.replaceChild(fragment, textNode);
@@ -84,16 +84,16 @@
color: var(--fg2);
gap: var(--u-md);
& span {
font-size: var(--font-lg);
font-weight: 900;
}
& p {
margin: 0;
}
}
.learningAdviceTitle {
font-size: var(--font-lg);
font-weight: 900;
}
.learningAdviceHighlight {
border-color: var(--primary1);
opacity: 1 !important;
@@ -105,6 +105,43 @@
gap: 1rem;
}
.learningIntro {
margin: 0;
line-height: 1.6;
text-wrap: pretty;
}
.learningAdviceBody {
display: flex;
flex-direction: column;
gap: var(--u-sm);
line-height: 1.6;
text-wrap: pretty;
& p {
margin: 0;
}
}
.learningAdviceList {
margin: 0;
margin-left: 0.5rem;
padding-left: 1.5rem;
display: flex;
flex-direction: column;
gap: var(--u-xs);
& li {
margin: 0;
}
}
.learningAdviceRedLetter {
color: #c62828;
font: inherit;
font-weight: inherit;
}
@media (max-width: 768px) {
.learningAnswersHeader {
flex-direction: column;
+172 -2
View File
@@ -96,6 +96,9 @@ export default function LearningAnswersPage() {
});
};
const introText =
language.value === "de" ? DE_RESULTS_INTRO_TEXT : EN_RESULTS_INTRO_TEXT;
return (
<div className={layout.view}>
<div className={styles.learningAnswersHeader}>
@@ -130,12 +133,15 @@ export default function LearningAnswersPage() {
</Button>
</div>
<div className={styles.learningAnswers}>{renderAverages()}</div>
<p className={styles.learningIntro}>{introText}</p>
<div className={styles.learningAdvices}>{renderAdvices()}</div>
</div>
);
}
function LearningAdvice({ part, language, highlight }) {
const adviceText = getLanguageField(part, "advice", language);
return (
<div
className={[
@@ -144,9 +150,11 @@ function LearningAdvice({ part, language, highlight }) {
].join(" ")}
>
<span className={styles.learningAdviceTitle}>
{language === "de" ? "Ratschlag" : "Advice"} {part.step_label}
{language === "de" ? "Lerntipp" : "Learning Tip"} {part.step_label}
</span>
<p>{getLanguageField(part, "advice", language)}</p>
<div className={styles.learningAdviceBody}>
{renderRichPlainText(adviceText)}
</div>
</div>
);
}
@@ -174,3 +182,165 @@ function LearningAverage({ average, part, language }) {
</div>
);
}
const EN_RESULTS_INTRO_TEXT = `Here you can see the average scores for each section of the test. The study tips are
sorted according to these scores. The first tip is the one with the highest average
score. Read all six tips to find out how you can further improve your language
learning.`;
const DE_RESULTS_INTRO_TEXT = `Hier siehst du die Mittelwerte der einzelnen Sektionen des Tests. Die Hinweis-Texte
zum Lernen sind nach diesen Werten sortiert. Der erste Text ist der mit dem
höchsten Mittelwert. Lies alle 6 Texte, so kannst du herausfinden, wie du dein
Sprachenlernen noch verbessern kannst.`;
function renderRichPlainText(text) {
const blocks = normalizeTextBlocks(text);
return blocks.map((block, index) => {
if (block.type === "list") {
return (
<ul key={`list-${index}`} className={styles.learningAdviceList}>
{block.items.map((item, itemIndex) => (
<li key={`item-${index}-${itemIndex}`}>
{renderAdviceInlineFormatting(item, `item-${index}-${itemIndex}`)}
</li>
))}
</ul>
);
}
return (
<p key={`paragraph-${index}`}>
{renderAdviceInlineFormatting(block.text, `paragraph-${index}`)}
</p>
);
});
}
function renderAdviceInlineFormatting(text, keyPrefix) {
if (!text) return text;
const wordsRegex = /(Gitarre|sitzen|saß|gesessen)/g;
const parts = [];
let lastIndex = 0;
let match;
let counter = 0;
while ((match = wordsRegex.exec(text)) !== null) {
const [word] = match;
const start = match.index;
if (start > lastIndex) {
parts.push(text.slice(lastIndex, start));
}
parts.push(
<span key={`${keyPrefix}-word-${counter}`}>
{renderHighlightedWord(word, `${keyPrefix}-letters-${counter}`)}
</span>
);
lastIndex = start + word.length;
counter += 1;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length ? parts : text;
}
function renderHighlightedWord(word, keyPrefix) {
const highlightByWord = {
Gitarre: new Set([1, 3, 6]),
sitzen: new Set([1]),
saß: new Set([1]),
gesessen: new Set([3]),
};
const highlights = highlightByWord[word];
if (!highlights) return word;
return [...word].map((char, index) => {
if (!highlights.has(index)) {
return char;
}
return (
<span
key={`${keyPrefix}-${index}`}
className={styles.learningAdviceRedLetter}
>
{char}
</span>
);
});
}
function normalizeTextBlocks(text) {
if (!text || typeof text !== "string") return [];
const lines = text.replace(/\r\n/g, "\n").split("\n");
const blocks = [];
let paragraph = "";
let listItems = [];
const pushParagraph = () => {
const cleaned = paragraph.trim();
if (cleaned) blocks.push({ type: "paragraph", text: cleaned });
paragraph = "";
};
const pushList = () => {
if (listItems.length) blocks.push({ type: "list", items: [...listItems] });
listItems = [];
};
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) {
pushParagraph();
pushList();
continue;
}
const bulletMatch = trimmedLine.match(/^[•-]\s*(.*)$/);
if (bulletMatch) {
pushParagraph();
const bulletText = bulletMatch[1].trim();
if (bulletText) listItems.push(bulletText);
continue;
}
if (listItems.length) {
if (isListToParagraphBoundary(trimmedLine)) {
pushList();
paragraph = trimmedLine;
continue;
}
const lastIndex = listItems.length - 1;
listItems[lastIndex] = `${listItems[lastIndex]} ${trimmedLine}`;
continue;
}
if (paragraph && paragraph.endsWith(":")) {
pushParagraph();
paragraph = trimmedLine;
continue;
}
paragraph = paragraph ? `${paragraph} ${trimmedLine}` : trimmedLine;
}
pushParagraph();
pushList();
return blocks;
}
function isListToParagraphBoundary(line) {
return /^(If\b|Wenn\b|Lernen\b)/i.test(line);
}
+14 -2
View File
@@ -18,7 +18,18 @@ import { DB } from "@/backend/db";
*/
export async function getPageData(type, slug) {
const result = await DB.pool(
"SELECT title, description, content, page_order, slug, page_group FROM html_pages WHERE page_group = $1 AND slug = $2",
`SELECT
hp.title,
hp.description,
hp.content,
hp.page_order,
hp.slug,
hp.page_group,
hp.updated_at,
COALESCE(NULLIF(u.username, ''), u.email) AS updated_by_username
FROM html_pages hp
LEFT JOIN users u ON u.id = hp.updated_by
WHERE hp.page_group = $1 AND hp.slug = $2`,
[type, slug]
);
@@ -48,12 +59,13 @@ export async function getPageData(type, slug) {
*/
export async function setPageData(type, slug, newData) {
const result = await DB.pool(
"UPDATE html_pages SET title = $1, description = $2, slug = $3, content = $4 WHERE page_group = $5 AND slug = $6",
"UPDATE html_pages SET title = $1, description = $2, slug = $3, content = $4, updated_by = $5 WHERE page_group = $6 AND slug = $7",
[
newData.title,
newData.description ?? null,
newData.slug,
newData.content,
newData.updated_by ?? null,
type,
slug,
]
@@ -0,0 +1,38 @@
"use client";
export default function AdminLastModified({ updatedAt, updatedBy, className }) {
const date = formatDateDdMmYyyy(updatedAt);
const displayUser = String(updatedBy || "Unbekannt").trim() || "Unbekannt";
const message = date
? `Letzte Änderung: ${date} durch ${displayUser}`
: "Letzte Änderung: noch nicht gespeichert";
return (
<div
className={className}
style={{
display: "flex",
justifyContent: "flex-end",
width: "100%",
marginBottom: "0.75rem",
color: "var(--fg3)",
fontSize: "var(--font-sm)",
}}
>
{message}
</div>
);
}
function formatDateDdMmYyyy(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "";
}
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = String(date.getFullYear());
return `${day}/${month}/${year}`;
}