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 typing import List, Optional
from sqlalchemy import Column, ForeignKey, Integer from sqlalchemy import Column, ForeignKey, Integer
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from enum import Enum
from src.db.users import UserRead from src.db.users import UserRead
from src.db.trails import TrailRead from src.db.trails import TrailRead
from src.db.courses.chapters import ChapterRead from src.db.courses.chapters import ChapterRead
from src.db.resource_authors import ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum from src.db.resource_authors import ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum
class ThumbnailType(str, Enum):
IMAGE = "image"
VIDEO = "video"
BOTH = "both"
class AuthorWithRole(SQLModel): class AuthorWithRole(SQLModel):
user: UserRead user: UserRead
authorship: ResourceAuthorshipEnum authorship: ResourceAuthorshipEnum
@ -21,7 +28,9 @@ class CourseBase(SQLModel):
about: Optional[str] about: Optional[str]
learnings: Optional[str] learnings: Optional[str]
tags: 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 public: bool
open_to_contributors: bool open_to_contributors: bool
@ -38,6 +47,9 @@ class Course(CourseBase, table=True):
class CourseCreate(CourseBase): class CourseCreate(CourseBase):
org_id: int = Field(default=None, foreign_key="organization.id") 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 pass
@ -47,6 +59,9 @@ class CourseUpdate(CourseBase):
about: Optional[str] about: Optional[str]
learnings: Optional[str] learnings: Optional[str]
tags: 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] public: Optional[bool]
open_to_contributors: Optional[bool] open_to_contributors: Optional[bool]
@ -58,6 +73,9 @@ class CourseRead(CourseBase):
course_uuid: str course_uuid: str
creation_date: str creation_date: str
update_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 pass
@ -67,6 +85,9 @@ class FullCourseRead(CourseBase):
course_uuid: Optional[str] course_uuid: Optional[str]
creation_date: Optional[str] creation_date: Optional[str]
update_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, Activities
chapters: List[ChapterRead] chapters: List[ChapterRead]
authors: List[AuthorWithRole] authors: List[AuthorWithRole]

View file

@ -12,7 +12,9 @@ from src.db.courses.courses import (
CourseCreate, CourseCreate,
CourseRead, CourseRead,
CourseUpdate, CourseUpdate,
FullCourseRead,
FullCourseReadWithTrail, FullCourseReadWithTrail,
ThumbnailType,
) )
from src.security.auth import get_current_user from src.security.auth import get_current_user
from src.services.courses.courses import ( from src.services.courses.courses import (
@ -55,6 +57,7 @@ async def api_create_course(
learnings: str = Form(None), learnings: str = Form(None),
tags: str = Form(None), tags: str = Form(None),
about: str = Form(), about: str = Form(),
thumbnail_type: ThumbnailType = Form(default=ThumbnailType.IMAGE),
current_user: PublicUser = Depends(get_current_user), current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session), db_session: Session = Depends(get_db_session),
thumbnail: UploadFile | None = None, thumbnail: UploadFile | None = None,
@ -67,14 +70,16 @@ async def api_create_course(
description=description, description=description,
org_id=org_id, org_id=org_id,
public=public, public=public,
thumbnail_type=thumbnail_type,
thumbnail_image="", thumbnail_image="",
thumbnail_video="",
about=about, about=about,
learnings=learnings, learnings=learnings,
tags=tags, tags=tags,
open_to_contributors=False, open_to_contributors=False,
) )
return await create_course( 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( async def api_create_course_thumbnail(
request: Request, request: Request,
course_uuid: str, course_uuid: str,
thumbnail_type: ThumbnailType = Form(default=ThumbnailType.IMAGE),
thumbnail: UploadFile | None = None, thumbnail: UploadFile | None = None,
db_session: Session = Depends(get_db_session), db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user), current_user: PublicUser = Depends(get_current_user),
) -> CourseRead: ) -> CourseRead:
""" """
Update new Course Thumbnail Update Course Thumbnail (Image or Video)
""" """
return await update_course_thumbnail( 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, with_unpublished_activities: bool = False,
db_session: Session = Depends(get_db_session), db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user), current_user: PublicUser = Depends(get_current_user),
) -> FullCourseReadWithTrail: ) -> FullCourseRead:
""" """
Get single Course Metadata (chapters, activities) by course_uuid Get single Course Metadata (chapters, activities) by course_uuid
""" """

View file

@ -19,6 +19,7 @@ from src.db.courses.courses import (
CourseUpdate, CourseUpdate,
FullCourseRead, FullCourseRead,
AuthorWithRole, AuthorWithRole,
ThumbnailType,
) )
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship, authorization_verify_based_on_roles_and_authorship,
@ -398,6 +399,7 @@ async def create_course(
current_user: PublicUser | AnonymousUser, current_user: PublicUser | AnonymousUser,
db_session: Session, db_session: Session,
thumbnail_file: UploadFile | None = None, thumbnail_file: UploadFile | None = None,
thumbnail_type: ThumbnailType = ThumbnailType.IMAGE,
): ):
course = Course.model_validate(course_object) course = Course.model_validate(course_object)
@ -424,10 +426,16 @@ async def create_course(
await upload_thumbnail( await upload_thumbnail(
thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid # type: ignore 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_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: else:
course.thumbnail_image = "" course.thumbnail_image = ""
course.thumbnail_video = ""
course.thumbnail_type = ThumbnailType.IMAGE
# Insert course # Insert course
db_session.add(course) db_session.add(course)
@ -486,6 +494,7 @@ async def update_course_thumbnail(
current_user: PublicUser | AnonymousUser, current_user: PublicUser | AnonymousUser,
db_session: Session, db_session: Session,
thumbnail_file: UploadFile | None = None, thumbnail_file: UploadFile | None = None,
thumbnail_type: ThumbnailType = ThumbnailType.IMAGE,
): ):
statement = select(Course).where(Course.course_uuid == course_uuid) statement = select(Course).where(Course.course_uuid == course_uuid)
course = db_session.exec(statement).first() course = db_session.exec(statement).first()
@ -514,7 +523,12 @@ async def update_course_thumbnail(
# Update course # Update course
if name_in_disk: if name_in_disk:
if thumbnail_type == ThumbnailType.IMAGE:
course.thumbnail_image = name_in_disk 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: else:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,

View file

@ -10,7 +10,7 @@ import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/Ge
import { import {
getCourseThumbnailMediaDirectory, getCourseThumbnailMediaDirectory,
} from '@services/media/media' } 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 { useOrg } from '@components/Contexts/OrgContext'
import { CourseProvider } from '@components/Contexts/CourseContext' import { CourseProvider } from '@components/Contexts/CourseContext'
import { useMediaQuery } from 'usehooks-ts' import { useMediaQuery } from 'usehooks-ts'
@ -24,6 +24,7 @@ import useSWR from 'swr'
const CourseClient = (props: any) => { const CourseClient = (props: any) => {
const [learnings, setLearnings] = useState<any>([]) const [learnings, setLearnings] = useState<any>([])
const [expandedChapters, setExpandedChapters] = useState<{[key: string]: boolean}>({}) const [expandedChapters, setExpandedChapters] = useState<{[key: string]: boolean}>({})
const [activeThumbnailType, setActiveThumbnailType] = useState<'image' | 'video'>('image')
const courseuuid = props.courseuuid const courseuuid = props.courseuuid
const orgslug = props.orgslug const orgslug = props.orgslug
const course = props.course 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="flex flex-col md:flex-row gap-8 pt-2">
<div className="w-full md:w-3/4 space-y-4"> <div className="w-full md:w-3/4 space-y-4">
{props.course?.thumbnail_image && org ? ( {(() => {
<div const showVideo = course.thumbnail_type === 'video' || (course.thumbnail_type === 'both' && activeThumbnailType === 'video');
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 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={{ style={{
backgroundImage: `url(${getCourseThumbnailMediaDirectory( backgroundImage: `url(${getCourseThumbnailMediaDirectory(
org?.org_uuid, org?.org_uuid,
@ -164,8 +215,39 @@ const CourseClient = (props: any) => {
course?.thumbnail_image 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 <div
className="inset-0 ring-1 ring-inset ring-black/10 rounded-lg shadow-xl relative w-full h-[400px] bg-cover bg-center" className="inset-0 ring-1 ring-inset ring-black/10 rounded-lg shadow-xl relative w-full h-[400px] bg-cover bg-center"
style={{ style={{
@ -173,7 +255,9 @@ const CourseClient = (props: any) => {
backgroundSize: 'auto', backgroundSize: 'auto',
}} }}
></div> ></div>
)} );
}
})()}
{(() => { {(() => {
const cleanCourseUuid = course.course_uuid?.replace('course_', ''); const cleanCourseUuid = course.course_uuid?.replace('course_', '');

View file

@ -110,13 +110,14 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }} 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 == 'content' ? (<EditCourseStructure orgslug={params.orgslug} />) : ('')}
{params.subpage == 'general' ? (<EditCourseGeneral orgslug={params.orgslug} />) : ('')} {params.subpage == 'general' ? (<EditCourseGeneral orgslug={params.orgslug} />) : ('')}
{params.subpage == 'access' ? (<EditCourseAccess orgslug={params.orgslug} />) : ('')} {params.subpage == 'access' ? (<EditCourseAccess orgslug={params.orgslug} />) : ('')}
{params.subpage == 'contributors' ? (<EditCourseContributors orgslug={params.orgslug} />) : ('')} {params.subpage == 'contributors' ? (<EditCourseContributors orgslug={params.orgslug} />) : ('')}
</div>
</motion.div> </motion.div>
</CourseProvider> </CourseProvider>
</div> </div>

View file

@ -12,6 +12,13 @@ import ThumbnailUpdate from './ThumbnailUpdate';
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext'; import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext';
import FormTagInput from '@components/Objects/StyledElements/Form/TagInput'; import FormTagInput from '@components/Objects/StyledElements/Form/TagInput';
import LearningItemsList from './LearningItemsList'; import LearningItemsList from './LearningItemsList';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/ui/select";
type EditCourseStructureProps = { type EditCourseStructureProps = {
orgslug: string orgslug: string
@ -102,15 +109,22 @@ function EditCourseGeneral(props: EditCourseStructureProps) {
} }
}; };
const formik = useFormik({ // Create initial values object
initialValues: { const getInitialValues = () => {
const thumbnailType = courseStructure?.thumbnail_type || 'image';
return {
name: courseStructure?.name || '', name: courseStructure?.name || '',
description: courseStructure?.description || '', description: courseStructure?.description || '',
about: courseStructure?.about || '', about: courseStructure?.about || '',
learnings: initializeLearnings(courseStructure?.learnings || ''), learnings: initializeLearnings(courseStructure?.learnings || ''),
tags: courseStructure?.tags || '', tags: courseStructure?.tags || '',
public: courseStructure?.public || false, public: courseStructure?.public || false,
}, thumbnail_type: thumbnailType,
};
};
const formik = useFormik({
initialValues: getInitialValues(),
validate, validate,
onSubmit: async values => { onSubmit: async values => {
try { try {
@ -123,6 +137,14 @@ function EditCourseGeneral(props: EditCourseStructureProps) {
enableReinitialize: true, enableReinitialize: true,
}) as any; }) as any;
// Reset form when courseStructure changes
useEffect(() => {
if (courseStructure && !isLoading) {
const newValues = getInitialValues();
formik.resetForm({ values: newValues });
}
}, [courseStructure, isLoading]);
useEffect(() => { useEffect(() => {
if (!isLoading) { if (!isLoading) {
const formikValues = formik.values as any; const formikValues = formik.values as any;
@ -142,19 +164,24 @@ function EditCourseGeneral(props: EditCourseStructureProps) {
} }
}, [formik.values, isLoading]); }, [formik.values, isLoading]);
if (isLoading || !courseStructure) {
return <div>Loading...</div>;
}
return ( return (
<div> <div className="h-full">
<div className="h-6"></div> <div className="h-6" />
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl shadow-xs px-6 py-5"> <div className="px-10 pb-10">
{courseStructure && ( <div className="bg-white rounded-xl shadow-xs">
<div className="editcourse-form"> <FormLayout onSubmit={formik.handleSubmit} className="p-6">
{error && ( {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} /> <AlertTriangle size={18} />
<div className="font-bold text-sm">{error}</div> <div className="font-bold text-sm">{error}</div>
</div> </div>
)} )}
<FormLayout onSubmit={formik.handleSubmit}>
<div className="space-y-6">
<FormField name="name"> <FormField name="name">
<FormLabelAndMessage label="Name" message={formik.errors.name} /> <FormLabelAndMessage label="Name" message={formik.errors.name} />
<Form.Control asChild> <Form.Control asChild>
@ -215,15 +242,41 @@ function EditCourseGeneral(props: EditCourseStructureProps) {
</Form.Control> </Form.Control>
</FormField> </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"> <FormField name="thumbnail">
<FormLabelAndMessage label="Thumbnail" /> <FormLabelAndMessage label="Thumbnail" />
<Form.Control asChild> <Form.Control asChild>
<ThumbnailUpdate /> <ThumbnailUpdate thumbnailType={formik.values.thumbnail_type} />
</Form.Control> </Form.Control>
</FormField> </FormField>
</div>
</FormLayout> </FormLayout>
</div> </div>
)}
</div> </div>
</div> </div>
); );

View file

@ -3,29 +3,48 @@ import { useOrg } from '@components/Contexts/OrgContext'
import { getAPIUrl } from '@services/config/config' import { getAPIUrl } from '@services/config/config'
import { updateCourseThumbnail } from '@services/courses/courses' import { updateCourseThumbnail } from '@services/courses/courses'
import { getCourseThumbnailMediaDirectory } from '@services/media/media' 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 { useLHSession } from '@components/Contexts/LHSessionContext'
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { mutate } from 'swr' import { mutate } from 'swr'
import UnsplashImagePicker from './UnsplashImagePicker' import UnsplashImagePicker from './UnsplashImagePicker'
import toast from 'react-hot-toast'
const MAX_FILE_SIZE = 8_000_000; // 8MB const MAX_FILE_SIZE = 8_000_000; // 8MB for images
const VALID_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/png'] as const; 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() { type ThumbnailUpdateProps = {
const fileInputRef = useRef<HTMLInputElement>(null); 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 course = useCourse() as any
const session = useLHSession() as any; const session = useLHSession() as any;
const org = useOrg() 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 [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string>('')
const [showError, setShowError] = useState(false)
const [showUnsplashPicker, setShowUnsplashPicker] = useState(false) const [showUnsplashPicker, setShowUnsplashPicker] = useState(false)
const [activeTab, setActiveTab] = useState<TabType>('image')
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false 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 // Cleanup blob URLs when component unmounts or when thumbnail changes
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -35,42 +54,55 @@ function ThumbnailUpdate() {
}; };
}, [localThumbnail]); }, [localThumbnail]);
const validateFile = (file: File): boolean => { const showError = (message: string) => {
if (!VALID_MIME_TYPES.includes(file.type as ValidMimeType)) { toast.error(message, {
setError(`Invalid file type: ${file.type}. Please upload only PNG or JPG/JPEG images`); duration: 3000,
setShowError(true); 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; return false;
} }
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
setError(`File size (${(file.size / 1024 / 1024).toFixed(2)}MB) exceeds the 8MB limit`); showError(`File size (${(file.size / 1024 / 1024).toFixed(2)}MB) exceeds the 8MB limit`);
setShowError(true); 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; 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; return true;
} }
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>, type: 'image' | 'video') => {
setError('');
setShowError(false);
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) { if (!file) {
setError('Please select a file'); showError('Please select a file');
setShowError(true);
return; return;
} }
if (!validateFile(file)) { if (!validateFile(file, type)) {
event.target.value = ''; event.target.value = '';
return; return;
} }
const blobUrl = URL.createObjectURL(file); const blobUrl = URL.createObjectURL(file);
setLocalThumbnail({ file, url: blobUrl }); setLocalThumbnail({ file, url: blobUrl, type });
await updateThumbnail(file); await updateThumbnail(file, type);
} }
const handleUnsplashSelect = async (imageUrl: string) => { const handleUnsplashSelect = async (imageUrl: string) => {
@ -79,31 +111,35 @@ function ThumbnailUpdate() {
const response = await fetch(imageUrl); const response = await fetch(imageUrl);
const blob = await response.blob(); 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'); throw new Error('Invalid image format from Unsplash');
} }
const file = new File([blob], `unsplash_${Date.now()}.jpg`, { type: blob.type }); const file = new File([blob], `unsplash_${Date.now()}.jpg`, { type: blob.type });
if (!validateFile(file)) { if (!validateFile(file, 'image')) {
return; return;
} }
const blobUrl = URL.createObjectURL(file); const blobUrl = URL.createObjectURL(file);
setLocalThumbnail({ file, url: blobUrl }); setLocalThumbnail({ file, url: blobUrl, type: 'image' });
await updateThumbnail(file); await updateThumbnail(file, 'image');
} catch (err) { } catch (err) {
setError('Failed to process Unsplash image'); showError('Failed to process Unsplash image');
setIsLoading(false); setIsLoading(false);
} }
} }
const updateThumbnail = async (file: File) => { const updateThumbnail = async (file: File, type: 'image' | 'video') => {
setIsLoading(true); setIsLoading(true);
try { try {
const formData = new FormData();
formData.append('thumbnail', file);
formData.append('thumbnail_type', type);
const res = await updateCourseThumbnail( const res = await updateCourseThumbnail(
course.courseStructure.course_uuid, course.courseStructure.course_uuid,
file, formData,
session.data?.tokens?.access_token session.data?.tokens?.access_token
); );
@ -111,87 +147,195 @@ function ThumbnailUpdate() {
await new Promise((r) => setTimeout(r, 1500)); await new Promise((r) => setTimeout(r, 1500));
if (res.success === false) { if (res.success === false) {
setError(res.HTTPmessage); showError(res.HTTPmessage);
setShowError(true);
} else { } else {
setError(''); setLocalThumbnail(null);
setShowError(false); toast.success('Thumbnail updated successfully', {
duration: 3000,
position: 'top-center',
});
} }
} catch (err) { } catch (err) {
setError('Failed to update thumbnail'); showError('Failed to update thumbnail');
setShowError(true);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
} }
return ( const getThumbnailUrl = (type: 'image' | 'video') => {
<div className="w-auto rounded-xl border border-gray-200 h-[250px] light-shadow bg-gray-50 transition-all duration-200 relative"> if (type === 'image') {
{showError && error && ( return course.courseStructure.thumbnail_image
<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"> ? getCourseThumbnailMediaDirectory(
<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(
org?.org_uuid, org?.org_uuid,
course.courseStructure.course_uuid, course.courseStructure.course_uuid,
course.courseStructure.thumbnail_image 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" : '/empty_thumbnail.png';
alt="Course thumbnail" } else {
/> return course.courseStructure.thumbnail_video
)} ? getCourseThumbnailMediaDirectory(
org?.org_uuid,
course.courseStructure.course_uuid,
course.courseStructure.thumbnail_video
)
: undefined;
}
};
{!isLoading && ( const renderThumbnailPreview = () => {
<div className="flex space-x-2"> if (localThumbnail) {
<input if (localThumbnail.type === 'video') {
ref={fileInputRef} return (
type="file" <div className="max-w-[480px] mx-auto">
className="hidden" <video
accept=".jpg,.jpeg,.png" src={localThumbnail.url}
onChange={handleFileChange} 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> </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> </div>
);
}
}
{isLoading && ( const currentThumbnailUrl = getThumbnailUrl(activeTab);
<div className="flex justify-center items-center"> 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"> <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" /> <ArrowBigUpDash size={16} className="mr-2 animate-bounce" />
Uploading... Uploading...
</div> </div>
</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> </div>
{showUnsplashPicker && ( {showUnsplashPicker && (

View file

@ -80,9 +80,7 @@ export async function getCourseById(course_id: string, next: any, access_token:a
return res return res
} }
export async function updateCourseThumbnail(course_uuid: any, thumbnail: any, access_token:any) { export async function updateCourseThumbnail(course_uuid: any, formData: FormData, access_token:any) {
const formData = new FormData()
formData.append('thumbnail', thumbnail)
const result: any = await fetch( const result: any = await fetch(
`${getAPIUrl()}courses/${course_uuid}/thumbnail`, `${getAPIUrl()}courses/${course_uuid}/thumbnail`,
RequestBodyFormWithAuthHeader('PUT', formData, null, access_token) RequestBodyFormWithAuthHeader('PUT', formData, null, access_token)