fix: enable adding video from library to another lesson
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
import { classroomio } from '$lib/utils/services/api';
|
import { classroomio } from '$lib/utils/services/api';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { getBrowserTimezone, instantToZonedWallClock, zonedWallClockToInstant } from '$lib/utils/functions/date';
|
import { getBrowserTimezone, instantToZonedWallClock, zonedWallClockToInstant } from '$lib/utils/functions/date';
|
||||||
import { getVideoTitle, isHlsUploadVideo, type LessonVideo } from './video/video-card-utils';
|
import { getVideoTitle, type LessonVideo } from './video/video-card-utils';
|
||||||
|
|
||||||
type LessonCompletionPolicy = 'manual' | 'video_watch' | 'none';
|
type LessonCompletionPolicy = 'manual' | 'video_watch' | 'none';
|
||||||
|
|
||||||
@@ -328,9 +328,6 @@
|
|||||||
/>
|
/>
|
||||||
<Field.Label for={`watch-enforced-${video.assetId}`} class="font-normal">
|
<Field.Label for={`watch-enforced-${video.assetId}`} class="font-normal">
|
||||||
{getVideoTitle(video, index)}
|
{getVideoTitle(video, index)}
|
||||||
{#if isHlsUploadVideo(video)}
|
|
||||||
<span class="ui:text-muted-foreground ml-1 text-xs">(HLS)</span>
|
|
||||||
{/if}
|
|
||||||
</Field.Label>
|
</Field.Label>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
+46
-22
@@ -1,10 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
|
import { Spinner } from '@cio/ui/base/spinner';
|
||||||
|
import { Empty } from '@cio/ui/custom/empty';
|
||||||
import { InputField } from '@cio/ui/custom/input-field';
|
import { InputField } from '@cio/ui/custom/input-field';
|
||||||
import { lessonApi } from '$features/course/api';
|
import { lessonApi } from '$features/course/api';
|
||||||
import { mediaApi } from '$features/media/api';
|
import { mediaApi } from '$features/media/api';
|
||||||
import type { OrganizationAsset } from '$features/media/utils';
|
import type { OrganizationAsset } from '$features/media/utils';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,10 +20,10 @@
|
|||||||
|
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
let addingAssetId = $state<string | null>(null);
|
||||||
const assets = $derived(
|
// Local copy so adding a video swipes it out of this list without mutating the
|
||||||
mediaApi.assets.filter((asset) => asset.kind === 'video' && (asset.status ?? 'active') !== 'archived')
|
// shared mediaApi.assets store (which also backs the media manager page).
|
||||||
);
|
let assets = $state<OrganizationAsset[]>([]);
|
||||||
|
|
||||||
async function loadLibrary() {
|
async function loadLibrary() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -27,24 +32,32 @@
|
|||||||
limit: 100,
|
limit: 100,
|
||||||
search: search.trim() || undefined
|
search: search.trim() || undefined
|
||||||
});
|
});
|
||||||
|
assets = mediaApi.assets.filter((asset) => asset.kind === 'video' && (asset.status ?? 'active') !== 'archived');
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addFromLibrary(asset: OrganizationAsset) {
|
async function addFromLibrary(asset: OrganizationAsset) {
|
||||||
if (!lessonApi.lesson) return;
|
if (!lessonApi.lesson) return;
|
||||||
|
|
||||||
const position = Array.isArray(lessonApi.lesson.videos) ? lessonApi.lesson.videos.length : 0;
|
addingAssetId = asset.id;
|
||||||
const lessonVideo = await mediaApi.buildLessonVideoFromAsset(asset, {
|
try {
|
||||||
lessonId: lessonId || undefined,
|
const position = Array.isArray(lessonApi.lesson.videos) ? lessonApi.lesson.videos.length : 0;
|
||||||
position,
|
const lessonVideo = await mediaApi.buildLessonVideoFromAsset(asset, {
|
||||||
slotType: 'lesson_video'
|
lessonId: lessonId || undefined,
|
||||||
});
|
position,
|
||||||
|
slotType: 'lesson_video'
|
||||||
|
});
|
||||||
|
|
||||||
if (!lessonVideo) {
|
if (!lessonVideo) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lessonApi.updateLessonState('videos', [lessonVideo], { append: true });
|
||||||
|
assets = assets.filter((item) => item.id !== asset.id);
|
||||||
|
snackbar.success('snackbar.media_manager.added_to_lesson');
|
||||||
|
} finally {
|
||||||
|
addingAssetId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
lessonApi.updateLessonState('videos', [lessonVideo], { append: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -66,22 +79,33 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<p class="ui:text-muted-foreground text-sm">
|
<Empty
|
||||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.loading_library')}
|
description={$t('course.navItem.lessons.materials.tabs.video.add_video.loading_library')}
|
||||||
</p>
|
icon={Spinner}
|
||||||
|
class="ui:max-h-40 py-6"
|
||||||
|
/>
|
||||||
{:else if assets.length === 0}
|
{:else if assets.length === 0}
|
||||||
<p class="ui:text-muted-foreground text-sm">
|
<p class="ui:text-muted-foreground text-sm">
|
||||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.no_library_videos')}
|
{$t('course.navItem.lessons.materials.tabs.video.add_video.no_library_videos')}
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="max-h-[360px] space-y-2 overflow-auto pr-1">
|
<div class="max-h-[360px] space-y-2 overflow-x-hidden">
|
||||||
{#each assets as asset (asset.id)}
|
{#each assets as asset (asset.id)}
|
||||||
<div class="flex items-center justify-between rounded-md border p-3">
|
<div
|
||||||
<div class="min-w-0">
|
class="flex items-center justify-between rounded-md border p-3"
|
||||||
<p class="truncate text-sm font-medium">{asset.title || asset.storageKey || asset.id}</p>
|
animate:flip={{ duration: 250 }}
|
||||||
|
out:fly={{ x: -400, duration: 300 }}
|
||||||
|
>
|
||||||
|
<div class="max-w-md">
|
||||||
|
<p class="truncate text-sm font-medium wrap-break-word">{asset.title || asset.storageKey || asset.id}</p>
|
||||||
<p class="ui:text-muted-foreground text-xs">{asset.provider}</p>
|
<p class="ui:text-muted-foreground text-xs">{asset.provider}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onclick={() => addFromLibrary(asset)}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onclick={() => addFromLibrary(asset)}
|
||||||
|
loading={addingAssetId === asset.id}
|
||||||
|
disabled={addingAssetId !== null}
|
||||||
|
>
|
||||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_from_library')}
|
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_from_library')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
ZAssetUpdate,
|
ZAssetUpdate,
|
||||||
ZYouTubeMetadataQuery
|
ZYouTubeMetadataQuery
|
||||||
} from '@cio/utils/validation/assets';
|
} from '@cio/utils/validation/assets';
|
||||||
import { mapAssetToLessonVideo } from '../utils/media-manager-utils';
|
import { getAssetHlsManifestLink, isHlsAsset, mapAssetToLessonVideo } from '../utils/media-manager-utils';
|
||||||
|
|
||||||
import type { AttachAssetRequest } from '../utils/types';
|
import type { AttachAssetRequest } from '../utils/types';
|
||||||
import type { DetachAssetRequest } from '../utils/types';
|
import type { DetachAssetRequest } from '../utils/types';
|
||||||
@@ -407,7 +407,9 @@ export class MediaApi extends BaseApiWithErrors {
|
|||||||
asset: OrganizationAsset,
|
asset: OrganizationAsset,
|
||||||
params: { lessonId?: string; position?: number; slotType?: string } = {}
|
params: { lessonId?: string; position?: number; slotType?: string } = {}
|
||||||
) {
|
) {
|
||||||
const url = await this.getVideoPlaybackUrl(asset);
|
// HLS assets have no presignable storageKey — the manifest is served via the
|
||||||
|
// /hls proxy + signed cookie, so the link is the relative manifest path.
|
||||||
|
const url = isHlsAsset(asset) ? getAssetHlsManifestLink(asset) : await this.getVideoPlaybackUrl(asset);
|
||||||
if (!url) {
|
if (!url) {
|
||||||
snackbar.error('snackbar.media_manager.resolve_video_failed');
|
snackbar.error('snackbar.media_manager.resolve_video_failed');
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ export function mapAssetProviderToLessonVideoType(provider: string): LessonVideo
|
|||||||
return 'upload';
|
return 'upload';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An HLS asset has no playable `storageKey` — its renditions live behind the
|
||||||
|
* `/hls/{assetId}/...` proxy and are streamed via a signed cookie, not a
|
||||||
|
* presigned URL. The lesson video stores the relative manifest path + `hls`
|
||||||
|
* flag so the player resolves and mints the cookie itself.
|
||||||
|
*/
|
||||||
|
export function isHlsAsset(asset: OrganizationAsset): boolean {
|
||||||
|
return Boolean(asset.hlsManifestKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAssetHlsManifestLink(asset: OrganizationAsset): string {
|
||||||
|
return `/hls/${asset.id}/master.m3u8`;
|
||||||
|
}
|
||||||
|
|
||||||
export function mapAssetToLessonVideo(
|
export function mapAssetToLessonVideo(
|
||||||
asset: OrganizationAsset,
|
asset: OrganizationAsset,
|
||||||
options: { url: string; key?: string | null }
|
options: { url: string; key?: string | null }
|
||||||
@@ -78,6 +92,7 @@ export function mapAssetToLessonVideo(
|
|||||||
duration?: number;
|
duration?: number;
|
||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
hls?: boolean;
|
||||||
};
|
};
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
@@ -92,7 +107,8 @@ export function mapAssetToLessonVideo(
|
|||||||
thumbnailUrl: asset.thumbnailUrl ?? undefined,
|
thumbnailUrl: asset.thumbnailUrl ?? undefined,
|
||||||
duration: asset.durationSeconds ?? undefined,
|
duration: asset.durationSeconds ?? undefined,
|
||||||
aspectRatio: asset.aspectRatio ?? undefined,
|
aspectRatio: asset.aspectRatio ?? undefined,
|
||||||
createdAt: asset.createdAt ?? undefined
|
createdAt: asset.createdAt ?? undefined,
|
||||||
|
...(isHlsAsset(asset) ? { hls: true } : {})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,7 +361,8 @@
|
|||||||
"video_added_to_lesson": "Video tilføjet til lektion",
|
"video_added_to_lesson": "Video tilføjet til lektion",
|
||||||
"delete_success": "Video slettet",
|
"delete_success": "Video slettet",
|
||||||
"delete_in_use": "Denne video er stadig knyttet til en lektion. Fjern det der først.",
|
"delete_in_use": "Denne video er stadig knyttet til en lektion. Fjern det der først.",
|
||||||
"delete_failed": "Videoen kunne ikke slettes"
|
"delete_failed": "Videoen kunne ikke slettes",
|
||||||
|
"added_to_lesson": "Video tilføjet til lektion"
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"group_created": "Taggruppe oprettet",
|
"group_created": "Taggruppe oprettet",
|
||||||
|
|||||||
@@ -361,7 +361,8 @@
|
|||||||
"video_added_to_lesson": "Video zur Lektion hinzugefügt",
|
"video_added_to_lesson": "Video zur Lektion hinzugefügt",
|
||||||
"delete_success": "Video gelöscht",
|
"delete_success": "Video gelöscht",
|
||||||
"delete_in_use": "Dieses Video ist noch einer Lektion beigefügt. Entfernen Sie es zuerst dort.",
|
"delete_in_use": "Dieses Video ist noch einer Lektion beigefügt. Entfernen Sie es zuerst dort.",
|
||||||
"delete_failed": "Video konnte nicht gelöscht werden"
|
"delete_failed": "Video konnte nicht gelöscht werden",
|
||||||
|
"added_to_lesson": "Video zur Lektion hinzugefügt"
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"group_created": "Tag-Gruppe erstellt",
|
"group_created": "Tag-Gruppe erstellt",
|
||||||
|
|||||||
@@ -4144,6 +4144,7 @@
|
|||||||
"delete_success": "Video deleted",
|
"delete_success": "Video deleted",
|
||||||
"delete_in_use": "This video is still attached to a lesson. Remove it there first.",
|
"delete_in_use": "This video is still attached to a lesson. Remove it there first.",
|
||||||
"delete_failed": "Failed to delete video",
|
"delete_failed": "Failed to delete video",
|
||||||
|
"added_to_lesson": "Video added to lesson",
|
||||||
"download_failed": "Failed to get asset download URL",
|
"download_failed": "Failed to get asset download URL",
|
||||||
"thumbnail_uploaded": "Thumbnail uploaded",
|
"thumbnail_uploaded": "Thumbnail uploaded",
|
||||||
"thumbnail_upload_failed": "Failed to upload thumbnail",
|
"thumbnail_upload_failed": "Failed to upload thumbnail",
|
||||||
|
|||||||
@@ -361,7 +361,8 @@
|
|||||||
"video_added_to_lesson": "Vídeo añadido a la lección.",
|
"video_added_to_lesson": "Vídeo añadido a la lección.",
|
||||||
"delete_success": "Vídeo eliminado",
|
"delete_success": "Vídeo eliminado",
|
||||||
"delete_in_use": "Este vídeo todavía está adjunto a una lección. Quítelo allí primero.",
|
"delete_in_use": "Este vídeo todavía está adjunto a una lección. Quítelo allí primero.",
|
||||||
"delete_failed": "No se pudo eliminar el video"
|
"delete_failed": "No se pudo eliminar el video",
|
||||||
|
"added_to_lesson": "Vídeo añadido a la lección."
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"group_created": "Grupo de etiquetas creado",
|
"group_created": "Grupo de etiquetas creado",
|
||||||
|
|||||||
@@ -361,7 +361,8 @@
|
|||||||
"video_added_to_lesson": "Vidéo ajoutée à la leçon",
|
"video_added_to_lesson": "Vidéo ajoutée à la leçon",
|
||||||
"delete_success": "Vidéo supprimée",
|
"delete_success": "Vidéo supprimée",
|
||||||
"delete_in_use": "Cette vidéo est toujours jointe à une leçon. Retirez-le d'abord.",
|
"delete_in_use": "Cette vidéo est toujours jointe à une leçon. Retirez-le d'abord.",
|
||||||
"delete_failed": "Échec de la suppression de la vidéo"
|
"delete_failed": "Échec de la suppression de la vidéo",
|
||||||
|
"added_to_lesson": "Vidéo ajoutée à la leçon"
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"group_created": "Groupe de balises créé",
|
"group_created": "Groupe de balises créé",
|
||||||
|
|||||||
@@ -361,7 +361,8 @@
|
|||||||
"video_added_to_lesson": "पाठ में वीडियो जोड़ा गया",
|
"video_added_to_lesson": "पाठ में वीडियो जोड़ा गया",
|
||||||
"delete_success": "वीडियो हटा दिया गया",
|
"delete_success": "वीडियो हटा दिया गया",
|
||||||
"delete_in_use": "यह वीडियो अभी भी एक पाठ से जुड़ा हुआ है। पहले इसे वहां से हटाएं.",
|
"delete_in_use": "यह वीडियो अभी भी एक पाठ से जुड़ा हुआ है। पहले इसे वहां से हटाएं.",
|
||||||
"delete_failed": "वीडियो हटाने में विफल"
|
"delete_failed": "वीडियो हटाने में विफल",
|
||||||
|
"added_to_lesson": "पाठ में वीडियो जोड़ा गया"
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"group_created": "टैग समूह बनाया गया",
|
"group_created": "टैग समूह बनाया गया",
|
||||||
|
|||||||
@@ -361,7 +361,8 @@
|
|||||||
"video_added_to_lesson": "Film dodany do lekcji",
|
"video_added_to_lesson": "Film dodany do lekcji",
|
||||||
"delete_success": "Film usunięty",
|
"delete_success": "Film usunięty",
|
||||||
"delete_in_use": "Ten film jest nadal dołączony do lekcji. Najpierw usuń go tam.",
|
"delete_in_use": "Ten film jest nadal dołączony do lekcji. Najpierw usuń go tam.",
|
||||||
"delete_failed": "Nie udało się usunąć filmu"
|
"delete_failed": "Nie udało się usunąć filmu",
|
||||||
|
"added_to_lesson": "Film dodany do lekcji"
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"group_created": "Utworzono grupę tagów",
|
"group_created": "Utworzono grupę tagów",
|
||||||
|
|||||||
@@ -361,7 +361,8 @@
|
|||||||
"video_added_to_lesson": "Vídeo adicionado à aula",
|
"video_added_to_lesson": "Vídeo adicionado à aula",
|
||||||
"delete_success": "Vídeo excluído",
|
"delete_success": "Vídeo excluído",
|
||||||
"delete_in_use": "Este vídeo ainda está anexado a uma aula. Remova-o primeiro.",
|
"delete_in_use": "Este vídeo ainda está anexado a uma aula. Remova-o primeiro.",
|
||||||
"delete_failed": "Falha ao excluir vídeo"
|
"delete_failed": "Falha ao excluir vídeo",
|
||||||
|
"added_to_lesson": "Vídeo adicionado à aula"
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"group_created": "Grupo de tags criado",
|
"group_created": "Grupo de tags criado",
|
||||||
|
|||||||
@@ -361,7 +361,8 @@
|
|||||||
"video_added_to_lesson": "Видео добавлено в урок",
|
"video_added_to_lesson": "Видео добавлено в урок",
|
||||||
"delete_success": "Видео удалено",
|
"delete_success": "Видео удалено",
|
||||||
"delete_in_use": "Это видео все еще прикреплено к уроку. Сначала уберите его оттуда.",
|
"delete_in_use": "Это видео все еще прикреплено к уроку. Сначала уберите его оттуда.",
|
||||||
"delete_failed": "Не удалось удалить видео."
|
"delete_failed": "Не удалось удалить видео.",
|
||||||
|
"added_to_lesson": "Видео добавлено в урок"
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"group_created": "Группа тегов создана.",
|
"group_created": "Группа тегов создана.",
|
||||||
|
|||||||
@@ -361,7 +361,8 @@
|
|||||||
"video_added_to_lesson": "Video đã được thêm vào bài học",
|
"video_added_to_lesson": "Video đã được thêm vào bài học",
|
||||||
"delete_success": "Đã xóa video",
|
"delete_success": "Đã xóa video",
|
||||||
"delete_in_use": "Video này vẫn được đính kèm với một bài học. Loại bỏ nó ở đó đầu tiên.",
|
"delete_in_use": "Video này vẫn được đính kèm với một bài học. Loại bỏ nó ở đó đầu tiên.",
|
||||||
"delete_failed": "Không thể xóa video"
|
"delete_failed": "Không thể xóa video",
|
||||||
|
"added_to_lesson": "Video đã được thêm vào bài học"
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"group_created": "Đã tạo nhóm thẻ",
|
"group_created": "Đã tạo nhóm thẻ",
|
||||||
|
|||||||
Reference in New Issue
Block a user