+ @if (!loading && !error && html) {
+
+ AI makes mistakes.
+ This document was generated by AI from the lecture recording and may contain errors or omissions.
+ It is not a substitute for the lecture or official course materials. Please verify important information.
+
+ }
+ @if (video) {
+
+ Lecture: {{ video.title }}
+ Instructor: {{ video.lecturer }}
+
+ }
+ @if (loading) {
+
+
+
+ } @else if (error) {
+ Could not load the document. Please try again later.
+ } @else if (html) {
+
+ @if (headings.length > 1) {
+
+ }
+
+
+
+ @if (models.transcription || models.article) {
+
+ by
+ @if (models.transcription) {
+ {{ models.transcription }}
+ }
+ @if (models.transcription && models.article) {
+ ·
+ }
+ @if (models.article) {
+ {{ models.article }}
+ }
+
+ }
+ } @else {
+ No document is available for this lecture.
+ }
+
diff --git a/src/app/home/course/modal-document.component.scss b/src/app/home/course/modal-document.component.scss
new file mode 100644
index 0000000..97bbd62
--- /dev/null
+++ b/src/app/home/course/modal-document.component.scss
@@ -0,0 +1,161 @@
+// Modal sizing: near full-screen on desktop (Ionic's default is a small centered card).
+// Targets the ion-modal host that Ionic attaches to the app root, which only works unscoped (see ViewEncapsulation.None).
+ion-modal.modal-document {
+ --width: min(1400px, 95vw);
+ --height: 92vh;
+ --max-width: 100%;
+ --max-height: 100%;
+}
+
+.markdown-body {
+ line-height: 1.6;
+ overflow-wrap: anywhere;
+
+ :is(h1, h2, h3, h4, h5, h6) {
+ margin: 1.5rem 0 0.5rem;
+ line-height: 1.25;
+ }
+
+ h1 {
+ font-size: 2rem;
+ font-weight: 800;
+ padding-bottom: 0.3rem;
+ border-bottom: 2px solid var(--ion-color-step-300, #ccc);
+ }
+
+ h2 {
+ font-size: 1.6rem;
+ font-weight: 700;
+ padding-bottom: 0.2rem;
+ border-bottom: 1px solid var(--ion-color-step-200, #ddd);
+ }
+
+ h3 {
+ font-size: 1.3rem;
+ font-weight: 600;
+ color: var(--ion-color-primary);
+ }
+
+ h4 {
+ font-size: 1.1rem;
+ font-weight: 600;
+ font-style: italic;
+ }
+
+ :is(h5, h6) {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--ion-color-medium);
+ }
+
+ table {
+ border-collapse: collapse;
+ display: block;
+ overflow-x: auto;
+ }
+
+ :is(th, td) {
+ border: 1px solid var(--ion-color-step-200, #ccc);
+ padding: 0.25rem 0.5rem;
+ }
+
+ pre {
+ overflow-x: auto;
+ padding: 0.75rem;
+ background: var(--ion-color-step-100, #f4f4f4);
+ }
+
+ blockquote {
+ margin-left: 0;
+ padding-left: 1rem;
+ border-left: 4px solid var(--ion-color-step-300, #ccc);
+ }
+}
+
+.ai-disclaimer {
+ display: block;
+ margin: 1rem 0;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.85rem;
+ border-radius: 4px;
+ border-left: 4px solid var(--ion-color-warning);
+ background: var(--ion-color-step-100, #f4f4f4);
+
+ // Keep it visible while reading on large screens
+ @media (min-width: 992px) {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ }
+}
+
+.ai-models {
+ display: block;
+ margin-top: 0.25rem;
+ font-size: 0.75rem;
+ color: var(--ion-color-medium);
+}
+
+.doc-layout {
+ display: flex;
+ align-items: flex-start;
+ gap: 1.5rem;
+
+ .markdown-body {
+ flex: 1;
+ min-width: 0;
+ }
+}
+
+.doc-outline {
+ position: sticky;
+ top: 0;
+ flex: 0 0 240px;
+ max-height: calc(92vh - 140px);
+ overflow-y: auto;
+ padding: 0.5rem 0;
+ font-size: 0.85rem;
+ border-right: 1px solid var(--ion-color-step-200, #ddd);
+
+ a {
+ display: block;
+ padding: 0.25rem 0.75rem;
+ cursor: pointer;
+ color: var(--ion-color-medium);
+ border-left: 3px solid transparent;
+
+ &:hover {
+ color: var(--ion-color-primary);
+ }
+
+ &.active {
+ color: var(--ion-color-primary);
+ font-weight: 600;
+ border-left-color: var(--ion-color-primary);
+ }
+
+ &.level-1 {
+ font-weight: 700;
+ }
+
+ &.level-2 {
+ padding-left: 1.5rem;
+ }
+
+ &.level-3 {
+ padding-left: 2.25rem;
+ font-size: 0.7rem;
+ }
+ }
+
+ // Sit below the sticky disclaimer
+ @media (min-width: 992px) {
+ top: 5rem;
+ max-height: calc(92vh - 13rem);
+ }
+
+ // Not enough room beside the text on narrow screens
+ @media (max-width: 767px) {
+ display: none;
+ }
+}
diff --git a/src/app/home/course/modal-document.component.ts b/src/app/home/course/modal-document.component.ts
new file mode 100644
index 0000000..173939b
--- /dev/null
+++ b/src/app/home/course/modal-document.component.ts
@@ -0,0 +1,117 @@
+import {Component, ElementRef, inject, Input, OnInit, ViewEncapsulation} from '@angular/core';
+import {
+ IonButton,
+ IonButtons,
+ IonContent,
+ IonHeader,
+ IonNote,
+ IonSpinner,
+ IonTitle,
+ IonToolbar,
+ ModalController,
+} from '@ionic/angular/standalone';
+import {marked} from 'marked';
+import {Lecture, ManService} from '../../man.service';
+
+// Matches a leading YAML frontmatter block: `---` ... `---` at the very start of the document.
+const FRONTMATTER = /^?---[ \t]*\r?\n[\s\S]*?\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/;
+
+export interface DocHeading {
+ index: number; // position among the rendered h1-h3 elements
+ text: string;
+ level: number;
+}
+
+export function stripFrontmatter(markdown: string): string {
+ return markdown.replace(FRONTMATTER, '');
+}
+
+const unquote =(value: string | undefined) => value?.trim().replace(/^(["'])(.*)\1$/, '$2') || null;
+
+// Reads the AI model names from the frontmatter without a full YAML parser (only two simple scalar fields are needed).
+export function extractModels(markdown: string): {transcription: string | null, article: string | null} {
+ const frontmatter = markdown.match(FRONTMATTER)?.[0] ?? '';
+ return {
+ transcription: unquote(frontmatter.match(/^transcription:[ \t]*\r?\n(?:[ \t]+.*\r?\n)*?[ \t]+model:[ \t]*(.+?)[ \t]*\r?$/m)?.[1]),
+ article: unquote(frontmatter.match(/^article_model:[ \t]*(.+?)[ \t]*\r?$/m)?.[1]),
+ };
+}
+
+@Component({
+ selector: 'app-modal-document',
+ templateUrl: 'modal-document.component.html',
+ styleUrls: ['modal-document.component.scss'],
+ // [innerHTML] content doesn't get Angular's scoping attributes, so scoped styles would never match it
+ encapsulation: ViewEncapsulation.None,
+ imports: [IonButton, IonButtons, IonContent, IonHeader, IonNote, IonSpinner, IonTitle, IonToolbar],
+})
+export class ModalDocumentComponent implements OnInit {
+ private manService = inject(ManService);
+ private modalCtrl = inject(ModalController);
+ private host = inject