Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,25 @@
},
"private": true,
"dependencies": {
"@angular/common": "^20.3.31",
"@angular/core": "^20.3.31",
"@angular/fire": "^20.0.1",
"@angular/forms": "^20.3.31",
"@angular/platform-browser": "^20.3.31",
"@angular/router": "^20.3.31",
"@angular/service-worker": "^20.3.31",
"@angular/common": "^20.3.33",
"@angular/core": "^20.3.33",
"@angular/fire": "^20.1.0",
"@angular/forms": "^20.3.33",
"@angular/platform-browser": "^20.3.33",
"@angular/router": "^20.3.33",
"@angular/service-worker": "^20.3.33",
"@ionic/angular": "^8.8.19",
"@primeuix/themes": "^1.2.5",
"core-js": "^3.50.0",
"ionicons": "^8.1.0",
"laravel-echo": "^2.5.0",
"marked": "^18.0.14",
"primeng": "^20.4.0",
"pusher-js": "^8.6.0",
"rxjs": "^7.8.2",
"tslib": "^2.8.1",
"ulid": "^3.0.2",
"video.js": "^8.24.0",
"video.js": "^8.24.1",
"videojs-hotkeys": "^0.2.30",
"videojs-youtube": "^3.0.1",
"zone.js": "^0.15.1"
Expand All @@ -45,18 +46,18 @@
"@angular-eslint/schematics": "^20.7.0",
"@angular-eslint/template-parser": "^20.7.0",
"@angular/cli": "^20.3.37",
"@angular/compiler": "^20.3.31",
"@angular/compiler-cli": "^20.3.31",
"@angular/language-service": "^20.3.31",
"@angular/compiler": "^20.3.33",
"@angular/compiler-cli": "^20.3.33",
"@angular/language-service": "^20.3.33",
"@eslint/eslintrc": "^3.3.7",
"@eslint/js": "^9.39.5",
"@ionic/angular-toolkit": "^12.3.0",
"@types/jasmine": "~6.0.0",
"@types/jasminewd2": "^2.0.13",
"@types/node": "^24.13.5",
"@types/node": "^24.19.0",
"@types/video.js": "^7.3.58",
"@typescript-eslint/eslint-plugin": "^8.70.0",
"@typescript-eslint/parser": "^8.70.0",
"@typescript-eslint/eslint-plugin": "^8.71.0",
"@typescript-eslint/parser": "^8.71.0",
"eslint": "^9.39.5",
"jasmine-core": "^6.3.0",
"karma": "^6.4.4",
Expand Down
1,247 changes: 622 additions & 625 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion src/app/home/course/course.page.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@
size="small" tabindex="0" class="set-playback-speed-button">
Set playback speed
</ion-button>
@if (currentVideo.id && currentVideo.has_document) {
<ion-button (click)="openDocumentModal()" fill="outline" size="small" class="set-playback-speed-button">
<ion-icon slot="start" name="document-text-outline"></ion-icon>
Transcript
</ion-button>
}
@if (currentVideo.sourceExternal?.includes('1.mp4')) {
<span>
| <a [href]="sanitize('//player.docchula.com/?url='+currentVideo.sourceExternal)"
Expand Down Expand Up @@ -173,4 +179,4 @@
</ion-col>
</ion-row>
</ion-grid>
</ion-content>
</ion-content>
59 changes: 57 additions & 2 deletions src/app/home/course/course.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,12 @@ import {
import {DomSanitizer} from '@angular/platform-browser';
import {PlayHistory} from '../../play-tracker.service';
import {addIcons} from "ionicons";
import {checkmarkOutline, closeOutline, documentAttachOutline, download, pauseCircleOutline} from "ionicons/icons";
import {checkmarkOutline, closeOutline, documentAttachOutline, documentTextOutline, download, pauseCircleOutline} from "ionicons/icons";
import type Player from 'video.js/dist/types/player';
import {ulid} from 'ulid';
import {AsyncPipe, DatePipe, DecimalPipe, NgClass} from '@angular/common';
import {ModalEvaluationComponent} from './modal-evaluation.component';
import {ModalDocumentComponent} from './modal-document.component';
import {PomodoroTimerComponent} from '../../shared/pomodoro-timer.component';
import {Analytics, logEvent} from '@angular/fire/analytics';
import {ConsentService} from '../../consent.service';
Expand Down Expand Up @@ -125,7 +126,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
isPlayerReady = false;

constructor() {
addIcons({ download, documentAttachOutline, checkmarkOutline, closeOutline, pauseCircleOutline });
addIcons({ download, documentAttachOutline, documentTextOutline, checkmarkOutline, closeOutline, pauseCircleOutline });
}

ngOnInit() {
Expand Down Expand Up @@ -483,6 +484,60 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
await modal.present();
}

async openDocumentModal() {
if (!await this.confirmAiDisclaimer()) {
return;
}
const modal = await this.modalCtrl.create({
component: ModalDocumentComponent,
cssClass: 'modal-document',
componentProps: { video: this.currentVideo },
});
await modal.present();
}

// Resolves true only if the user explicitly accepts the AI disclaimer (or accepted it within the last 7 days
// and ticked "don't show again").
private async confirmAiDisclaimer(): Promise<boolean> {
const storageKey = 'aiDocDisclaimerAcceptedAt';
try {
const acceptedAt = Number(localStorage.getItem(storageKey));
if (acceptedAt && Date.now() - acceptedAt < 7 * 24 * 60 * 60 * 1000) {
return true;
}
} catch {
// Storage unavailable; just ask again
}
const alert = await this.alertController.create({
header: 'AI-generated content',
message: 'This document is generated by AI from the lecture recording. ' +
'This feature is intended only to assist with content mapping and searching, ' +
'not as a source of study material or a replacement for the lecture and official course materials. ' +
'AI can make mistakes, so please verify important information before relying on it.',
backdropDismiss: false,
inputs: [
{type: 'checkbox', label: "Don't show this again for 7 days", value: 'skip', checked: false},
],
buttons: [
{text: 'Cancel', role: 'cancel'},
{text: 'I understand and accept', role: 'confirm'},
],
});
await alert.present();
const {role, data} = await alert.onDidDismiss();
if (role !== 'confirm') {
return false;
}
if (data?.values?.includes('skip')) {
try {
localStorage.setItem(storageKey, String(Date.now()));
} catch {
// Storage unavailable; the user will be asked again next time
}
}
return true;
}

preventMouseEvent($event: MouseEvent) {
// Prevent right-click only if video is downloadable
if (this.currentVideo?.sources?.filter(s => s.path?.endsWith('.mp4') || s.src?.endsWith('.mp4') || s.path?.endsWith('.webm')).length > 0) {
Expand Down
59 changes: 59 additions & 0 deletions src/app/home/course/modal-document.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<ion-header>
<ion-toolbar>
<ion-title>Transcript</ion-title>
<ion-buttons slot="end">
<ion-button (click)="close()" [strong]="true">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding" [scrollEvents]="true" (ionScroll)="onScroll($event)">
@if (!loading && !error && html) {
<ion-note class="ai-disclaimer">
<strong>AI makes mistakes.</strong>
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.
</ion-note>
}
@if (video) {
<div style="border-left: 4px solid #1976d2; padding-left: 1rem;">
<span style="font-weight: bold">Lecture:</span> {{ video.title }}<br/>
<span style="font-weight: bold">Instructor:</span> {{ video.lecturer }}
</div>
}
@if (loading) {
<div class="ion-text-center ion-padding">
<ion-spinner></ion-spinner>
</div>
} @else if (error) {
<p>Could not load the document. Please try again later.</p>
} @else if (html) {
<div class="doc-layout">
@if (headings.length > 1) {
<nav class="doc-outline" aria-label="Document outline">
@for (h of headings; track h.index) {
<a [class]="'level-' + h.level" [class.active]="h.index === activeHeading"
(click)="scrollTo(h.index)" (keyup.enter)="scrollTo(h.index)" tabindex="0">{{ h.text }}</a>
}
</nav>
}
<div class="markdown-body" [innerHTML]="html"></div>
</div>

@if (models.transcription || models.article) {
<small class="ai-models">
by
@if (models.transcription) {
{{ models.transcription }}
}
@if (models.transcription && models.article) {
&middot;
}
@if (models.article) {
{{ models.article }}
}
</small>
}
} @else {
<p>No document is available for this lecture.</p>
}
</ion-content>
161 changes: 161 additions & 0 deletions src/app/home/course/modal-document.component.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading