mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
494 lines
15 KiB
TypeScript
494 lines
15 KiB
TypeScript
import { NodeViewWrapper } from '@tiptap/react'
|
|
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 { 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'])
|
|
|
|
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 [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')
|
|
|
|
// 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 handleDragEnter = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setIsDragging(true)
|
|
}
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
if (e.currentTarget === uploadZoneRef.current) {
|
|
setIsDragging(false)
|
|
}
|
|
}
|
|
|
|
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 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 || !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>
|
|
)
|
|
}
|
|
|
|
export default VideoBlockComponent
|