feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -13,6 +13,12 @@ interface MaterialTab {
|
||||
}
|
||||
|
||||
export const tabs: MaterialTab[] = [
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.video.title',
|
||||
icon: VideoIcon,
|
||||
value: 3,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.note.title',
|
||||
icon: BookOpenIcon,
|
||||
@@ -25,12 +31,6 @@ export const tabs: MaterialTab[] = [
|
||||
value: 2,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.video.title',
|
||||
icon: VideoIcon,
|
||||
value: 3,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.document.title',
|
||||
icon: FileTextIcon,
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
import { lessonApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import type { Content } from '@cio/ui/custom/editor';
|
||||
import type { Content, TiptapEditor } from '@cio/ui/custom/editor';
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import AIButton from '$features/course/components/lesson/ai-button.svelte';
|
||||
import QuoteSelection from '$features/course/components/lesson/note/quote-selection.svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { saveDraft } from '$features/course/utils/lesson-draft';
|
||||
|
||||
@@ -20,6 +21,19 @@
|
||||
|
||||
let { mode = MODES.view, lessonId = '', isLoading, callAI = () => {} }: Props = $props();
|
||||
|
||||
let noteRoot: HTMLElement | undefined = $state();
|
||||
let editRoot: HTMLElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (mode !== MODES.edit) {
|
||||
editRoot = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
function bindEditorRoot(editor: TiptapEditor) {
|
||||
editRoot = editor.view.dom as HTMLElement;
|
||||
}
|
||||
|
||||
let hasAtLeastOneTranslation = $derived(
|
||||
Object.values(lessonApi.translations[lessonId] || {}).some((content) => {
|
||||
return content && !!content.length;
|
||||
@@ -52,15 +66,20 @@
|
||||
<TextEditor
|
||||
{content}
|
||||
onChange={(content) => onEditorChange(content)}
|
||||
onReady={bindEditorRoot}
|
||||
placeholder={$t('course.navItem.lessons.materials.tabs.note.placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<QuoteSelection root={editRoot} enabled />
|
||||
{:else}
|
||||
<!-- View Mode -->
|
||||
{#if !isHtmlValueEmpty(content)}
|
||||
<HTMLRender>
|
||||
<SafeHtmlContent {content} />
|
||||
</HTMLRender>
|
||||
<div class="relative mx-auto w-full max-w-2xl" bind:this={noteRoot}>
|
||||
<HTMLRender>
|
||||
<SafeHtmlContent {content} />
|
||||
</HTMLRender>
|
||||
<QuoteSelection root={noteRoot} enabled />
|
||||
</div>
|
||||
{:else if hasAtLeastOneTranslation}
|
||||
<p class="text-md py-2 font-normal italic dark:text-white">
|
||||
{$t('course.navItem.lessons.materials.no_translation')}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
import QuoteIcon from '@lucide/svelte/icons/quote';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { quoteInChat } from '$features/ai-assistant/utils/store';
|
||||
|
||||
interface Props {
|
||||
root: HTMLElement | undefined;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
let { root = undefined, enabled = false }: Props = $props();
|
||||
|
||||
let top = $state(0);
|
||||
let left = $state(0);
|
||||
let visible = $state(false);
|
||||
let picked = $state('');
|
||||
let dragging = false;
|
||||
let buttonEl: HTMLButtonElement | undefined = $state();
|
||||
|
||||
function reset() {
|
||||
visible = false;
|
||||
picked = '';
|
||||
}
|
||||
|
||||
function endRect(range: Range): DOMRect | null {
|
||||
const rects = range.getClientRects();
|
||||
|
||||
if (rects.length > 0) {
|
||||
return rects[rects.length - 1];
|
||||
}
|
||||
|
||||
const box = range.getBoundingClientRect();
|
||||
|
||||
return box.width > 0 || box.height > 0 ? box : null;
|
||||
}
|
||||
|
||||
function inRoot(rootEl: HTMLElement, node: Node | null): boolean {
|
||||
if (!node) return false;
|
||||
|
||||
const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as HTMLElement);
|
||||
|
||||
return el ? rootEl.contains(el) : false;
|
||||
}
|
||||
|
||||
/** Re-evaluate selection and either reposition / show the chip or hide it. */
|
||||
function evaluate() {
|
||||
if (!browser || !enabled || !root) {
|
||||
reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = window.getSelection();
|
||||
|
||||
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
|
||||
reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
|
||||
if (!inRoot(root, range.commonAncestorContainer) && !inRoot(root, sel.anchorNode) && !inRoot(root, sel.focusNode)) {
|
||||
reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = sel.toString();
|
||||
|
||||
if (!raw.trim()) {
|
||||
reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = endRect(range);
|
||||
|
||||
if (!rect) {
|
||||
reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
picked = raw;
|
||||
visible = true;
|
||||
|
||||
const btn = 28;
|
||||
const pad = 6;
|
||||
const gap = 4;
|
||||
const desiredLeft = rect.right + gap;
|
||||
const desiredTop = rect.top + (rect.height - btn) / 2;
|
||||
|
||||
left = Math.min(Math.max(pad, desiredLeft), window.innerWidth - btn - pad);
|
||||
top = Math.max(pad, Math.min(desiredTop, window.innerHeight - btn - pad));
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection-change handler: only used to *hide* the chip if the selection
|
||||
* collapses or moves out of the note. We never *show* the chip from here so
|
||||
* the chip can't appear (and thrash the DOM) mid-drag.
|
||||
*/
|
||||
function onSelectionChange() {
|
||||
if (!visible) return;
|
||||
|
||||
const sel = window.getSelection();
|
||||
|
||||
if (!sel || sel.isCollapsed || !sel.toString().trim()) {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
const target = e.target as Node | null;
|
||||
|
||||
if (buttonEl && target && buttonEl.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragging = true;
|
||||
|
||||
if (visible) reset();
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (!dragging) return;
|
||||
|
||||
dragging = false;
|
||||
evaluate();
|
||||
}
|
||||
|
||||
function onKeyUp(e: KeyboardEvent) {
|
||||
if (!e.shiftKey) return;
|
||||
|
||||
evaluate();
|
||||
}
|
||||
|
||||
function onScrollOrResize() {
|
||||
if (!visible) return;
|
||||
|
||||
evaluate();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('pointerup', onPointerUp, true);
|
||||
document.addEventListener('keyup', onKeyUp);
|
||||
document.addEventListener('selectionchange', onSelectionChange);
|
||||
window.addEventListener('resize', onScrollOrResize);
|
||||
document.addEventListener('scroll', onScrollOrResize, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('pointerup', onPointerUp, true);
|
||||
document.removeEventListener('keyup', onKeyUp);
|
||||
document.removeEventListener('selectionchange', onSelectionChange);
|
||||
window.removeEventListener('resize', onScrollOrResize);
|
||||
document.removeEventListener('scroll', onScrollOrResize, true);
|
||||
};
|
||||
});
|
||||
|
||||
function submit(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const text = picked;
|
||||
reset();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
quoteInChat(text);
|
||||
}
|
||||
|
||||
function portal(node: HTMLElement) {
|
||||
if (!browser) return;
|
||||
|
||||
document.body.appendChild(node);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
bind:this={buttonEl}
|
||||
use:portal
|
||||
title={t.get('ai_assistant.quote_lesson_note')}
|
||||
aria-label={t.get('ai_assistant.quote_lesson_note')}
|
||||
aria-hidden={!visible}
|
||||
tabindex={visible ? 0 : -1}
|
||||
onpointerdown={(e) => e.preventDefault()}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={submit}
|
||||
style:position="fixed"
|
||||
style:top="{top}px"
|
||||
style:left="{left}px"
|
||||
style:z-index="2147483000"
|
||||
style:opacity={visible ? '1' : '0'}
|
||||
style:visibility={visible ? 'visible' : 'hidden'}
|
||||
style:pointer-events={visible ? 'auto' : 'none'}
|
||||
class="ui:bg-secondary ui:text-secondary-foreground ui:border-border ui:hover:bg-accent inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border shadow-md transition-opacity"
|
||||
>
|
||||
<QuoteIcon size={14} />
|
||||
</button>
|
||||
+9
-3
@@ -24,11 +24,17 @@
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$lessonVideoUpload.isModalOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) onClose();
|
||||
onOpenChange={(details) => {
|
||||
if (!details.open && $lessonVideoUpload.isUploading) return;
|
||||
if (!details.open) onClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="flex max-h-[680px] max-w-2xl! flex-col overflow-hidden">
|
||||
<Dialog.Content
|
||||
class="flex max-h-[680px] max-w-2xl! flex-col overflow-hidden"
|
||||
onCloseAutoFocus={(e) => {
|
||||
if ($lessonVideoUpload.isUploading) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.video.add_video.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import type { AssetTranscriptPayload } from '$features/media/utils/types';
|
||||
|
||||
type SeekFn = (seconds: number) => void;
|
||||
|
||||
class LessonVideoBus {
|
||||
/** Asset id of the currently mounted upload-video player (if any). */
|
||||
assetId = $state<string | null>(null);
|
||||
/** Last reported playback time in seconds. */
|
||||
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. */
|
||||
transcript = $state<AssetTranscriptPayload | null>(null);
|
||||
/** True while the transcript fetch is in flight. */
|
||||
transcriptLoading = $state(false);
|
||||
|
||||
private seekFn: SeekFn = () => {};
|
||||
|
||||
setSeekFn(seekFn: SeekFn) {
|
||||
this.seekFn = seekFn;
|
||||
}
|
||||
|
||||
seek(seconds: number) {
|
||||
this.seekFn(seconds);
|
||||
}
|
||||
|
||||
/** Reset all per-lesson state (called when the upload asset changes or the player unmounts). */
|
||||
reset(opts?: { keepAssetId?: boolean }) {
|
||||
if (!opts?.keepAssetId) {
|
||||
this.assetId = null;
|
||||
}
|
||||
|
||||
this.currentTimeSeconds = 0;
|
||||
this.hasPlayed = false;
|
||||
this.transcript = null;
|
||||
this.transcriptLoading = false;
|
||||
this.seekFn = () => {};
|
||||
}
|
||||
}
|
||||
|
||||
export const lessonVideoBus = new LessonVideoBus();
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { MediaPlayer } from '@cio/ui/custom/media-player';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { jobsApi, JobPoller, type MediaJobEnvelope } from '$features/jobs';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { sidePanel } from '$features/side-panel';
|
||||
import type { LessonVideo } from './video-card-utils';
|
||||
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
||||
import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition';
|
||||
|
||||
interface Props {
|
||||
video: LessonVideo;
|
||||
}
|
||||
|
||||
let { video }: Props = $props();
|
||||
|
||||
// The parent keys this component on the upload assetId / link, so each
|
||||
// mount maps to a single asset; lifecycle is plain onMount/onDestroy.
|
||||
const uploadAssetId =
|
||||
video.type === 'upload' ? ((video as LessonVideo & { assetId?: string }).assetId ?? null) : null;
|
||||
|
||||
let isMounted = true;
|
||||
let vttRefetchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let jobPoller: JobPoller<MediaJobEnvelope> | null = null;
|
||||
let hasAutoOpenedTranscript = false;
|
||||
|
||||
function clearVttRefetchTimer() {
|
||||
if (vttRefetchTimer) {
|
||||
clearTimeout(vttRefetchTimer);
|
||||
vttRefetchTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleVttRefetch(expiresAtIso: string | undefined) {
|
||||
clearVttRefetchTimer();
|
||||
if (!expiresAtIso) return;
|
||||
|
||||
const deadlineMs = new Date(expiresAtIso).getTime() - 60_000;
|
||||
const delayMs = deadlineMs - Date.now();
|
||||
|
||||
if (delayMs <= 0) {
|
||||
void loadTranscript();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
vttRefetchTimer = setTimeout(() => {
|
||||
void loadTranscript();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function stopJobPoller() {
|
||||
jobPoller?.stop();
|
||||
jobPoller = null;
|
||||
}
|
||||
|
||||
async function loadTranscript(): Promise<void> {
|
||||
if (!uploadAssetId) {
|
||||
if (isMounted) {
|
||||
lessonVideoBus.transcript = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
lessonVideoBus.transcriptLoading = true;
|
||||
|
||||
try {
|
||||
const data = await mediaApi.getAssetTranscript(uploadAssetId);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
lessonVideoBus.transcript = data;
|
||||
scheduleVttRefetch(data?.vttUrlExpiresAt);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
lessonVideoBus.transcriptLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function watchJobsUntilTerminalThenReload(): Promise<void> {
|
||||
if (!uploadAssetId || lessonVideoBus.transcript || !isMounted) return;
|
||||
|
||||
stopJobPoller();
|
||||
|
||||
const runs = await jobsApi.getMediaJobsForAsset(uploadAssetId);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
const latest = runs?.[0];
|
||||
|
||||
if (!latest) return;
|
||||
|
||||
if (latest.job.status === 'completed') {
|
||||
void loadTranscript();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (latest.job.status === 'failed' || latest.job.status === 'canceled') {
|
||||
return;
|
||||
}
|
||||
|
||||
jobPoller = new JobPoller<MediaJobEnvelope>({
|
||||
fetch: () => jobsApi.getMediaJob(latest.job.id),
|
||||
onUpdate: (envelope) => {
|
||||
if (envelope.job.status === 'completed') {
|
||||
stopJobPoller();
|
||||
void loadTranscript();
|
||||
}
|
||||
|
||||
if (envelope.job.status === 'failed' || envelope.job.status === 'canceled') {
|
||||
stopJobPoller();
|
||||
}
|
||||
}
|
||||
});
|
||||
jobPoller.start();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
lessonVideoBus.reset();
|
||||
lessonVideoBus.assetId = uploadAssetId;
|
||||
|
||||
void (async () => {
|
||||
await loadTranscript();
|
||||
if (!isMounted) return;
|
||||
|
||||
await watchJobsUntilTerminalThenReload();
|
||||
})();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
isMounted = false;
|
||||
clearVttRefetchTimer();
|
||||
stopJobPoller();
|
||||
lessonVideoBus.reset();
|
||||
sidePanel.closeIfScope('lesson');
|
||||
});
|
||||
|
||||
const tracks = $derived(
|
||||
lessonVideoBus.transcript?.vttUrl
|
||||
? [
|
||||
{
|
||||
kind: 'captions' as const,
|
||||
src: lessonVideoBus.transcript.vttUrl,
|
||||
srclang: lessonVideoBus.transcript.language,
|
||||
label: t.get('course.navItem.lessons.materials.tabs.video.transcript.captions_label'),
|
||||
default: false
|
||||
}
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
// Plyr can't add a <track> after construction, so when the captions URL
|
||||
// appears asynchronously we remount the player once. Include a stable key when
|
||||
// a transcript exists but VTT is not ready yet so optional controls (e.g. transcript panel) attach.
|
||||
const playerRemountKey = $derived.by(() => {
|
||||
if (!lessonVideoBus.transcript) return 'no-transcript';
|
||||
|
||||
return lessonVideoBus.transcript.vttUrl ?? 'transcript-awaiting-vtt';
|
||||
});
|
||||
|
||||
function handleFirstPlay() {
|
||||
lessonVideoBus.hasPlayed = true;
|
||||
|
||||
if (hasAutoOpenedTranscript) return;
|
||||
if (!lessonVideoBus.transcript?.segments?.length) return;
|
||||
|
||||
hasAutoOpenedTranscript = true;
|
||||
sidePanel.open(TRANSCRIPT_PANEL_ID);
|
||||
}
|
||||
|
||||
const transcriptPanelControl = $derived(
|
||||
lessonVideoBus.transcript
|
||||
? {
|
||||
label: t.get('course.navItem.lessons.materials.tabs.video.transcript.open_side_panel'),
|
||||
onClick: () => {
|
||||
sidePanel.open(TRANSCRIPT_PANEL_ID);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
{#key playerRemountKey}
|
||||
<MediaPlayer
|
||||
source={{
|
||||
type: video.type,
|
||||
url: video.link,
|
||||
metadata: video.metadata as { thumbnailUrl?: string; title?: string } | undefined,
|
||||
tracks
|
||||
}}
|
||||
options={{
|
||||
maxHeight: '569px',
|
||||
width: '100%',
|
||||
controls: true,
|
||||
playsinline: true,
|
||||
onTimeUpdate: (seconds) => {
|
||||
lessonVideoBus.currentTimeSeconds = seconds;
|
||||
},
|
||||
onPlayerReady: (player) => {
|
||||
lessonVideoBus.setSeekFn((seconds) => {
|
||||
player.currentTime = seconds;
|
||||
});
|
||||
},
|
||||
onFirstPlay: handleFirstPlay,
|
||||
transcriptPanelControl
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { Image } from '$features/ui';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import VideoCardDropdown from './video-card-dropdown.svelte';
|
||||
import {
|
||||
getVideoThumbnailUrl,
|
||||
getVideoTitle,
|
||||
getVideoDurationSeconds,
|
||||
formatVideoDuration,
|
||||
getVideoCreatedAt,
|
||||
formatVideoCreatedAt,
|
||||
type LessonVideo
|
||||
} from './video-card-utils';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
video: LessonVideo;
|
||||
index: number;
|
||||
isEditMode: boolean;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
let { video, index, isEditMode, onRemove }: Props = $props();
|
||||
|
||||
const thumbnailUrl = $derived(getVideoThumbnailUrl(video));
|
||||
const title = $derived(getVideoTitle(video, index));
|
||||
const durationSeconds = $derived(getVideoDurationSeconds(video));
|
||||
const durationFormatted = $derived(formatVideoDuration(durationSeconds ?? undefined));
|
||||
const createdAtIso = $derived(getVideoCreatedAt(video));
|
||||
const createdAtFormatted = $derived(formatVideoCreatedAt(createdAtIso ?? undefined));
|
||||
|
||||
const isYoutubeWithLink = $derived(video.type === 'youtube' && !!video.link);
|
||||
|
||||
const courseLogoSrc = $derived.by(() => {
|
||||
const raw = courseApi.course?.logo;
|
||||
|
||||
if (typeof raw === 'string' && raw.trim().length > 0) {
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const channelLine = $derived.by(() => {
|
||||
const courseTitle = courseApi.course?.title?.trim();
|
||||
|
||||
if (courseTitle) {
|
||||
return courseTitle;
|
||||
}
|
||||
|
||||
return t.get('course.navItem.lessons.materials.tabs.video.simple_card.course_fallback');
|
||||
});
|
||||
|
||||
const sourceKindLabel = $derived.by(() => {
|
||||
const key =
|
||||
video.type === 'youtube'
|
||||
? 'kind_youtube'
|
||||
: video.type === 'upload'
|
||||
? 'kind_upload'
|
||||
: video.type === 'google_drive'
|
||||
? 'kind_google_drive'
|
||||
: 'kind_generic';
|
||||
|
||||
return t.get(`course.navItem.lessons.materials.tabs.video.simple_card.${key}`);
|
||||
});
|
||||
|
||||
const metaLine = $derived.by(() => {
|
||||
if (createdAtFormatted) {
|
||||
return `${sourceKindLabel} · ${createdAtFormatted}`;
|
||||
}
|
||||
|
||||
return sourceKindLabel;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- YouTube-like: thumbnail on top; avatar + stacked text + overflow menu -->
|
||||
<div class="group w-full max-w-full min-w-0 {isEditMode ? 'rounded-lg border' : ''}">
|
||||
<div class="ui:bg-muted relative aspect-video w-full min-w-0 overflow-hidden rounded-xl">
|
||||
{#if thumbnailUrl}
|
||||
<Image src={thumbnailUrl} alt={title} className="absolute inset-0 block h-full w-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-full min-h-0 w-full flex-col items-center justify-center gap-2 px-3 py-4"
|
||||
role="img"
|
||||
aria-label={title}
|
||||
>
|
||||
<VideoIcon class="ui:text-muted-foreground size-12 shrink-0" />
|
||||
<span class="ui:text-muted-foreground line-clamp-2 max-w-full text-center text-xs font-medium">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if durationFormatted}
|
||||
<div class="absolute right-2 bottom-2 rounded bg-black/80 px-1.5 py-0.5 text-xs font-medium text-white">
|
||||
{durationFormatted}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex min-w-0 gap-3 px-3 py-3">
|
||||
<Avatar.Root class="mt-0.5 size-9 shrink-0">
|
||||
{#if courseLogoSrc}
|
||||
<Avatar.Image src={courseLogoSrc} alt={channelLine} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<VideoIcon class="ui:text-muted-foreground size-4" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-1">
|
||||
<div class="ui:text-foreground min-w-0 flex-1 text-base leading-snug font-semibold" {title}>
|
||||
{#if isYoutubeWithLink}
|
||||
<HoverableItem class="block min-w-0">
|
||||
{#snippet children(isHovered)}
|
||||
<a
|
||||
href={video.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:ui:underline focus:ui:underline focus:ui:outline-none flex min-w-0 items-start gap-1.5"
|
||||
>
|
||||
<span class="line-clamp-2 min-w-0 flex-1 break-words">{title}</span>
|
||||
<ExternalLinkIcon
|
||||
{isHovered}
|
||||
size={14}
|
||||
class="ui:text-muted-foreground mt-0.5 shrink-0"
|
||||
ariaHidden={true}
|
||||
/>
|
||||
</a>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{:else}
|
||||
<p class="line-clamp-2 min-w-0 break-words">{title}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isEditMode}
|
||||
<VideoCardDropdown {video} {onRemove} menuPlacement="inline" />
|
||||
{/if}
|
||||
</div>
|
||||
<p class="ui:text-muted-foreground mt-0.5 line-clamp-1 text-sm leading-snug">
|
||||
{channelLine}
|
||||
</p>
|
||||
<p class="ui:text-muted-foreground mt-0.5 line-clamp-1 text-sm leading-snug">{metaLine}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
import type { SidePanelDefinition } from '$features/side-panel';
|
||||
import TranscriptSidePanel from './transcript-side-panel.svelte';
|
||||
|
||||
export const TRANSCRIPT_PANEL_ID = 'transcript';
|
||||
|
||||
export const transcriptPanelDefinition: SidePanelDefinition = {
|
||||
id: TRANSCRIPT_PANEL_ID,
|
||||
titleKey: 'course.navItem.lessons.materials.tabs.video.transcript.title',
|
||||
scope: 'lesson',
|
||||
component: TranscriptSidePanel as Component<Record<string, unknown>>,
|
||||
defaultWidth: 380,
|
||||
minWidth: 320,
|
||||
maxWidth: 560,
|
||||
widthStorageKey: 'transcript-panel-width'
|
||||
};
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { AssetTranscriptPayload } from '$features/media/utils/types';
|
||||
|
||||
type Segment = AssetTranscriptPayload['segments'][number];
|
||||
|
||||
interface Props {
|
||||
segments: Segment[];
|
||||
currentTimeSeconds: number;
|
||||
language: string;
|
||||
onSeek: (seconds: number) => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { segments, currentTimeSeconds, language, onSeek, class: className = '' }: Props = $props();
|
||||
|
||||
const transcriptTitleKey = 'course.navItem.lessons.materials.tabs.video.transcript.title';
|
||||
const transcriptLanguageKey = 'course.navItem.lessons.materials.tabs.video.transcript.language';
|
||||
|
||||
function isActiveSegment(segment: Segment, index: number): boolean {
|
||||
const isLast = index === segments.length - 1;
|
||||
|
||||
if (isLast) {
|
||||
return currentTimeSeconds >= segment.start && currentTimeSeconds <= segment.end;
|
||||
}
|
||||
|
||||
return currentTimeSeconds >= segment.start && currentTimeSeconds < segment.end;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent, start: number) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
|
||||
event.preventDefault();
|
||||
onSeek(start);
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="ui:border-border flex min-h-[200px] flex-col rounded-md border p-3 {className}"
|
||||
aria-label={t.get(transcriptTitleKey)}
|
||||
>
|
||||
<div class="ui:text-muted-foreground mb-2 shrink-0 text-xs font-medium">
|
||||
{t.get(transcriptLanguageKey, { language })}
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{#each segments as segment, index (segment.start + segment.end + index)}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:text-foreground hover:ui:bg-muted/80 focus:ui:outline-none focus-visible:ui:ring-2 focus-visible:ui:ring-ring mb-1 w-full rounded-md px-2 py-1.5 text-left text-sm leading-snug transition-colors last:mb-0 {isActiveSegment(
|
||||
segment,
|
||||
index
|
||||
)
|
||||
? 'ui:bg-primary/10 ui:font-medium'
|
||||
: ''}"
|
||||
data-active={isActiveSegment(segment, index) ? 'true' : undefined}
|
||||
onclick={() => onSeek(segment.start)}
|
||||
onkeydown={(e) => handleKeydown(e, segment.start)}
|
||||
>
|
||||
<span class="ui:text-muted-foreground mr-1.5 text-xs tabular-nums">
|
||||
{Math.floor(segment.start / 60)}:{Math.floor(segment.start % 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}
|
||||
</span>
|
||||
{segment.text}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import CaptionsIcon from '@lucide/svelte/icons/captions';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { sidePanel } from '$features/side-panel';
|
||||
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
||||
import type { AssetTranscriptPayload } from '$features/media/utils/types';
|
||||
|
||||
type Segment = AssetTranscriptPayload['segments'][number];
|
||||
|
||||
const transcript = $derived(lessonVideoBus.transcript);
|
||||
const transcriptLoading = $derived(lessonVideoBus.transcriptLoading);
|
||||
const segments = $derived<Segment[]>(transcript?.segments ?? []);
|
||||
const language = $derived(transcript?.language ?? '');
|
||||
const currentTimeSeconds = $derived(lessonVideoBus.currentTimeSeconds);
|
||||
|
||||
function isActiveSegment(segment: Segment, index: number): boolean {
|
||||
const isLast = index === segments.length - 1;
|
||||
|
||||
if (isLast) {
|
||||
return currentTimeSeconds >= segment.start && currentTimeSeconds <= segment.end;
|
||||
}
|
||||
|
||||
return currentTimeSeconds >= segment.start && currentTimeSeconds < segment.end;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent, start: number) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
|
||||
event.preventDefault();
|
||||
lessonVideoBus.seek(start);
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<header class="border-border flex items-center justify-between gap-2 border-b px-4 py-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<CaptionsIcon size={18} class="ui:text-primary shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.transcript.title')}
|
||||
</h2>
|
||||
{#if language}
|
||||
<p class="ui:text-muted-foreground truncate text-xs">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.transcript.language', { language })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<IconButton
|
||||
onclick={() => sidePanel.close()}
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
tooltip={$t('side_panel.close_aria')}
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{#if segments.length}
|
||||
{#each segments as segment, index (segment.start + segment.end + index)}
|
||||
{@const active = isActiveSegment(segment, index)}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:text-foreground hover:ui:bg-muted/80 focus-visible:ui:ring-ring mb-1 w-full rounded-md px-2 py-1.5 text-left text-sm leading-snug transition-colors last:mb-0 focus:outline-none focus-visible:ring-2 {active
|
||||
? 'ui:bg-primary/10 ui:font-medium'
|
||||
: ''}"
|
||||
data-active={active ? 'true' : undefined}
|
||||
onclick={() => lessonVideoBus.seek(segment.start)}
|
||||
onkeydown={(e) => handleKeydown(e, segment.start)}
|
||||
>
|
||||
<span class="ui:text-muted-foreground mr-1.5 text-xs tabular-nums">
|
||||
{formatTimestamp(segment.start)}
|
||||
</span>
|
||||
{segment.text}
|
||||
</button>
|
||||
{/each}
|
||||
{:else if transcriptLoading}
|
||||
<p class="ui:text-muted-foreground py-8 text-center text-sm">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.transcript.loading')}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="ui:text-muted-foreground py-8 text-center text-sm">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.transcript.empty')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,8 +10,11 @@
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { JobPoller, jobsApi, type MediaJobEnvelope } from '$features/jobs';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
const ADD_VIDEO = 'course.navItem.lessons.materials.tabs.video.add_video';
|
||||
const PROCESSING_BASE = `${ADD_VIDEO}.processing`;
|
||||
|
||||
interface Props {
|
||||
lessonId?: string;
|
||||
@@ -31,12 +34,64 @@
|
||||
} | null = $state(null);
|
||||
let isLoaded = $state(false);
|
||||
let fileName = $state('');
|
||||
let processingJob: MediaJobEnvelope['job'] | null = $state(null);
|
||||
let activePoller: JobPoller<MediaJobEnvelope> | null = null;
|
||||
|
||||
const isDisabled = $derived($lessonVideoUpload.isUploading || $isFreePlan);
|
||||
|
||||
const videoUploader = new VideoUploader();
|
||||
const mediaUploader = new MediaUploader();
|
||||
|
||||
function processingLabel(stage: string | null, status: string): string | null {
|
||||
if (status === 'failed') return t.get(`${PROCESSING_BASE}.failed`);
|
||||
if (status === 'canceled') return t.get(`${PROCESSING_BASE}.canceled`);
|
||||
if (status === 'completed') return t.get(`${PROCESSING_BASE}.completed`);
|
||||
|
||||
switch (stage) {
|
||||
case 'probing':
|
||||
return t.get(`${PROCESSING_BASE}.probing`);
|
||||
case 'thumbnailing':
|
||||
return t.get(`${PROCESSING_BASE}.thumbnailing`);
|
||||
case 'extracting-audio':
|
||||
return t.get(`${PROCESSING_BASE}.extracting_audio`);
|
||||
case 'transcribing':
|
||||
return t.get(`${PROCESSING_BASE}.transcribing`);
|
||||
case 'compressing':
|
||||
return t.get(`${PROCESSING_BASE}.compressing`);
|
||||
default:
|
||||
return t.get(`${PROCESSING_BASE}.queued`);
|
||||
}
|
||||
}
|
||||
|
||||
function startProcessingPoll(assetId: string) {
|
||||
activePoller?.stop();
|
||||
activePoller = null;
|
||||
processingJob = null;
|
||||
|
||||
void (async () => {
|
||||
const runs = await jobsApi.getMediaJobsForAsset(assetId);
|
||||
const latest = runs?.[0];
|
||||
if (!latest) return;
|
||||
|
||||
processingJob = latest.job;
|
||||
if (latest.job.status === 'completed' || latest.job.status === 'failed' || latest.job.status === 'canceled') {
|
||||
return;
|
||||
}
|
||||
|
||||
activePoller = new JobPoller<MediaJobEnvelope>({
|
||||
fetch: () => jobsApi.getMediaJob(latest.job.id),
|
||||
onUpdate: (envelope) => {
|
||||
processingJob = envelope.job;
|
||||
}
|
||||
});
|
||||
activePoller.start();
|
||||
})();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
activePoller?.stop();
|
||||
});
|
||||
|
||||
async function handleUpload(files: File[]) {
|
||||
const videoFile = files[0];
|
||||
if (!videoFile) return;
|
||||
@@ -149,6 +204,10 @@
|
||||
{ append: true }
|
||||
);
|
||||
|
||||
if (assetId) {
|
||||
startProcessingPoll(assetId);
|
||||
}
|
||||
|
||||
isLoaded = false;
|
||||
} catch (err: any) {
|
||||
console.error('Error uploading video', err, '\n\n', err.response);
|
||||
@@ -217,13 +276,13 @@
|
||||
<div
|
||||
class="border-primary-300 flex h-full w-full flex-col items-center justify-center rounded-xl border border-dashed"
|
||||
>
|
||||
<div class="flex w-[60%] max-w-[500px] flex-col justify-center gap-5">
|
||||
<p class="mt-5 text-center">
|
||||
<div class="flex w-full max-w-[500px] flex-col items-center gap-5 px-6 text-center">
|
||||
<p>
|
||||
{$t(`${ADD_VIDEO}.uploading`)}
|
||||
</p>
|
||||
<Progress class="w-full" value={$lessonVideoUpload.uploadProgress} max={100} />
|
||||
<p class="text-sm">{helperText}</p>
|
||||
<div class="mt-3">
|
||||
<div class="mt-3 flex justify-center">
|
||||
<Button variant="outline" onclick={cancelUpload}>
|
||||
{$t(`${ADD_VIDEO}.cancel_upload`)}
|
||||
</Button>
|
||||
@@ -236,7 +295,7 @@
|
||||
accept="video/*"
|
||||
maxFiles={1}
|
||||
fileCount={0}
|
||||
maxFileSize={500 * FileDropZone.MEGABYTE}
|
||||
maxFileSize={800 * FileDropZone.MEGABYTE}
|
||||
onUpload={handleUpload}
|
||||
{onFileRejected}
|
||||
>
|
||||
@@ -299,6 +358,15 @@
|
||||
</video>
|
||||
{/if}
|
||||
<p>{fileName}</p>
|
||||
{#if processingJob}
|
||||
{@const label = processingLabel(processingJob.stage, processingJob.status)}
|
||||
{#if label}
|
||||
<p class="ui:text-muted-foreground text-xs">{label}</p>
|
||||
{#if processingJob.status !== 'completed' && processingJob.status !== 'failed' && processingJob.status !== 'canceled'}
|
||||
<Progress class="w-[200px]" value={processingJob.progressPercent ?? 0} max={100} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<Button onclick={() => ($lessonVideoUpload.isModalOpen = false)}>
|
||||
{$t('course.navItem.lessons.materials.button_done')}
|
||||
|
||||
+97
-3
@@ -2,25 +2,119 @@
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { JobPoller, jobsApi, type MediaJobEnvelope } from '$features/jobs';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { LessonVideo } from './video-card-utils';
|
||||
|
||||
interface Props {
|
||||
video: LessonVideo;
|
||||
onRemove: () => void;
|
||||
/** `inline` aligns with the title row (YouTube-style). `corner` is top-right overlay on the nearest `relative` ancestor. */
|
||||
menuPlacement?: 'inline' | 'corner';
|
||||
}
|
||||
|
||||
let { onRemove }: Props = $props();
|
||||
let { video, onRemove, menuPlacement = 'inline' }: Props = $props();
|
||||
|
||||
let isTranscribing = $state(false);
|
||||
let activePoller: JobPoller<MediaJobEnvelope> | null = null;
|
||||
|
||||
const assetId = $derived((video as LessonVideo & { assetId?: string }).assetId ?? null);
|
||||
|
||||
const canGenerateTranscript = $derived(video.type === 'upload' && !!assetId);
|
||||
|
||||
onDestroy(() => {
|
||||
activePoller?.stop();
|
||||
});
|
||||
|
||||
async function startTranscriptionPoll(afterAssetId: string) {
|
||||
activePoller?.stop();
|
||||
activePoller = null;
|
||||
|
||||
const runs = await jobsApi.getMediaJobsForAsset(afterAssetId);
|
||||
const latest = runs?.[0];
|
||||
|
||||
if (!latest) {
|
||||
isTranscribing = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (latest.job.status === 'completed') {
|
||||
isTranscribing = false;
|
||||
snackbar.success('snackbar.media_manager.transcription_completed');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (latest.job.status === 'failed' || latest.job.status === 'canceled') {
|
||||
isTranscribing = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
activePoller = new JobPoller<MediaJobEnvelope>({
|
||||
fetch: () => jobsApi.getMediaJob(latest.job.id),
|
||||
onUpdate: (envelope) => {
|
||||
if (envelope.job.status === 'completed') {
|
||||
activePoller?.stop();
|
||||
activePoller = null;
|
||||
isTranscribing = false;
|
||||
snackbar.success('snackbar.media_manager.transcription_completed');
|
||||
}
|
||||
|
||||
if (envelope.job.status === 'failed' || envelope.job.status === 'canceled') {
|
||||
activePoller?.stop();
|
||||
activePoller = null;
|
||||
isTranscribing = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
activePoller.start();
|
||||
}
|
||||
|
||||
async function handleGenerateTranscript() {
|
||||
if (!assetId) return;
|
||||
|
||||
const ok = await mediaApi.generateTranscript(assetId);
|
||||
if (!ok) return;
|
||||
|
||||
isTranscribing = true;
|
||||
await startTranscriptionPoll(assetId);
|
||||
}
|
||||
|
||||
const generateLabel = $derived(
|
||||
isTranscribing
|
||||
? t.get('course.navItem.lessons.materials.tabs.video.generate_transcript_in_progress')
|
||||
: t.get('course.navItem.lessons.materials.tabs.video.generate_transcript')
|
||||
);
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="ui:data-[state=open]:opacity-100 absolute top-2 right-2 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
|
||||
class={menuPlacement === 'corner'
|
||||
? 'ui:data-[state=open]:opacity-100 absolute top-2 right-2 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100'
|
||||
: 'ui:data-[state=open]:opacity-100 -mt-0.5 -mr-1 flex shrink-0 items-center justify-center opacity-100'}
|
||||
aria-label={$t('course.navItem.lessons.materials.tabs.video.simple_card.menu_aria')}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconButton variant="outline">
|
||||
<IconButton variant="ghost">
|
||||
<EllipsisVerticalIcon size={16} />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if canGenerateTranscript}
|
||||
<DropdownMenu.Item
|
||||
disabled={isTranscribing}
|
||||
onclick={() => {
|
||||
void handleGenerateTranscript();
|
||||
}}
|
||||
>
|
||||
{generateLabel}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<DropdownMenu.Item class="ui:text-red-600" onclick={onRemove}>
|
||||
{$t('course.navItem.lessons.materials.tabs.video.remove_video')}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Image } from '$features/ui';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
|
||||
import VideoCardDropdown from './video-card-dropdown.svelte';
|
||||
import {
|
||||
getVideoThumbnailUrl,
|
||||
getVideoTitle,
|
||||
getVideoDurationSeconds,
|
||||
formatVideoDuration,
|
||||
getVideoCreatedAt,
|
||||
formatVideoCreatedAt,
|
||||
type LessonVideo
|
||||
} from './video-card-utils';
|
||||
|
||||
interface Props {
|
||||
video: LessonVideo;
|
||||
index: number;
|
||||
isEditMode: boolean;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
let { video, index, isEditMode, onRemove }: Props = $props();
|
||||
|
||||
const thumbnailUrl = $derived(getVideoThumbnailUrl(video));
|
||||
const title = $derived(getVideoTitle(video, index));
|
||||
const durationSeconds = $derived(getVideoDurationSeconds(video));
|
||||
const durationFormatted = $derived(formatVideoDuration(durationSeconds ?? undefined));
|
||||
const createdAtIso = $derived(getVideoCreatedAt(video));
|
||||
const createdAtFormatted = $derived(formatVideoCreatedAt(createdAtIso ?? undefined));
|
||||
|
||||
const isYoutubeWithLink = $derived(video.type === 'youtube' && !!video.link);
|
||||
</script>
|
||||
|
||||
<Item.Root variant="outline" class="group relative max-w-[320px] cursor-default! p-3!">
|
||||
<div class="flex flex-col">
|
||||
{#if isEditMode}
|
||||
<VideoCardDropdown {onRemove} />
|
||||
{/if}
|
||||
|
||||
<Item.Header>
|
||||
<Item.Media variant="image" class="relative h-50! w-full! overflow-hidden rounded-sm">
|
||||
{#if thumbnailUrl}
|
||||
<Image src={thumbnailUrl} alt={title} className="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
class="ui:bg-muted flex h-full w-full flex-col items-center justify-center gap-2 px-3 py-4"
|
||||
role="img"
|
||||
aria-label={title}
|
||||
>
|
||||
<VideoIcon class="ui:text-muted-foreground size-10 shrink-0" />
|
||||
<span class="ui:text-muted-foreground line-clamp-2 max-w-full text-center text-xs font-medium">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if durationFormatted}
|
||||
<div class="absolute right-2 bottom-2 rounded bg-black/70 px-1.5 py-0.5 text-xs font-medium text-white">
|
||||
{durationFormatted}
|
||||
</div>
|
||||
{/if}
|
||||
</Item.Media>
|
||||
</Item.Header>
|
||||
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-2 min-h-[44px] text-base!" {title}>
|
||||
{#if isYoutubeWithLink}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
<a
|
||||
href={video.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:text-foreground hover:ui:underline focus:ui:underline focus:ui:outline-none inline-flex items-center gap-1.5"
|
||||
>
|
||||
<span class="line-clamp-2">{title}</span>
|
||||
<ExternalLinkIcon {isHovered} size={14} class="ui:text-muted-foreground shrink-0" ariaHidden={true} />
|
||||
</a>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{:else}
|
||||
{title}
|
||||
{/if}
|
||||
</Item.Title>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">
|
||||
{#if createdAtFormatted && durationFormatted}
|
||||
{createdAtFormatted} · {durationFormatted}
|
||||
{:else if createdAtFormatted}
|
||||
{createdAtFormatted}
|
||||
{:else if durationFormatted}
|
||||
{durationFormatted}
|
||||
{:else}
|
||||
–
|
||||
{/if}
|
||||
</p>
|
||||
</Item.Content>
|
||||
</div>
|
||||
</Item.Root>
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { lessonApi } from '$features/course/api';
|
||||
import { MediaPlayer } from '@cio/ui/custom/media-player';
|
||||
import { DeleteModal } from '$features/ui';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
@@ -9,7 +8,8 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import VideoCard from './video-card.svelte';
|
||||
import LessonVideoSimpleCard from './lesson-video-simple-card.svelte';
|
||||
import LessonVideoPlayer from './lesson-video-player.svelte';
|
||||
|
||||
interface Props {
|
||||
mode?: (typeof MODES)[keyof typeof MODES];
|
||||
@@ -41,20 +41,8 @@
|
||||
</script>
|
||||
|
||||
{#snippet content(video)}
|
||||
{#key video.link}
|
||||
<MediaPlayer
|
||||
source={{
|
||||
type: video.type,
|
||||
url: video.link,
|
||||
metadata: video.metadata
|
||||
}}
|
||||
options={{
|
||||
maxHeight: '569px',
|
||||
width: '100%',
|
||||
controls: true,
|
||||
playsinline: true
|
||||
}}
|
||||
/>
|
||||
{#key video.type === 'upload' ? ((video as typeof video & { assetId?: string }).assetId ?? video.link) : video.link}
|
||||
<LessonVideoPlayer {video} />
|
||||
{/key}
|
||||
{/snippet}
|
||||
|
||||
@@ -65,9 +53,9 @@
|
||||
</Button>
|
||||
|
||||
{#if videos.length}
|
||||
<Item.Group class="grid! w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Item.Group class="grid! w-full grid-cols-1 gap-x-5 gap-y-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each videos as video, index}
|
||||
<VideoCard {video} {index} isEditMode={true} onRemove={() => requestRemoveVideo(index)} />
|
||||
<LessonVideoSimpleCard {video} {index} isEditMode={true} onRemove={() => requestRemoveVideo(index)} />
|
||||
{/each}
|
||||
</Item.Group>
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user