Merge branch 'main' into feature/circuitjs

This commit is contained in:
Matthias Jung
2026-08-11 00:11:55 +02:00
committed by GitHub
20 changed files with 880 additions and 80 deletions
+89 -19
View File
@@ -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>
+133
View File
@@ -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>
+15
View File
@@ -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">