mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: better video file uploader
This commit is contained in:
parent
5fd794e9eb
commit
298414d57f
1 changed files with 469 additions and 60 deletions
|
|
@ -1,85 +1,494 @@
|
|||
import { NodeViewWrapper } from '@tiptap/react'
|
||||
import { Video } from 'lucide-react'
|
||||
import React, { useEffect } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import {
|
||||
Loader2, Video, Upload, X, HelpCircle,
|
||||
Maximize2, Minimize2, ArrowLeftRight,
|
||||
CheckCircle2, AlertCircle
|
||||
} from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { uploadNewVideoFile } from '../../../../../services/blocks/Video/video'
|
||||
import { getActivityBlockMediaDirectory } from '@services/media/media'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import { useCourse } from '@components/Contexts/CourseContext'
|
||||
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import { FileUploadBlock, FileUploadBlockButton, FileUploadBlockInput } from '../../FileUploadBlock'
|
||||
import { constructAcceptValue } from '@/lib/constants';
|
||||
import { constructAcceptValue } from '@/lib/constants'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const SUPPORTED_FILES = constructAcceptValue(['webm', 'mp4'])
|
||||
|
||||
function VideoBlockComponents(props: any) {
|
||||
const org = useOrg() as any
|
||||
const course = useCourse() as any
|
||||
const editorState = useEditorProvider() as any
|
||||
const isEditable = editorState.isEditable
|
||||
const [video, setVideo] = React.useState(null)
|
||||
const session = useLHSession() as any
|
||||
const access_token = session?.data?.tokens?.access_token;
|
||||
const VIDEO_SIZES = {
|
||||
small: { width: 480, label: 'Small' },
|
||||
medium: { width: 720, label: 'Medium' },
|
||||
large: { width: 960, label: 'Large' },
|
||||
full: { width: '100%', label: 'Full Width' }
|
||||
} as const
|
||||
|
||||
type VideoSize = keyof typeof VIDEO_SIZES
|
||||
|
||||
// Helper function to determine video size from width
|
||||
const getVideoSizeFromWidth = (width: number | string | undefined): VideoSize => {
|
||||
if (!width) return 'medium'
|
||||
if (width === '100%') return 'full'
|
||||
|
||||
const numWidth = typeof width === 'string' ? parseInt(width) : width
|
||||
|
||||
if (numWidth <= VIDEO_SIZES.small.width) return 'small'
|
||||
if (numWidth <= VIDEO_SIZES.medium.width) return 'medium'
|
||||
if (numWidth <= VIDEO_SIZES.large.width) return 'large'
|
||||
return 'full'
|
||||
}
|
||||
|
||||
const VideoWrapper = styled.div`
|
||||
transition: all 0.2s ease;
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #eaeaea;
|
||||
`
|
||||
|
||||
const VideoContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const UploadZone = styled(motion.div)<{ isDragging: boolean }>`
|
||||
border: 2px dashed ${props => props.isDragging ? '#3b82f6' : '#e5e7eb'};
|
||||
background: ${props => props.isDragging ? 'rgba(59, 130, 246, 0.05)' : '#ffffff'};
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
`
|
||||
|
||||
const SizeButton = styled(motion.button)<{ isActive: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
color: ${props => props.isActive ? '#ffffff' : '#4b5563'};
|
||||
background: ${props => props.isActive ? '#3b82f6' : 'transparent'};
|
||||
border: 1px solid ${props => props.isActive ? '#3b82f6' : '#e5e7eb'};
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${props => props.isActive ? '#2563eb' : '#f9fafb'};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`
|
||||
|
||||
interface Organization {
|
||||
org_uuid: string
|
||||
}
|
||||
|
||||
interface Course {
|
||||
courseStructure: {
|
||||
course_uuid: string
|
||||
}
|
||||
}
|
||||
|
||||
interface EditorState {
|
||||
isEditable: boolean
|
||||
}
|
||||
|
||||
interface Session {
|
||||
data?: {
|
||||
tokens?: {
|
||||
access_token?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy interface for backward compatibility
|
||||
interface LegacyVideoBlockObject {
|
||||
block_uuid: string
|
||||
content: {
|
||||
file_id: string
|
||||
file_format: string
|
||||
}
|
||||
size?: {
|
||||
width?: number | string
|
||||
}
|
||||
}
|
||||
|
||||
interface VideoBlockObject {
|
||||
block_uuid: string
|
||||
content: {
|
||||
file_id: string
|
||||
file_format: string
|
||||
}
|
||||
size: VideoSize
|
||||
}
|
||||
|
||||
interface VideoBlockProps {
|
||||
node: {
|
||||
attrs: {
|
||||
blockObject: VideoBlockObject | LegacyVideoBlockObject | null
|
||||
}
|
||||
}
|
||||
extension: {
|
||||
options: {
|
||||
activity: {
|
||||
activity_uuid: string
|
||||
}
|
||||
}
|
||||
}
|
||||
updateAttributes: (attrs: { blockObject: VideoBlockObject | null }) => void
|
||||
}
|
||||
|
||||
function VideoBlockComponent({ node, extension, updateAttributes }: VideoBlockProps) {
|
||||
const org = useOrg() as Organization | null
|
||||
const course = useCourse() as Course | null
|
||||
const editorState = useEditorProvider() as EditorState
|
||||
const session = useLHSession() as Session
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const uploadZoneRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
// Convert legacy block object to new format
|
||||
const convertLegacyBlock = React.useCallback((block: LegacyVideoBlockObject): VideoBlockObject => {
|
||||
const videoSize = getVideoSizeFromWidth(block.size?.width)
|
||||
return {
|
||||
...block,
|
||||
size: videoSize
|
||||
}
|
||||
}, [])
|
||||
|
||||
const initialBlockObject = React.useMemo(() => {
|
||||
if (!node.attrs.blockObject) return null
|
||||
if ('size' in node.attrs.blockObject && typeof node.attrs.blockObject.size === 'string') {
|
||||
return node.attrs.blockObject as VideoBlockObject
|
||||
}
|
||||
return convertLegacyBlock(node.attrs.blockObject as LegacyVideoBlockObject)
|
||||
}, [node.attrs.blockObject, convertLegacyBlock])
|
||||
|
||||
const [video, setVideo] = React.useState<File | null>(null)
|
||||
const [isLoading, setIsLoading] = React.useState(false)
|
||||
const [blockObject, setblockObject] = React.useState(
|
||||
props.node.attrs.blockObject
|
||||
)
|
||||
const fileId = blockObject
|
||||
? `${blockObject.content.file_id}.${blockObject.content.file_format}`
|
||||
: null
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = React.useState(false)
|
||||
const [uploadProgress, setUploadProgress] = React.useState(0)
|
||||
const [blockObject, setBlockObject] = React.useState<VideoBlockObject | null>(initialBlockObject)
|
||||
const [selectedSize, setSelectedSize] = React.useState<VideoSize>(initialBlockObject?.size || 'medium')
|
||||
|
||||
const handleVideoChange = (event: React.ChangeEvent<any>) => {
|
||||
setVideo(event.target.files[0])
|
||||
// Update block object when size changes
|
||||
React.useEffect(() => {
|
||||
if (blockObject && blockObject.size !== selectedSize) {
|
||||
const newBlockObject = {
|
||||
...blockObject,
|
||||
size: selectedSize
|
||||
}
|
||||
setBlockObject(newBlockObject)
|
||||
updateAttributes({ blockObject: newBlockObject })
|
||||
}
|
||||
}, [selectedSize])
|
||||
|
||||
const isEditable = editorState?.isEditable
|
||||
const access_token = session?.data?.tokens?.access_token
|
||||
const fileId = blockObject ? `${blockObject.content.file_id}.${blockObject.content.file_format}` : null
|
||||
|
||||
const handleVideoChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
setVideo(file)
|
||||
setError(null)
|
||||
handleUpload(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
let object = await uploadNewVideoFile(
|
||||
video,
|
||||
props.extension.options.activity.activity_uuid, access_token
|
||||
)
|
||||
setIsLoading(false)
|
||||
setblockObject(object)
|
||||
props.updateAttributes({
|
||||
blockObject: object,
|
||||
})
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
useEffect(() => { }, [course, org])
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.currentTarget === uploadZoneRef.current) {
|
||||
setIsDragging(false)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(blockObject)
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file && SUPPORTED_FILES.split(',').some(format => file.name.toLowerCase().endsWith(format.trim()))) {
|
||||
setVideo(file)
|
||||
setError(null)
|
||||
handleUpload(file)
|
||||
} else {
|
||||
setError('Please upload a supported video format (MP4 or WebM)')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
if (!access_token) return
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setUploadProgress(0)
|
||||
|
||||
// Simulate upload progress
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(prev => Math.min(prev + 10, 90))
|
||||
}, 200)
|
||||
|
||||
const object = await uploadNewVideoFile(
|
||||
file,
|
||||
extension.options.activity.activity_uuid,
|
||||
access_token
|
||||
)
|
||||
|
||||
clearInterval(progressInterval)
|
||||
setUploadProgress(100)
|
||||
|
||||
const newBlockObject = {
|
||||
...object,
|
||||
size: selectedSize
|
||||
}
|
||||
setBlockObject(newBlockObject)
|
||||
updateAttributes({ blockObject: newBlockObject })
|
||||
setVideo(null)
|
||||
|
||||
// Reset progress after a delay
|
||||
setTimeout(() => {
|
||||
setUploadProgress(0)
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
setError('Failed to upload video. Please try again.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
setBlockObject(null)
|
||||
updateAttributes({ blockObject: null })
|
||||
setVideo(null)
|
||||
setError(null)
|
||||
setUploadProgress(0)
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: VideoSize) => {
|
||||
setSelectedSize(size)
|
||||
}
|
||||
|
||||
const videoUrl = blockObject && org?.org_uuid && course?.courseStructure.course_uuid ? getActivityBlockMediaDirectory(
|
||||
org.org_uuid,
|
||||
course.courseStructure.course_uuid,
|
||||
extension.options.activity.activity_uuid,
|
||||
blockObject.block_uuid,
|
||||
fileId || '',
|
||||
'videoBlock'
|
||||
) : null
|
||||
|
||||
// If we're in preview mode and have a video, show only the video player
|
||||
if (!isEditable && blockObject && videoUrl) {
|
||||
const width = VIDEO_SIZES[blockObject.size].width
|
||||
return (
|
||||
<NodeViewWrapper className="block-video w-full">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="w-full flex justify-center"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: typeof width === 'number' ? width : '100%',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<video
|
||||
controls
|
||||
className="w-full aspect-video object-contain rounded-lg shadow-sm"
|
||||
src={videoUrl}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
// If we're in preview mode but don't have a video, show nothing
|
||||
if (!isEditable && (!blockObject || !videoUrl)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Show the full editor UI when in edit mode
|
||||
return (
|
||||
<NodeViewWrapper className="block-video">
|
||||
<FileUploadBlock isEditable={isEditable} isLoading={isLoading} isEmpty={!blockObject} Icon={Video}>
|
||||
<FileUploadBlockInput onChange={handleVideoChange} accept={SUPPORTED_FILES} />
|
||||
<FileUploadBlockButton onClick={handleSubmit} disabled={!video}/>
|
||||
</FileUploadBlock>
|
||||
<NodeViewWrapper className="block-video w-full">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<VideoWrapper className="flex flex-col space-y-4 rounded-lg py-6 px-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 text-sm text-zinc-500">
|
||||
<Video size={16} />
|
||||
<span className="font-medium">Video Block</span>
|
||||
</div>
|
||||
{blockObject && (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={handleRemove}
|
||||
className="text-zinc-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{blockObject && (
|
||||
<BlockVideo>
|
||||
<video
|
||||
controls
|
||||
className="rounded-lg shadow-sm h-96 w-full object-scale-down bg-black"
|
||||
src={`${getActivityBlockMediaDirectory(
|
||||
org?.org_uuid,
|
||||
course?.courseStructure.course_uuid,
|
||||
props.extension.options.activity.activity_uuid,
|
||||
blockObject.block_uuid,
|
||||
blockObject ? fileId : ' ',
|
||||
'videoBlock'
|
||||
)}`}
|
||||
></video>
|
||||
</BlockVideo>
|
||||
)}
|
||||
{(!blockObject || !videoUrl) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={handleVideoChange}
|
||||
accept={SUPPORTED_FILES}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<UploadZone
|
||||
ref={uploadZoneRef}
|
||||
isDragging={isDragging}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="relative"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{isLoading ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-500" />
|
||||
<div className="text-sm text-zinc-600">Uploading video... {uploadProgress}%</div>
|
||||
<div className="w-48 h-1 bg-gray-200 rounded-full mx-auto overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-blue-500 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${uploadProgress}%` }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<Upload className="w-8 h-8 mx-auto text-blue-500" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-700">
|
||||
Drop your video here or click to browse
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">
|
||||
Supports MP4 and WebM formats
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</UploadZone>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500 font-medium bg-red-50 rounded-lg p-3">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{blockObject && videoUrl && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="text-sm text-zinc-500 font-medium flex items-center gap-1">
|
||||
<ArrowLeftRight size={14} />
|
||||
Video Size:
|
||||
</div>
|
||||
{(Object.keys(VIDEO_SIZES) as VideoSize[]).map((size) => (
|
||||
<SizeButton
|
||||
key={size}
|
||||
isActive={selectedSize === size}
|
||||
onClick={() => handleSizeChange(size)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{size === selectedSize && <CheckCircle2 size={14} />}
|
||||
{VIDEO_SIZES[size].label}
|
||||
</SizeButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<VideoContainer>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: typeof VIDEO_SIZES[selectedSize].width === 'number'
|
||||
? VIDEO_SIZES[selectedSize].width
|
||||
: '100%',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<div className="relative rounded-lg overflow-hidden bg-black/5">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/10 backdrop-blur-sm">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
<video
|
||||
controls
|
||||
className={cn(
|
||||
"w-full aspect-video object-contain bg-black/95 shadow-sm transition-all duration-200",
|
||||
isLoading && "opacity-50 blur-sm"
|
||||
)}
|
||||
src={videoUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VideoContainer>
|
||||
</motion.div>
|
||||
)}
|
||||
</VideoWrapper>
|
||||
</motion.div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const BlockVideo = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
export default VideoBlockComponents
|
||||
export default VideoBlockComponent
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue