feat(dashboard): restyle lesson transcript and summarize actions (#758)

* feat(dashboard): restyle lesson transcript and summarize actions

Add separator lines around lesson material actions below the video,
switch transcript and summarize controls to plain ghost buttons with
hover-animated icons, and separate the two actions with a pipe when both
are visible. Display rules are unchanged.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* refactor(dashboard): inline summarize action into material actions

Fold lesson-summarize-button into lesson-material-actions and remove the
standalone component. Lessons without videos use the same actions bar with
separators disabled.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): align lesson actions with note width on mobile

Constrain material actions to max-w-2xl when there is no video so they
match the note column. Improve small-screen layout with tighter padding,
flex-wrap, and hide the pipe separator below the sm breakpoint.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* add script update

* fix(dashboard): tighten spacing between lesson video and note

Remove gap-2 from the lesson content stack and drop bottom margin on the
last video wrapper so material actions separators control the gap before
the note.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): show bottom separator on note-only lesson actions

Lessons without video were passing showSeparators=false, hiding both
lines. Split into top/bottom controls so summarize still gets a line before
the note without one above the action row.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): always show top separator on lesson material actions

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): reduce lesson material action separator margin to my-2

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat(dashboard): consolidate multi-video transcript actions and navigation

Register transcript sources per upload video, show a single material
actions row after all videos, and add prev/next video navigation in the
transcript side panel when multiple transcripts are available.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): address greptile review on lesson material actions

Require lessonId on material actions and stop overwriting the transcript
seek wrapper with a stale localSeekFn snapshot in the sync effect.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): stop transcript registry effect infinite loop

Replace the reactive sync effect with explicit registry updates from
loadTranscript and skip no-op transcript source writes to avoid reading
and writing transcriptSources in the same effect pass.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(ui): raise tooltip z-index above app bar side panel

Use z-menu-elevated for tooltip content so header icon tooltips are not
hidden behind the fixed transcript side panel (z-app-bar).

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-07-15 22:28:40 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 656fa4fa7e
commit 3980aa0a9b
23 changed files with 580 additions and 98 deletions
@@ -10,4 +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';
export { default as LessonMaterialActions } from './lesson-material-actions.svelte';
@@ -0,0 +1,78 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { Separator } from '@cio/ui/base/separator';
import { ContentIcon, HoverableItem, PremiumIcon } from '@cio/ui/custom/moving-icons';
import { sendPromptToAssistant } from '$features/ai-assistant/utils/store';
import { t } from '$lib/utils/functions/translations';
import { buildLessonSummarizePrompt } from './utils/lesson-summarize-prompt';
interface Props {
showTranscript?: boolean;
showSummarize?: boolean;
showTopSeparator?: boolean;
showBottomSeparator?: boolean;
/** Match the note column (`max-w-2xl`) instead of the full video column. */
alignWithNote?: boolean;
lessonId: string;
onTranscript?: () => void;
}
let {
showTranscript = false,
showSummarize = false,
showTopSeparator = true,
showBottomSeparator = true,
alignWithNote = false,
lessonId,
onTranscript = () => {}
}: Props = $props();
function handleSummarizeLesson() {
sendPromptToAssistant(buildLessonSummarizePrompt(lessonId));
}
</script>
{#if showTranscript || showSummarize}
<div class={alignWithNote ? 'mx-auto w-full max-w-2xl' : 'w-full'}>
{#if showTopSeparator}
<Separator class="my-2" />
{/if}
<div class="flex flex-wrap items-center gap-x-2 gap-y-2 py-1 sm:gap-x-3">
{#if showTranscript}
<HoverableItem>
{#snippet children(isHovered)}
<Button variant="ghost" size="sm" class="ui:h-auto ui:px-1 ui:py-1 sm:ui:px-2" onclick={onTranscript}>
<ContentIcon {isHovered} size={16} />
{$t('course.navItem.lessons.materials.show_transcript')}
</Button>
{/snippet}
</HoverableItem>
{/if}
{#if showTranscript && showSummarize}
<span class="ui:text-muted-foreground hidden text-sm sm:inline" aria-hidden="true">|</span>
{/if}
{#if showSummarize}
<HoverableItem>
{#snippet children(isHovered)}
<Button
variant="ghost"
size="sm"
class="ui:h-auto ui:px-1 ui:py-1 sm:ui:px-2"
onclick={handleSummarizeLesson}
>
<PremiumIcon {isHovered} size={16} />
{$t('course.navItem.lessons.materials.summarize_lesson')}
</Button>
{/snippet}
</HoverableItem>
{/if}
</div>
{#if showBottomSeparator}
<Separator class="my-2" />
{/if}
</div>
{/if}
@@ -1,21 +0,0 @@
<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>
@@ -1,18 +1,32 @@
import { SvelteMap } from 'svelte/reactivity';
import type { AssetTranscriptPayload } from '$features/media/utils/types';
type SeekFn = (seconds: number) => void;
export type LessonTranscriptSource = {
assetId: string;
videoIndex: number;
transcript: AssetTranscriptPayload | null;
transcriptLoading: boolean;
currentTimeSeconds: number;
seek: SeekFn;
};
class LessonVideoBus {
/** Asset id of the currently mounted upload-video player (if any). */
assetId = $state<string | null>(null);
/** Last reported playback time in seconds. */
/** Last reported playback time in seconds for the active player asset. */
currentTimeSeconds = $state(0);
/** True after the user (or autoplay) has started playback at least once for the current asset. */
hasPlayed = $state(false);
/** Latest transcript payload for the active asset; null if none / still loading. */
/** Latest transcript payload for the active transcript source. */
transcript = $state<AssetTranscriptPayload | null>(null);
/** True while the transcript fetch is in flight. */
/** True while the active transcript fetch is in flight. */
transcriptLoading = $state(false);
/** Transcript sources keyed by upload asset id. */
transcriptSources = new SvelteMap<string, LessonTranscriptSource>();
/** Which upload asset transcript is shown in the side panel. */
activeTranscriptAssetId = $state<string | null>(null);
private seekFn: SeekFn = () => {};
@@ -21,14 +35,136 @@ class LessonVideoBus {
}
seek(seconds: number) {
const activeSource = this.getActiveTranscriptSource();
if (activeSource) {
activeSource.seek(seconds);
return;
}
this.seekFn(seconds);
}
registerTranscriptSource(source: LessonTranscriptSource) {
this.transcriptSources.set(source.assetId, source);
}
unregisterTranscriptSource(assetId: string) {
this.transcriptSources.delete(assetId);
if (this.activeTranscriptAssetId === assetId) {
const nextSource = this.getNavigableTranscriptSources()[0] ?? null;
this.activeTranscriptAssetId = nextSource?.assetId ?? null;
this.syncActiveTranscriptToLegacyFields();
}
}
updateTranscriptSource(assetId: string, patch: Partial<LessonTranscriptSource>) {
const existing = this.transcriptSources.get(assetId);
if (!existing) return;
let next = existing;
let changed = false;
for (const key of Object.keys(patch) as (keyof LessonTranscriptSource)[]) {
const value = patch[key];
if (value !== undefined && existing[key] !== value) {
if (!changed) {
next = { ...existing };
changed = true;
}
next[key] = value as LessonTranscriptSource[typeof key];
}
}
if (!changed) return;
this.transcriptSources.set(assetId, next);
if (this.activeTranscriptAssetId === assetId) {
this.syncActiveTranscriptToLegacyFields();
}
}
getActiveTranscriptSource(): LessonTranscriptSource | null {
const assetId = this.activeTranscriptAssetId;
if (!assetId) return null;
return this.transcriptSources.get(assetId) ?? null;
}
getNavigableTranscriptSources(): LessonTranscriptSource[] {
return [...this.transcriptSources.values()]
.filter((source) => (source.transcript?.segments?.length ?? 0) > 0)
.sort((left, right) => left.videoIndex - right.videoIndex);
}
hasTranscriptAvailable(): boolean {
return [...this.transcriptSources.values()].some((source) => source.transcript != null);
}
selectTranscriptSource(assetId: string) {
if (!this.transcriptSources.has(assetId)) return;
this.activeTranscriptAssetId = assetId;
this.syncActiveTranscriptToLegacyFields();
}
prepareTranscriptPanelSelection() {
const navigableSources = this.getNavigableTranscriptSources();
if (navigableSources.length === 0) {
const fallbackSource = [...this.transcriptSources.values()]
.filter((source) => source.transcript || source.transcriptLoading)
.sort((left, right) => left.videoIndex - right.videoIndex)[0];
if (!fallbackSource) return false;
this.selectTranscriptSource(fallbackSource.assetId);
return true;
}
const currentAssetId = this.activeTranscriptAssetId;
const hasCurrent = currentAssetId != null && navigableSources.some((source) => source.assetId === currentAssetId);
if (!hasCurrent) {
this.selectTranscriptSource(navigableSources[0].assetId);
}
return true;
}
/** Replace the active transcript (e.g. after an edit is saved). */
setTranscript(transcript: AssetTranscriptPayload | null) {
const assetId = this.activeTranscriptAssetId;
if (assetId) {
this.updateTranscriptSource(assetId, { transcript });
return;
}
this.transcript = transcript;
}
private syncActiveTranscriptToLegacyFields() {
const source = this.getActiveTranscriptSource();
if (!source) {
this.transcript = null;
this.transcriptLoading = false;
return;
}
this.assetId = source.assetId;
this.transcript = source.transcript;
this.transcriptLoading = source.transcriptLoading;
this.seekFn = source.seek;
}
/** Reset all per-lesson state (called when the upload asset changes or the player unmounts). */
reset(opts?: { keepAssetId?: boolean }) {
if (!opts?.keepAssetId) {
@@ -39,6 +175,8 @@ class LessonVideoBus {
this.hasPlayed = false;
this.transcript = null;
this.transcriptLoading = false;
this.activeTranscriptAssetId = null;
this.transcriptSources.clear();
this.seekFn = () => {};
}
}
@@ -1,6 +1,5 @@
<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';
@@ -17,7 +16,6 @@
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
@@ -100,10 +98,10 @@
video: LessonVideo;
courseId: string;
lessonId: string;
showSummarizeButton?: boolean;
videoIndex: number;
}
let { video, courseId, lessonId, showSummarizeButton = false }: Props = $props();
let { video, courseId, lessonId, videoIndex }: Props = $props();
let localTranscript = $state<AssetTranscriptPayload | null>(null);
let localTranscriptLoading = $state(false);
@@ -128,19 +126,26 @@
let localSeekFn: (seconds: number) => void = () => {};
function ownsVideoBus(): boolean {
function ownsPlaybackBus(): boolean {
return uploadAssetId !== null && lessonVideoBus.assetId === uploadAssetId;
}
function syncVideoBus(): void {
function syncPlaybackBus(): void {
if (!uploadAssetId) return;
lessonVideoBus.assetId = uploadAssetId;
lessonVideoBus.transcript = localTranscript;
lessonVideoBus.transcriptLoading = localTranscriptLoading;
lessonVideoBus.setSeekFn(localSeekFn);
}
function syncTranscriptToRegistry(): void {
if (!uploadAssetId) return;
lessonVideoBus.updateTranscriptSource(uploadAssetId, {
transcript: localTranscript,
transcriptLoading: localTranscriptLoading
});
}
$effect(() => {
const rawUrl = localTranscript?.vttUrl;
if (!rawUrl) {
@@ -254,9 +259,7 @@
if (!uploadAssetId) {
if (isMounted) {
localTranscript = null;
if (ownsVideoBus()) {
lessonVideoBus.transcript = null;
}
syncTranscriptToRegistry();
}
return;
@@ -265,9 +268,7 @@
if (!isMounted) return;
localTranscriptLoading = true;
if (ownsVideoBus()) {
lessonVideoBus.transcriptLoading = true;
}
syncTranscriptToRegistry();
try {
const data = await mediaApi.getAssetTranscript(uploadAssetId);
@@ -276,15 +277,11 @@
localTranscript = data;
scheduleVttRefetch(data?.vttUrlExpiresAt);
if (ownsVideoBus()) {
lessonVideoBus.transcript = data;
}
syncTranscriptToRegistry();
} finally {
if (isMounted) {
localTranscriptLoading = false;
if (ownsVideoBus()) {
lessonVideoBus.transcriptLoading = false;
}
syncTranscriptToRegistry();
}
}
}
@@ -392,8 +389,19 @@
}
onMount(() => {
if (uploadAssetId) {
lessonVideoBus.registerTranscriptSource({
assetId: uploadAssetId,
videoIndex,
transcript: null,
transcriptLoading: false,
currentTimeSeconds: 0,
seek: (seconds) => localSeekFn(seconds)
});
}
if (lessonVideoBus.assetId === null || lessonVideoBus.assetId === uploadAssetId) {
syncVideoBus();
syncPlaybackBus();
}
void restoreWatchProgress();
@@ -413,9 +421,22 @@
stopJobPoller();
revokeBlobTrackUrl?.();
revokeBlobTrackUrl = null;
if (ownsVideoBus()) {
if (uploadAssetId) {
lessonVideoBus.unregisterTranscriptSource(uploadAssetId);
}
if (lessonVideoBus.transcriptSources.size === 0) {
lessonVideoBus.reset();
sidePanel.closeIfScope('lesson');
return;
}
if (uploadAssetId && ownsPlaybackBus()) {
lessonVideoBus.assetId = null;
lessonVideoBus.currentTimeSeconds = 0;
lessonVideoBus.hasPlayed = false;
lessonVideoBus.setSeekFn(() => {});
}
});
@@ -434,12 +455,14 @@
);
function handleFirstPlay() {
syncVideoBus();
syncPlaybackBus();
lessonVideoBus.hasPlayed = true;
}
function openTranscriptPanel() {
syncVideoBus();
if (!uploadAssetId) return;
lessonVideoBus.selectTranscriptSource(uploadAssetId);
sidePanel.open(TRANSCRIPT_PANEL_ID);
}
@@ -468,7 +491,11 @@
controls: true,
playsinline: true,
onTimeUpdate: (seconds) => {
if (ownsVideoBus()) {
if (uploadAssetId) {
lessonVideoBus.updateTranscriptSource(uploadAssetId, { currentTimeSeconds: seconds });
}
if (ownsPlaybackBus()) {
lessonVideoBus.currentTimeSeconds = seconds;
}
},
@@ -476,7 +503,8 @@
localSeekFn = (seconds) => {
player.currentTime = seconds;
};
if (ownsVideoBus()) {
if (ownsPlaybackBus()) {
lessonVideoBus.setSeekFn(localSeekFn);
}
},
@@ -494,18 +522,4 @@
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>
@@ -1,10 +1,13 @@
<script lang="ts">
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import XIcon from '@lucide/svelte/icons/x';
import PencilIcon from '@lucide/svelte/icons/pencil';
import CaptionsIcon from '@lucide/svelte/icons/captions';
import { IconButton } from '@cio/ui/custom/icon-button';
import { Button } from '@cio/ui/base/button';
import { Textarea } from '@cio/ui/base/textarea';
import { Separator } from '@cio/ui/base/separator';
import { t } from '$lib/utils/functions/translations';
import { isOrgAdmin } from '$lib/utils/store/org';
import { sidePanel } from '$features/side-panel';
@@ -12,12 +15,20 @@
import { lessonVideoBus } from './lesson-video-bus.svelte';
import type { TranscriptSegment } from '$features/media/utils/types';
const transcript = $derived(lessonVideoBus.transcript);
const transcriptLoading = $derived(lessonVideoBus.transcriptLoading);
const activeSource = $derived.by(() => lessonVideoBus.getActiveTranscriptSource());
const transcript = $derived(activeSource?.transcript ?? null);
const transcriptLoading = $derived(activeSource?.transcriptLoading ?? false);
const segments = $derived<TranscriptSegment[]>(transcript?.segments ?? []);
const language = $derived(transcript?.language ?? '');
const assetId = $derived(lessonVideoBus.assetId);
const currentTimeSeconds = $derived(lessonVideoBus.currentTimeSeconds);
const assetId = $derived(activeSource?.assetId ?? null);
const currentTimeSeconds = $derived(activeSource?.currentTimeSeconds ?? 0);
const navigableSources = $derived.by(() => lessonVideoBus.getNavigableTranscriptSources());
const activeNavIndex = $derived(navigableSources.findIndex((source) => source.assetId === assetId));
const activeVideoNumber = $derived(activeNavIndex >= 0 ? activeNavIndex + 1 : 1);
const canGoToPreviousVideo = $derived(activeNavIndex > 0);
const canGoToNextVideo = $derived(activeNavIndex >= 0 && activeNavIndex < navigableSources.length - 1);
const showVideoNavigation = $derived(navigableSources.length > 1);
let editing = $state(false);
let saving = $state(false);
@@ -43,12 +54,34 @@
}
function formatTimestamp(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60)
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60)
.toString()
.padStart(2, '0');
return `${m}:${s}`;
return `${minutes}:${remainingSeconds}`;
}
function goToPreviousVideo() {
if (!canGoToPreviousVideo) return;
if (editing) {
cancelEditing();
}
const previousSource = navigableSources[activeNavIndex - 1];
lessonVideoBus.selectTranscriptSource(previousSource.assetId);
}
function goToNextVideo() {
if (!canGoToNextVideo) return;
if (editing) {
cancelEditing();
}
const nextSource = navigableSources[activeNavIndex + 1];
lessonVideoBus.selectTranscriptSource(nextSource.assetId);
}
function startEditing() {
@@ -122,6 +155,38 @@
</div>
</header>
{#if showVideoNavigation && !editing}
<div class="border-border flex items-center justify-between gap-2 border-b px-4 py-2">
<IconButton
onclick={goToPreviousVideo}
variant="outline"
size="icon-xs"
disabled={!canGoToPreviousVideo}
tooltip={$t('course.navItem.lessons.materials.tabs.video.transcript.previous_video')}
>
<ChevronLeftIcon size={16} />
</IconButton>
<p class="min-w-0 truncate text-center text-sm font-medium">
{$t('course.navItem.lessons.materials.tabs.video.transcript.video_nav_label', {
number: activeVideoNumber
})}
</p>
<IconButton
onclick={goToNextVideo}
variant="outline"
size="icon-xs"
disabled={!canGoToNextVideo}
tooltip={$t('course.navItem.lessons.materials.tabs.video.transcript.next_video')}
>
<ChevronRightIcon size={16} />
</IconButton>
</div>
{:else if showVideoNavigation && editing}
<Separator />
{/if}
<div class="min-h-0 flex-1 overflow-y-auto p-3">
{#if editing}
{#each draft as segment, index (index)}
@@ -187,16 +187,27 @@
async function handleViewTranscript() {
if (!assetId) return;
lessonVideoBus.assetId = assetId;
lessonVideoBus.transcriptLoading = true;
if (!lessonVideoBus.transcriptSources.has(assetId)) {
lessonVideoBus.registerTranscriptSource({
assetId,
videoIndex: 0,
transcript: null,
transcriptLoading: true,
currentTimeSeconds: 0,
seek: () => {}
});
}
lessonVideoBus.selectTranscriptSource(assetId);
lessonVideoBus.updateTranscriptSource(assetId, { transcriptLoading: true });
sidePanel.open(TRANSCRIPT_PANEL_ID);
try {
const data = await mediaApi.getAssetTranscript(assetId);
lessonVideoBus.transcript = data;
lessonVideoBus.updateTranscriptSource(assetId, { transcript: data, transcriptLoading: false });
hasTranscript = !!data?.segments?.length;
} finally {
lessonVideoBus.transcriptLoading = false;
} catch {
lessonVideoBus.updateTranscriptSource(assetId, { transcriptLoading: false });
}
}
@@ -5,11 +5,15 @@
import { Empty } from '@cio/ui/custom/empty';
import * as Item from '@cio/ui/base/item';
import { lessonVideoUpload } from '$features/course/components/lesson/store';
import LessonMaterialActions from '$features/course/components/lesson/lesson-material-actions.svelte';
import { sidePanel } from '$features/side-panel';
import { t } from '$lib/utils/functions/translations';
import MODES from '$lib/utils/constants/mode';
import VideoIcon from '@lucide/svelte/icons/video';
import LessonVideoSimpleCard from './lesson-video-simple-card.svelte';
import LessonVideoPlayer from './lesson-video-player.svelte';
import { lessonVideoBus } from './lesson-video-bus.svelte';
import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition';
interface Props {
mode?: (typeof MODES)[keyof typeof MODES];
@@ -21,6 +25,11 @@
const videos = $derived(lessonApi.lesson?.videos || []);
const hasTranscript = $derived.by(() => {
lessonVideoBus.transcriptSources;
return lessonVideoBus.hasTranscriptAvailable();
});
let openDeleteVideoModal = $state(false);
let videoIndexToDelete = $state<number | null>(null);
@@ -40,11 +49,17 @@
}
openDeleteVideoModal = false;
}
function openTranscriptPanel() {
if (!lessonVideoBus.prepareTranscriptPanelSelection()) return;
sidePanel.open(TRANSCRIPT_PANEL_ID);
}
</script>
{#snippet content(video, index)}
{#key video.type === 'upload' ? ((video as typeof video & { assetId?: string }).assetId ?? video.link) : video.link}
<LessonVideoPlayer {video} {courseId} {lessonId} showSummarizeButton={index === videos.length - 1} />
<LessonVideoPlayer {video} {courseId} {lessonId} videoIndex={index} />
{/key}
{/snippet}
@@ -82,10 +97,17 @@
{#if videos.length}
<div class="w-full">
{#each videos as video, index}
<div class="mb-5 w-full overflow-hidden">
<div class="{index < videos.length - 1 ? 'mb-5' : ''} w-full overflow-hidden">
{@render content(video, index)}
</div>
{/each}
<LessonMaterialActions
showTranscript={hasTranscript}
showSummarize={videos.length > 0}
{lessonId}
onTranscript={openTranscriptPanel}
/>
</div>
{/if}
{/if}
@@ -47,7 +47,7 @@
AddVideoModal,
AddDocumentModal,
LessonSettingsTab,
LessonSummarizeButton
LessonMaterialActions
} from '$features/course/components/lesson';
import type { TLocale } from '@cio/db/types';
@@ -525,11 +525,9 @@
</UnderlineTabs.Root>
{:else if lessonApi.lesson && !isMaterialsEmpty}
{#key lessonId}
<div class="mb-20 flex w-full flex-col gap-2" in:fade={{ delay: 500 }} out:fade>
<div class="mb-20 flex w-full flex-col" in:fade={{ delay: 500 }} out:fade>
{#if !hasLessonVideos}
<div class="mb-2 flex flex-wrap gap-2">
<LessonSummarizeButton {lessonId} />
</div>
<LessonMaterialActions showSummarize {lessonId} alignWithNote />
{/if}
{#each viewModeComponents as Component, index (index)}
@@ -2064,7 +2064,10 @@
"edit": "Rediger udskrift",
"save": "Gem",
"cancel": "Annuller",
"segment_label": "Segmenter kl. {timestamp}"
"segment_label": "Segmenter kl. {timestamp}",
"video_nav_label": "Video {number}",
"previous_video": "Forrige videotransskript",
"next_video": "Næste videotransskript"
},
"simple_card": {
"kind_youtube": "YouTube",
@@ -2064,7 +2064,10 @@
"edit": "Transkript bearbeiten",
"save": "Speichern",
"cancel": "Abbrechen",
"segment_label": "Segmentieren um {timestamp}"
"segment_label": "Segmentieren um {timestamp}",
"video_nav_label": "Video {number}",
"previous_video": "Vorheriges Video-Transkript",
"next_video": "Nächstes Video-Transkript"
},
"simple_card": {
"kind_youtube": "YouTube",
@@ -2384,7 +2384,10 @@
"edit": "Edit transcript",
"save": "Save",
"cancel": "Cancel",
"segment_label": "Segment at {timestamp}"
"segment_label": "Segment at {timestamp}",
"video_nav_label": "Video {number}",
"previous_video": "Previous video transcript",
"next_video": "Next video transcript"
}
},
"document": {
@@ -2064,7 +2064,10 @@
"edit": "Editar transcripción",
"save": "Guardar",
"cancel": "Cancelar",
"segment_label": "Segmentar en {timestamp}"
"segment_label": "Segmentar en {timestamp}",
"video_nav_label": "Video {number}",
"previous_video": "Transcripción del video anterior",
"next_video": "Transcripción del video siguiente"
},
"simple_card": {
"kind_youtube": "youtube",
@@ -2064,7 +2064,10 @@
"edit": "Modifier la transcription",
"save": "Enregistrer",
"cancel": "Annuler",
"segment_label": "Segment à {timestamp}"
"segment_label": "Segment à {timestamp}",
"video_nav_label": "Vidéo {number}",
"previous_video": "Transcription de la vidéo précédente",
"next_video": "Transcription de la vidéo suivante"
},
"simple_card": {
"kind_youtube": "YouTube",
@@ -2064,7 +2064,10 @@
"edit": "प्रतिलेख संपादित करें",
"save": "सहेजें",
"cancel": "रद्द करें",
"segment_label": "{timestamp} पर खंड"
"segment_label": "{timestamp} पर खंड",
"video_nav_label": "वीडियो {number}",
"previous_video": "पिछले वीडियो का प्रतिलेख",
"next_video": "अगले वीडियो का प्रतिलेख"
},
"simple_card": {
"kind_youtube": "यूट्यूब",
@@ -2064,7 +2064,10 @@
"edit": "Edytuj transkrypcję",
"save": "Zapisz",
"cancel": "Anuluj",
"segment_label": "Segment o {timestamp}"
"segment_label": "Segment o {timestamp}",
"video_nav_label": "Wideo {number}",
"previous_video": "Transkrypcja poprzedniego wideo",
"next_video": "Transkrypcja następnego wideo"
},
"simple_card": {
"kind_youtube": "YouTube",
@@ -2064,7 +2064,10 @@
"edit": "Editar transcrição",
"save": "Salvar",
"cancel": "Cancelar",
"segment_label": "Segmentar em {timestamp}"
"segment_label": "Segmentar em {timestamp}",
"video_nav_label": "Vídeo {number}",
"previous_video": "Transcrição do vídeo anterior",
"next_video": "Transcrição do próximo vídeo"
},
"simple_card": {
"kind_youtube": "YouTube",
@@ -2064,7 +2064,10 @@
"edit": "Изменить стенограмму",
"save": "Сохранить",
"cancel": "Отмена",
"segment_label": "Сегмент в {timestamp}"
"segment_label": "Сегмент в {timestamp}",
"video_nav_label": "Видео {number}",
"previous_video": "Транскрипт предыдущего видео",
"next_video": "Транскрипт следующего видео"
},
"simple_card": {
"kind_youtube": "Ютуб",
@@ -2064,7 +2064,10 @@
"edit": "Chỉnh sửa bản ghi",
"save": "Lưu",
"cancel": "Hủy bỏ",
"segment_label": "Đoạn tại {timestamp}"
"segment_label": "Đoạn tại {timestamp}",
"video_nav_label": "Video {number}",
"previous_video": "Bản ghi video trước",
"next_video": "Bản ghi video tiếp theo"
},
"simple_card": {
"kind_youtube": "YouTube",
+1
View File
@@ -69,6 +69,7 @@
"db:login-link": "tsx src/scripts/generate-login-link.ts",
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts",
"db:backfill-profile-email-verification": "tsx src/scripts/backfill-profile-email-verification.ts",
"db:update-user-email": "tsx src/scripts/update-user-email.ts",
"db:notify-students-over-limit": "tsx src/scripts/notify-students-over-limit.ts",
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
"pull": "drizzle-kit pull --config drizzle.config.ts",
@@ -0,0 +1,145 @@
/**
* Updates a user's email address everywhere it is stored:
* "user" (auth), profile, organizationmember, groupmember and cohort_member.
*
* Membership tables are matched by profile_id OR the old email so rows that
* only carry the denormalized email (pre-signup invites) are updated too.
* Pending organization_invite rows are reported but not modified.
*
* Usage:
* pnpm db:update-user-email -- --old old@example.com --new new@example.com # dry run (default)
* pnpm db:update-user-email -- --old old@example.com --new new@example.com --execute # apply updates
*/
import 'dotenv/config';
import postgres from 'postgres';
const connectionString = process.env.DATABASE_URL ?? process.env.PRIVATE_DATABASE_URL ?? '';
const shouldExecute = process.argv.includes('--execute');
function readArg(flag: string): string {
const flagIndex = process.argv.indexOf(flag);
const value = flagIndex === -1 ? '' : (process.argv[flagIndex + 1] ?? '');
return value.trim().toLowerCase();
}
const oldEmail = readArg('--old');
const newEmail = readArg('--new');
if (!connectionString) {
console.error('DATABASE_URL or PRIVATE_DATABASE_URL environment variable is required');
process.exit(1);
}
if (!oldEmail || !newEmail) {
console.error('Usage: pnpm db:update-user-email -- --old old@example.com --new new@example.com [--execute]');
process.exit(1);
}
if (oldEmail === newEmail) {
console.error('The old and new email addresses are identical.');
process.exit(1);
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
console.error(`"${newEmail}" does not look like a valid email address.`);
process.exit(1);
}
async function main() {
const sql = postgres(connectionString);
try {
const users = await sql`
SELECT id, name, email, email_verified FROM "user" WHERE lower(email) = ${oldEmail}
`;
if (users.length === 0) {
console.error(`No user found with email ${oldEmail}`);
process.exit(1);
}
const targetUser = users[0];
console.log(`Found user ${targetUser.id} (${targetUser.name}) <${targetUser.email}>`);
const conflicts = await sql`
SELECT 'user' AS source, id::text FROM "user" WHERE lower(email) = ${newEmail}
UNION ALL
SELECT 'profile' AS source, id::text FROM profile WHERE lower(email) = ${newEmail}
`;
if (conflicts.length > 0) {
console.error(`The email ${newEmail} is already taken:`);
for (const conflict of conflicts) {
console.error(` ${conflict.source} ${conflict.id}`);
}
process.exit(1);
}
const userId = targetUser.id;
const [profileRows, orgMemberRows, groupMemberRows, cohortMemberRows, pendingInvites] = await Promise.all([
sql`SELECT id FROM profile WHERE id = ${userId}`,
sql`SELECT id FROM organizationmember WHERE profile_id = ${userId} OR lower(email) = ${oldEmail}`,
sql`SELECT id FROM groupmember WHERE profile_id = ${userId} OR lower(email) = ${oldEmail}`,
sql`SELECT id FROM cohort_member WHERE profile_id = ${userId} OR lower(email) = ${oldEmail}`,
sql`SELECT id FROM organization_invite WHERE lower(email) = ${oldEmail}`
]);
console.log('');
console.log(`Rows to update with ${newEmail}:`);
console.log(` user: 1`);
console.log(` profile: ${profileRows.length}`);
console.log(` organizationmember: ${orgMemberRows.length}`);
console.log(` groupmember: ${groupMemberRows.length}`);
console.log(` cohort_member: ${cohortMemberRows.length}`);
if (pendingInvites.length > 0) {
console.log('');
console.warn(
`Note: ${pendingInvites.length} pending organization_invite row(s) still reference ${oldEmail} and will NOT be modified.`
);
}
if (!shouldExecute) {
console.log('');
console.log('Dry run only. Re-run with --execute to apply updates.');
return;
}
await sql.begin(async (tx) => {
await tx`
UPDATE "user" SET email = ${newEmail}, updated_at = NOW() WHERE id = ${userId}
`;
await tx`
UPDATE profile SET email = ${newEmail}, updated_at = NOW() WHERE id = ${userId}
`;
await tx`
UPDATE organizationmember SET email = ${newEmail}
WHERE profile_id = ${userId} OR lower(email) = ${oldEmail}
`;
await tx`
UPDATE groupmember SET email = ${newEmail}
WHERE profile_id = ${userId} OR lower(email) = ${oldEmail}
`;
await tx`
UPDATE cohort_member SET email = ${newEmail}
WHERE profile_id = ${userId} OR lower(email) = ${oldEmail}
`;
});
console.log('');
console.log(`Done. ${oldEmail} → ${newEmail} for user ${userId}.`);
console.log(
`email_verified was left as-is (${targetUser.email_verified}). The user logs in with the new email; their password is unchanged.`
);
} catch (error) {
console.error('update-user-email error:', error);
process.exit(1);
} finally {
await sql.end();
}
}
main();
@@ -22,7 +22,7 @@
{sideOffset}
{side}
class={cn(
'ui:bg-primary ui:text-primary-foreground ui:animate-in ui:fade-in-0 ui:zoom-in-95 ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=closed]:zoom-out-95 ui:data-[side=bottom]:slide-in-from-top-2 ui:data-[side=left]:slide-in-from-right-2 ui:data-[side=right]:slide-in-from-left-2 ui:data-[side=top]:slide-in-from-bottom-2 ui:origin-(--bits-tooltip-content-transform-origin) ui:z-50 ui:w-fit ui:text-balance ui:rounded-md ui:px-3 ui:py-1.5 ui:text-xs',
'ui:bg-primary ui:text-primary-foreground ui:animate-in ui:fade-in-0 ui:zoom-in-95 ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=closed]:zoom-out-95 ui:data-[side=bottom]:slide-in-from-top-2 ui:data-[side=left]:slide-in-from-right-2 ui:data-[side=right]:slide-in-from-left-2 ui:data-[side=top]:slide-in-from-bottom-2 ui:origin-(--bits-tooltip-content-transform-origin) ui:z-menu-elevated ui:w-fit ui:text-balance ui:rounded-md ui:px-3 ui:py-1.5 ui:text-xs',
className
)}
{...restProps}
@@ -32,7 +32,7 @@
{#snippet child({ props })}
<div
class={cn(
'ui:bg-primary ui:z-50 ui:size-2.5 ui:rotate-45 ui:rounded-[2px]',
'ui:bg-primary ui:z-menu-elevated ui:size-2.5 ui:rotate-45 ui:rounded-[2px]',
'ui:data-[side=top]:translate-x-1/2 ui:data-[side=top]:translate-y-[calc(-50%+2px)]',
'ui:data-[side=bottom]:-translate-x-1/2 ui:data-[side=bottom]:-translate-y-[calc(-50%+1px)]',
'ui:data-[side=right]:translate-x-[calc(50%+2px)] ui:data-[side=right]:translate-y-1/2',
+3 -2
View File
@@ -23,8 +23,9 @@
* z-menu-elevated (250) Dropdown/select/popover content that must escape a z-app-bar,
* z-app-bar-elevated, or z-modal ancestor.
*
* Anything not listed (tooltips, dropdowns, sheets, drawers) uses Tailwind's default `z-50`,
* which already sits above normal page content.
* Anything not listed (dropdowns, sheets, drawers) uses Tailwind's default `z-50`,
* which already sits above normal page content. Tooltips use `z-menu-elevated` so they
* remain visible over `z-app-bar` chrome such as the transcript side panel.
*/
@utility z-app-bar {
z-index: 100;