feat: add video thumbnails to courses

This commit is contained in:
swve 2025-06-20 22:43:42 +02:00
parent d72abd15fb
commit 2966ac91b7
9 changed files with 518 additions and 164 deletions

View file

@ -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 ###

View file

@ -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]

View file

@ -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
"""

View file

@ -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
)
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:
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,

View file

@ -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<any>([])
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,9 +155,59 @@ const CourseClient = (props: any) => {
<div className="flex flex-col md:flex-row gap-8 pt-2">
<div className="w-full md:w-3/4 space-y-4">
{props.course?.thumbnail_image && org ? (
<div
className="inset-0 ring-1 ring-inset ring-black/10 rounded-lg shadow-xl relative w-full h-[200px] md:h-[400px] bg-cover bg-center"
{(() => {
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 (
<div className="relative inset-0 ring-1 ring-inset ring-black/10 rounded-lg shadow-xl w-full h-[200px] md:h-[400px]">
{course.thumbnail_type === 'both' && (
<div className="absolute top-3 right-3 z-10">
<div className="bg-black/20 backdrop-blur-sm rounded-lg p-1 flex space-x-1">
<button
onClick={() => setActiveThumbnailType('image')}
className={`flex items-center px-2 py-1 rounded-md text-xs font-medium transition-colors ${
activeThumbnailType === 'image'
? 'bg-white/90 text-gray-900 shadow-sm'
: 'text-white/80 hover:text-white hover:bg-white/10'
}`}
>
<ImageIcon size={12} className="mr-1" />
Image
</button>
<button
onClick={() => setActiveThumbnailType('video')}
className={`flex items-center px-2 py-1 rounded-md text-xs font-medium transition-colors ${
activeThumbnailType === 'video'
? 'bg-white/90 text-gray-900 shadow-sm'
: 'text-white/80 hover:text-white hover:bg-white/10'
}`}
>
<Video size={12} className="mr-1" />
Video
</button>
</div>
</div>
)}
<div className="w-full h-full">
<video
src={getCourseThumbnailMediaDirectory(
org?.org_uuid,
course?.course_uuid,
course?.thumbnail_video
)}
className="w-full h-full bg-black rounded-lg"
controls
preload="metadata"
playsInline
/>
</div>
</div>
);
} else if (showImage && course.thumbnail_image) {
return (
<div className="relative inset-0 ring-1 ring-inset ring-black/10 rounded-lg shadow-xl w-full h-[200px] md:h-[400px] bg-cover bg-center"
style={{
backgroundImage: `url(${getCourseThumbnailMediaDirectory(
org?.org_uuid,
@ -164,8 +215,39 @@ const CourseClient = (props: any) => {
course?.thumbnail_image
)})`,
}}
></div>
) : (
>
{course.thumbnail_type === 'both' && (
<div className="absolute top-3 right-3 z-10">
<div className="bg-black/20 backdrop-blur-sm rounded-lg p-1 flex space-x-1">
<button
onClick={() => setActiveThumbnailType('image')}
className={`flex items-center px-2 py-1 rounded-md text-xs font-medium transition-colors ${
activeThumbnailType === 'image'
? 'bg-white/90 text-gray-900 shadow-sm'
: 'text-white/80 hover:text-white hover:bg-white/10'
}`}
>
<ImageIcon size={12} className="mr-1" />
Image
</button>
<button
onClick={() => setActiveThumbnailType('video')}
className={`flex items-center px-2 py-1 rounded-md text-xs font-medium transition-colors ${
activeThumbnailType === 'video'
? 'bg-white/90 text-gray-900 shadow-sm'
: 'text-white/80 hover:text-white hover:bg-white/10'
}`}
>
<Video size={12} className="mr-1" />
Video
</button>
</div>
</div>
)}
</div>
);
} else {
return (
<div
className="inset-0 ring-1 ring-inset ring-black/10 rounded-lg shadow-xl relative w-full h-[400px] bg-cover bg-center"
style={{
@ -173,7 +255,9 @@ const CourseClient = (props: any) => {
backgroundSize: 'auto',
}}
></div>
)}
);
}
})()}
{(() => {
const cleanCourseUuid = course.course_uuid?.replace('course_', '');

View file

@ -110,13 +110,14 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
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"
>
<div className="absolute inset-0">
{params.subpage == 'content' ? (<EditCourseStructure orgslug={params.orgslug} />) : ('')}
{params.subpage == 'general' ? (<EditCourseGeneral orgslug={params.orgslug} />) : ('')}
{params.subpage == 'access' ? (<EditCourseAccess orgslug={params.orgslug} />) : ('')}
{params.subpage == 'contributors' ? (<EditCourseContributors orgslug={params.orgslug} />) : ('')}
</div>
</motion.div>
</CourseProvider>
</div>

View file

@ -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 <div>Loading...</div>;
}
return (
<div>
<div className="h-6"></div>
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl shadow-xs px-6 py-5">
{courseStructure && (
<div className="editcourse-form">
<div className="h-full">
<div className="h-6" />
<div className="px-10 pb-10">
<div className="bg-white rounded-xl shadow-xs">
<FormLayout onSubmit={formik.handleSubmit} className="p-6">
{error && (
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-4 transition-all shadow-xs">
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-4 mb-6 transition-all shadow-xs">
<AlertTriangle size={18} />
<div className="font-bold text-sm">{error}</div>
</div>
)}
<FormLayout onSubmit={formik.handleSubmit}>
<div className="space-y-6">
<FormField name="name">
<FormLabelAndMessage label="Name" message={formik.errors.name} />
<Form.Control asChild>
@ -215,15 +242,41 @@ function EditCourseGeneral(props: EditCourseStructureProps) {
</Form.Control>
</FormField>
<FormField name="thumbnail_type">
<FormLabelAndMessage label="Thumbnail Type" />
<Form.Control asChild>
<Select
value={formik.values.thumbnail_type}
onValueChange={(value) => {
if (!value) return;
formik.setFieldValue('thumbnail_type', value);
}}
>
<SelectTrigger className="w-full bg-white">
<SelectValue>
{formik.values.thumbnail_type === 'image' ? 'Image' :
formik.values.thumbnail_type === 'video' ? 'Video' :
formik.values.thumbnail_type === 'both' ? 'Both' : 'Image'}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="image">Image</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="both">Both</SelectItem>
</SelectContent>
</Select>
</Form.Control>
</FormField>
<FormField name="thumbnail">
<FormLabelAndMessage label="Thumbnail" />
<Form.Control asChild>
<ThumbnailUpdate />
<ThumbnailUpdate thumbnailType={formik.values.thumbnail_type} />
</Form.Control>
</FormField>
</div>
</FormLayout>
</div>
)}
</div>
</div>
);

View file

@ -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<HTMLInputElement>(null);
type ThumbnailUpdateProps = {
thumbnailType: 'image' | 'video' | 'both';
}
type TabType = 'image' | 'video';
function ThumbnailUpdate({ thumbnailType }: ThumbnailUpdateProps) {
const imageInputRef = useRef<HTMLInputElement>(null);
const videoInputRef = useRef<HTMLInputElement>(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<string>('')
const [showError, setShowError] = useState(false)
const [showUnsplashPicker, setShowUnsplashPicker] = useState(false)
const [activeTab, setActiveTab] = useState<TabType>('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);
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) {
setError(`File size (${(file.size / 1024 / 1024).toFixed(2)}MB) exceeds the 8MB limit`);
setShowError(true);
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;
}
setShowError(false);
if (file.size > MAX_VIDEO_FILE_SIZE) {
showError(`File size (${(file.size / 1024 / 1024).toFixed(2)}MB) exceeds the 100MB limit`);
return false;
}
}
return true;
}
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
setError('');
setShowError(false);
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>, 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 (
<div className="w-auto rounded-xl border border-gray-200 h-[250px] light-shadow bg-gray-50 transition-all duration-200 relative">
{showError && error && (
<div className="absolute top-4 left-0 right-0 mx-auto w-[90%] z-50 bg-red-50 rounded-lg text-red-800 p-3 transition-all border border-red-200 shadow-lg">
<div className="text-sm font-medium text-center">{error}</div>
</div>
)}
<div className="flex flex-col justify-center items-center h-full p-6 space-y-4">
<div className="flex flex-col items-center space-y-4">
{localThumbnail ? (
<img
src={localThumbnail.url}
className={`${
isLoading ? 'animate-pulse' : ''
} shadow-sm w-[280px] h-[140px] object-cover rounded-lg border border-gray-200`}
alt="Course thumbnail"
/>
) : (
<img
src={`${course.courseStructure.thumbnail_image ? getCourseThumbnailMediaDirectory(
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'}`}
className="shadow-sm w-[280px] h-[140px] object-cover rounded-lg border border-gray-200 bg-gray-50"
alt="Course thumbnail"
/>
)}
)
: '/empty_thumbnail.png';
} else {
return course.courseStructure.thumbnail_video
? getCourseThumbnailMediaDirectory(
org?.org_uuid,
course.courseStructure.course_uuid,
course.courseStructure.thumbnail_video
)
: undefined;
}
};
{!isLoading && (
<div className="flex space-x-2">
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".jpg,.jpeg,.png"
onChange={handleFileChange}
const renderThumbnailPreview = () => {
if (localThumbnail) {
if (localThumbnail.type === 'video') {
return (
<div className="max-w-[480px] mx-auto">
<video
src={localThumbnail.url}
className={`${isLoading ? 'animate-pulse' : ''} w-full aspect-video object-cover rounded-lg border border-gray-200`}
controls
/>
<button
type="button"
className="bg-gray-50 text-gray-800 px-4 py-2 rounded-md text-sm font-medium flex items-center hover:bg-gray-100 transition-colors duration-200 border border-gray-200"
onClick={() => fileInputRef.current?.click()}
>
<UploadCloud size={16} className="mr-2" />
Upload
</button>
<button
className="bg-gray-50 text-gray-800 px-4 py-2 rounded-md text-sm font-medium flex items-center hover:bg-gray-100 transition-colors duration-200 border border-gray-200"
onClick={() => setShowUnsplashPicker(true)}
>
<ImageIcon size={16} className="mr-2" />
Gallery
</button>
</div>
)}
);
} else {
return (
<div className="max-w-[480px] mx-auto">
<img
src={localThumbnail.url}
alt="Course thumbnail preview"
className={`${isLoading ? 'animate-pulse' : ''} w-full aspect-video object-cover rounded-lg border border-gray-200`}
/>
</div>
);
}
}
{isLoading && (
<div className="flex justify-center items-center">
const currentThumbnailUrl = getThumbnailUrl(activeTab);
if (activeTab === 'video' && currentThumbnailUrl) {
return (
<div className="max-w-[480px] mx-auto">
<video
src={currentThumbnailUrl}
className="w-full aspect-video object-cover rounded-lg border border-gray-200"
controls
/>
</div>
);
} else if (currentThumbnailUrl) {
return (
<div className="max-w-[480px] mx-auto">
<img
src={currentThumbnailUrl}
alt="Current course thumbnail"
className="w-full aspect-video object-cover rounded-lg border border-gray-200"
/>
</div>
);
}
return null;
};
const renderTabContent = () => {
if (isLoading) {
return (
<div className="flex justify-center items-center mt-4">
<div className="font-medium text-sm text-green-800 bg-green-50 rounded-full px-4 py-2 flex items-center">
<ArrowBigUpDash size={16} className="mr-2 animate-bounce" />
Uploading...
</div>
</div>
);
}
if (activeTab === 'image') {
return (
<div className="flex gap-2 mt-4">
<input
ref={imageInputRef}
type="file"
className="hidden"
accept=".jpg,.jpeg,.png"
onChange={(e) => handleFileChange(e, 'image')}
/>
<button
type="button"
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
onClick={() => imageInputRef.current?.click()}
>
<UploadCloud size={16} />
Upload Image
</button>
<button
type="button"
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
onClick={() => setShowUnsplashPicker(true)}
>
<ImageIcon size={16} />
Gallery
</button>
</div>
);
}
return (
<div className="flex gap-2 mt-4">
<input
ref={videoInputRef}
type="file"
className="hidden"
accept=".mp4,.webm"
onChange={(e) => handleFileChange(e, 'video')}
/>
<button
type="button"
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
onClick={() => videoInputRef.current?.click()}
>
<Video size={16} />
Upload Video
</button>
</div>
);
};
return (
<div className="w-full bg-white rounded-xl">
{/* Tabs Navigation */}
{thumbnailType === 'both' && (
<div className="flex border-b border-gray-100">
<button
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${
activeTab === 'image'
? 'text-blue-600 border-b-2 border-blue-600 bg-blue-50/50'
: 'text-gray-600 hover:text-gray-900'
}`}
onClick={() => setActiveTab('image')}
>
<ImageIcon size={16} />
Image
</button>
<button
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${
activeTab === 'video'
? 'text-blue-600 border-b-2 border-blue-600 bg-blue-50/50'
: 'text-gray-600 hover:text-gray-900'
}`}
onClick={() => setActiveTab('video')}
>
<Video size={16} />
Video
</button>
</div>
)}
<p className="text-xs text-gray-500">Supported formats: PNG, JPG/JPEG</p>
<div className="p-6">
<div className="space-y-6">
{renderThumbnailPreview()}
{renderTabContent()}
<p className="text-sm text-gray-500">
{activeTab === 'image' && 'Supported formats: PNG, JPG/JPEG (max 8MB)'}
{activeTab === 'video' && 'Supported formats: MP4, WebM (max 100MB)'}
</p>
</div>
</div>
{showUnsplashPicker && (

View file

@ -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