From 2966ac91b7b481d25367defcce5a77dbe9e62832 Mon Sep 17 00:00:00 2001 From: swve Date: Fri, 20 Jun 2025 22:43:42 +0200 Subject: [PATCH] feat: add video thumbnails to courses --- .../versions/9e031a0358d1_video_thumbnails.py | 33 ++ apps/api/src/db/courses/courses.py | 23 +- apps/api/src/routers/courses/courses.py | 14 +- apps/api/src/services/courses/courses.py | 20 +- .../(withmenu)/course/[courseuuid]/course.tsx | 128 +++++-- .../course/[courseuuid]/[subpage]/page.tsx | 13 +- .../EditCourseGeneral/EditCourseGeneral.tsx | 91 ++++- .../EditCourseGeneral/ThumbnailUpdate.tsx | 354 ++++++++++++------ apps/web/services/courses/courses.ts | 6 +- 9 files changed, 518 insertions(+), 164 deletions(-) create mode 100644 apps/api/migrations/versions/9e031a0358d1_video_thumbnails.py diff --git a/apps/api/migrations/versions/9e031a0358d1_video_thumbnails.py b/apps/api/migrations/versions/9e031a0358d1_video_thumbnails.py new file mode 100644 index 00000000..7bede50e --- /dev/null +++ b/apps/api/migrations/versions/9e031a0358d1_video_thumbnails.py @@ -0,0 +1,33 @@ +"""Video Thumbnails + +Revision ID: 9e031a0358d1 +Revises: eb10d15465b3 +Create Date: 2025-06-20 21:28:50.735540 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa # noqa: F401 +import sqlmodel # noqa: F401 +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '9e031a0358d1' +down_revision: Union[str, None] = 'eb10d15465b3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('course', sa.Column('thumbnail_type', postgresql.ENUM('IMAGE', 'VIDEO', 'BOTH', name='thumbnailtype', create_type=False), nullable=True)) + op.add_column('course', sa.Column('thumbnail_video', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('course', 'thumbnail_video') + op.drop_column('course', 'thumbnail_type') + # ### end Alembic commands ### diff --git a/apps/api/src/db/courses/courses.py b/apps/api/src/db/courses/courses.py index 1c6ca173..e8c02e79 100644 --- a/apps/api/src/db/courses/courses.py +++ b/apps/api/src/db/courses/courses.py @@ -1,12 +1,19 @@ from typing import List, Optional from sqlalchemy import Column, ForeignKey, Integer from sqlmodel import Field, SQLModel +from enum import Enum from src.db.users import UserRead from src.db.trails import TrailRead from src.db.courses.chapters import ChapterRead from src.db.resource_authors import ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum +class ThumbnailType(str, Enum): + IMAGE = "image" + VIDEO = "video" + BOTH = "both" + + class AuthorWithRole(SQLModel): user: UserRead authorship: ResourceAuthorshipEnum @@ -21,7 +28,9 @@ class CourseBase(SQLModel): about: Optional[str] learnings: Optional[str] tags: Optional[str] - thumbnail_image: Optional[str] + thumbnail_type: Optional[ThumbnailType] = Field(default=ThumbnailType.IMAGE) + thumbnail_image: Optional[str] = Field(default="") + thumbnail_video: Optional[str] = Field(default="") public: bool open_to_contributors: bool @@ -38,6 +47,9 @@ class Course(CourseBase, table=True): class CourseCreate(CourseBase): org_id: int = Field(default=None, foreign_key="organization.id") + thumbnail_type: Optional[ThumbnailType] = Field(default=ThumbnailType.IMAGE) + thumbnail_image: Optional[str] = Field(default="") + thumbnail_video: Optional[str] = Field(default="") pass @@ -47,6 +59,9 @@ class CourseUpdate(CourseBase): about: Optional[str] learnings: Optional[str] tags: Optional[str] + thumbnail_type: Optional[ThumbnailType] = Field(default=ThumbnailType.IMAGE) + thumbnail_image: Optional[str] = Field(default="") + thumbnail_video: Optional[str] = Field(default="") public: Optional[bool] open_to_contributors: Optional[bool] @@ -58,6 +73,9 @@ class CourseRead(CourseBase): course_uuid: str creation_date: str update_date: str + thumbnail_type: Optional[ThumbnailType] = Field(default=ThumbnailType.IMAGE) + thumbnail_image: Optional[str] = Field(default="") + thumbnail_video: Optional[str] = Field(default="") pass @@ -67,6 +85,9 @@ class FullCourseRead(CourseBase): course_uuid: Optional[str] creation_date: Optional[str] update_date: Optional[str] + thumbnail_type: Optional[ThumbnailType] = Field(default=ThumbnailType.IMAGE) + thumbnail_image: Optional[str] = Field(default="") + thumbnail_video: Optional[str] = Field(default="") # Chapters, Activities chapters: List[ChapterRead] authors: List[AuthorWithRole] diff --git a/apps/api/src/routers/courses/courses.py b/apps/api/src/routers/courses/courses.py index cc81284e..fb526dcd 100644 --- a/apps/api/src/routers/courses/courses.py +++ b/apps/api/src/routers/courses/courses.py @@ -12,7 +12,9 @@ from src.db.courses.courses import ( CourseCreate, CourseRead, CourseUpdate, + FullCourseRead, FullCourseReadWithTrail, + ThumbnailType, ) from src.security.auth import get_current_user from src.services.courses.courses import ( @@ -55,6 +57,7 @@ async def api_create_course( learnings: str = Form(None), tags: str = Form(None), about: str = Form(), + thumbnail_type: ThumbnailType = Form(default=ThumbnailType.IMAGE), current_user: PublicUser = Depends(get_current_user), db_session: Session = Depends(get_db_session), thumbnail: UploadFile | None = None, @@ -67,14 +70,16 @@ async def api_create_course( description=description, org_id=org_id, public=public, + thumbnail_type=thumbnail_type, thumbnail_image="", + thumbnail_video="", about=about, learnings=learnings, tags=tags, open_to_contributors=False, ) return await create_course( - request, org_id, course, current_user, db_session, thumbnail + request, org_id, course, current_user, db_session, thumbnail, thumbnail_type ) @@ -82,15 +87,16 @@ async def api_create_course( async def api_create_course_thumbnail( request: Request, course_uuid: str, + thumbnail_type: ThumbnailType = Form(default=ThumbnailType.IMAGE), thumbnail: UploadFile | None = None, db_session: Session = Depends(get_db_session), current_user: PublicUser = Depends(get_current_user), ) -> CourseRead: """ - Update new Course Thumbnail + Update Course Thumbnail (Image or Video) """ return await update_course_thumbnail( - request, course_uuid, current_user, db_session, thumbnail + request, course_uuid, current_user, db_session, thumbnail, thumbnail_type ) @@ -131,7 +137,7 @@ async def api_get_course_meta( with_unpublished_activities: bool = False, db_session: Session = Depends(get_db_session), current_user: PublicUser = Depends(get_current_user), -) -> FullCourseReadWithTrail: +) -> FullCourseRead: """ Get single Course Metadata (chapters, activities) by course_uuid """ diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index 737cd8fe..61cad71d 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -19,6 +19,7 @@ from src.db.courses.courses import ( CourseUpdate, FullCourseRead, AuthorWithRole, + ThumbnailType, ) from src.security.rbac.rbac import ( authorization_verify_based_on_roles_and_authorship, @@ -398,6 +399,7 @@ async def create_course( current_user: PublicUser | AnonymousUser, db_session: Session, thumbnail_file: UploadFile | None = None, + thumbnail_type: ThumbnailType = ThumbnailType.IMAGE, ): course = Course.model_validate(course_object) @@ -424,10 +426,16 @@ async def create_course( await upload_thumbnail( thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid # type: ignore ) - course.thumbnail_image = name_in_disk - + if thumbnail_type == ThumbnailType.IMAGE: + course.thumbnail_image = name_in_disk + course.thumbnail_type = ThumbnailType.IMAGE + elif thumbnail_type == ThumbnailType.VIDEO: + course.thumbnail_video = name_in_disk + course.thumbnail_type = ThumbnailType.VIDEO else: course.thumbnail_image = "" + course.thumbnail_video = "" + course.thumbnail_type = ThumbnailType.IMAGE # Insert course db_session.add(course) @@ -486,6 +494,7 @@ async def update_course_thumbnail( current_user: PublicUser | AnonymousUser, db_session: Session, thumbnail_file: UploadFile | None = None, + thumbnail_type: ThumbnailType = ThumbnailType.IMAGE, ): statement = select(Course).where(Course.course_uuid == course_uuid) course = db_session.exec(statement).first() @@ -514,7 +523,12 @@ async def update_course_thumbnail( # Update course if name_in_disk: - course.thumbnail_image = name_in_disk + if thumbnail_type == ThumbnailType.IMAGE: + course.thumbnail_image = name_in_disk + course.thumbnail_type = ThumbnailType.IMAGE if not course.thumbnail_video else ThumbnailType.BOTH + elif thumbnail_type == ThumbnailType.VIDEO: + course.thumbnail_video = name_in_disk + course.thumbnail_type = ThumbnailType.VIDEO if not course.thumbnail_image else ThumbnailType.BOTH else: raise HTTPException( status_code=500, diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx index c4d70d1d..f4c96468 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx @@ -10,7 +10,7 @@ import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/Ge import { getCourseThumbnailMediaDirectory, } from '@services/media/media' -import { ArrowRight, Backpack, Check, File, Sparkles, StickyNote, Video, Square } from 'lucide-react' +import { ArrowRight, Backpack, Check, File, Sparkles, StickyNote, Video, Square, Image as ImageIcon } from 'lucide-react' import { useOrg } from '@components/Contexts/OrgContext' import { CourseProvider } from '@components/Contexts/CourseContext' import { useMediaQuery } from 'usehooks-ts' @@ -24,6 +24,7 @@ import useSWR from 'swr' const CourseClient = (props: any) => { const [learnings, setLearnings] = useState([]) const [expandedChapters, setExpandedChapters] = useState<{[key: string]: boolean}>({}) + const [activeThumbnailType, setActiveThumbnailType] = useState<'image' | 'video'>('image') const courseuuid = props.courseuuid const orgslug = props.orgslug const course = props.course @@ -154,26 +155,109 @@ const CourseClient = (props: any) => {
- {props.course?.thumbnail_image && org ? ( -
- ) : ( -
- )} + {(() => { + const showVideo = course.thumbnail_type === 'video' || (course.thumbnail_type === 'both' && activeThumbnailType === 'video'); + const showImage = course.thumbnail_type === 'image' || (course.thumbnail_type === 'both' && activeThumbnailType === 'image'); + + if (showVideo && course.thumbnail_video) { + return ( +
+ {course.thumbnail_type === 'both' && ( +
+
+ + +
+
+ )} +
+
+
+ ); + } else if (showImage && course.thumbnail_image) { + return ( +
+ {course.thumbnail_type === 'both' && ( +
+
+ + +
+
+ )} +
+ ); + } else { + return ( +
+ ); + } + })()} {(() => { const cleanCourseUuid = course.course_uuid?.replace('course_', ''); @@ -362,4 +446,4 @@ const CourseClient = (props: any) => { ) } -export default CourseClient +export default CourseClient \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx index 00eb734a..0124cd5f 100644 --- a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx @@ -110,13 +110,14 @@ function CourseOverviewPage(props: { params: Promise }) { animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.1, type: 'spring', stiffness: 80 }} - className="h-full overflow-y-auto" + className="h-full overflow-y-auto relative" > - {params.subpage == 'content' ? () : ('')} - {params.subpage == 'general' ? () : ('')} - {params.subpage == 'access' ? () : ('')} - {params.subpage == 'contributors' ? () : ('')} - +
+ {params.subpage == 'content' ? () : ('')} + {params.subpage == 'general' ? () : ('')} + {params.subpage == 'access' ? () : ('')} + {params.subpage == 'contributors' ? () : ('')} +
diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral.tsx index c3de469a..622d9721 100644 --- a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral.tsx @@ -12,6 +12,13 @@ import ThumbnailUpdate from './ThumbnailUpdate'; import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext'; import FormTagInput from '@components/Objects/StyledElements/Form/TagInput'; import LearningItemsList from './LearningItemsList'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/ui/select"; type EditCourseStructureProps = { orgslug: string @@ -102,15 +109,22 @@ function EditCourseGeneral(props: EditCourseStructureProps) { } }; - const formik = useFormik({ - initialValues: { + // Create initial values object + const getInitialValues = () => { + const thumbnailType = courseStructure?.thumbnail_type || 'image'; + return { name: courseStructure?.name || '', description: courseStructure?.description || '', about: courseStructure?.about || '', learnings: initializeLearnings(courseStructure?.learnings || ''), tags: courseStructure?.tags || '', public: courseStructure?.public || false, - }, + thumbnail_type: thumbnailType, + }; + }; + + const formik = useFormik({ + initialValues: getInitialValues(), validate, onSubmit: async values => { try { @@ -123,6 +137,14 @@ function EditCourseGeneral(props: EditCourseStructureProps) { enableReinitialize: true, }) as any; + // Reset form when courseStructure changes + useEffect(() => { + if (courseStructure && !isLoading) { + const newValues = getInitialValues(); + formik.resetForm({ values: newValues }); + } + }, [courseStructure, isLoading]); + useEffect(() => { if (!isLoading) { const formikValues = formik.values as any; @@ -142,19 +164,24 @@ function EditCourseGeneral(props: EditCourseStructureProps) { } }, [formik.values, isLoading]); + if (isLoading || !courseStructure) { + return
Loading...
; + } + return ( -
-
-
- {courseStructure && ( -
+
+
+
+
+ {error && ( -
+
{error}
)} - + +
@@ -207,23 +234,49 @@ function EditCourseGeneral(props: EditCourseStructureProps) { - formik.setFieldValue('tags', value)} - value={formik.values.tags} - /> + formik.setFieldValue('tags', value)} + value={formik.values.tags} + /> + + + + + + + - + - -
- )} +
+
+
); diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx index 705aec1b..284ad8d7 100644 --- a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx @@ -3,29 +3,48 @@ import { useOrg } from '@components/Contexts/OrgContext' import { getAPIUrl } from '@services/config/config' import { updateCourseThumbnail } from '@services/courses/courses' import { getCourseThumbnailMediaDirectory } from '@services/media/media' -import { ArrowBigUpDash, UploadCloud, Image as ImageIcon } from 'lucide-react' +import { ArrowBigUpDash, UploadCloud, Image as ImageIcon, Video } from 'lucide-react' import { useLHSession } from '@components/Contexts/LHSessionContext' import React, { useState, useEffect, useRef } from 'react' import { mutate } from 'swr' import UnsplashImagePicker from './UnsplashImagePicker' +import toast from 'react-hot-toast' -const MAX_FILE_SIZE = 8_000_000; // 8MB -const VALID_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/png'] as const; +const MAX_FILE_SIZE = 8_000_000; // 8MB for images +const MAX_VIDEO_FILE_SIZE = 100_000_000; // 100MB for videos +const VALID_IMAGE_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/png'] as const; +const VALID_VIDEO_MIME_TYPES = ['video/mp4', 'video/webm'] as const; -type ValidMimeType = typeof VALID_MIME_TYPES[number]; +type ValidImageMimeType = typeof VALID_IMAGE_MIME_TYPES[number]; +type ValidVideoMimeType = typeof VALID_VIDEO_MIME_TYPES[number]; -function ThumbnailUpdate() { - const fileInputRef = useRef(null); +type ThumbnailUpdateProps = { + thumbnailType: 'image' | 'video' | 'both'; +} + +type TabType = 'image' | 'video'; + +function ThumbnailUpdate({ thumbnailType }: ThumbnailUpdateProps) { + const imageInputRef = useRef(null); + const videoInputRef = useRef(null); const course = useCourse() as any const session = useLHSession() as any; const org = useOrg() as any - const [localThumbnail, setLocalThumbnail] = useState<{ file: File; url: string } | null>(null) + const [localThumbnail, setLocalThumbnail] = useState<{ file: File; url: string; type: 'image' | 'video' } | null>(null) const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState('') - const [showError, setShowError] = useState(false) const [showUnsplashPicker, setShowUnsplashPicker] = useState(false) + const [activeTab, setActiveTab] = useState('image') const withUnpublishedActivities = course ? course.withUnpublishedActivities : false + // Set initial active tab based on thumbnailType + useEffect(() => { + if (thumbnailType === 'video') { + setActiveTab('video'); + } else { + setActiveTab('image'); + } + }, [thumbnailType]); + // Cleanup blob URLs when component unmounts or when thumbnail changes useEffect(() => { return () => { @@ -35,42 +54,55 @@ function ThumbnailUpdate() { }; }, [localThumbnail]); - const validateFile = (file: File): boolean => { - if (!VALID_MIME_TYPES.includes(file.type as ValidMimeType)) { - setError(`Invalid file type: ${file.type}. Please upload only PNG or JPG/JPEG images`); - setShowError(true); - return false; + const showError = (message: string) => { + toast.error(message, { + duration: 3000, + position: 'top-center', + }); + }; + + const validateFile = (file: File, type: 'image' | 'video'): boolean => { + if (type === 'image') { + if (!VALID_IMAGE_MIME_TYPES.includes(file.type as ValidImageMimeType)) { + showError(`Invalid file type: ${file.type}. Please upload only PNG or JPG/JPEG images`); + return false; + } + + if (file.size > MAX_FILE_SIZE) { + showError(`File size (${(file.size / 1024 / 1024).toFixed(2)}MB) exceeds the 8MB limit`); + return false; + } + } else { + if (!VALID_VIDEO_MIME_TYPES.includes(file.type as ValidVideoMimeType)) { + showError(`Invalid file type: ${file.type}. Please upload only MP4 or WebM videos`); + return false; + } + + if (file.size > MAX_VIDEO_FILE_SIZE) { + showError(`File size (${(file.size / 1024 / 1024).toFixed(2)}MB) exceeds the 100MB limit`); + return false; + } } - if (file.size > MAX_FILE_SIZE) { - setError(`File size (${(file.size / 1024 / 1024).toFixed(2)}MB) exceeds the 8MB limit`); - setShowError(true); - return false; - } - - setShowError(false); return true; } - const handleFileChange = async (event: React.ChangeEvent) => { - setError(''); - setShowError(false); + const handleFileChange = async (event: React.ChangeEvent, type: 'image' | 'video') => { const file = event.target.files?.[0]; if (!file) { - setError('Please select a file'); - setShowError(true); + showError('Please select a file'); return; } - if (!validateFile(file)) { + if (!validateFile(file, type)) { event.target.value = ''; return; } const blobUrl = URL.createObjectURL(file); - setLocalThumbnail({ file, url: blobUrl }); - await updateThumbnail(file); + setLocalThumbnail({ file, url: blobUrl, type }); + await updateThumbnail(file, type); } const handleUnsplashSelect = async (imageUrl: string) => { @@ -79,31 +111,35 @@ function ThumbnailUpdate() { const response = await fetch(imageUrl); const blob = await response.blob(); - if (!VALID_MIME_TYPES.includes(blob.type as ValidMimeType)) { + if (!VALID_IMAGE_MIME_TYPES.includes(blob.type as ValidImageMimeType)) { throw new Error('Invalid image format from Unsplash'); } const file = new File([blob], `unsplash_${Date.now()}.jpg`, { type: blob.type }); - if (!validateFile(file)) { + if (!validateFile(file, 'image')) { return; } const blobUrl = URL.createObjectURL(file); - setLocalThumbnail({ file, url: blobUrl }); - await updateThumbnail(file); + setLocalThumbnail({ file, url: blobUrl, type: 'image' }); + await updateThumbnail(file, 'image'); } catch (err) { - setError('Failed to process Unsplash image'); + showError('Failed to process Unsplash image'); setIsLoading(false); } } - const updateThumbnail = async (file: File) => { + const updateThumbnail = async (file: File, type: 'image' | 'video') => { setIsLoading(true); try { + const formData = new FormData(); + formData.append('thumbnail', file); + formData.append('thumbnail_type', type); + const res = await updateCourseThumbnail( course.courseStructure.course_uuid, - file, + formData, session.data?.tokens?.access_token ); @@ -111,87 +147,195 @@ function ThumbnailUpdate() { await new Promise((r) => setTimeout(r, 1500)); if (res.success === false) { - setError(res.HTTPmessage); - setShowError(true); + showError(res.HTTPmessage); } else { - setError(''); - setShowError(false); + setLocalThumbnail(null); + toast.success('Thumbnail updated successfully', { + duration: 3000, + position: 'top-center', + }); } } catch (err) { - setError('Failed to update thumbnail'); - setShowError(true); + showError('Failed to update thumbnail'); } finally { setIsLoading(false); } } - return ( -
- {showError && error && ( -
-
{error}
-
- )} -
-
- {localThumbnail ? ( + const getThumbnailUrl = (type: 'image' | 'video') => { + if (type === 'image') { + return course.courseStructure.thumbnail_image + ? getCourseThumbnailMediaDirectory( + org?.org_uuid, + course.courseStructure.course_uuid, + course.courseStructure.thumbnail_image + ) + : '/empty_thumbnail.png'; + } else { + return course.courseStructure.thumbnail_video + ? getCourseThumbnailMediaDirectory( + org?.org_uuid, + course.courseStructure.course_uuid, + course.courseStructure.thumbnail_video + ) + : undefined; + } + }; + + const renderThumbnailPreview = () => { + if (localThumbnail) { + if (localThumbnail.type === 'video') { + return ( +
+
+ ); + } else { + return ( +
Course thumbnail - ) : ( - Course thumbnail - )} - - {!isLoading && ( -
- - - -
- )} -
- - {isLoading && ( -
-
- - Uploading... -
- )} - -

Supported formats: PNG, JPG/JPEG

+ ); + } + } + + const currentThumbnailUrl = getThumbnailUrl(activeTab); + if (activeTab === 'video' && currentThumbnailUrl) { + return ( +
+
+ ); + } else if (currentThumbnailUrl) { + return ( +
+ Current course thumbnail +
+ ); + } + + return null; + }; + + const renderTabContent = () => { + if (isLoading) { + return ( +
+
+ + Uploading... +
+
+ ); + } + + if (activeTab === 'image') { + return ( +
+ handleFileChange(e, 'image')} + /> + + +
+ ); + } + + return ( +
+ handleFileChange(e, 'video')} + /> + +
+ ); + }; + + return ( +
+ {/* Tabs Navigation */} + {thumbnailType === 'both' && ( +
+ + +
+ )} + +
+
+ {renderThumbnailPreview()} + {renderTabContent()} + +

+ {activeTab === 'image' && 'Supported formats: PNG, JPG/JPEG (max 8MB)'} + {activeTab === 'video' && 'Supported formats: MP4, WebM (max 100MB)'} +

+
{showUnsplashPicker && ( diff --git a/apps/web/services/courses/courses.ts b/apps/web/services/courses/courses.ts index dc1ca336..5bdfc5f7 100644 --- a/apps/web/services/courses/courses.ts +++ b/apps/web/services/courses/courses.ts @@ -80,12 +80,10 @@ export async function getCourseById(course_id: string, next: any, access_token:a return res } -export async function updateCourseThumbnail(course_uuid: any, thumbnail: any, access_token:any) { - const formData = new FormData() - formData.append('thumbnail', thumbnail) +export async function updateCourseThumbnail(course_uuid: any, formData: FormData, access_token:any) { const result: any = await fetch( `${getAPIUrl()}courses/${course_uuid}/thumbnail`, - RequestBodyFormWithAuthHeader('PUT', formData, null,access_token) + RequestBodyFormWithAuthHeader('PUT', formData, null, access_token) ) const res = await getResponseMetadata(result) return res