diff --git a/app/client/src/common/modalStyles.js b/app/client/src/common/modalStyles.js
new file mode 100644
index 00000000..d2b3bd8c
--- /dev/null
+++ b/app/client/src/common/modalStyles.js
@@ -0,0 +1,65 @@
+// Shared dark-theme style constants for dialogs and modals
+
+export const labelSx = {
+ fontSize: 12,
+ color: '#FFFFFFB3',
+ mb: 1,
+ textTransform: 'uppercase',
+ letterSpacing: '0.08em',
+}
+
+export const inputSx = {
+ '& .MuiOutlinedInput-root': {
+ color: 'white',
+ bgcolor: '#FFFFFF0D',
+ borderRadius: '8px',
+ '& fieldset': { borderColor: '#FFFFFF26' },
+ '&:hover fieldset': { borderColor: '#FFFFFF55' },
+ '&.Mui-focused fieldset': { borderColor: '#3399FF' },
+ },
+}
+
+export const rowBoxSx = {
+ display: 'flex',
+ alignItems: 'center',
+ gap: 1.5,
+ bgcolor: '#FFFFFF0D',
+ border: '1px solid #FFFFFF26',
+ borderRadius: '8px',
+ px: 1.5,
+ py: 1,
+}
+
+export const dialogPaperSx = {
+ bgcolor: '#041223',
+ border: '1px solid #FFFFFF1A',
+ borderRadius: '12px',
+ boxShadow: '0 16px 48px #00000099',
+}
+
+export const dialogTitleSx = {
+ fontWeight: 800,
+ color: 'white',
+}
+
+export const checkboxSx = {
+ color: '#FFFFFF33',
+ '&.Mui-checked': { color: '#3399FF' },
+ p: 0.5,
+}
+
+export const helperTextSx = {
+ fontSize: 14,
+ color: '#FFFFFFB3',
+}
+
+export const timeInputStyle = {
+ background: '#FFFFFF0D',
+ border: '1px solid #FFFFFF26',
+ borderRadius: 6,
+ color: 'white',
+ fontSize: 13,
+ padding: '4px 8px',
+ colorScheme: 'dark',
+ flex: 1,
+}
diff --git a/app/client/src/components/cards/UploadCard.js b/app/client/src/components/cards/UploadCard.js
index ae893b76..c664490b 100644
--- a/app/client/src/components/cards/UploadCard.js
+++ b/app/client/src/components/cards/UploadCard.js
@@ -15,13 +15,15 @@ import {
Chip,
CircularProgress,
InputAdornment,
+ Checkbox,
+ FormControlLabel,
} from '@mui/material'
import CloudUploadIcon from '@mui/icons-material/CloudUpload'
import styled from '@emotion/styled'
import { keyframes } from '@emotion/react'
-import { motion } from 'framer-motion'
import { VideoService, GameService, TagService } from '../../services'
import { getSetting } from '../../common/utils'
+import { dialogPaperSx, dialogTitleSx, inputSx, labelSx, checkboxSx, helperTextSx } from '../../common/modalStyles'
import logo from '../../assets/logo.png'
const Input = styled('input')({
@@ -40,6 +42,16 @@ const borderSpin = keyframes`
to { transform: rotate(360deg); }
`
+const shimmer = keyframes`
+ 0% { background-position: -400px 0; }
+ 100% { background-position: 400px 0; }
+`
+
+const fadeIn = keyframes`
+ from { opacity: 0; }
+ to { opacity: 1; }
+`
+
const maskCss = {
maskImage: `url(${logo})`,
maskSize: 'contain',
@@ -74,6 +86,7 @@ function LogoProgress({ progress, size = 44 }) {
)
}
+
const UploadCard = React.forwardRef(function UploadCard(
{ authenticated, handleAlert, mini, onUploadComplete, onProgress, dropOnly = false },
ref,
@@ -97,6 +110,12 @@ const UploadCard = React.forwardRef(function UploadCard(
const [gameSearchLoading, setGameSearchLoading] = React.useState(false)
const [gameCreating, setGameCreating] = React.useState(false)
const [gameInput, setGameInput] = React.useState('')
+ const [uploadToGameFolder, setUploadToGameFolder] = React.useState(false)
+ const [titleInput, setTitleInput] = React.useState('')
+ const [editingTitle, setEditingTitle] = React.useState(false)
+ const [titleDraft, setTitleDraft] = React.useState('')
+ const [thumbnail, setThumbnail] = React.useState(null)
+ const [thumbnailReady, setThumbnailReady] = React.useState(false)
const [availableFolders, setAvailableFolders] = React.useState([])
const [selectedFolder, setSelectedFolder] = React.useState('')
// Stored metadata to attach on next upload
@@ -110,13 +129,48 @@ const UploadCard = React.forwardRef(function UploadCard(
},
}))
+ const extractThumbnail = (file) => {
+ setThumbnail(null)
+ setThumbnailReady(false)
+ const url = URL.createObjectURL(file)
+ const video = document.createElement('video')
+ video.preload = 'auto'
+ video.muted = true
+ video.src = url
+ video.addEventListener('loadeddata', () => {
+ video.currentTime = Math.min(video.duration * 0.1, 5)
+ })
+ video.addEventListener('seeked', () => {
+ requestAnimationFrame(() => {
+ const canvas = document.createElement('canvas')
+ canvas.width = video.videoWidth
+ canvas.height = video.videoHeight
+ canvas.getContext('2d').drawImage(video, 0, 0)
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.85)
+ URL.revokeObjectURL(url)
+ setThumbnail(dataUrl)
+ setThumbnailReady(true)
+ })
+ })
+ video.addEventListener('error', () => {
+ setThumbnailReady(true)
+ URL.revokeObjectURL(url)
+ })
+ video.load()
+ }
+
const openMetadataDialog = (file) => {
setPendingFile(file)
+ extractThumbnail(file)
setSelectedGame(null)
setSelectedTags([])
setTagInput('')
setGameInput('')
setGameOptions([])
+ setUploadToGameFolder(false)
+ setTitleInput('')
+ setEditingTitle(false)
+ setTitleDraft('')
Promise.all([GameService.getGames(), TagService.getTags(), VideoService.getUploadFolders()])
.then(([gRes, tRes, fRes]) => {
const games = gRes.data || []
@@ -167,12 +221,15 @@ const UploadCard = React.forwardRef(function UploadCard(
pendingMetadata.current = {
tag_ids: resolvedTags.length ? resolvedTags.map((t) => t.id).join(',') : null,
game_id: selectedGame ? selectedGame.id : null,
- folder: selectedFolder || null,
+ folder: (uploadToGameFolder && selectedGame ? selectedGame.name : selectedFolder) || null,
+ title: titleInput.trim() || null,
}
setDialogOpen(false)
setSelectedFile(pendingFile)
setIsSelected(true)
setPendingFile(null)
+ setThumbnail(null)
+ setThumbnailReady(false)
}
const handleDialogCancel = () => {
@@ -183,6 +240,11 @@ const UploadCard = React.forwardRef(function UploadCard(
setTagInput('')
setGameOptions([])
setGameInput('')
+ setUploadToGameFolder(false)
+ setTitleInput('')
+ setEditingTitle(false)
+ setThumbnail(null)
+ setThumbnailReady(false)
}
const handleGameInputChange = async (_, value) => {
@@ -209,6 +271,7 @@ const UploadCard = React.forwardRef(function UploadCard(
const handleGameChange = async (_, newValue) => {
if (!newValue) {
setSelectedGame(null)
+ setUploadToGameFolder(false)
return
}
if (newValue._source === 'db') {
@@ -289,7 +352,7 @@ const UploadCard = React.forwardRef(function UploadCard(
if (!selectedFile) return
const chunkSize = 90 * 1024 * 1024 // 90MB chunk size
- const { tag_ids, game_id, folder } = pendingMetadata.current
+ const { tag_ids, game_id, folder, title } = pendingMetadata.current
async function upload() {
const formData = new FormData()
@@ -297,6 +360,7 @@ const UploadCard = React.forwardRef(function UploadCard(
if (tag_ids) formData.append('tag_ids', tag_ids)
if (game_id) formData.append('game_id', game_id)
if (folder) formData.append('folder', folder)
+ if (title) formData.append('title', title)
try {
if (authenticated) {
await VideoService.upload(formData, uploadProgress)
@@ -363,6 +427,7 @@ const UploadCard = React.forwardRef(function UploadCard(
if (tag_ids) formData.append('tag_ids', tag_ids)
if (game_id) formData.append('game_id', game_id)
if (folder) formData.append('folder', folder)
+ if (title) formData.append('title', title)
authenticated
? await VideoService.uploadChunked(formData, uploadProgressChunked, selectedFile.size, start)
@@ -397,6 +462,76 @@ const UploadCard = React.forwardRef(function UploadCard(
// eslint-disable-next-line
}, [selectedFile])
+ const filenameStem = pendingFile ? pendingFile.name.replace(/\.[^/.]+$/, '') : ''
+ const displayTitle = titleInput || filenameStem || 'Untitled'
+
+ const inlineTitleEl = (
+
+ {editingTitle ? (
+ setTitleDraft(e.target.value)}
+ onBlur={() => {
+ setTitleInput(titleDraft.trim())
+ setEditingTitle(false)
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') e.target.blur()
+ if (e.key === 'Escape') {
+ setTitleDraft(titleInput)
+ setEditingTitle(false)
+ }
+ }}
+ maxLength={200}
+ style={{
+ width: '100%',
+ background: '#FFFFFF1F',
+ border: 'none',
+ borderRadius: '6px',
+ outline: 'none',
+ color: 'white',
+ fontWeight: 800,
+ fontSize: 22,
+ lineHeight: 1.3,
+ padding: '2px 6px',
+ boxSizing: 'border-box',
+ fontFamily: '"Montserrat",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
+ }}
+ />
+ ) : (
+ { setTitleDraft(titleInput); setEditingTitle(true) }}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ cursor: 'text',
+ borderRadius: '6px',
+ px: '6px',
+ mx: '-6px',
+ transition: 'background 0.15s',
+ '&:hover': { background: '#FFFFFF1F' },
+ }}
+ >
+
+ {displayTitle}
+
+
+ )}
+
+ )
+
const canUpload = authenticated ? !!uiConfig?.show_admin_upload : !!uiConfig?.allow_public_upload
if (!canUpload) return null
@@ -405,26 +540,58 @@ const UploadCard = React.forwardRef(function UploadCard(