Merge branch 'main' into feature/circuitjs
@@ -64,6 +64,14 @@
|
||||
border-right-width: 2px;
|
||||
}
|
||||
|
||||
.exam-answer-sheet-homework .answer-table tr > :first-child {
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
.exam-answer-sheet-homework .answer-table {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.answer-number,
|
||||
.answer-mark {
|
||||
width: 100%;
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
V: { label: "Kenntnisse von Vorschriften", minutes: 45 },
|
||||
N: { label: "Technische Kenntnisse Klasse N", minutes: 45 },
|
||||
E: { label: "Technische Kenntnisse Klasse E", minutes: 45 },
|
||||
A: { label: "Technische Kenntnisse Klasse A", minutes: 60 }
|
||||
A: { label: "Technische Kenntnisse Klasse A", minutes: 60 },
|
||||
H: { label: "Hausaufgabe", minutes: null }
|
||||
};
|
||||
|
||||
var EXAM_CHOICES = [
|
||||
@@ -28,49 +29,50 @@
|
||||
{ id: "part-A", group: "single", label: "A", detail: "Technik Klasse A", parts: ["A"], color: "#3BB583" }
|
||||
];
|
||||
|
||||
function normalizeCatalog(rawCatalog, metadata, questionIndex) {
|
||||
var questions = {};
|
||||
var partForSection = { 1: "B", 2: "V" };
|
||||
var partForClass = { "1": "N", "2": "E", "3": "A" };
|
||||
function queryValue(name) {
|
||||
return new URLSearchParams(window.location.search).get(name);
|
||||
}
|
||||
|
||||
function pictureId(value) {
|
||||
return value ? String(value) : "";
|
||||
}
|
||||
|
||||
function walk(section, sectionIndex, category) {
|
||||
(section.questions || []).forEach(function (question) {
|
||||
var part = sectionIndex === 0
|
||||
? partForClass[String(question.class)]
|
||||
: partForSection[sectionIndex];
|
||||
if (!part) return;
|
||||
|
||||
var questionMetadata = metadata[question.number] || {};
|
||||
questions[question.number] = {
|
||||
id: question.number,
|
||||
part: part,
|
||||
category: category.slice(),
|
||||
question: question.question,
|
||||
layout: questionMetadata.layout || "default",
|
||||
picture: pictureId(questionMetadata.picture_question),
|
||||
answers: ["a", "b", "c", "d"].map(function (letter) {
|
||||
return {
|
||||
text: question["answer_" + letter] || "",
|
||||
picture: pictureId(questionMetadata["picture_" + letter])
|
||||
};
|
||||
}),
|
||||
hasSolution: Boolean(questionIndex[question.number] && questionIndex[question.number].has_solution)
|
||||
};
|
||||
});
|
||||
|
||||
(section.sections || []).forEach(function (child, childIndex) {
|
||||
walk(child, sectionIndex, category.concat(childIndex));
|
||||
});
|
||||
}
|
||||
|
||||
(rawCatalog.sections || []).forEach(function (section, sectionIndex) {
|
||||
walk(section, sectionIndex, []);
|
||||
function parseHomeworkQuestionIds() {
|
||||
var value = queryValue("homework");
|
||||
if (!value) return [];
|
||||
var seen = new Set();
|
||||
return value.toUpperCase().split(/[\s+,]+/).filter(function (questionId) {
|
||||
if (!/^[A-Z]{2}\d{3}$/.test(questionId) || seen.has(questionId)) return false;
|
||||
seen.add(questionId);
|
||||
return true;
|
||||
});
|
||||
return questions;
|
||||
}
|
||||
|
||||
function parseSharedResult() {
|
||||
var fields = (queryValue("result") || "").split(".");
|
||||
if (fields[0] !== "1") return null;
|
||||
if (fields[1] === "H") {
|
||||
var homeworkScore = Number(fields[2]);
|
||||
var homeworkTotal = Number(fields[3]);
|
||||
if (fields.length !== 4 || !Number.isInteger(homeworkScore) || !Number.isInteger(homeworkTotal) ||
|
||||
homeworkTotal < 1 || homeworkScore < 0 || homeworkScore > homeworkTotal) return null;
|
||||
return { kind: "homework", score: homeworkScore, total: homeworkTotal };
|
||||
}
|
||||
var choice = EXAM_CHOICES.find(function (item) { return item.id === fields[1]; });
|
||||
if (!choice || fields.length !== choice.parts.length + 2) return null;
|
||||
var scores = fields.slice(2).map(Number);
|
||||
if (scores.some(function (score) { return !Number.isInteger(score) || score < 0 || score > QUESTION_COUNT; })) return null;
|
||||
return { kind: "exam", choice: choice, scores: scores, passed: scores.every(function (score) { return score >= 19; }) };
|
||||
}
|
||||
|
||||
function copyText(value) {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(value);
|
||||
var input = document.createElement("textarea");
|
||||
input.value = value;
|
||||
input.setAttribute("readonly", "");
|
||||
input.style.position = "fixed";
|
||||
input.style.opacity = "0";
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
var copied = document.execCommand("copy");
|
||||
input.remove();
|
||||
return copied ? Promise.resolve() : Promise.reject(new Error("Kopieren nicht möglich"));
|
||||
}
|
||||
|
||||
function shuffled(values) {
|
||||
@@ -175,6 +177,8 @@
|
||||
var session = ref(null);
|
||||
var view = ref("exam");
|
||||
var reviewPartIndex = ref(null);
|
||||
var sharedResult = ref(null);
|
||||
var shareMessage = ref("");
|
||||
var now = ref(Date.now());
|
||||
var timerId = null;
|
||||
var catalogLoadHandler = null;
|
||||
@@ -191,6 +195,9 @@
|
||||
});
|
||||
var currentChoice = computed(function () {
|
||||
if (!session.value) return null;
|
||||
if (session.value.mode === "homework") {
|
||||
return { id: "H", label: "Hausaufgabe", parts: ["H"] };
|
||||
}
|
||||
return EXAM_CHOICES.find(function (choice) { return choice.id === session.value.choiceId; });
|
||||
});
|
||||
var displayedPartIndex = computed(function () {
|
||||
@@ -204,8 +211,14 @@
|
||||
if (!currentPart.value) return null;
|
||||
return PART_DEFINITIONS[currentPart.value.code];
|
||||
});
|
||||
var isHomework = computed(function () {
|
||||
return Boolean(session.value && session.value.mode === "homework");
|
||||
});
|
||||
var currentQuestionCount = computed(function () {
|
||||
return currentPart.value ? currentPart.value.questions.length : 0;
|
||||
});
|
||||
var remainingSeconds = computed(function () {
|
||||
if (!currentPart.value || currentPart.value.status !== "running") return 0;
|
||||
if (isHomework.value || !currentPart.value || currentPart.value.status !== "running") return 0;
|
||||
if (currentPart.value.paused) return currentPart.value.pausedRemainingSeconds;
|
||||
return Math.max(0, Math.ceil((currentPart.value.deadline - now.value) / 1000));
|
||||
});
|
||||
@@ -229,6 +242,7 @@
|
||||
}
|
||||
|
||||
var overallState = computed(function () {
|
||||
if (isHomework.value) return "homework";
|
||||
if (session.value.parts.some(function (part) { return part.status !== "evaluated"; })) return "incomplete";
|
||||
var failed = session.value.parts.filter(function (part) { return part.score < 19; });
|
||||
if (failed.length === 0) return "passed";
|
||||
@@ -236,15 +250,19 @@
|
||||
return "failed";
|
||||
});
|
||||
var overallAlertClass = computed(function () {
|
||||
return { passed: "alert-success", oral: "alert-warning", failed: "alert-danger", incomplete: "alert-danger" }[overallState.value];
|
||||
return { homework: "alert-primary", passed: "alert-success", oral: "alert-warning", failed: "alert-danger", incomplete: "alert-danger" }[overallState.value];
|
||||
});
|
||||
var overallIcon = computed(function () {
|
||||
return { passed: "verified", oral: "record_voice_over", failed: "cancel", incomplete: "stop_circle" }[overallState.value];
|
||||
return { homework: "task_alt", passed: "verified", oral: "record_voice_over", failed: "cancel", incomplete: "stop_circle" }[overallState.value];
|
||||
});
|
||||
var overallTitle = computed(function () {
|
||||
return { passed: "Prüfung bestanden", oral: "Mündliche Nachprüfung möglich", failed: "Prüfung nicht bestanden", incomplete: "Prüfung vorzeitig beendet" }[overallState.value];
|
||||
return { homework: "Hausaufgabe ausgewertet", passed: "Prüfung bestanden", oral: "Mündliche Nachprüfung möglich", failed: "Prüfung nicht bestanden", incomplete: "Prüfung vorzeitig beendet" }[overallState.value];
|
||||
});
|
||||
var overallText = computed(function () {
|
||||
if (overallState.value === "homework") {
|
||||
var homeworkPart = session.value.parts[0];
|
||||
return homeworkPart.score + " von " + homeworkPart.questions.length + " Fragen wurden richtig beantwortet.";
|
||||
}
|
||||
if (overallState.value === "passed") return "Gratulation! Du hast jeden Prüfungsteil mit mindestens 19 Punkten bestanden.";
|
||||
if (overallState.value === "oral") return "In genau einem Prüfungsteil wurden 17 oder 18 Punkte erreicht; nach den Prüfungsregeln kann eine mündliche Nachprüfung möglich sein.";
|
||||
if (overallState.value === "failed") return "Mindestens ein Prüfungsteil wurde nicht bestanden.";
|
||||
@@ -259,6 +277,10 @@
|
||||
return String(number).padStart(2, "0");
|
||||
}
|
||||
|
||||
function displayQuestionNumber(questionState, questionIndex) {
|
||||
return isHomework.value ? questionState.id : padNumber(questionIndex + 1);
|
||||
}
|
||||
|
||||
function pictureAlt(picture, fallback) {
|
||||
return altTexts[picture] || fallback;
|
||||
}
|
||||
@@ -305,9 +327,29 @@
|
||||
};
|
||||
}
|
||||
|
||||
function createHomeworkPart(questionIds) {
|
||||
var questions = questionIds.map(function (questionId) { return catalog.value[questionId]; }).filter(Boolean);
|
||||
if (questions.length !== questionIds.length) {
|
||||
throw new Error("Mindestens eine Frage aus dem Hausaufgabenlink ist nicht im aktuellen Fragenkatalog enthalten.");
|
||||
}
|
||||
if (questions.length === 0) throw new Error("Der Hausaufgabenlink enthält keine gültigen Fragen.");
|
||||
return {
|
||||
code: "H",
|
||||
status: "pending",
|
||||
deadline: null,
|
||||
paused: false,
|
||||
pausedRemainingSeconds: null,
|
||||
score: null,
|
||||
autoSubmitted: false,
|
||||
questions: questions.map(function (question) {
|
||||
return { id: question.id, order: shuffled([0, 1, 2, 3]), selected: null, history: [] };
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function activatePart(part) {
|
||||
part.status = "running";
|
||||
part.deadline = Date.now() + PART_DEFINITIONS[part.code].minutes * 60 * 1000;
|
||||
part.deadline = part.code === "H" ? null : Date.now() + PART_DEFINITIONS[part.code].minutes * 60 * 1000;
|
||||
part.paused = false;
|
||||
part.pausedRemainingSeconds = null;
|
||||
now.value = Date.now();
|
||||
@@ -315,7 +357,7 @@
|
||||
|
||||
function toggleTimer() {
|
||||
var part = currentPart.value;
|
||||
if (!part || part.status !== "running") return;
|
||||
if (isHomework.value || !part || part.status !== "running") return;
|
||||
if (part.paused) {
|
||||
part.deadline = Date.now() + part.pausedRemainingSeconds * 1000;
|
||||
part.pausedRemainingSeconds = null;
|
||||
@@ -340,6 +382,7 @@
|
||||
activatePart(parts[0]);
|
||||
session.value = {
|
||||
version: STORAGE_VERSION,
|
||||
mode: "exam",
|
||||
choiceId: choice.id,
|
||||
currentPart: 0,
|
||||
startedAt: Date.now(),
|
||||
@@ -353,6 +396,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startHomework(questionIds) {
|
||||
try {
|
||||
var part = createHomeworkPart(questionIds);
|
||||
activatePart(part);
|
||||
session.value = {
|
||||
version: STORAGE_VERSION,
|
||||
mode: "homework",
|
||||
choiceId: "H",
|
||||
homeworkIds: questionIds.slice(),
|
||||
currentPart: 0,
|
||||
startedAt: Date.now(),
|
||||
parts: [part]
|
||||
};
|
||||
view.value = "exam";
|
||||
reviewPartIndex.value = null;
|
||||
renderMathSoon(Vue);
|
||||
} catch (error) {
|
||||
loadError.value = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function selectAnswer(questionIndex, answerIndex) {
|
||||
if (currentPart.value.status !== "running" || currentPart.value.paused) return;
|
||||
var question = currentPart.value.questions[questionIndex];
|
||||
@@ -414,10 +478,10 @@
|
||||
}
|
||||
|
||||
function requestSubmit() {
|
||||
var unanswered = QUESTION_COUNT - answeredCount.value;
|
||||
var unanswered = currentQuestionCount.value - answeredCount.value;
|
||||
var text = unanswered > 0
|
||||
? "Du hast " + unanswered + " Fragen nicht beantwortet. Möchtest du den Prüfungsteil trotzdem abgeben?"
|
||||
: "Möchtest du diesen Prüfungsteil jetzt verbindlich abgeben?";
|
||||
? "Du hast " + unanswered + " Fragen nicht beantwortet. Möchtest du " + (isHomework.value ? "die Hausaufgabe" : "den Prüfungsteil") + " trotzdem abgeben?"
|
||||
: "Möchtest du " + (isHomework.value ? "die Hausaufgabe" : "diesen Prüfungsteil") + " jetzt verbindlich abgeben?";
|
||||
if (window.confirm(text)) submitPart(false);
|
||||
}
|
||||
|
||||
@@ -444,6 +508,12 @@
|
||||
function showSummary() {
|
||||
reviewPartIndex.value = null;
|
||||
view.value = "summary";
|
||||
if (completedResultUrl.value) {
|
||||
var target = new URL(completedResultUrl.value);
|
||||
if (window.location.pathname !== target.pathname || window.location.search !== target.search) {
|
||||
window.location.assign(target.toString());
|
||||
}
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
@@ -463,16 +533,32 @@
|
||||
session.value = null;
|
||||
view.value = "exam";
|
||||
reviewPartIndex.value = null;
|
||||
sharedResult.value = null;
|
||||
shareMessage.value = "";
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function abortExam() {
|
||||
if (!window.confirm("Möchtest du diese Simulation abbrechen? Der gespeicherte Prüfungsstand wird gelöscht.")) return;
|
||||
var noun = isHomework.value ? "Hausaufgabe" : "Simulation";
|
||||
if (!window.confirm("Möchtest du diese " + noun + " abbrechen? Der gespeicherte Stand wird gelöscht.")) return;
|
||||
resetExam();
|
||||
window.history.replaceState(null, "", "simulation.html");
|
||||
}
|
||||
|
||||
function newExam() {
|
||||
resetExam();
|
||||
window.history.replaceState(null, "", "simulation.html");
|
||||
}
|
||||
|
||||
function retryHomework() {
|
||||
var questionIds = session.value && session.value.homeworkIds;
|
||||
if (!questionIds) return;
|
||||
clearStoredSession();
|
||||
var homeworkUrl = new URL("simulation.html", window.location.href);
|
||||
homeworkUrl.search = "?homework=" + questionIds.join("+");
|
||||
window.history.replaceState(null, "", homeworkUrl.toString());
|
||||
startHomework(questionIds);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function scrollToQuestion(index) {
|
||||
@@ -481,37 +567,132 @@
|
||||
}
|
||||
|
||||
function resultLabel(part) {
|
||||
if (part.code === "H") return "Ausgewertet";
|
||||
if (part.score >= 19) return "Bestanden";
|
||||
if (part.score >= 17) return "Nachprüfung möglich";
|
||||
return "Nicht bestanden";
|
||||
}
|
||||
|
||||
function resultAlertClass(part) {
|
||||
if (part.code === "H") return "alert-primary";
|
||||
if (part.score >= 19) return "alert-success";
|
||||
if (part.score >= 17) return "alert-warning";
|
||||
return "alert-danger";
|
||||
}
|
||||
|
||||
function resultBadgeClass(part) {
|
||||
if (part.code === "H") return "text-bg-primary";
|
||||
if (part.score >= 19) return "text-bg-success";
|
||||
if (part.score >= 17) return "text-bg-warning";
|
||||
return "text-bg-danger";
|
||||
}
|
||||
|
||||
function resultIcon(part) {
|
||||
if (part.code === "H") return "task_alt";
|
||||
if (part.score >= 19) return "check_circle";
|
||||
if (part.score >= 17) return "record_voice_over";
|
||||
return "cancel";
|
||||
}
|
||||
|
||||
function resultDescription(part) {
|
||||
if (part.code === "H") return part.score + " von " + part.questions.length + " Fragen wurden richtig beantwortet.";
|
||||
var prefix = part.autoSubmitted ? "Die Zeit ist abgelaufen und der Prüfungsteil wurde automatisch abgegeben. " : "";
|
||||
if (part.score >= 19) return prefix + "Die erforderlichen 19 Punkte wurden erreicht.";
|
||||
if (part.score >= 17) return prefix + "Mit 17 oder 18 Punkten kann nach den Prüfungsregeln eine mündliche Nachprüfung möglich sein.";
|
||||
return prefix + "Die erforderlichen 19 Punkte wurden nicht erreicht.";
|
||||
}
|
||||
|
||||
function restoreSession() {
|
||||
function encodedResult(result) {
|
||||
if (result.kind === "homework") return "1.H." + result.score + "." + result.total;
|
||||
return "1." + result.choice.id + "." + result.scores.join(".");
|
||||
}
|
||||
|
||||
function resultImageCode(result) {
|
||||
if (!result) return "";
|
||||
if (result.kind === "homework") return result.score === result.total ? "aenbv" : "";
|
||||
if (!result.passed) return "";
|
||||
var order = ["A", "E", "N", "B", "V"];
|
||||
return order.filter(function (part) {
|
||||
return result.choice.parts.indexOf(part) !== -1;
|
||||
}).join("").toLowerCase();
|
||||
}
|
||||
|
||||
function shareableSessionResult() {
|
||||
if (!session.value) return null;
|
||||
if (isHomework.value) {
|
||||
return { kind: "homework", score: session.value.parts[0].score, total: session.value.parts[0].questions.length };
|
||||
}
|
||||
if (session.value.parts.some(function (part) { return part.status !== "evaluated"; })) return null;
|
||||
return {
|
||||
kind: "exam",
|
||||
choice: currentChoice.value,
|
||||
scores: session.value.parts.map(function (part) { return part.score; }),
|
||||
passed: overallState.value === "passed"
|
||||
};
|
||||
}
|
||||
|
||||
var resultForSharing = computed(function () {
|
||||
if (sharedResult.value) return sharedResult.value;
|
||||
if (view.value !== "summary") return null;
|
||||
return shareableSessionResult();
|
||||
});
|
||||
|
||||
function buildResultUrl(result) {
|
||||
if (!result) return "";
|
||||
var imageCode = resultImageCode(result);
|
||||
var url = new URL(imageCode ? "result-" + imageCode + ".html" : "result.html", window.location.href);
|
||||
url.search = "?result=" + encodedResult(result);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
var completedResultUrl = computed(function () {
|
||||
return buildResultUrl(shareableSessionResult());
|
||||
});
|
||||
|
||||
var resultShareUrl = computed(function () {
|
||||
return buildResultUrl(resultForSharing.value);
|
||||
});
|
||||
|
||||
var resultImageUrl = computed(function () {
|
||||
var imageCode = resultImageCode(resultForSharing.value);
|
||||
return imageCode ? "assets/images/exam-" + imageCode + ".jpeg" : "";
|
||||
});
|
||||
|
||||
var shareText = computed(function () {
|
||||
var result = resultForSharing.value;
|
||||
if (!result) return "";
|
||||
if (result.kind === "homework") {
|
||||
return "Meine 50ohm.de-Hausaufgabe: " + result.score + " von " + result.total + " Fragen richtig.";
|
||||
}
|
||||
var scores = result.choice.parts.map(function (part, index) { return part + ": " + result.scores[index] + "/25"; }).join(", ");
|
||||
return "Meine 50ohm.de-Prüfungssimulation " + result.choice.label + ": " + scores + " – " + (result.passed ? "bestanden!" : "noch nicht bestanden.");
|
||||
});
|
||||
|
||||
var socialShareLinks = computed(function () {
|
||||
var textWithUrl = shareText.value + " " + resultShareUrl.value;
|
||||
return {
|
||||
mastodon: "https://share.joinmastodon.org/#text=" + encodeURIComponent(textWithUrl),
|
||||
bluesky: "https://bsky.app/intent/compose?text=" + encodeURIComponent(textWithUrl),
|
||||
facebook: "https://www.facebook.com/sharer/sharer.php?u=" + encodeURIComponent(resultShareUrl.value),
|
||||
x: "https://x.com/intent/tweet?text=" + encodeURIComponent(textWithUrl)
|
||||
};
|
||||
});
|
||||
|
||||
function copyResultLink() {
|
||||
copyText(resultShareUrl.value)
|
||||
.then(function () { shareMessage.value = "Ergebnislink kopiert."; })
|
||||
.catch(function () { shareMessage.value = "Der Link konnte nicht automatisch kopiert werden."; });
|
||||
}
|
||||
|
||||
function shareNative() {
|
||||
if (!navigator.share) {
|
||||
copyResultLink();
|
||||
return;
|
||||
}
|
||||
navigator.share({ title: "50ohm.de-Ergebnis", text: shareText.value, url: resultShareUrl.value }).catch(function () {});
|
||||
}
|
||||
|
||||
function restoreSession(homeworkQuestionIds, allowHomeworkResult) {
|
||||
try {
|
||||
var raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
@@ -520,9 +701,16 @@
|
||||
clearStoredSession();
|
||||
return;
|
||||
}
|
||||
var homeworkMode = stored.session.mode === "homework";
|
||||
var choice = EXAM_CHOICES.find(function (item) { return item.id === stored.session.choiceId; });
|
||||
var valid = choice && stored.session.parts.every(function (part) {
|
||||
return Array.isArray(part.questions) && part.questions.length === QUESTION_COUNT && part.questions.every(function (question) {
|
||||
var homeworkRequested = homeworkQuestionIds.length > 0;
|
||||
var homeworkMatches = homeworkMode && (allowHomeworkResult || (homeworkQuestionIds.length > 0 &&
|
||||
JSON.stringify(stored.session.homeworkIds) === JSON.stringify(homeworkQuestionIds)));
|
||||
var requestedSessionMatches = allowHomeworkResult
|
||||
? (homeworkMatches || choice)
|
||||
: (homeworkRequested ? homeworkMatches : (!homeworkMode && choice));
|
||||
var valid = requestedSessionMatches && stored.session.parts.every(function (part) {
|
||||
return Array.isArray(part.questions) && (homeworkMode || part.questions.length === QUESTION_COUNT) && part.questions.every(function (question) {
|
||||
return Boolean(catalog.value[question.id]);
|
||||
});
|
||||
});
|
||||
@@ -535,6 +723,7 @@
|
||||
if (!part.paused) part.pausedRemainingSeconds = null;
|
||||
});
|
||||
session.value = stored.session;
|
||||
if (!session.value.mode) session.value.mode = "exam";
|
||||
view.value = stored.view === "summary" ? "summary" : "exam";
|
||||
if (session.value.parts[session.value.currentPart].status === "running" &&
|
||||
!session.value.parts[session.value.currentPart].paused &&
|
||||
@@ -562,7 +751,7 @@
|
||||
});
|
||||
|
||||
watch(remainingSeconds, function (seconds) {
|
||||
if (session.value && currentPart.value && currentPart.value.status === "running" &&
|
||||
if (!isHomework.value && session.value && currentPart.value && currentPart.value.status === "running" &&
|
||||
!currentPart.value.paused && seconds <= 0) submitPart(true);
|
||||
});
|
||||
|
||||
@@ -580,9 +769,26 @@
|
||||
return Promise.all(responses.map(function (response) { return response.json(); }));
|
||||
})
|
||||
.then(function (data) {
|
||||
catalog.value = normalizeCatalog(data[0], data[1] || {}, data[2] || {});
|
||||
catalog.value = window.FiftyOhmQuestions.normalizeCatalog(data[0], data[1] || {}, data[2] || {});
|
||||
if (Object.keys(catalog.value).length === 0) throw new Error("Der Fragenkatalog ist leer.");
|
||||
restoreSession();
|
||||
var parsedResult = parseSharedResult();
|
||||
var homeworkQuestionIds = parseHomeworkQuestionIds();
|
||||
if (queryValue("result")) {
|
||||
if (!parsedResult) throw new Error("Der Ergebnislink ist ungültig.");
|
||||
restoreSession([], true);
|
||||
var storedResult = shareableSessionResult();
|
||||
if (storedResult && encodedResult(storedResult) === queryValue("result")) {
|
||||
sharedResult.value = null;
|
||||
view.value = "summary";
|
||||
} else {
|
||||
session.value = null;
|
||||
sharedResult.value = parsedResult;
|
||||
view.value = "shared";
|
||||
}
|
||||
} else {
|
||||
restoreSession(homeworkQuestionIds);
|
||||
if (homeworkQuestionIds.length > 0 && !session.value) startHomework(homeworkQuestionIds);
|
||||
}
|
||||
loading.value = false;
|
||||
renderMathSoon(Vue);
|
||||
})
|
||||
@@ -627,12 +833,16 @@
|
||||
loadError: loadError,
|
||||
session: session,
|
||||
view: view,
|
||||
sharedResult: sharedResult,
|
||||
shareMessage: shareMessage,
|
||||
primaryChoices: primaryChoices,
|
||||
upgradeChoices: upgradeChoices,
|
||||
singlePartChoices: singlePartChoices,
|
||||
currentChoice: currentChoice,
|
||||
currentPart: currentPart,
|
||||
currentPartDefinition: currentPartDefinition,
|
||||
isHomework: isHomework,
|
||||
currentQuestionCount: currentQuestionCount,
|
||||
displayedPartIndex: displayedPartIndex,
|
||||
partDefinitions: PART_DEFINITIONS,
|
||||
answerLabels: ANSWER_LABELS,
|
||||
@@ -648,6 +858,7 @@
|
||||
overallTitle: overallTitle,
|
||||
overallText: overallText,
|
||||
padNumber: padNumber,
|
||||
displayQuestionNumber: displayQuestionNumber,
|
||||
pictureAlt: pictureAlt,
|
||||
questionData: questionData,
|
||||
startExam: startExam,
|
||||
@@ -662,12 +873,21 @@
|
||||
reviewPart: reviewPart,
|
||||
abortExam: abortExam,
|
||||
newExam: newExam,
|
||||
retryHomework: retryHomework,
|
||||
scrollToQuestion: scrollToQuestion,
|
||||
resultLabel: resultLabel,
|
||||
resultAlertClass: resultAlertClass,
|
||||
resultBadgeClass: resultBadgeClass,
|
||||
resultIcon: resultIcon,
|
||||
resultDescription: resultDescription
|
||||
resultDescription: resultDescription,
|
||||
resultForSharing: resultForSharing,
|
||||
completedResultUrl: completedResultUrl,
|
||||
resultShareUrl: resultShareUrl,
|
||||
resultImageUrl: resultImageUrl,
|
||||
shareText: shareText,
|
||||
socialShareLinks: socialShareLinks,
|
||||
copyResultLink: copyResultLink,
|
||||
shareNative: shareNative
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.homework-generator {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.homework-steps .badge {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.homework-question {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.homework-question:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var COURSE_CHOICES = [
|
||||
{ edition: "N", label: "N-Kurs", detail: "N", color: "#47ABE8" },
|
||||
{ edition: "NE", label: "E-Kurs", detail: "N + E", color: "#FE756C" },
|
||||
{ edition: "NEA", label: "A-Kurs", detail: "N + E + A", color: "#3BB583" },
|
||||
{ edition: "E", label: "Aufstockung N → E", detail: "E", color: "#FE756C" },
|
||||
{ edition: "EA", label: "Aufstockung N → A", detail: "E + A", color: "#3BB583" },
|
||||
{ edition: "A", label: "Aufstockung E → A", detail: "A", color: "#3BB583" }
|
||||
];
|
||||
|
||||
function renderMathSoon() {
|
||||
Vue.nextTick(function () {
|
||||
if (typeof window.renderFiftyOhmMath !== "function") return;
|
||||
window.renderFiftyOhmMath(document.getElementById("homework-generator"));
|
||||
});
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||
var input = document.createElement("textarea");
|
||||
input.value = text;
|
||||
input.setAttribute("readonly", "");
|
||||
input.style.position = "fixed";
|
||||
input.style.opacity = "0";
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
var copied = document.execCommand("copy");
|
||||
input.remove();
|
||||
return copied ? Promise.resolve() : Promise.reject(new Error("Kopieren nicht möglich"));
|
||||
}
|
||||
|
||||
var app = Vue.createApp({
|
||||
setup: function () {
|
||||
var ref = Vue.ref;
|
||||
var computed = Vue.computed;
|
||||
var reactive = Vue.reactive;
|
||||
|
||||
var loading = ref(true);
|
||||
var loadError = ref("");
|
||||
var step = ref(1);
|
||||
var catalog = ref({});
|
||||
var questionIndex = ref({});
|
||||
var questionsBySection = ref({});
|
||||
var selectedCourse = ref(null);
|
||||
var toc = ref(null);
|
||||
var selectedChapterIds = ref([]);
|
||||
var selectedSectionIds = ref([]);
|
||||
var questionSelection = reactive({});
|
||||
var generatedUrl = ref("");
|
||||
var copyMessage = ref("");
|
||||
var tocCache = {};
|
||||
|
||||
var stepLabels = [
|
||||
{ number: 1, label: "Kurs" },
|
||||
{ number: 2, label: "Kapitel" },
|
||||
{ number: 3, label: "Sektionen" },
|
||||
{ number: 4, label: "Fragen" },
|
||||
{ number: 5, label: "Link" }
|
||||
];
|
||||
|
||||
function sectionQuestionIds(section) {
|
||||
if (!selectedCourse.value) return [];
|
||||
return questionsBySection.value[selectedCourse.value.edition + ":" + section.ident] || [];
|
||||
}
|
||||
|
||||
function chapterQuestionCount(chapter) {
|
||||
return chapter.sections.reduce(function (sum, section) {
|
||||
return sum + sectionQuestionIds(section).length;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
var selectedChapters = computed(function () {
|
||||
if (!toc.value) return [];
|
||||
return toc.value.chapters.filter(function (chapter) {
|
||||
return selectedChapterIds.value.indexOf(chapter.ident) !== -1;
|
||||
});
|
||||
});
|
||||
|
||||
var selectedSectionGroups = computed(function () {
|
||||
var groups = [];
|
||||
selectedChapters.value.forEach(function (chapter) {
|
||||
chapter.sections.forEach(function (section) {
|
||||
if (selectedSectionIds.value.indexOf(section.ident) === -1) return;
|
||||
groups.push({ chapter: chapter, section: section, questionIds: sectionQuestionIds(section) });
|
||||
});
|
||||
});
|
||||
return groups;
|
||||
});
|
||||
|
||||
var orderedQuestions = computed(function () {
|
||||
return selectedSectionGroups.value.reduce(function (result, group) {
|
||||
return result.concat(group.questionIds);
|
||||
}, []);
|
||||
});
|
||||
|
||||
var selectedQuestionIds = computed(function () {
|
||||
return orderedQuestions.value.filter(function (questionId) { return questionSelection[questionId]; });
|
||||
});
|
||||
|
||||
function buildSectionIndex() {
|
||||
var result = {};
|
||||
Object.keys(questionIndex.value).forEach(function (questionId) {
|
||||
var entry = questionIndex.value[questionId];
|
||||
(entry.editions || []).forEach(function (edition) {
|
||||
var key = edition + ":" + entry.section;
|
||||
if (!result[key]) result[key] = [];
|
||||
result[key].push(questionId);
|
||||
});
|
||||
});
|
||||
Object.keys(result).forEach(function (key) {
|
||||
result[key].sort(function (left, right) {
|
||||
return left.localeCompare(right, "de", { numeric: true });
|
||||
});
|
||||
});
|
||||
questionsBySection.value = result;
|
||||
}
|
||||
|
||||
function goToStep(number) {
|
||||
step.value = number;
|
||||
copyMessage.value = "";
|
||||
if (number === 4) renderMathSoon();
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function selectCourse(course) {
|
||||
selectedCourse.value = course;
|
||||
selectedChapterIds.value = [];
|
||||
selectedSectionIds.value = [];
|
||||
generatedUrl.value = "";
|
||||
if (tocCache[course.edition]) {
|
||||
toc.value = tocCache[course.edition];
|
||||
goToStep(2);
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
fetch("assets/toc/" + course.edition + ".json")
|
||||
.then(function (response) {
|
||||
if (!response.ok) throw new Error("Inhaltsverzeichnis nicht gefunden (HTTP " + response.status + ").");
|
||||
return response.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
tocCache[course.edition] = data;
|
||||
toc.value = data;
|
||||
loading.value = false;
|
||||
goToStep(2);
|
||||
})
|
||||
.catch(function (error) {
|
||||
loadError.value = error.message || "Unbekannter Ladefehler";
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function openSectionSelection() {
|
||||
var preselectedSections = [];
|
||||
selectedChapters.value.forEach(function (chapter) {
|
||||
chapter.sections.forEach(function (section) {
|
||||
if (sectionQuestionIds(section).length > 0) preselectedSections.push(section.ident);
|
||||
});
|
||||
});
|
||||
selectedSectionIds.value = preselectedSections;
|
||||
goToStep(3);
|
||||
}
|
||||
|
||||
function openQuestionSelection() {
|
||||
orderedQuestions.value.forEach(function (questionId) { questionSelection[questionId] = true; });
|
||||
goToStep(4);
|
||||
}
|
||||
|
||||
function setAllQuestions(selected) {
|
||||
orderedQuestions.value.forEach(function (questionId) { questionSelection[questionId] = selected; });
|
||||
}
|
||||
|
||||
function generateLink() {
|
||||
var url = new URL("simulation.html", window.location.href);
|
||||
url.search = "?homework=" + selectedQuestionIds.value.join("+");
|
||||
generatedUrl.value = url.toString();
|
||||
goToStep(5);
|
||||
}
|
||||
|
||||
function copyLink() {
|
||||
copyText(generatedUrl.value)
|
||||
.then(function () { copyMessage.value = "Der Hausaufgabenlink wurde kopiert."; })
|
||||
.catch(function () { copyMessage.value = "Der Link konnte nicht automatisch kopiert werden. Bitte kopiere ihn aus dem Feld."; });
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
fetch("assets/fragenkatalog.json"),
|
||||
fetch("assets/metadata.json"),
|
||||
fetch("assets/question_index.json")
|
||||
])
|
||||
.then(function (responses) {
|
||||
var names = ["Fragenkatalog", "Metadaten", "Fragenindex"];
|
||||
responses.forEach(function (response, index) {
|
||||
if (!response.ok) throw new Error(names[index] + " nicht gefunden (HTTP " + response.status + ").");
|
||||
});
|
||||
return Promise.all(responses.map(function (response) { return response.json(); }));
|
||||
})
|
||||
.then(function (data) {
|
||||
questionIndex.value = data[2] || {};
|
||||
catalog.value = window.FiftyOhmQuestions.normalizeCatalog(data[0], data[1] || {}, questionIndex.value);
|
||||
buildSectionIndex();
|
||||
loading.value = false;
|
||||
})
|
||||
.catch(function (error) {
|
||||
loadError.value = error.message || "Unbekannter Ladefehler";
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
return {
|
||||
loading: loading,
|
||||
loadError: loadError,
|
||||
step: step,
|
||||
stepLabels: stepLabels,
|
||||
courseChoices: COURSE_CHOICES,
|
||||
catalog: catalog,
|
||||
selectedCourse: selectedCourse,
|
||||
toc: toc,
|
||||
selectedChapterIds: selectedChapterIds,
|
||||
selectedSectionIds: selectedSectionIds,
|
||||
questionSelection: questionSelection,
|
||||
selectedChapters: selectedChapters,
|
||||
selectedSectionGroups: selectedSectionGroups,
|
||||
orderedQuestions: orderedQuestions,
|
||||
selectedQuestionIds: selectedQuestionIds,
|
||||
generatedUrl: generatedUrl,
|
||||
copyMessage: copyMessage,
|
||||
sectionQuestionIds: sectionQuestionIds,
|
||||
chapterQuestionCount: chapterQuestionCount,
|
||||
goToStep: goToStep,
|
||||
selectCourse: selectCourse,
|
||||
openSectionSelection: openSectionSelection,
|
||||
openQuestionSelection: openQuestionSelection,
|
||||
setAllQuestions: setAllQuestions,
|
||||
generateLink: generateLink,
|
||||
copyLink: copyLink
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.mount("#homework-generator");
|
||||
})();
|
||||
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 324 KiB |
@@ -0,0 +1,52 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function normalizeCatalog(rawCatalog, metadata, questionIndex) {
|
||||
var questions = {};
|
||||
var partForSection = { 1: "B", 2: "V" };
|
||||
var partForClass = { "1": "N", "2": "E", "3": "A" };
|
||||
|
||||
function pictureId(value) {
|
||||
return value ? String(value) : "";
|
||||
}
|
||||
|
||||
function walk(section, sectionIndex, category) {
|
||||
(section.questions || []).forEach(function (question) {
|
||||
var part = sectionIndex === 0
|
||||
? partForClass[String(question.class)]
|
||||
: partForSection[sectionIndex];
|
||||
if (!part) return;
|
||||
|
||||
var questionMetadata = metadata[question.number] || {};
|
||||
questions[question.number] = {
|
||||
id: question.number,
|
||||
part: part,
|
||||
category: category.slice(),
|
||||
question: question.question,
|
||||
layout: questionMetadata.layout || "default",
|
||||
picture: pictureId(questionMetadata.picture_question),
|
||||
answers: ["a", "b", "c", "d"].map(function (letter) {
|
||||
return {
|
||||
text: question["answer_" + letter] || "",
|
||||
picture: pictureId(questionMetadata["picture_" + letter])
|
||||
};
|
||||
}),
|
||||
hasSolution: Boolean(questionIndex[question.number] && questionIndex[question.number].has_solution)
|
||||
};
|
||||
});
|
||||
|
||||
(section.sections || []).forEach(function (child, childIndex) {
|
||||
walk(child, sectionIndex, category.concat(childIndex));
|
||||
});
|
||||
}
|
||||
|
||||
(rawCatalog.sections || []).forEach(function (section, sectionIndex) {
|
||||
walk(section, sectionIndex, []);
|
||||
});
|
||||
return questions;
|
||||
}
|
||||
|
||||
window.FiftyOhmQuestions = {
|
||||
normalizeCatalog: normalizeCatalog
|
||||
};
|
||||
})();
|
||||
@@ -129,6 +129,7 @@ class Build:
|
||||
sidebar=None,
|
||||
page_stylesheets=None,
|
||||
page_scripts=None,
|
||||
social_image=None,
|
||||
):
|
||||
page_template = self.env.get_template("html/page.html")
|
||||
return page_template.render(
|
||||
@@ -137,6 +138,7 @@ class Build:
|
||||
sidebar=sidebar,
|
||||
page_stylesheets=page_stylesheets or [],
|
||||
page_scripts=page_scripts or [],
|
||||
social_image=social_image,
|
||||
)
|
||||
|
||||
def __build_exam_simulator(self):
|
||||
@@ -159,11 +161,51 @@ class Build:
|
||||
content = exam_template.render()
|
||||
page = self.__build_page(
|
||||
content,
|
||||
page_stylesheets=["assets/exam-simulator.css?ts=202608051415"],
|
||||
page_scripts=["assets/vue.global.prod.js", "assets/exam-simulator.js?ts=202608051500"],
|
||||
page_stylesheets=["assets/exam-simulator.css?ts=20260806"],
|
||||
page_scripts=[
|
||||
"assets/vue.global.prod.js",
|
||||
"assets/question-catalog.js?ts=20260806",
|
||||
"assets/exam-simulator.js?ts=202608064",
|
||||
],
|
||||
)
|
||||
with (self.config.p_build / "simulation.html").open("w", encoding="utf-8") as file:
|
||||
file.write(page)
|
||||
with (self.config.p_build / "result.html").open("w", encoding="utf-8") as file:
|
||||
file.write(page)
|
||||
|
||||
for social_image in sorted((self.config.p_assets / "images").glob("exam-*.jpeg")):
|
||||
result_page = self.__build_page(
|
||||
content,
|
||||
page_stylesheets=["assets/exam-simulator.css?ts=20260806"],
|
||||
page_scripts=[
|
||||
"assets/vue.global.prod.js",
|
||||
"assets/question-catalog.js?ts=20260806",
|
||||
"assets/exam-simulator.js?ts=202608064",
|
||||
],
|
||||
social_image=f"assets/images/{social_image.name}",
|
||||
)
|
||||
result_name = f"result-{social_image.stem.removeprefix('exam-')}.html"
|
||||
with (self.config.p_build / result_name).open("w", encoding="utf-8") as file:
|
||||
file.write(result_page)
|
||||
|
||||
def __build_homework_tool(self):
|
||||
toc_target = self.config.p_build_assets / "toc"
|
||||
toc_target.mkdir(parents=True, exist_ok=True)
|
||||
for edition in ("N", "NE", "NEA", "E", "EA", "A"):
|
||||
shutil.copyfile(self.config.p_data_toc / f"{edition}.json", toc_target / f"{edition}.json")
|
||||
|
||||
homework_template = self.env.get_template("html/homework.html")
|
||||
page = self.__build_page(
|
||||
homework_template.render(),
|
||||
page_stylesheets=["assets/homework.css?ts=20260806"],
|
||||
page_scripts=[
|
||||
"assets/vue.global.prod.js",
|
||||
"assets/question-catalog.js?ts=20260806",
|
||||
"assets/homework.js?ts=202608061",
|
||||
],
|
||||
)
|
||||
with (self.config.p_build / "hausaufgabe.html").open("w", encoding="utf-8") as file:
|
||||
file.write(page)
|
||||
|
||||
def __copy_picture(self, id):
|
||||
file = f"{id}.svg"
|
||||
@@ -542,6 +584,7 @@ class Build:
|
||||
self.__build_html_page(contents, "infos")
|
||||
self.__build_html_page(contents, "suche")
|
||||
self.__build_exam_simulator()
|
||||
self.__build_homework_tool()
|
||||
|
||||
def build_solutions(self):
|
||||
for solution_file in self.config.p_data_solutions.glob("*.md"):
|
||||
|
||||
@@ -9,6 +9,52 @@
|
||||
<p class="mb-0" v-text="loadError"></p>
|
||||
</div>
|
||||
|
||||
<section v-else-if="view === 'shared' && sharedResult" class="mx-auto mb-5 exam-summary" aria-labelledby="shared-heading">
|
||||
<h1 id="shared-heading">Geteiltes Ergebnis</h1>
|
||||
<template v-if="sharedResult.kind === 'homework'">
|
||||
<p class="lead">Hausaufgabe auf 50ohm.de</p>
|
||||
<div class="alert alert-primary">
|
||||
<strong><span v-text="sharedResult.score"></span> von <span v-text="sharedResult.total"></span> Fragen richtig beantwortet</strong>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="lead" v-text="sharedResult.choice.label"></p>
|
||||
<div class="alert" :class="sharedResult.passed ? 'alert-success' : 'alert-danger'">
|
||||
<strong v-text="sharedResult.passed ? 'Prüfung bestanden' : 'Prüfung nicht bestanden'"></strong>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead><tr><th>Teil</th><th>Ergebnis</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(part, index) in sharedResult.choice.parts" :key="part">
|
||||
<td><b v-text="part + ' – ' + partDefinitions[part].label"></b></td>
|
||||
<td><b v-text="sharedResult.scores[index]"></b>/25</td>
|
||||
<td><span class="badge" :class="sharedResult.scores[index] >= 19 ? 'text-bg-success' : 'text-bg-danger'"
|
||||
v-text="sharedResult.scores[index] >= 19 ? 'Bestanden' : 'Nicht bestanden'"></span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
<img v-if="resultImageUrl" class="img-fluid border mb-4" :src="resultImageUrl"
|
||||
alt="50ohm.de-Prüfungssimulation">
|
||||
<div v-if="resultForSharing" class="border-top pt-3 mt-3">
|
||||
<h2 class="h5">Ergebnis teilen</h2>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm d-inline-flex align-items-center" @click="shareNative">
|
||||
<span class="material-icons fs-6 me-1" aria-hidden="true">share</span> Teilen …
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @click="copyResultLink">Link kopieren</button>
|
||||
<a class="btn btn-outline-secondary btn-sm" :href="socialShareLinks.mastodon" target="_blank" rel="noopener noreferrer">Mastodon</a>
|
||||
<a class="btn btn-outline-secondary btn-sm" :href="socialShareLinks.bluesky" target="_blank" rel="noopener noreferrer">Bluesky</a>
|
||||
<a class="btn btn-outline-secondary btn-sm" :href="socialShareLinks.facebook" target="_blank" rel="noopener noreferrer">Facebook</a>
|
||||
<a class="btn btn-outline-secondary btn-sm" :href="socialShareLinks.x" target="_blank" rel="noopener noreferrer">X (ehem. Twitter)</a>
|
||||
</div>
|
||||
<small v-if="shareMessage" class="d-block mt-2" role="status" v-text="shareMessage"></small>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary mt-4" @click="newExam">Eigene Simulation starten</button>
|
||||
</section>
|
||||
|
||||
<section v-else-if="!session" class="mx-auto mb-5 exam-start" aria-labelledby="exam-heading">
|
||||
<h1 id="exam-heading">Prüfungssimulation <sup class="exam-beta fs-6">BETA</sup></h1>
|
||||
<p class="lead">Welche Prüfung möchtest du simulieren?</p>
|
||||
@@ -56,7 +102,7 @@
|
||||
</section>
|
||||
|
||||
<section v-else-if="view === 'summary'" class="mx-auto mb-5 exam-summary" aria-labelledby="summary-heading">
|
||||
<h1 id="summary-heading">Gesamtergebnis</h1>
|
||||
<h1 id="summary-heading" v-text="isHomework ? 'Ergebnis der Hausaufgabe' : 'Gesamtergebnis'"></h1>
|
||||
<p class="lead" v-text="currentChoice.label"></p>
|
||||
<div class="alert d-flex align-items-center gap-2" :class="overallAlertClass">
|
||||
<span class="material-icons" aria-hidden="true" v-text="overallIcon"></span>
|
||||
@@ -91,7 +137,7 @@
|
||||
<tr v-for="(part, index) in session.parts" :key="part.code">
|
||||
<td><b v-text="part.code + ' – ' + partDefinitions[part.code].label"></b></td>
|
||||
<template v-if="part.status === 'evaluated'">
|
||||
<td><b v-text="part.score"></b>/25</td>
|
||||
<td><b v-text="part.score"></b>/<span v-text="part.questions.length"></span></td>
|
||||
<td><span class="badge" :class="resultBadgeClass(part)" v-text="resultLabel(part)"></span></td>
|
||||
<td><button type="button" class="btn btn-outline-dark btn-sm" @click="reviewPart(index)">Auswertung</button></td>
|
||||
</template>
|
||||
@@ -102,13 +148,33 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" @click="newExam">Neue Simulation</button>
|
||||
<img v-if="resultImageUrl" class="img-fluid border mb-4" :src="resultImageUrl"
|
||||
alt="50ohm.de-Prüfungssimulation">
|
||||
<div v-if="resultForSharing" class="border-top pt-3 mt-3">
|
||||
<h2 class="h5">Ergebnis teilen</h2>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm d-inline-flex align-items-center" @click="shareNative">
|
||||
<span class="material-icons fs-6 me-1" aria-hidden="true">share</span> Teilen …
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @click="copyResultLink">Link kopieren</button>
|
||||
<a class="btn btn-outline-secondary btn-sm" :href="socialShareLinks.mastodon" target="_blank" rel="noopener noreferrer">Mastodon</a>
|
||||
<a class="btn btn-outline-secondary btn-sm" :href="socialShareLinks.bluesky" target="_blank" rel="noopener noreferrer">Bluesky</a>
|
||||
<a class="btn btn-outline-secondary btn-sm" :href="socialShareLinks.facebook" target="_blank" rel="noopener noreferrer">Facebook</a>
|
||||
<a class="btn btn-outline-secondary btn-sm" :href="socialShareLinks.x" target="_blank" rel="noopener noreferrer">X (ehem. Twitter)</a>
|
||||
</div>
|
||||
<small v-if="shareMessage" class="d-block mt-2" role="status" v-text="shareMessage"></small>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 mt-4">
|
||||
<button v-if="isHomework" type="button" class="btn btn-primary" @click="retryHomework">Hausaufgabe wiederholen</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @click="newExam">Neue Simulation</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="exam-session" aria-labelledby="part-heading">
|
||||
<header class="exam-toolbar bg-white border-bottom py-2 mb-3">
|
||||
<div>
|
||||
<small class="text-secondary" v-text="currentChoice.label + ' · Teil ' + (displayedPartIndex + 1) + ' von ' + session.parts.length"></small>
|
||||
<small class="text-secondary"
|
||||
v-text="isHomework ? currentQuestionCount + ' ausgewählte Fragen' : currentChoice.label + ' · Teil ' + (displayedPartIndex + 1) + ' von ' + session.parts.length"></small>
|
||||
<h1 id="part-heading" class="h4 mb-0" v-text="currentPartDefinition.label"></h1>
|
||||
</div>
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
@@ -118,7 +184,7 @@
|
||||
<path d="M192 112L304 112L304 200C304 239.8 336.2 272 376 272L464 272L464 512C464 520.8 456.8 528 448 528L192 528C183.2 528 176 520.8 176 512L176 128C176 119.2 183.2 112 192 112zM352 131.9L444.1 224L376 224C362.7 224 352 213.3 352 200L352 131.9zM192 64C156.7 64 128 92.7 128 128L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 250.5C512 233.5 505.3 217.2 493.3 205.2L370.7 82.7C358.7 70.7 342.5 64 325.5 64L192 64zM248 320C234.7 320 224 330.7 224 344C224 357.3 234.7 368 248 368L392 368C405.3 368 416 357.3 416 344C416 330.7 405.3 320 392 320L248 320zM248 416C234.7 416 224 426.7 224 440C224 453.3 234.7 464 248 464L392 464C405.3 464 416 453.3 416 440C416 426.7 405.3 416 392 416L248 416z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button v-if="currentPart.status === 'running'" type="button"
|
||||
<button v-if="currentPart.status === 'running' && !isHomework" type="button"
|
||||
class="btn btn-sm d-inline-flex align-items-center"
|
||||
:class="currentPart.paused ? 'btn-warning' : (remainingSeconds <= 300 ? 'btn-danger' : 'btn-secondary')"
|
||||
@click="toggleTimer"
|
||||
@@ -128,13 +194,15 @@
|
||||
v-text="currentPart.paused ? 'play_arrow' : 'pause'"></span>
|
||||
<span role="timer" v-text="formattedTime"></span>
|
||||
</button>
|
||||
<span v-else class="btn btn-secondary btn-sm d-inline-flex align-items-center">Abgegeben</span>
|
||||
<span v-else-if="currentPart.status !== 'running'" class="btn btn-secondary btn-sm d-inline-flex align-items-center">Abgegeben</span>
|
||||
<button v-if="currentPart.status === 'evaluated' && !isReviewing && hasNextPart"
|
||||
type="button" class="btn btn-primary btn-sm" @click="startNextPart">Nächsten Teil starten</button>
|
||||
<button v-else-if="currentPart.status === 'evaluated' && !isReviewing"
|
||||
type="button" class="btn btn-primary btn-sm" @click="showSummary">Gesamtergebnis</button>
|
||||
<a v-else-if="currentPart.status === 'evaluated' && !isReviewing"
|
||||
class="btn btn-primary btn-sm" :href="completedResultUrl"
|
||||
v-text="isHomework ? 'Ergebnis' : 'Gesamtergebnis'"></a>
|
||||
<button v-else-if="isReviewing" type="button" class="btn btn-primary btn-sm" @click="showSummary">Gesamtergebnis</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm d-inline-flex align-items-center" @click="abortExam">Simulation abbrechen</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm d-inline-flex align-items-center" @click="abortExam"
|
||||
v-text="isHomework ? 'Hausaufgabe abbrechen' : 'Simulation abbrechen'"></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -142,7 +210,7 @@
|
||||
:class="resultAlertClass(currentPart)">
|
||||
<span class="material-icons" aria-hidden="true" v-text="resultIcon(currentPart)"></span>
|
||||
<div>
|
||||
<strong><span v-text="currentPart.score"></span> von 25 Punkten – <span v-text="resultLabel(currentPart)"></span></strong>
|
||||
<strong><span v-text="currentPart.score"></span> von <span v-text="currentQuestionCount"></span> Punkten – <span v-text="resultLabel(currentPart)"></span></strong>
|
||||
<div v-text="resultDescription(currentPart)"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,7 +222,7 @@
|
||||
<article class="question exam-question" :class="'layout-' + questionData(questionState).layout"
|
||||
:id="'exam-question-' + questionIndex">
|
||||
<div class="question-text">
|
||||
<span v-text="padNumber(questionIndex + 1) + ': '"></span><span v-html="questionData(questionState).question"></span>
|
||||
<span v-text="displayQuestionNumber(questionState, questionIndex) + ': '"></span><span v-html="questionData(questionState).question"></span>
|
||||
</div>
|
||||
<div v-if="questionData(questionState).picture" class="question-picture">
|
||||
<img :src="'pictures/' + questionData(questionState).picture + '.svg'"
|
||||
@@ -187,8 +255,8 @@
|
||||
|
||||
<div class="border-top pt-3 mb-5 d-flex flex-wrap gap-2 align-items-center justify-content-end">
|
||||
<template v-if="currentPart.status === 'running'">
|
||||
<span class="me-auto"><b v-text="answeredCount"></b> von 25 Fragen beantwortet.</span>
|
||||
<button type="button" class="btn btn-primary" @click="requestSubmit">Prüfungsteil abgeben</button>
|
||||
<span class="me-auto"><b v-text="answeredCount"></b> von <span v-text="currentQuestionCount"></span> Fragen beantwortet.</span>
|
||||
<button type="button" class="btn btn-primary" @click="requestSubmit" v-text="isHomework ? 'Hausaufgabe abgeben' : 'Prüfungsteil abgeben'"></button>
|
||||
</template>
|
||||
<template v-else-if="isReviewing">
|
||||
<button type="button" class="btn btn-primary" @click="showSummary">Zurück zum Gesamtergebnis</button>
|
||||
@@ -198,17 +266,19 @@
|
||||
<button type="button" class="btn btn-outline-secondary" @click="finishExamEarly">Simulation hier beenden</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button type="button" class="btn btn-primary" @click="showSummary">Gesamtergebnis anzeigen</button>
|
||||
<a class="btn btn-primary" :href="completedResultUrl"
|
||||
v-text="isHomework ? 'Ergebnis anzeigen' : 'Gesamtergebnis anzeigen'"></a>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="exam-answer-sheet" aria-label="Antwortbogen">
|
||||
<aside class="exam-answer-sheet" :class="{ 'exam-answer-sheet-homework': isHomework }" aria-label="Antwortbogen">
|
||||
<div class="border border-dark bg-white p-2">
|
||||
<h2 class="h5 text-center">Antwortbogen</h2>
|
||||
<div class="border-top border-bottom border-dark py-1 mb-2">
|
||||
<strong class="d-block" v-text="currentPartDefinition.label"></strong>
|
||||
<small><span v-text="currentPartDefinition.minutes"></span> Minuten · 25 Fragen</small>
|
||||
<small v-if="isHomework"><span v-text="currentQuestionCount"></span> Fragen · ohne Zeitlimit</small>
|
||||
<small v-else><span v-text="currentPartDefinition.minutes"></span> Minuten · 25 Fragen</small>
|
||||
</div>
|
||||
<table class="answer-table">
|
||||
<thead><tr><th>Nr.</th><th v-for="label in answerLabels" :key="label" v-text="label"></th></tr></thead>
|
||||
@@ -216,20 +286,20 @@
|
||||
<tr v-for="(question, questionIndex) in currentPart.questions" :key="question.id">
|
||||
<th scope="row">
|
||||
<button type="button" class="answer-number" @click="scrollToQuestion(questionIndex)"
|
||||
:aria-label="'Zu Frage ' + (questionIndex + 1)" v-text="padNumber(questionIndex + 1)"></button>
|
||||
:aria-label="'Zu Frage ' + displayQuestionNumber(question, questionIndex)" v-text="displayQuestionNumber(question, questionIndex)"></button>
|
||||
</th>
|
||||
<td v-for="(_, answerIndex) in answerLabels" :key="answerIndex"
|
||||
:class="answerCellClass(questionIndex, answerIndex)">
|
||||
<button type="button" class="answer-mark"
|
||||
:disabled="currentPart.status !== 'running' || currentPart.paused"
|
||||
@click="selectAnswer(questionIndex, answerIndex)"
|
||||
:aria-label="'Frage ' + (questionIndex + 1) + ', Antwort ' + answerLabels[answerIndex]"
|
||||
:aria-label="'Frage ' + displayQuestionNumber(question, questionIndex) + ', Antwort ' + answerLabels[answerIndex]"
|
||||
v-html="answerMark(questionIndex, answerIndex)"></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<small class="d-block text-end mt-1"><b v-text="answeredCount"></b> von 25 beantwortet</small>
|
||||
<small class="d-block text-end mt-1"><b v-text="answeredCount"></b> von <span v-text="currentQuestionCount"></span> beantwortet</small>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<div id="homework-generator" class="homework-generator mx-auto mb-5" v-cloak>
|
||||
<div v-if="loading" class="d-flex align-items-center justify-content-center gap-3 py-5" role="status">
|
||||
<div class="spinner-border text-primary" aria-hidden="true"></div>
|
||||
<span>Kursdaten werden geladen …</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="alert alert-danger" role="alert">
|
||||
<h1 class="h4">Das Hausaufgabentool konnte nicht geladen werden</h1>
|
||||
<p class="mb-0" v-text="loadError"></p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<h1>Hausaufgabe erstellen</h1>
|
||||
<p class="lead">Stelle aus den behandelten Kapiteln und Sektionen eine Hausaufgabe zusammen.</p>
|
||||
|
||||
<ol class="homework-steps list-unstyled d-flex flex-wrap gap-2 mb-4" aria-label="Arbeitsschritte">
|
||||
<li v-for="item in stepLabels" :key="item.number"
|
||||
class="badge rounded-pill"
|
||||
:class="step === item.number ? 'text-bg-primary' : (step > item.number ? 'text-bg-secondary' : 'bg-light text-dark border')"
|
||||
v-text="item.number + '. ' + item.label"></li>
|
||||
</ol>
|
||||
|
||||
<section v-if="step === 1" aria-labelledby="homework-course-heading">
|
||||
<h2 id="homework-course-heading" class="h4">Kurs auswählen</h2>
|
||||
<p>Welchen Kurs bietest du an?</p>
|
||||
<div class="row row-cols-1 row-cols-md-3 g-2">
|
||||
<div v-for="course in courseChoices" :key="course.edition" class="col">
|
||||
<button type="button" class="btn w-100 h-100 py-3 text-white"
|
||||
:style="{ backgroundColor: course.color, borderColor: course.color }"
|
||||
@click="selectCourse(course)">
|
||||
<span class="d-block" v-text="course.label"></span>
|
||||
<small class="d-block" v-text="course.detail"></small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="step === 2" aria-labelledby="homework-chapter-heading">
|
||||
<h2 id="homework-chapter-heading" class="h4">Kapitel auswählen</h2>
|
||||
<p><strong v-text="selectedCourse.label"></strong>: Wähle ein oder mehrere behandelte Kapitel aus.</p>
|
||||
<div class="list-group mb-3">
|
||||
<label v-for="chapter in toc.chapters" :key="chapter.ident"
|
||||
class="list-group-item d-flex align-items-start gap-2"
|
||||
:class="{ 'text-secondary bg-light': chapterQuestionCount(chapter) === 0 }">
|
||||
<input class="form-check-input flex-shrink-0 mt-1" type="checkbox" :value="chapter.ident"
|
||||
v-model="selectedChapterIds" :disabled="chapterQuestionCount(chapter) === 0">
|
||||
<span class="flex-grow-1">
|
||||
<span class="d-block" v-text="chapter.title"></span>
|
||||
<small v-if="chapterQuestionCount(chapter) > 0" class="text-secondary"
|
||||
v-text="chapterQuestionCount(chapter) + ' Prüfungsfragen'"></small>
|
||||
<small v-else>Keine Prüfungsfragen vorhanden</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="goToStep(1)">Zurück</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="selectedChapterIds.length === 0"
|
||||
@click="openSectionSelection">Weiter zu den Sektionen</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="step === 3" aria-labelledby="homework-section-heading">
|
||||
<h2 id="homework-section-heading" class="h4">Sektionen auswählen</h2>
|
||||
<p>Wähle die Sektionen aus, die bereits behandelt wurden.</p>
|
||||
<div v-for="chapter in selectedChapters" :key="chapter.ident" class="mb-4">
|
||||
<h3 class="h5" v-text="chapter.title"></h3>
|
||||
<div class="list-group">
|
||||
<label v-for="section in chapter.sections" :key="section.ident"
|
||||
class="list-group-item d-flex align-items-start gap-2"
|
||||
:class="{ 'text-secondary bg-light': sectionQuestionIds(section).length === 0 }">
|
||||
<input class="form-check-input flex-shrink-0 mt-1" type="checkbox" :value="section.ident"
|
||||
v-model="selectedSectionIds" :disabled="sectionQuestionIds(section).length === 0">
|
||||
<span class="flex-grow-1">
|
||||
<span class="d-block" v-text="section.title"></span>
|
||||
<small v-if="sectionQuestionIds(section).length > 0" class="text-secondary"
|
||||
v-text="sectionQuestionIds(section).length + ' Prüfungsfragen'"></small>
|
||||
<small v-else>Keine Prüfungsfragen vorhanden</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="goToStep(2)">Zurück</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="selectedSectionIds.length === 0"
|
||||
@click="openQuestionSelection">Weiter zu den Fragen</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="step === 4" aria-labelledby="homework-question-heading">
|
||||
<h2 id="homework-question-heading" class="h4">Fragen auswählen</h2>
|
||||
<p>Alle Fragen der gewählten Sektionen sind vorausgewählt. Nicht benötigte Fragen kannst du abwählen.</p>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @click="setAllQuestions(true)">Alle auswählen</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @click="setAllQuestions(false)">Alle abwählen</button>
|
||||
<span class="ms-auto"><strong v-text="selectedQuestionIds.length"></strong> von <span v-text="orderedQuestions.length"></span> ausgewählt</span>
|
||||
</div>
|
||||
|
||||
<div class="course">
|
||||
<div v-for="group in selectedSectionGroups" :key="group.section.ident" class="mb-4">
|
||||
<h3 class="h5" v-text="group.chapter.title + ' – ' + group.section.title"></h3>
|
||||
<label v-for="questionId in group.questionIds" :key="questionId"
|
||||
class="homework-question d-flex align-items-start gap-2 border p-2 mb-2">
|
||||
<input class="form-check-input flex-shrink-0 mt-1" type="checkbox" v-model="questionSelection[questionId]">
|
||||
<span>
|
||||
<strong v-text="questionId + ': '"></strong><span v-html="catalog[questionId].question"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="goToStep(3)">Zurück</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="selectedQuestionIds.length === 0"
|
||||
@click="generateLink">Hausaufgabenlink erzeugen</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else aria-labelledby="homework-link-heading">
|
||||
<h2 id="homework-link-heading" class="h4">Hausaufgabe ist bereit</h2>
|
||||
<p>Der Link enthält <strong v-text="selectedQuestionIds.length"></strong> Fragen in der Reihenfolge des Kurses.</p>
|
||||
<label for="homework-link" class="form-label">Hausaufgabenlink</label>
|
||||
<div class="input-group mb-3">
|
||||
<input id="homework-link" class="form-control" type="text" readonly :value="generatedUrl" @focus="$event.target.select()">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="copyLink">Link kopieren</button>
|
||||
</div>
|
||||
<div v-if="copyMessage" class="alert alert-success py-2" role="status" v-text="copyMessage"></div>
|
||||
<div class="d-flex flex-wrap justify-content-between gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="goToStep(4)">Fragen ändern</button>
|
||||
<a class="btn btn-primary" :href="generatedUrl" target="_blank" rel="noopener">Hausaufgabe öffnen</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
@@ -4,6 +4,21 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>50Ω Lernplattform für Amateurfunk</title>
|
||||
{% if social_image %}
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="50ohm.de">
|
||||
<meta property="og:title" content="Ergebnis der 50ohm.de-Prüfungssimulation">
|
||||
<meta property="og:description" content="Mein Ergebnis der Prüfungssimulation auf 50ohm.de">
|
||||
<meta property="og:image" content="https://50ohm.de/{{ social_image }}">
|
||||
<meta property="og:image:alt" content="50ohm.de-Prüfungssimulation">
|
||||
<meta property="og:image:width" content="1448">
|
||||
<meta property="og:image:height" content="1086">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Ergebnis der 50ohm.de-Prüfungssimulation">
|
||||
<meta name="twitter:description" content="Mein Ergebnis der Prüfungssimulation auf 50ohm.de">
|
||||
<meta name="twitter:image" content="https://50ohm.de/{{ social_image }}">
|
||||
<meta name="twitter:image:alt" content="50ohm.de-Prüfungssimulation">
|
||||
{% endif %}
|
||||
|
||||
<link rel="icon" href="assets/favicon/icon.png" type="image/png">
|
||||
<link rel="icon" href="assets/favicon/icon.svg" type="image/svg+xml">
|
||||
|
||||