feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import UploadVideo from './upload-video.svelte';
|
||||
import EmbedLink from './embed-link.svelte';
|
||||
import LibraryVideo from './library-video.svelte';
|
||||
import GoogleDriveVideo from './google-drive-video.svelte';
|
||||
import * as CONSTANTS from './constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
@@ -57,6 +58,9 @@
|
||||
<UnderlineTabs.Content value="4" class="mt-3">
|
||||
<LibraryVideo {lessonId} />
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value="5" class="mt-3">
|
||||
<GoogleDriveVideo {lessonId} />
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
|
||||
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
|
||||
import LibraryIcon from '@lucide/svelte/icons/library';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import YoutubeIcon from '@lucide/svelte/icons/youtube';
|
||||
@@ -23,5 +24,10 @@ export const videoTabs = [
|
||||
value: 4,
|
||||
title: 'course.navItem.lessons.materials.tabs.video.library',
|
||||
icon: LibraryIcon
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
title: 'course.navItem.lessons.materials.tabs.video.google_drive',
|
||||
icon: HardDriveIcon
|
||||
}
|
||||
];
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Lesson, LessonVideoType } from '$features/course/utils/types';
|
||||
import type { TAssetProvider } from '@cio/utils/validation/assets';
|
||||
import { lessonApi } from '$features/course/api';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { copyToClipboard, removeVideo } from '$lib/utils/functions/formatYoutubeVideo';
|
||||
import {
|
||||
googleDrivePreviewUrl,
|
||||
googleDriveThumbnailUrl,
|
||||
pickGoogleDriveVideo
|
||||
} from '$lib/utils/functions/google-drive-picker';
|
||||
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
interface Props {
|
||||
lessonId?: string;
|
||||
}
|
||||
|
||||
let { lessonId = '' }: Props = $props();
|
||||
|
||||
let isOpeningPicker = $state(false);
|
||||
let errorMessage = $state('');
|
||||
|
||||
const clientId = $derived((env.PUBLIC_GOOGLE_PICKER_CLIENT_ID ?? '').trim());
|
||||
const apiKey = $derived((env.PUBLIC_GOOGLE_PICKER_API_KEY ?? '').trim());
|
||||
const isConfigured = $derived(Boolean(clientId && apiKey));
|
||||
|
||||
function isGoogleDriveLessonVideo(video: NonNullable<Lesson['videos']>[number]): boolean {
|
||||
return (video as { type: string }).type === 'google_drive';
|
||||
}
|
||||
|
||||
async function openPicker() {
|
||||
errorMessage = '';
|
||||
|
||||
if (!isConfigured) {
|
||||
errorMessage = $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_not_configured');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lessonApi.lesson) return;
|
||||
|
||||
isOpeningPicker = true;
|
||||
|
||||
try {
|
||||
const picked = await pickGoogleDriveVideo({ clientId, apiKey });
|
||||
|
||||
if (!picked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewUrl = googleDrivePreviewUrl(picked.fileId);
|
||||
const thumbnailUrl = googleDriveThumbnailUrl(picked.fileId);
|
||||
const createdAt = new Date().toISOString();
|
||||
const videoMetadata = {
|
||||
createdAt,
|
||||
title: picked.name,
|
||||
googleDriveFileId: picked.fileId,
|
||||
mimeType: picked.mimeType,
|
||||
thumbnailUrl
|
||||
};
|
||||
|
||||
const existingCount = Array.isArray(lessonApi.lesson.videos) ? lessonApi.lesson.videos.length : 0;
|
||||
|
||||
const asset = await mediaApi.createAsset({
|
||||
kind: 'video',
|
||||
provider: 'google_drive' as TAssetProvider,
|
||||
storageProvider: 'external',
|
||||
storageKey: picked.fileId,
|
||||
sourceUrl: previewUrl,
|
||||
isExternal: true,
|
||||
title: picked.name,
|
||||
thumbnailUrl,
|
||||
metadata: videoMetadata
|
||||
});
|
||||
|
||||
if (asset && lessonId) {
|
||||
await mediaApi.attachAsset(asset.id, {
|
||||
targetType: 'lesson',
|
||||
targetId: lessonId,
|
||||
slotType: 'lesson_video',
|
||||
position: existingCount
|
||||
});
|
||||
}
|
||||
|
||||
const newVideo = {
|
||||
type: 'google_drive' as LessonVideoType,
|
||||
link: previewUrl,
|
||||
assetId: asset?.id,
|
||||
fileName: picked.name,
|
||||
metadata: videoMetadata
|
||||
};
|
||||
|
||||
lessonApi.updateLessonState('videos', [newVideo as unknown as NonNullable<Lesson['videos']>[number]], {
|
||||
append: true
|
||||
});
|
||||
lessonApi.isDirty = true;
|
||||
} catch (error) {
|
||||
console.error('Google Drive picker error:', error);
|
||||
errorMessage = $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_error');
|
||||
} finally {
|
||||
isOpeningPicker = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_hint')}
|
||||
</p>
|
||||
|
||||
<Button type="button" onclick={openPicker} disabled={isOpeningPicker} class="w-fit">
|
||||
{isOpeningPicker
|
||||
? $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_opening')
|
||||
: $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_choose')}
|
||||
</Button>
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="ui:text-destructive text-sm">{errorMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="mt-4 pl-2 text-sm">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}:
|
||||
<strong>
|
||||
{lessonApi.lesson?.videos?.filter(isGoogleDriveLessonVideo).length || 0}
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
<div class="">
|
||||
{#each lessonApi.lesson?.videos || [] as video, index (video.assetId ?? `${video.type}-${index}-${video.link}`)}
|
||||
{#if isGoogleDriveLessonVideo(video)}
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge class="max-w-md truncate" variant="secondary">
|
||||
{video.fileName || video.link}
|
||||
</Badge>
|
||||
<IconButton onclick={() => copyToClipboard(video.link)}>
|
||||
<CopyIcon size={16} />
|
||||
</IconButton>
|
||||
<IconButton onclick={() => removeVideo(index)}>
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -9,6 +9,8 @@ type VideoMetadata = {
|
||||
duration?: number;
|
||||
createdAt?: string;
|
||||
thumbnailUrl?: string;
|
||||
googleDriveFileId?: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -23,6 +25,13 @@ export function getVideoThumbnailUrl(video: LessonVideo): string | null {
|
||||
const meta = video.metadata as VideoMetadata | undefined;
|
||||
if (meta?.thumbnailUrl && typeof meta.thumbnailUrl === 'string') return meta.thumbnailUrl;
|
||||
}
|
||||
if (video.type === 'google_drive') {
|
||||
const meta = video.metadata as VideoMetadata | undefined;
|
||||
if (meta?.thumbnailUrl && typeof meta.thumbnailUrl === 'string') return meta.thumbnailUrl;
|
||||
if (meta?.googleDriveFileId && typeof meta.googleDriveFileId === 'string') {
|
||||
return `https://drive.google.com/thumbnail?id=${meta.googleDriveFileId}&sz=w400`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -67,6 +76,7 @@ export function getVideoTitle(video: LessonVideo, index: number): string {
|
||||
|
||||
if (video.type === 'youtube') return 'YouTube video';
|
||||
if (video.type === 'generic') return 'Embedded video';
|
||||
if (video.type === 'google_drive') return 'Google Drive video';
|
||||
|
||||
return `Video ${index + 1}`;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Image } from '$features/ui';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
|
||||
import VideoCardDropdown from './video-card-dropdown.svelte';
|
||||
import {
|
||||
getVideoThumbnailUrl,
|
||||
@@ -67,15 +67,19 @@
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-2 min-h-[44px] text-base!" {title}>
|
||||
{#if isYoutubeWithLink}
|
||||
<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 class="ui:text-muted-foreground size-3.5 shrink-0" aria-hidden="true" />
|
||||
</a>
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user