mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: add details to video activities
wip: uploadable video activities
This commit is contained in:
parent
3173e6b417
commit
31b5104dd5
7 changed files with 181 additions and 3 deletions
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Activity Details
|
||||||
|
|
||||||
|
Revision ID: a5afa69dd917
|
||||||
|
Revises: adb944cc8bec
|
||||||
|
Create Date: 2025-04-22 16:04:58.028488
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa # noqa: F401
|
||||||
|
import sqlmodel # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a5afa69dd917'
|
||||||
|
down_revision: Union[str, None] = 'adb944cc8bec'
|
||||||
|
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('activity', sa.Column('details', sa.JSON(), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('activity', 'details')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
@ -32,6 +32,7 @@ class ActivityBase(SQLModel):
|
||||||
activity_type: ActivityTypeEnum
|
activity_type: ActivityTypeEnum
|
||||||
activity_sub_type: ActivitySubTypeEnum
|
activity_sub_type: ActivitySubTypeEnum
|
||||||
content: dict = Field(default={}, sa_column=Column(JSON))
|
content: dict = Field(default={}, sa_column=Column(JSON))
|
||||||
|
details: Optional[dict] = Field(default=None, sa_column=Column(JSON))
|
||||||
published: bool = False
|
published: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -53,6 +54,7 @@ class ActivityCreate(ActivityBase):
|
||||||
chapter_id: int
|
chapter_id: int
|
||||||
activity_type: ActivityTypeEnum = ActivityTypeEnum.TYPE_CUSTOM
|
activity_type: ActivityTypeEnum = ActivityTypeEnum.TYPE_CUSTOM
|
||||||
activity_sub_type: ActivitySubTypeEnum = ActivitySubTypeEnum.SUBTYPE_CUSTOM
|
activity_sub_type: ActivitySubTypeEnum = ActivitySubTypeEnum.SUBTYPE_CUSTOM
|
||||||
|
details: dict = Field(default={}, sa_column=Column(JSON))
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -61,6 +63,7 @@ class ActivityUpdate(ActivityBase):
|
||||||
content: dict = Field(default={}, sa_column=Column(JSON))
|
content: dict = Field(default={}, sa_column=Column(JSON))
|
||||||
activity_type: Optional[ActivityTypeEnum]
|
activity_type: Optional[ActivityTypeEnum]
|
||||||
activity_sub_type: Optional[ActivitySubTypeEnum]
|
activity_sub_type: Optional[ActivitySubTypeEnum]
|
||||||
|
details: Optional[dict] = Field(default=None, sa_column=Column(JSON))
|
||||||
published_version: Optional[int]
|
published_version: Optional[int]
|
||||||
version: Optional[int]
|
version: Optional[int]
|
||||||
|
|
||||||
|
|
@ -72,4 +75,5 @@ class ActivityRead(ActivityBase):
|
||||||
activity_uuid: str
|
activity_uuid: str
|
||||||
creation_date: str
|
creation_date: str
|
||||||
update_date: str
|
update_date: str
|
||||||
|
details: Optional[dict] = Field(default=None, sa_column=Column(JSON))
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
from fastapi import APIRouter, Depends, UploadFile, Form, Request
|
from fastapi import APIRouter, Depends, UploadFile, Form, Request
|
||||||
from src.db.courses.activities import ActivityCreate, ActivityRead, ActivityUpdate
|
from src.db.courses.activities import ActivityCreate, ActivityRead, ActivityUpdate
|
||||||
from src.db.users import PublicUser
|
from src.db.users import PublicUser
|
||||||
|
|
@ -113,6 +113,7 @@ async def api_create_video_activity(
|
||||||
request: Request,
|
request: Request,
|
||||||
name: str = Form(),
|
name: str = Form(),
|
||||||
chapter_id: str = Form(),
|
chapter_id: str = Form(),
|
||||||
|
details: Optional[dict] = Form(default=None),
|
||||||
current_user: PublicUser = Depends(get_current_user),
|
current_user: PublicUser = Depends(get_current_user),
|
||||||
video_file: UploadFile | None = None,
|
video_file: UploadFile | None = None,
|
||||||
db_session=Depends(get_db_session),
|
db_session=Depends(get_db_session),
|
||||||
|
|
@ -127,6 +128,7 @@ async def api_create_video_activity(
|
||||||
current_user,
|
current_user,
|
||||||
db_session,
|
db_session,
|
||||||
video_file,
|
video_file,
|
||||||
|
details,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import Literal
|
from typing import Literal, Optional
|
||||||
from src.db.courses.courses import Course
|
from src.db.courses.courses import Course
|
||||||
from src.db.organizations import Organization
|
from src.db.organizations import Organization
|
||||||
|
|
||||||
|
|
@ -31,6 +31,7 @@ async def create_video_activity(
|
||||||
current_user: PublicUser,
|
current_user: PublicUser,
|
||||||
db_session: Session,
|
db_session: Session,
|
||||||
video_file: UploadFile | None = None,
|
video_file: UploadFile | None = None,
|
||||||
|
details: Optional[dict] = None,
|
||||||
):
|
):
|
||||||
# RBAC check
|
# RBAC check
|
||||||
await rbac_check(request, "activity_x", current_user, "create", db_session)
|
await rbac_check(request, "activity_x", current_user, "create", db_session)
|
||||||
|
|
@ -99,6 +100,7 @@ async def create_video_activity(
|
||||||
"filename": "video." + video_format,
|
"filename": "video." + video_format,
|
||||||
"activity_uuid": activity_uuid,
|
"activity_uuid": activity_uuid,
|
||||||
},
|
},
|
||||||
|
details=details,
|
||||||
version=1,
|
version=1,
|
||||||
creation_date=str(datetime.now()),
|
creation_date=str(datetime.now()),
|
||||||
update_date=str(datetime.now()),
|
update_date=str(datetime.now()),
|
||||||
|
|
@ -144,6 +146,7 @@ class ExternalVideo(BaseModel):
|
||||||
uri: str
|
uri: str
|
||||||
type: Literal["youtube", "vimeo"]
|
type: Literal["youtube", "vimeo"]
|
||||||
chapter_id: str
|
chapter_id: str
|
||||||
|
details: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
class ExternalVideoInDB(BaseModel):
|
class ExternalVideoInDB(BaseModel):
|
||||||
|
|
@ -194,6 +197,7 @@ async def create_external_video_activity(
|
||||||
"type": data.type,
|
"type": data.type,
|
||||||
"activity_uuid": activity_uuid,
|
"activity_uuid": activity_uuid,
|
||||||
},
|
},
|
||||||
|
details=data.details,
|
||||||
version=1,
|
version=1,
|
||||||
creation_date=str(datetime.now()),
|
creation_date=str(datetime.now()),
|
||||||
update_date=str(datetime.now()),
|
update_date=str(datetime.now()),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@ import YouTube from 'react-youtube'
|
||||||
import { getActivityMediaDirectory } from '@services/media/media'
|
import { getActivityMediaDirectory } from '@services/media/media'
|
||||||
import { useOrg } from '@components/Contexts/OrgContext'
|
import { useOrg } from '@components/Contexts/OrgContext'
|
||||||
|
|
||||||
|
interface VideoDetails {
|
||||||
|
startTime?: number
|
||||||
|
endTime?: number | null
|
||||||
|
autoplay?: boolean
|
||||||
|
muted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface VideoActivityProps {
|
interface VideoActivityProps {
|
||||||
activity: {
|
activity: {
|
||||||
activity_sub_type: string
|
activity_sub_type: string
|
||||||
|
|
@ -11,6 +18,7 @@ interface VideoActivityProps {
|
||||||
filename?: string
|
filename?: string
|
||||||
uri?: string
|
uri?: string
|
||||||
}
|
}
|
||||||
|
details?: VideoDetails
|
||||||
}
|
}
|
||||||
course: {
|
course: {
|
||||||
course_uuid: string
|
course_uuid: string
|
||||||
|
|
@ -20,6 +28,7 @@ interface VideoActivityProps {
|
||||||
function VideoActivity({ activity, course }: VideoActivityProps) {
|
function VideoActivity({ activity, course }: VideoActivityProps) {
|
||||||
const org = useOrg() as any
|
const org = useOrg() as any
|
||||||
const [videoId, setVideoId] = React.useState('')
|
const [videoId, setVideoId] = React.useState('')
|
||||||
|
const videoRef = React.useRef<HTMLVideoElement>(null)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (activity?.content?.uri) {
|
if (activity?.content?.uri) {
|
||||||
|
|
@ -39,6 +48,26 @@ function VideoActivity({ activity, course }: VideoActivityProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle native video time update
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (video && activity.details?.endTime) {
|
||||||
|
if (video.currentTime >= activity.details.endTime) {
|
||||||
|
video.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle native video load
|
||||||
|
const handleVideoLoad = () => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (video && activity.details) {
|
||||||
|
video.currentTime = activity.details.startTime || 0
|
||||||
|
video.autoplay = activity.details.autoplay || false
|
||||||
|
video.muted = activity.details.muted || false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-full px-2 sm:px-4">
|
<div className="w-full max-w-full px-2 sm:px-4">
|
||||||
{activity && (
|
{activity && (
|
||||||
|
|
@ -47,9 +76,12 @@ function VideoActivity({ activity, course }: VideoActivityProps) {
|
||||||
<div className="my-3 md:my-5 w-full">
|
<div className="my-3 md:my-5 w-full">
|
||||||
<div className="relative w-full aspect-video rounded-lg overflow-hidden ring-1 ring-gray-300/30 dark:ring-gray-600/30 sm:ring-gray-200/10 sm:dark:ring-gray-700/20 shadow-xs sm:shadow-none">
|
<div className="relative w-full aspect-video rounded-lg overflow-hidden ring-1 ring-gray-300/30 dark:ring-gray-600/30 sm:ring-gray-200/10 sm:dark:ring-gray-700/20 shadow-xs sm:shadow-none">
|
||||||
<video
|
<video
|
||||||
|
ref={videoRef}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
controls
|
controls
|
||||||
src={getVideoSrc()}
|
src={getVideoSrc()}
|
||||||
|
onLoadedMetadata={handleVideoLoad}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
></video>
|
></video>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -63,7 +95,10 @@ function VideoActivity({ activity, course }: VideoActivityProps) {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
playerVars: {
|
playerVars: {
|
||||||
autoplay: 0
|
autoplay: activity.details?.autoplay ? 1 : 0,
|
||||||
|
mute: activity.details?.muted ? 1 : 0,
|
||||||
|
start: activity.details?.startTime || 0,
|
||||||
|
end: activity.details?.endTime || undefined
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
videoId={videoId}
|
videoId={videoId}
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,19 @@ import { constructAcceptValue } from '@/lib/constants'
|
||||||
|
|
||||||
const SUPPORTED_FILES = constructAcceptValue(['mp4', 'webm'])
|
const SUPPORTED_FILES = constructAcceptValue(['mp4', 'webm'])
|
||||||
|
|
||||||
|
interface VideoDetails {
|
||||||
|
startTime: number
|
||||||
|
endTime: number | null
|
||||||
|
autoplay: boolean
|
||||||
|
muted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface ExternalVideoObject {
|
interface ExternalVideoObject {
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
uri: string
|
uri: string
|
||||||
chapter_id: string
|
chapter_id: string
|
||||||
|
details: VideoDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
function VideoModal({
|
function VideoModal({
|
||||||
|
|
@ -32,6 +40,12 @@ function VideoModal({
|
||||||
const [name, setName] = React.useState('')
|
const [name, setName] = React.useState('')
|
||||||
const [youtubeUrl, setYoutubeUrl] = React.useState('')
|
const [youtubeUrl, setYoutubeUrl] = React.useState('')
|
||||||
const [selectedView, setSelectedView] = React.useState<'file' | 'youtube'>('file')
|
const [selectedView, setSelectedView] = React.useState<'file' | 'youtube'>('file')
|
||||||
|
const [videoDetails, setVideoDetails] = React.useState<VideoDetails>({
|
||||||
|
startTime: 0,
|
||||||
|
endTime: null,
|
||||||
|
autoplay: false,
|
||||||
|
muted: false
|
||||||
|
})
|
||||||
|
|
||||||
const handleVideoChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleVideoChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (event.target.files?.[0]) {
|
if (event.target.files?.[0]) {
|
||||||
|
|
@ -56,6 +70,7 @@ function VideoModal({
|
||||||
published_version: 1,
|
published_version: 1,
|
||||||
version: 1,
|
version: 1,
|
||||||
course_id: course.id,
|
course_id: course.id,
|
||||||
|
details: videoDetails
|
||||||
},
|
},
|
||||||
chapterId
|
chapterId
|
||||||
)
|
)
|
||||||
|
|
@ -67,6 +82,7 @@ function VideoModal({
|
||||||
type: 'youtube',
|
type: 'youtube',
|
||||||
uri: youtubeUrl,
|
uri: youtubeUrl,
|
||||||
chapter_id: chapterId,
|
chapter_id: chapterId,
|
||||||
|
details: videoDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
await submitExternalVideo(
|
await submitExternalVideo(
|
||||||
|
|
@ -80,6 +96,73 @@ function VideoModal({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VideoSettingsForm = () => (
|
||||||
|
<div className="space-y-4 mt-4 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<h3 className="font-medium text-gray-900 mb-3">Video Settings</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField name="start-time">
|
||||||
|
<FormLabel>Start Time (seconds)</FormLabel>
|
||||||
|
<Form.Control asChild>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={videoDetails.startTime}
|
||||||
|
onChange={(e) => setVideoDetails({
|
||||||
|
...videoDetails,
|
||||||
|
startTime: Math.max(0, parseInt(e.target.value) || 0)
|
||||||
|
})}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField name="end-time">
|
||||||
|
<FormLabel>End Time (seconds, optional)</FormLabel>
|
||||||
|
<Form.Control asChild>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={videoDetails.startTime + 1}
|
||||||
|
value={videoDetails.endTime || ''}
|
||||||
|
onChange={(e) => setVideoDetails({
|
||||||
|
...videoDetails,
|
||||||
|
endTime: e.target.value ? parseInt(e.target.value) : null
|
||||||
|
})}
|
||||||
|
placeholder="Leave empty for full duration"
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-6 mt-4">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={videoDetails.autoplay}
|
||||||
|
onChange={(e) => setVideoDetails({
|
||||||
|
...videoDetails,
|
||||||
|
autoplay: e.target.checked
|
||||||
|
})}
|
||||||
|
className="rounded border-gray-300 text-black focus:ring-black"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Autoplay video</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={videoDetails.muted}
|
||||||
|
onChange={(e) => setVideoDetails({
|
||||||
|
...videoDetails,
|
||||||
|
muted: e.target.checked
|
||||||
|
})}
|
||||||
|
className="rounded border-gray-300 text-black focus:ring-black"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Start muted</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormLayout onSubmit={handleSubmit}>
|
<FormLayout onSubmit={handleSubmit}>
|
||||||
<FormField name="video-activity-name">
|
<FormField name="video-activity-name">
|
||||||
|
|
@ -143,6 +226,7 @@ function VideoModal({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<VideoSettingsForm />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -160,6 +244,7 @@ function VideoModal({
|
||||||
/>
|
/>
|
||||||
</Form.Control>
|
</Form.Control>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<VideoSettingsForm />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,15 @@ export async function createFileActivity(
|
||||||
if (type === 'video') {
|
if (type === 'video') {
|
||||||
formData.append('name', data.name)
|
formData.append('name', data.name)
|
||||||
formData.append('video_file', file)
|
formData.append('video_file', file)
|
||||||
|
// Add video details
|
||||||
|
if (data.details) {
|
||||||
|
formData.append('details', JSON.stringify({
|
||||||
|
startTime: data.details.startTime || 0,
|
||||||
|
endTime: data.details.endTime || null,
|
||||||
|
autoplay: data.details.autoplay || false,
|
||||||
|
muted: data.details.muted || false
|
||||||
|
}))
|
||||||
|
}
|
||||||
endpoint = `${getAPIUrl()}activities/video`
|
endpoint = `${getAPIUrl()}activities/video`
|
||||||
} else if (type === 'documentpdf') {
|
} else if (type === 'documentpdf') {
|
||||||
formData.append('pdf_file', file)
|
formData.append('pdf_file', file)
|
||||||
|
|
@ -66,6 +75,14 @@ export async function createExternalVideoActivity(
|
||||||
data.chapter_id = chapter_id
|
data.chapter_id = chapter_id
|
||||||
data.activity_id = activity.id
|
data.activity_id = activity.id
|
||||||
|
|
||||||
|
// Add video details if provided
|
||||||
|
data.details = {
|
||||||
|
startTime: data.startTime || 0,
|
||||||
|
endTime: data.endTime || null,
|
||||||
|
autoplay: data.autoplay || false,
|
||||||
|
muted: data.muted || false
|
||||||
|
}
|
||||||
|
|
||||||
const result = await fetch(
|
const result = await fetch(
|
||||||
`${getAPIUrl()}activities/external_video`,
|
`${getAPIUrl()}activities/external_video`,
|
||||||
RequestBodyWithAuthHeader('POST', data, null, access_token)
|
RequestBodyWithAuthHeader('POST', data, null, access_token)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue