mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
feat: add time stamp, update: test's advices, fix: gap exercise, improve: drag und drop
This commit is contained in:
@@ -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
@@ -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,16 @@ 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]
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user