feat(dashboard): add Show transcript and Summarize lesson buttons in lesson view (#748)

* feat(dashboard): add lesson transcript and summarize actions in view mode

Add Show transcript and Summarize lesson outline buttons beneath lesson
videos in view mode. Show transcript opens the existing transcript side
panel when an uploaded video has a transcript. Summarize lesson appears
on every lesson and sends a contextual prompt to the AI assistant.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): localize summarize lesson AI prompts

Move hardcoded summarize prompt strings into translation keys so the
prompt sent to the AI assistant follows the user's app language.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-13 11:18:28 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent 3fbfa4b09f
commit fdc19aac41
16 changed files with 166 additions and 10 deletions
@@ -10,3 +10,4 @@ export { default as Document } from './document/document.svelte';
export { default as AddVideoModal } from './video/add-video-modal.svelte';
export { default as AddDocumentModal } from './document/add-document-modal.svelte';
export { default as LessonSettingsTab } from './lesson-settings-tab.svelte';
export { default as LessonSummarizeButton } from './lesson-summarize-button.svelte';
@@ -0,0 +1,21 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { sendPromptToAssistant } from '$features/ai-assistant/utils/store';
import { t } from '$lib/utils/functions/translations';
import { buildLessonSummarizePrompt } from './utils/lesson-summarize-prompt';
interface Props {
lessonId: string;
class?: string;
}
let { lessonId, class: className = '' }: Props = $props();
function handleSummarizeLesson() {
sendPromptToAssistant(buildLessonSummarizePrompt(lessonId));
}
</script>
<Button variant="outline" size="sm" class={className} onclick={handleSummarizeLesson}>
{$t('course.navItem.lessons.materials.summarize_lesson')}
</Button>
@@ -0,0 +1,37 @@
import { lessonApi } from '$features/course/api';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { t } from '$lib/utils/functions/translations';
import { isEnforceableLessonVideo, type LessonVideo } from '../video/video-card-utils';
export function hasUploadedLessonVideo(videos: LessonVideo[] | null | undefined): boolean {
return (videos ?? []).some((video) => isEnforceableLessonVideo(video));
}
export function hasLessonNoteContentForLesson(lessonId: string): boolean {
const rawNote = lessonApi.lesson?.note;
const hasLegacyNote = typeof rawNote === 'string' && rawNote.trim().length > 0;
if (hasLegacyNote) {
return true;
}
const translations = Object.values(lessonApi.translations[lessonId] || {});
return translations.some((content) => typeof content === 'string' && !isHtmlValueEmpty(content));
}
export function buildLessonSummarizePrompt(lessonId: string): string {
const videos = lessonApi.lesson?.videos ?? [];
const hasUploadedVideo = hasUploadedLessonVideo(videos);
const hasLessonNotes = hasLessonNoteContentForLesson(lessonId);
if (!hasUploadedVideo) {
return t.get('course.navItem.lessons.materials.summarize_lesson_prompt.lesson_only');
}
if (hasLessonNotes) {
return t.get('course.navItem.lessons.materials.summarize_lesson_prompt.transcript_and_notes');
}
return t.get('course.navItem.lessons.materials.summarize_lesson_prompt.transcript_only');
}
@@ -1,5 +1,6 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { Button } from '@cio/ui/base/button';
import { MediaPlayer } from '@cio/ui/custom/media-player';
import { presignApi } from '$features/course/api/presign.svelte';
import { mediaApi } from '$features/media/api';
@@ -16,6 +17,7 @@
import { resolveWatchEnforcedAssetIds, type LessonVideo } from './video-card-utils';
import { lessonVideoBus } from './lesson-video-bus.svelte';
import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition';
import LessonSummarizeButton from '../lesson-summarize-button.svelte';
/**
* HLS playback flag — set by the upload flow when the asset was encoded
@@ -98,9 +100,10 @@
video: LessonVideo;
courseId: string;
lessonId: string;
showSummarizeButton?: boolean;
}
let { video, courseId, lessonId }: Props = $props();
let { video, courseId, lessonId, showSummarizeButton = false }: Props = $props();
let localTranscript = $state<AssetTranscriptPayload | null>(null);
let localTranscriptLoading = $state(false);
@@ -435,14 +438,16 @@
lessonVideoBus.hasPlayed = true;
}
function openTranscriptPanel() {
syncVideoBus();
sidePanel.open(TRANSCRIPT_PANEL_ID);
}
const transcriptPanelControl = $derived(
localTranscript
? {
label: t.get('course.navItem.lessons.materials.tabs.video.transcript.open_side_panel'),
onClick: () => {
syncVideoBus();
sidePanel.open(TRANSCRIPT_PANEL_ID);
}
onClick: openTranscriptPanel
}
: undefined
);
@@ -489,4 +494,18 @@
seekPolicy
}}
/>
{#if localTranscript || showSummarizeButton}
<div class="mt-3 flex flex-wrap gap-2">
{#if localTranscript}
<Button variant="outline" size="sm" onclick={openTranscriptPanel}>
{$t('course.navItem.lessons.materials.show_transcript')}
</Button>
{/if}
{#if showSummarizeButton}
<LessonSummarizeButton {lessonId} />
{/if}
</div>
{/if}
</div>
@@ -42,9 +42,9 @@
}
</script>
{#snippet content(video)}
{#snippet content(video, index)}
{#key video.type === 'upload' ? ((video as typeof video & { assetId?: string }).assetId ?? video.link) : video.link}
<LessonVideoPlayer {video} {courseId} {lessonId} />
<LessonVideoPlayer {video} {courseId} {lessonId} showSummarizeButton={index === videos.length - 1} />
{/key}
{/snippet}
@@ -81,9 +81,9 @@
<!-- View Mode -->
{#if videos.length}
<div class="w-full">
{#each videos as video}
{#each videos as video, index}
<div class="mb-5 w-full overflow-hidden">
{@render content(video)}
{@render content(video, index)}
</div>
{/each}
</div>
@@ -46,7 +46,8 @@
Document,
AddVideoModal,
AddDocumentModal,
LessonSettingsTab
LessonSettingsTab,
LessonSummarizeButton
} from '$features/course/components/lesson';
import type { TLocale } from '@cio/db/types';
@@ -179,6 +180,7 @@
const viewModeComponents = $derived(getViewModeComponents(tabs));
const isMaterialsEmpty = $derived(tabs.every((tab) => tab.badgeValue === 0));
const hasLessonVideos = $derived((lessonApi.lesson?.videos?.length ?? 0) > 0);
let timeoutId: NodeJS.Timeout | undefined;
@@ -524,6 +526,12 @@
{:else if lessonApi.lesson && !isMaterialsEmpty}
{#key lessonId}
<div class="mb-20 flex w-full flex-col gap-2" in:fade={{ delay: 500 }} out:fade>
{#if !hasLessonVideos}
<div class="mb-2 flex flex-wrap gap-2">
<LessonSummarizeButton {lessonId} />
</div>
{/if}
{#each viewModeComponents as Component, index (index)}
<Component {mode} {lessonId} {courseId} />
{/each}