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:
co-authored by
Rotimi Best
parent
656fa4fa7e
commit
3980aa0a9b
@@ -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 AddVideoModal } from './video/add-video-modal.svelte';
|
||||||
export { default as AddDocumentModal } from './document/add-document-modal.svelte';
|
export { default as AddDocumentModal } from './document/add-document-modal.svelte';
|
||||||
export { default as LessonSettingsTab } from './lesson-settings-tab.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';
|
||||||
|
|||||||
+78
@@ -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}
|
||||||
-21
@@ -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>
|
|
||||||
+141
-3
@@ -1,18 +1,32 @@
|
|||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import type { AssetTranscriptPayload } from '$features/media/utils/types';
|
import type { AssetTranscriptPayload } from '$features/media/utils/types';
|
||||||
|
|
||||||
type SeekFn = (seconds: number) => void;
|
type SeekFn = (seconds: number) => void;
|
||||||
|
|
||||||
|
export type LessonTranscriptSource = {
|
||||||
|
assetId: string;
|
||||||
|
videoIndex: number;
|
||||||
|
transcript: AssetTranscriptPayload | null;
|
||||||
|
transcriptLoading: boolean;
|
||||||
|
currentTimeSeconds: number;
|
||||||
|
seek: SeekFn;
|
||||||
|
};
|
||||||
|
|
||||||
class LessonVideoBus {
|
class LessonVideoBus {
|
||||||
/** Asset id of the currently mounted upload-video player (if any). */
|
/** Asset id of the currently mounted upload-video player (if any). */
|
||||||
assetId = $state<string | null>(null);
|
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);
|
currentTimeSeconds = $state(0);
|
||||||
/** True after the user (or autoplay) has started playback at least once for the current asset. */
|
/** True after the user (or autoplay) has started playback at least once for the current asset. */
|
||||||
hasPlayed = $state(false);
|
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);
|
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);
|
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 = () => {};
|
private seekFn: SeekFn = () => {};
|
||||||
|
|
||||||
@@ -21,14 +35,136 @@ class LessonVideoBus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
seek(seconds: number) {
|
seek(seconds: number) {
|
||||||
|
const activeSource = this.getActiveTranscriptSource();
|
||||||
|
|
||||||
|
if (activeSource) {
|
||||||
|
activeSource.seek(seconds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.seekFn(seconds);
|
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). */
|
/** Replace the active transcript (e.g. after an edit is saved). */
|
||||||
setTranscript(transcript: AssetTranscriptPayload | null) {
|
setTranscript(transcript: AssetTranscriptPayload | null) {
|
||||||
|
const assetId = this.activeTranscriptAssetId;
|
||||||
|
|
||||||
|
if (assetId) {
|
||||||
|
this.updateTranscriptSource(assetId, { transcript });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.transcript = transcript;
|
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 all per-lesson state (called when the upload asset changes or the player unmounts). */
|
||||||
reset(opts?: { keepAssetId?: boolean }) {
|
reset(opts?: { keepAssetId?: boolean }) {
|
||||||
if (!opts?.keepAssetId) {
|
if (!opts?.keepAssetId) {
|
||||||
@@ -39,6 +175,8 @@ class LessonVideoBus {
|
|||||||
this.hasPlayed = false;
|
this.hasPlayed = false;
|
||||||
this.transcript = null;
|
this.transcript = null;
|
||||||
this.transcriptLoading = false;
|
this.transcriptLoading = false;
|
||||||
|
this.activeTranscriptAssetId = null;
|
||||||
|
this.transcriptSources.clear();
|
||||||
this.seekFn = () => {};
|
this.seekFn = () => {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-40
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { Button } from '@cio/ui/base/button';
|
|
||||||
import { MediaPlayer } from '@cio/ui/custom/media-player';
|
import { MediaPlayer } from '@cio/ui/custom/media-player';
|
||||||
import { presignApi } from '$features/course/api/presign.svelte';
|
import { presignApi } from '$features/course/api/presign.svelte';
|
||||||
import { mediaApi } from '$features/media/api';
|
import { mediaApi } from '$features/media/api';
|
||||||
@@ -17,7 +16,6 @@
|
|||||||
import { resolveWatchEnforcedAssetIds, type LessonVideo } from './video-card-utils';
|
import { resolveWatchEnforcedAssetIds, type LessonVideo } from './video-card-utils';
|
||||||
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
||||||
import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition';
|
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
|
* HLS playback flag — set by the upload flow when the asset was encoded
|
||||||
@@ -100,10 +98,10 @@
|
|||||||
video: LessonVideo;
|
video: LessonVideo;
|
||||||
courseId: string;
|
courseId: string;
|
||||||
lessonId: 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 localTranscript = $state<AssetTranscriptPayload | null>(null);
|
||||||
let localTranscriptLoading = $state(false);
|
let localTranscriptLoading = $state(false);
|
||||||
@@ -128,19 +126,26 @@
|
|||||||
|
|
||||||
let localSeekFn: (seconds: number) => void = () => {};
|
let localSeekFn: (seconds: number) => void = () => {};
|
||||||
|
|
||||||
function ownsVideoBus(): boolean {
|
function ownsPlaybackBus(): boolean {
|
||||||
return uploadAssetId !== null && lessonVideoBus.assetId === uploadAssetId;
|
return uploadAssetId !== null && lessonVideoBus.assetId === uploadAssetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncVideoBus(): void {
|
function syncPlaybackBus(): void {
|
||||||
if (!uploadAssetId) return;
|
if (!uploadAssetId) return;
|
||||||
|
|
||||||
lessonVideoBus.assetId = uploadAssetId;
|
lessonVideoBus.assetId = uploadAssetId;
|
||||||
lessonVideoBus.transcript = localTranscript;
|
|
||||||
lessonVideoBus.transcriptLoading = localTranscriptLoading;
|
|
||||||
lessonVideoBus.setSeekFn(localSeekFn);
|
lessonVideoBus.setSeekFn(localSeekFn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncTranscriptToRegistry(): void {
|
||||||
|
if (!uploadAssetId) return;
|
||||||
|
|
||||||
|
lessonVideoBus.updateTranscriptSource(uploadAssetId, {
|
||||||
|
transcript: localTranscript,
|
||||||
|
transcriptLoading: localTranscriptLoading
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const rawUrl = localTranscript?.vttUrl;
|
const rawUrl = localTranscript?.vttUrl;
|
||||||
if (!rawUrl) {
|
if (!rawUrl) {
|
||||||
@@ -254,9 +259,7 @@
|
|||||||
if (!uploadAssetId) {
|
if (!uploadAssetId) {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
localTranscript = null;
|
localTranscript = null;
|
||||||
if (ownsVideoBus()) {
|
syncTranscriptToRegistry();
|
||||||
lessonVideoBus.transcript = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -265,9 +268,7 @@
|
|||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
|
|
||||||
localTranscriptLoading = true;
|
localTranscriptLoading = true;
|
||||||
if (ownsVideoBus()) {
|
syncTranscriptToRegistry();
|
||||||
lessonVideoBus.transcriptLoading = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await mediaApi.getAssetTranscript(uploadAssetId);
|
const data = await mediaApi.getAssetTranscript(uploadAssetId);
|
||||||
@@ -276,15 +277,11 @@
|
|||||||
|
|
||||||
localTranscript = data;
|
localTranscript = data;
|
||||||
scheduleVttRefetch(data?.vttUrlExpiresAt);
|
scheduleVttRefetch(data?.vttUrlExpiresAt);
|
||||||
if (ownsVideoBus()) {
|
syncTranscriptToRegistry();
|
||||||
lessonVideoBus.transcript = data;
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
localTranscriptLoading = false;
|
localTranscriptLoading = false;
|
||||||
if (ownsVideoBus()) {
|
syncTranscriptToRegistry();
|
||||||
lessonVideoBus.transcriptLoading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,8 +389,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
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) {
|
if (lessonVideoBus.assetId === null || lessonVideoBus.assetId === uploadAssetId) {
|
||||||
syncVideoBus();
|
syncPlaybackBus();
|
||||||
}
|
}
|
||||||
|
|
||||||
void restoreWatchProgress();
|
void restoreWatchProgress();
|
||||||
@@ -413,9 +421,22 @@
|
|||||||
stopJobPoller();
|
stopJobPoller();
|
||||||
revokeBlobTrackUrl?.();
|
revokeBlobTrackUrl?.();
|
||||||
revokeBlobTrackUrl = null;
|
revokeBlobTrackUrl = null;
|
||||||
if (ownsVideoBus()) {
|
|
||||||
|
if (uploadAssetId) {
|
||||||
|
lessonVideoBus.unregisterTranscriptSource(uploadAssetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lessonVideoBus.transcriptSources.size === 0) {
|
||||||
lessonVideoBus.reset();
|
lessonVideoBus.reset();
|
||||||
sidePanel.closeIfScope('lesson');
|
sidePanel.closeIfScope('lesson');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadAssetId && ownsPlaybackBus()) {
|
||||||
|
lessonVideoBus.assetId = null;
|
||||||
|
lessonVideoBus.currentTimeSeconds = 0;
|
||||||
|
lessonVideoBus.hasPlayed = false;
|
||||||
|
lessonVideoBus.setSeekFn(() => {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -434,12 +455,14 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
function handleFirstPlay() {
|
function handleFirstPlay() {
|
||||||
syncVideoBus();
|
syncPlaybackBus();
|
||||||
lessonVideoBus.hasPlayed = true;
|
lessonVideoBus.hasPlayed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTranscriptPanel() {
|
function openTranscriptPanel() {
|
||||||
syncVideoBus();
|
if (!uploadAssetId) return;
|
||||||
|
|
||||||
|
lessonVideoBus.selectTranscriptSource(uploadAssetId);
|
||||||
sidePanel.open(TRANSCRIPT_PANEL_ID);
|
sidePanel.open(TRANSCRIPT_PANEL_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +491,11 @@
|
|||||||
controls: true,
|
controls: true,
|
||||||
playsinline: true,
|
playsinline: true,
|
||||||
onTimeUpdate: (seconds) => {
|
onTimeUpdate: (seconds) => {
|
||||||
if (ownsVideoBus()) {
|
if (uploadAssetId) {
|
||||||
|
lessonVideoBus.updateTranscriptSource(uploadAssetId, { currentTimeSeconds: seconds });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ownsPlaybackBus()) {
|
||||||
lessonVideoBus.currentTimeSeconds = seconds;
|
lessonVideoBus.currentTimeSeconds = seconds;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -476,7 +503,8 @@
|
|||||||
localSeekFn = (seconds) => {
|
localSeekFn = (seconds) => {
|
||||||
player.currentTime = seconds;
|
player.currentTime = seconds;
|
||||||
};
|
};
|
||||||
if (ownsVideoBus()) {
|
|
||||||
|
if (ownsPlaybackBus()) {
|
||||||
lessonVideoBus.setSeekFn(localSeekFn);
|
lessonVideoBus.setSeekFn(localSeekFn);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -494,18 +522,4 @@
|
|||||||
seekPolicy
|
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>
|
</div>
|
||||||
|
|||||||
+72
-7
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts">
|
<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 XIcon from '@lucide/svelte/icons/x';
|
||||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||||
import CaptionsIcon from '@lucide/svelte/icons/captions';
|
import CaptionsIcon from '@lucide/svelte/icons/captions';
|
||||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import { Textarea } from '@cio/ui/base/textarea';
|
import { Textarea } from '@cio/ui/base/textarea';
|
||||||
|
import { Separator } from '@cio/ui/base/separator';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||||
import { sidePanel } from '$features/side-panel';
|
import { sidePanel } from '$features/side-panel';
|
||||||
@@ -12,12 +15,20 @@
|
|||||||
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
||||||
import type { TranscriptSegment } from '$features/media/utils/types';
|
import type { TranscriptSegment } from '$features/media/utils/types';
|
||||||
|
|
||||||
const transcript = $derived(lessonVideoBus.transcript);
|
const activeSource = $derived.by(() => lessonVideoBus.getActiveTranscriptSource());
|
||||||
const transcriptLoading = $derived(lessonVideoBus.transcriptLoading);
|
const transcript = $derived(activeSource?.transcript ?? null);
|
||||||
|
const transcriptLoading = $derived(activeSource?.transcriptLoading ?? false);
|
||||||
const segments = $derived<TranscriptSegment[]>(transcript?.segments ?? []);
|
const segments = $derived<TranscriptSegment[]>(transcript?.segments ?? []);
|
||||||
const language = $derived(transcript?.language ?? '');
|
const language = $derived(transcript?.language ?? '');
|
||||||
const assetId = $derived(lessonVideoBus.assetId);
|
const assetId = $derived(activeSource?.assetId ?? null);
|
||||||
const currentTimeSeconds = $derived(lessonVideoBus.currentTimeSeconds);
|
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 editing = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
@@ -43,12 +54,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(seconds: number): string {
|
function formatTimestamp(seconds: number): string {
|
||||||
const m = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
const s = Math.floor(seconds % 60)
|
const remainingSeconds = Math.floor(seconds % 60)
|
||||||
.toString()
|
.toString()
|
||||||
.padStart(2, '0');
|
.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() {
|
function startEditing() {
|
||||||
@@ -122,6 +155,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
{#if editing}
|
{#if editing}
|
||||||
{#each draft as segment, index (index)}
|
{#each draft as segment, index (index)}
|
||||||
|
|||||||
+16
-5
@@ -187,16 +187,27 @@
|
|||||||
async function handleViewTranscript() {
|
async function handleViewTranscript() {
|
||||||
if (!assetId) return;
|
if (!assetId) return;
|
||||||
|
|
||||||
lessonVideoBus.assetId = assetId;
|
if (!lessonVideoBus.transcriptSources.has(assetId)) {
|
||||||
lessonVideoBus.transcriptLoading = true;
|
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);
|
sidePanel.open(TRANSCRIPT_PANEL_ID);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await mediaApi.getAssetTranscript(assetId);
|
const data = await mediaApi.getAssetTranscript(assetId);
|
||||||
lessonVideoBus.transcript = data;
|
lessonVideoBus.updateTranscriptSource(assetId, { transcript: data, transcriptLoading: false });
|
||||||
hasTranscript = !!data?.segments?.length;
|
hasTranscript = !!data?.segments?.length;
|
||||||
} finally {
|
} catch {
|
||||||
lessonVideoBus.transcriptLoading = false;
|
lessonVideoBus.updateTranscriptSource(assetId, { transcriptLoading: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,15 @@
|
|||||||
import { Empty } from '@cio/ui/custom/empty';
|
import { Empty } from '@cio/ui/custom/empty';
|
||||||
import * as Item from '@cio/ui/base/item';
|
import * as Item from '@cio/ui/base/item';
|
||||||
import { lessonVideoUpload } from '$features/course/components/lesson/store';
|
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 { t } from '$lib/utils/functions/translations';
|
||||||
import MODES from '$lib/utils/constants/mode';
|
import MODES from '$lib/utils/constants/mode';
|
||||||
import VideoIcon from '@lucide/svelte/icons/video';
|
import VideoIcon from '@lucide/svelte/icons/video';
|
||||||
import LessonVideoSimpleCard from './lesson-video-simple-card.svelte';
|
import LessonVideoSimpleCard from './lesson-video-simple-card.svelte';
|
||||||
import LessonVideoPlayer from './lesson-video-player.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 {
|
interface Props {
|
||||||
mode?: (typeof MODES)[keyof typeof MODES];
|
mode?: (typeof MODES)[keyof typeof MODES];
|
||||||
@@ -21,6 +25,11 @@
|
|||||||
|
|
||||||
const videos = $derived(lessonApi.lesson?.videos || []);
|
const videos = $derived(lessonApi.lesson?.videos || []);
|
||||||
|
|
||||||
|
const hasTranscript = $derived.by(() => {
|
||||||
|
lessonVideoBus.transcriptSources;
|
||||||
|
return lessonVideoBus.hasTranscriptAvailable();
|
||||||
|
});
|
||||||
|
|
||||||
let openDeleteVideoModal = $state(false);
|
let openDeleteVideoModal = $state(false);
|
||||||
let videoIndexToDelete = $state<number | null>(null);
|
let videoIndexToDelete = $state<number | null>(null);
|
||||||
|
|
||||||
@@ -40,11 +49,17 @@
|
|||||||
}
|
}
|
||||||
openDeleteVideoModal = false;
|
openDeleteVideoModal = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openTranscriptPanel() {
|
||||||
|
if (!lessonVideoBus.prepareTranscriptPanelSelection()) return;
|
||||||
|
|
||||||
|
sidePanel.open(TRANSCRIPT_PANEL_ID);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet content(video, index)}
|
{#snippet content(video, index)}
|
||||||
{#key video.type === 'upload' ? ((video as typeof video & { assetId?: string }).assetId ?? video.link) : video.link}
|
{#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}
|
{/key}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -82,10 +97,17 @@
|
|||||||
{#if videos.length}
|
{#if videos.length}
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
{#each videos as video, index}
|
{#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)}
|
{@render content(video, index)}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<LessonMaterialActions
|
||||||
|
showTranscript={hasTranscript}
|
||||||
|
showSummarize={videos.length > 0}
|
||||||
|
{lessonId}
|
||||||
|
onTranscript={openTranscriptPanel}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
AddVideoModal,
|
AddVideoModal,
|
||||||
AddDocumentModal,
|
AddDocumentModal,
|
||||||
LessonSettingsTab,
|
LessonSettingsTab,
|
||||||
LessonSummarizeButton
|
LessonMaterialActions
|
||||||
} from '$features/course/components/lesson';
|
} from '$features/course/components/lesson';
|
||||||
|
|
||||||
import type { TLocale } from '@cio/db/types';
|
import type { TLocale } from '@cio/db/types';
|
||||||
@@ -525,11 +525,9 @@
|
|||||||
</UnderlineTabs.Root>
|
</UnderlineTabs.Root>
|
||||||
{:else if lessonApi.lesson && !isMaterialsEmpty}
|
{:else if lessonApi.lesson && !isMaterialsEmpty}
|
||||||
{#key lessonId}
|
{#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}
|
{#if !hasLessonVideos}
|
||||||
<div class="mb-2 flex flex-wrap gap-2">
|
<LessonMaterialActions showSummarize {lessonId} alignWithNote />
|
||||||
<LessonSummarizeButton {lessonId} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each viewModeComponents as Component, index (index)}
|
{#each viewModeComponents as Component, index (index)}
|
||||||
|
|||||||
@@ -2064,7 +2064,10 @@
|
|||||||
"edit": "Rediger udskrift",
|
"edit": "Rediger udskrift",
|
||||||
"save": "Gem",
|
"save": "Gem",
|
||||||
"cancel": "Annuller",
|
"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": {
|
"simple_card": {
|
||||||
"kind_youtube": "YouTube",
|
"kind_youtube": "YouTube",
|
||||||
|
|||||||
@@ -2064,7 +2064,10 @@
|
|||||||
"edit": "Transkript bearbeiten",
|
"edit": "Transkript bearbeiten",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"cancel": "Abbrechen",
|
"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": {
|
"simple_card": {
|
||||||
"kind_youtube": "YouTube",
|
"kind_youtube": "YouTube",
|
||||||
|
|||||||
@@ -2384,7 +2384,10 @@
|
|||||||
"edit": "Edit transcript",
|
"edit": "Edit transcript",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"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": {
|
"document": {
|
||||||
|
|||||||
@@ -2064,7 +2064,10 @@
|
|||||||
"edit": "Editar transcripción",
|
"edit": "Editar transcripción",
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"cancel": "Cancelar",
|
"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": {
|
"simple_card": {
|
||||||
"kind_youtube": "youtube",
|
"kind_youtube": "youtube",
|
||||||
|
|||||||
@@ -2064,7 +2064,10 @@
|
|||||||
"edit": "Modifier la transcription",
|
"edit": "Modifier la transcription",
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"cancel": "Annuler",
|
"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": {
|
"simple_card": {
|
||||||
"kind_youtube": "YouTube",
|
"kind_youtube": "YouTube",
|
||||||
|
|||||||
@@ -2064,7 +2064,10 @@
|
|||||||
"edit": "प्रतिलेख संपादित करें",
|
"edit": "प्रतिलेख संपादित करें",
|
||||||
"save": "सहेजें",
|
"save": "सहेजें",
|
||||||
"cancel": "रद्द करें",
|
"cancel": "रद्द करें",
|
||||||
"segment_label": "{timestamp} पर खंड"
|
"segment_label": "{timestamp} पर खंड",
|
||||||
|
"video_nav_label": "वीडियो {number}",
|
||||||
|
"previous_video": "पिछले वीडियो का प्रतिलेख",
|
||||||
|
"next_video": "अगले वीडियो का प्रतिलेख"
|
||||||
},
|
},
|
||||||
"simple_card": {
|
"simple_card": {
|
||||||
"kind_youtube": "यूट्यूब",
|
"kind_youtube": "यूट्यूब",
|
||||||
|
|||||||
@@ -2064,7 +2064,10 @@
|
|||||||
"edit": "Edytuj transkrypcję",
|
"edit": "Edytuj transkrypcję",
|
||||||
"save": "Zapisz",
|
"save": "Zapisz",
|
||||||
"cancel": "Anuluj",
|
"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": {
|
"simple_card": {
|
||||||
"kind_youtube": "YouTube",
|
"kind_youtube": "YouTube",
|
||||||
|
|||||||
@@ -2064,7 +2064,10 @@
|
|||||||
"edit": "Editar transcrição",
|
"edit": "Editar transcrição",
|
||||||
"save": "Salvar",
|
"save": "Salvar",
|
||||||
"cancel": "Cancelar",
|
"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": {
|
"simple_card": {
|
||||||
"kind_youtube": "YouTube",
|
"kind_youtube": "YouTube",
|
||||||
|
|||||||
@@ -2064,7 +2064,10 @@
|
|||||||
"edit": "Изменить стенограмму",
|
"edit": "Изменить стенограмму",
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"segment_label": "Сегмент в {timestamp}"
|
"segment_label": "Сегмент в {timestamp}",
|
||||||
|
"video_nav_label": "Видео {number}",
|
||||||
|
"previous_video": "Транскрипт предыдущего видео",
|
||||||
|
"next_video": "Транскрипт следующего видео"
|
||||||
},
|
},
|
||||||
"simple_card": {
|
"simple_card": {
|
||||||
"kind_youtube": "Ютуб",
|
"kind_youtube": "Ютуб",
|
||||||
|
|||||||
@@ -2064,7 +2064,10 @@
|
|||||||
"edit": "Chỉnh sửa bản ghi",
|
"edit": "Chỉnh sửa bản ghi",
|
||||||
"save": "Lưu",
|
"save": "Lưu",
|
||||||
"cancel": "Hủy bỏ",
|
"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": {
|
"simple_card": {
|
||||||
"kind_youtube": "YouTube",
|
"kind_youtube": "YouTube",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
"db:login-link": "tsx src/scripts/generate-login-link.ts",
|
"db:login-link": "tsx src/scripts/generate-login-link.ts",
|
||||||
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.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: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",
|
"db:notify-students-over-limit": "tsx src/scripts/notify-students-over-limit.ts",
|
||||||
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
|
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
|
||||||
"pull": "drizzle-kit pull --config drizzle.config.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}
|
{sideOffset}
|
||||||
{side}
|
{side}
|
||||||
class={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<div
|
<div
|
||||||
class={cn(
|
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=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=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',
|
'ui:data-[side=right]:translate-x-[calc(50%+2px)] ui:data-[side=right]:translate-y-1/2',
|
||||||
|
|||||||
@@ -23,8 +23,9 @@
|
|||||||
* z-menu-elevated (250) Dropdown/select/popover content that must escape a z-app-bar,
|
* z-menu-elevated (250) Dropdown/select/popover content that must escape a z-app-bar,
|
||||||
* z-app-bar-elevated, or z-modal ancestor.
|
* z-app-bar-elevated, or z-modal ancestor.
|
||||||
*
|
*
|
||||||
* Anything not listed (tooltips, dropdowns, sheets, drawers) uses Tailwind's default `z-50`,
|
* Anything not listed (dropdowns, sheets, drawers) uses Tailwind's default `z-50`,
|
||||||
* which already sits above normal page content.
|
* 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 {
|
@utility z-app-bar {
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|||||||
Reference in New Issue
Block a user