The summary and quiz could not be loaded.
++ {{ items.length }} single-best-answer {{ items.length === 1 ? 'question' : 'questions' }} + on this lecture. + @if (answeredCount(); as answered) { + You have answered {{ answered }} so far. + } +
++ + {{ isRight(item) ? 'Correct' : 'Not quite. The answer is ' + letter(item.question.answer) + '.' }} +
+ @if (statsLine(item.question); as line) { +{{ line }}
+ } + @if (item.explanationHtml) { ++ {{ scoreOf() }} of {{ items.length }} correct +
+The AI is still writing questions on this lecture. Check back later.
+} @else { +There are no questions on this lecture.
+} +{{ announcement }}
diff --git a/src/app/shared/quiz-body.component.scss b/src/app/shared/quiz-body.component.scss new file mode 100644 index 0000000..e607df7 --- /dev/null +++ b/src/app/shared/quiz-body.component.scss @@ -0,0 +1,276 @@ +@use 'study-shared'; + +:host { + display: block; +} + +.share-note { + margin: -0.4rem 0 0.75rem; + color: var(--ion-color-medium); + font-size: 0.8rem; + overflow-wrap: anywhere; +} + +.quiz-start ion-button { + margin: 0.25rem 0 0; + + & + ion-button { + margin-left: 0.5rem; + } +} + +.quiz-progress { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.35rem 0.75rem; + margin-bottom: 0.6rem; + color: var(--ion-color-medium); + font-size: 0.8rem; +} + +.progress-dots { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + // Keeps the dots and the Redo button together at the end of the row. + margin-left: auto; +} + +.dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.18); +} + +.dot-right { + background: var(--ion-color-success); +} + +.dot-wrong { + background: var(--ion-color-danger); +} + +.dot-current { + outline: 2px solid var(--ion-color-primary); + outline-offset: 1px; +} + +.quiz-prompt { + font-size: 1rem; + font-weight: 600; + line-height: 1.5; +} + +.quiz-choices { + display: grid; + gap: 0.5rem; + margin: 0.75rem 0; +} + +.quiz-choice { + display: grid; + grid-template-columns: 1.9rem minmax(0, 1fr) auto; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.55rem 0.75rem; + border: 1px solid rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.16); + border-radius: 0.6rem; + background: transparent; + color: var(--ion-text-color); + font: inherit; + line-height: 1.4; + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease; + + &:hover:not(:disabled) { + border-color: var(--ion-color-primary); + background: rgba(var(--ion-color-primary-rgb), 0.06); + } + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: 2px; + } + + &:disabled { + cursor: default; + } + + &.is-correct { + border-color: var(--ion-color-success); + background: rgba(var(--ion-color-success-rgb), 0.14); + } + + &.is-wrong { + border-color: var(--ion-color-danger); + background: rgba(var(--ion-color-danger-rgb), 0.12); + } + + &.is-muted { + opacity: 0.6; + } +} + +.choice-letter { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.9rem; + height: 1.9rem; + border-radius: 50%; + background: rgba(var(--ion-color-primary-rgb), 0.12); + color: var(--ion-color-primary); + font-size: 0.85rem; + font-weight: 700; +} + +.is-correct .choice-letter { + background: var(--ion-color-success); + color: var(--ion-color-success-contrast); +} + +.is-wrong .choice-letter { + background: var(--ion-color-danger); + color: var(--ion-color-danger-contrast); +} + +.choice-mark { + font-size: 1.3rem; +} + +.is-correct .choice-mark { + color: var(--ion-color-success); +} + +.is-wrong .choice-mark { + color: var(--ion-color-danger); +} + +.quiz-feedback { + padding: 0.75rem 0.85rem; + border-left: 3px solid var(--ion-color-danger); + border-radius: 0.5rem; + background: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.04); + font-size: 0.9rem; + line-height: 1.5; + + &.is-right { + border-left-color: var(--ion-color-success); + } +} + +.feedback-verdict { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0 0 0.35rem; + font-weight: 700; + + ion-icon { + flex: none; + color: var(--ion-color-danger); + font-size: 1.15rem; + } +} + +.is-right .feedback-verdict ion-icon { + color: var(--ion-color-success); +} + +.feedback-stats { + margin: 0 0 0.5rem; + color: var(--ion-color-medium); + font-size: 0.8rem; +} + +.watch-button { + margin-top: 0.4rem; + --padding-start: 0; +} + +.quiz-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.75rem; + + ion-button { + margin: 0; + } +} + +.results-score { + margin: 0 0 0.5rem; + font-size: 1.05rem; +} + +.results-list { + margin: 0 0 0.75rem; + padding: 0; + list-style: none; +} + +.results-row { + display: grid; + grid-template-columns: 1.3rem 1.8em minmax(0, 1fr); + align-items: start; + gap: 0.4rem; + width: 100%; + padding: 0.4rem 0.3rem; + border: 0; + border-radius: 0.4rem; + background: transparent; + color: var(--ion-text-color); + font: inherit; + font-size: 0.9rem; + text-align: left; + cursor: pointer; + + &:hover { + background: rgba(var(--ion-color-primary-rgb), 0.06); + } + + &:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; + } + + ion-icon { + margin-top: 0.15em; + font-size: 1.1rem; + } +} + +.mark-right { + color: var(--ion-color-success); +} + +.mark-wrong { + color: var(--ion-color-danger); +} + +.results-number { + color: var(--ion-color-medium); + font-variant-numeric: tabular-nums; +} + +.results-prompt { + overflow-wrap: anywhere; +} + +// The reading window has room for a larger question and choices. +:host(.reader) { + .quiz-prompt { + font-size: 1.15rem; + } + + .quiz-choice { + padding: 0.75rem 1rem; + font-size: 1.05rem; + } +} diff --git a/src/app/shared/quiz-body.component.ts b/src/app/shared/quiz-body.component.ts new file mode 100644 index 0000000..d2b4f74 --- /dev/null +++ b/src/app/shared/quiz-body.component.ts @@ -0,0 +1,259 @@ +import {Component, DestroyRef, EventEmitter, inject, Input, OnChanges, Output} from '@angular/core'; +import {AlertController, IonButton, IonIcon} from '@ionic/angular'; +import {addIcons} from 'ionicons'; +import {checkmarkCircle, closeCircle, playCircleOutline, refreshOutline, shareSocialOutline} from 'ionicons/icons'; +import {Subscription} from 'rxjs'; +import {Lecture} from '../man.service'; +import {StudyAidsService} from '../study-aids.service'; +import {StudyAidsProgressService} from '../study-aids-progress.service'; +import {RichTextComponent} from './rich-text.component'; +import {formatClock, plainText, renderBlock, renderInline} from '../study-aids/checklist'; +import {CHOICE_LETTERS, effectiveAnswers, score} from '../study-aids/quiz'; +import {LecturePlayback, QuizQuestion, StudyAids} from '../study-aids/study-aids.model'; + +/** Fewer answers than this say nothing about how hard a question is. */ +const MIN_ANSWERS_FOR_STATS = 5; + +/** `?video=…&quiz=1` opens the lecture with its quiz ready, which is what a shared link sends. */ +export const QUIZ_LINK_PARAM = 'quiz'; + +interface QuizItem { + question: QuizQuestion; + promptHtml: string; + promptText: string; + choicesHtml: string[]; + explanationHtml: string | null; +} + +type ChoiceState = 'correct' | 'wrong' | 'muted' | null; + +/** + * A lecture's practice questions, one at a time, with the answer, the explanation and how the + * class did shown as soon as the reader picks a choice. Used on the card and in the reading window. + */ +@Component({ + selector: 'app-quiz-body', + templateUrl: './quiz-body.component.html', + styleUrls: ['./quiz-body.component.scss'], + imports: [IonButton, IonIcon, RichTextComponent], + host: {'[class.reader]': 'reader'}, +}) +export class QuizBodyComponent implements OnChanges { + private service = inject(StudyAidsService); + private progress = inject(StudyAidsProgressService); + private alertCtrl = inject(AlertController); + + @Input({required: true}) aids: StudyAids; + @Input({required: true}) video: Lecture; + @Input() playback: LecturePlayback | null = null; + /** Laid out for the reading window rather than the card. */ + @Input() reader = false; + @Output() seek = new EventEmitterThe summary and quiz could not be loaded.
+ } @else if (aidsOf(state); as aids) { + @switch (activeTab(aids)) { + @case ('summary') { + @let outlineEntries = outline(aids); +There is no summary, transcription or quiz for this lecture.
+ } + } ++ @if (reviewed(checklist, ticked); as count) { + {{ count }} of {{ checklist.pointKeys.length }} points ticked. + + } @else { + Tick each point once you know it. + } +
+ } +The AI is still writing a summary of this lecture. Check back later.
+} @else { +There is no summary of this lecture.
+} diff --git a/src/app/shared/summary-body.component.scss b/src/app/shared/summary-body.component.scss new file mode 100644 index 0000000..a82c364 --- /dev/null +++ b/src/app/shared/summary-body.component.scss @@ -0,0 +1,26 @@ +@use 'study-shared'; + +:host { + display: block; +} + +.review-progress { + margin: -0.25rem 0 0.75rem; + color: var(--ion-color-medium); + font-size: 0.8rem; +} + +.link-button { + margin-left: 0.25rem; + padding: 0; + border: 0; + background: none; + color: var(--ion-color-primary); + font: inherit; + text-decoration: underline; + cursor: pointer; +} + +:host(.reader) app-checklist-view { + font-size: 1.05rem; +} diff --git a/src/app/shared/summary-body.component.spec.ts b/src/app/shared/summary-body.component.spec.ts new file mode 100644 index 0000000..f74e572 --- /dev/null +++ b/src/app/shared/summary-body.component.spec.ts @@ -0,0 +1,87 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {of} from 'rxjs'; +import {SummaryBodyComponent} from './summary-body.component'; +import {StudyAidsService} from '../study-aids.service'; +import {StudyAids} from '../study-aids/study-aids.model'; + +const SUMMARY = '# Organelles\n\n## Lysosome [00:21:40]\n\n1. **Lysosome** : pH 5\n2. **Gaucher** : glucocerebrosidase\n'; + +function aids(changes: PartialThere is no transcript of this lecture.
+ } @else if (state.status === 'error') { ++ @if (state.limited) { + You have opened many transcripts in the last hour, so Flick has to wait before opening more. Try again later. + } @else { + The transcript could not be loaded. + } +
++ Word for word as the AI heard the lecture, so it can mishear names and terms. + @if (playback) { + Select a time to play the lecture from there. + } +
+ ++ @for (line of paragraph.lines; track line.index) { + @if (playback && paragraph.lines.length > 1) { + + } @else { + {{ line.text + ' ' }} + } + } +
+