diff --git a/app/client/src/App.js b/app/client/src/App.js
index ca57d78..47c52f7 100644
--- a/app/client/src/App.js
+++ b/app/client/src/App.js
@@ -8,6 +8,7 @@ import Dashboard from './views/Dashboard'
import NotFound from './views/NotFound'
import Settings from './views/Settings'
import Feed from './views/Feed'
+import FeedTesting from './views/FeedTesting'
import Games from './views/Games'
import GameVideos from './views/GameVideos'
import darkTheme from './common/darkTheme'
@@ -95,6 +96,16 @@ export default function App() {
}
/>
+
+
+
+
+
+ }
+ />
{
+ const [vids, setVideos] = React.useState(videos)
+ const [alert, setAlert] = React.useState({ open: false })
+ const [videoModal, setVideoModal] = React.useState({
+ open: false,
+ })
+
+ const previousVideosRef = React.useRef()
+ const previousVideos = previousVideosRef.current
+ if (videos !== previousVideos && videos !== vids) {
+ setVideos(videos)
+ }
+ React.useEffect(() => {
+ previousVideosRef.current = videos
+ })
+
+ const openVideo = (id) => {
+ setVideoModal({
+ open: true,
+ id,
+ })
+ }
+
+ const onModalClose = () => {
+ setVideoModal({ open: false })
+ }
+
+ const memoizedHandleAlert = useCallback((alert) => {
+ setAlert(alert)
+ }, [])
+
+ const handleScan = () => {
+ VideoService.scan().catch((err) =>
+ setAlert({
+ open: true,
+ type: 'error',
+ message: err.response?.data || 'Unknown Error',
+ }),
+ )
+ setAlert({
+ open: true,
+ type: 'info',
+ message: 'Scan initiated. This could take a few minutes.',
+ })
+ }
+
+ const handleUpdate = (update) => {
+ const { id, ...rest } = update
+ setVideos((vs) => vs.map((v) => (v.video_id === id ? { ...v, info: { ...v.info, ...rest } } : v)))
+ }
+
+ const handleDelete = (id) => {
+ setVideos((vs) => vs.filter((v) => v.video_id !== id))
+ }
+
+ const EMPTY_STATE = () => (
+
+
+ {!loadingIcon && (
+ <>
+
+
+ NO VIDEOS FOUND
+
+
+
+ {!feedView && (
+
+ } onClick={handleScan}>
+ Scan Library
+
+
+ )}
+ >
+ )}
+ {loadingIcon}
+
+ {!loadingIcon && (
+
+
+
+ )}
+
+ )
+
+ return (
+
+
+ setAlert({ ...alert, open })}
+ >
+ {alert.message}
+
+
+ {(!vids || vids.length === 0) && EMPTY_STATE()}
+ {vids && vids.length !== 0 && (
+
+ {showUploadCard && (
+
+
+
+ )}
+ {vids.map((v) => (
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+export default BetaVideoCards
diff --git a/app/client/src/components/admin/CompactBetaVideoCard.js b/app/client/src/components/admin/CompactBetaVideoCard.js
new file mode 100644
index 0000000..4c2f819
--- /dev/null
+++ b/app/client/src/components/admin/CompactBetaVideoCard.js
@@ -0,0 +1,256 @@
+import React from 'react'
+import { Box, Typography } from '@mui/material'
+import { getPublicWatchUrl, getServedBy, getUrl, toHHMMSS, getVideoUrl } from '../../common/utils'
+import { GameService } from '../../services'
+import _ from 'lodash'
+
+const URL = getUrl()
+const PURL = getPublicWatchUrl()
+const SERVED_BY = getServedBy()
+
+const CompactBetaVideoCard = ({
+ video,
+ openVideoHandler,
+ cardWidth,
+}) => {
+ const [intVideo, setIntVideo] = React.useState(video)
+ const [hover, setHover] = React.useState(false)
+ const [game, setGame] = React.useState(null)
+
+ const previousVideoRef = React.useRef()
+ const previousVideo = previousVideoRef.current
+ if (!_.isEqual(video, previousVideo) && !_.isEqual(video, intVideo)) {
+ setIntVideo(video)
+ }
+ React.useEffect(() => {
+ previousVideoRef.current = video
+ })
+
+ React.useEffect(() => {
+ GameService.getVideoGame(video.video_id)
+ .then((response) => {
+ if (response.data) {
+ setGame(response.data)
+ }
+ })
+ .catch(() => {
+ // No game linked
+ })
+ }, [video.video_id])
+
+ const debouncedMouseEnter = React.useRef(
+ _.debounce(() => {
+ setHover(true)
+ }, 750),
+ ).current
+
+ const handleMouseLeave = () => {
+ debouncedMouseEnter.cancel()
+ setHover(false)
+ }
+
+ const handleMouseDown = (e) => {
+ if (e.button === 1) {
+ window.open(`${PURL}${video.video_id}`, '_blank')
+ }
+ }
+
+ const previewVideoHeight =
+ video.info?.width && video.info?.height ? cardWidth * (video.info.height / video.info.width) : cardWidth / 1.77
+
+ const getPreviewVideoUrl = () => {
+ const has720p = video.info?.has_720p
+ const has1080p = video.info?.has_1080p
+
+ if (has720p) {
+ return getVideoUrl(video.video_id, '720p', video.extension)
+ }
+
+ if (has1080p) {
+ return getVideoUrl(video.video_id, '1080p', video.extension)
+ }
+
+ return getVideoUrl(video.video_id, 'original', video.extension)
+ }
+
+ const title = video.info?.title || 'Untitled'
+ const gameName = game?.name || ''
+ const viewCount = video.view_count || 0
+
+ return (
+
+ {/* Thumbnail */}
+ openVideoHandler(video.video_id)}
+ onMouseEnter={debouncedMouseEnter}
+ onMouseLeave={handleMouseLeave}
+ onMouseDown={handleMouseDown}
+ >
+
+
+ {hover && (
+
+ )}
+
+ {/* Duration badge */}
+
+
+ {toHHMMSS(video.info?.duration)}
+
+
+
+
+ {/* Info section below thumbnail */}
+
+ {/* Game icon */}
+
+ {game?.icon_url ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Text info */}
+
+ {/* Title */}
+
+ {title}
+
+
+ {/* Game name */}
+ {gameName && (
+
+ {gameName}
+
+ )}
+
+ {/* Views */}
+
+ {viewCount} {viewCount === 1 ? 'view' : 'views'}
+
+
+
+
+ )
+}
+
+export default CompactBetaVideoCard
diff --git a/app/client/src/views/FeedTesting.js b/app/client/src/views/FeedTesting.js
new file mode 100644
index 0000000..f1e48a7
--- /dev/null
+++ b/app/client/src/views/FeedTesting.js
@@ -0,0 +1,576 @@
+import React from 'react'
+import ReactDOM from 'react-dom'
+import {
+ Box,
+ Grid,
+ IconButton,
+ Button,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Typography,
+ Autocomplete,
+ TextField,
+} from '@mui/material'
+import EditIcon from '@mui/icons-material/Edit'
+import DeleteIcon from '@mui/icons-material/Delete'
+import CheckIcon from '@mui/icons-material/Check'
+import LinkIcon from '@mui/icons-material/Link'
+import BetaVideoCards from '../components/admin/BetaVideoCards'
+import VideoList from '../components/admin/VideoList'
+import GameSearch from '../components/game/GameSearch'
+import { VideoService, GameService, ReleaseService } from '../services'
+import LoadingSpinner from '../components/misc/LoadingSpinner'
+import { getSetting, setSetting } from '../common/utils'
+import Select from 'react-select'
+import SnackbarAlert from '../components/alert/SnackbarAlert'
+
+import selectFolderTheme from '../common/reactSelectFolderTheme'
+import selectSortTheme from '../common/reactSelectSortTheme'
+import { SORT_OPTIONS } from '../common/constants'
+
+const createSelectFolders = (folders) => {
+ return folders.map((f) => ({ value: f, label: f }))
+}
+
+const FeedTesting = ({ authenticated, searchText, cardSize, listStyle, showReleaseNotes, releaseNotes: releaseNotesProp }) => {
+ const [videos, setVideos] = React.useState([])
+ const [search, setSearch] = React.useState(searchText)
+ const [filteredVideos, setFilteredVideos] = React.useState([])
+ const [loading, setLoading] = React.useState(true)
+ const [folders, setFolders] = React.useState(['All Videos'])
+ const [selectedFolder, setSelectedFolder] = React.useState(
+ getSetting('folder') || { value: 'All Videos', label: 'All Videos' },
+ )
+ const [dateSortOrder, setDateSortOrder] = React.useState(SORT_OPTIONS?.[0] || { value: 'newest', label: 'Newest' })
+
+ const [alert, setAlert] = React.useState({ open: false })
+
+ const [prevCardSize, setPrevCardSize] = React.useState(cardSize)
+ const [prevListStyle, setPrevListStyle] = React.useState(listStyle)
+
+ // Edit mode state
+ const [editMode, setEditMode] = React.useState(false)
+ const [selectedVideos, setSelectedVideos] = React.useState(new Set())
+ const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
+ const [linkGameDialogOpen, setLinkGameDialogOpen] = React.useState(false)
+ const [games, setGames] = React.useState([])
+ const [selectedGame, setSelectedGame] = React.useState(null)
+ const [showAddNewGame, setShowAddNewGame] = React.useState(false)
+ const [featureAlertOpen, setFeatureAlertOpen] = React.useState(showReleaseNotes)
+ const releaseNotes = releaseNotesProp
+ const [toolbarTarget, setToolbarTarget] = React.useState(null)
+
+ if (searchText !== search) {
+ setSearch(searchText)
+ setFilteredVideos(videos.filter((v) => v.info.title.search(new RegExp(searchText, 'i')) >= 0))
+ }
+ if (cardSize !== prevCardSize) {
+ setPrevCardSize(cardSize)
+ }
+ if (listStyle !== prevListStyle) {
+ setPrevListStyle(listStyle)
+ }
+
+ function fetchVideos() {
+ VideoService.getVideos()
+ .then((res) => {
+ setVideos(res.data.videos)
+ setFilteredVideos(res.data.videos)
+ const tfolders = []
+ res.data.videos.forEach((v) => {
+ const split = v.path
+ .split('/')
+ .slice(0, -1)
+ .filter((f) => f !== '')
+ if (split.length > 0 && !tfolders.includes(split[0])) {
+ tfolders.push(split[0])
+ }
+ })
+ tfolders.sort((a, b) => (a.toLowerCase() > b.toLowerCase() ? 1 : -1)).unshift('All Videos')
+ setFolders(tfolders)
+ setLoading(false)
+ })
+ .catch((err) => {
+ setLoading(false)
+ setAlert({
+ open: true,
+ type: 'error',
+ message: typeof err.response?.data === 'string' ? err.response.data : 'Unknown Error',
+ })
+ console.log(err)
+ })
+ }
+
+ React.useEffect(() => {
+ fetchVideos()
+ // eslint-disable-next-line
+ }, [])
+
+ React.useEffect(() => {
+ setToolbarTarget(document.getElementById('navbar-toolbar-extra'))
+ }, [])
+
+ const handleFeatureAlertClose = () => {
+ if (releaseNotes?.version && authenticated) {
+ ReleaseService.setLastSeenVersion(releaseNotes.version).catch(() => {})
+ }
+ setFeatureAlertOpen(false)
+ }
+
+ const handleFolderSelection = (folder) => {
+ setSetting('folder', folder)
+ setSelectedFolder(folder)
+ }
+
+ // Check if date grouping should be shown
+ const showDateGroups = getSetting('ui_config')?.show_date_groups !== false
+ const isSortingByViews = dateSortOrder.value === 'most_views' || dateSortOrder.value === 'least_views'
+ const skipDateGrouping = isSortingByViews || !showDateGroups
+
+ // Get the filtered videos based on folder selection
+ const displayVideos = React.useMemo(() => {
+ if (selectedFolder.value === 'All Videos') {
+ return filteredVideos
+ }
+ return filteredVideos?.filter(
+ (v) =>
+ v.path
+ .split('/')
+ .slice(0, -1)
+ .filter((f) => f !== '')[0] === selectedFolder.value,
+ )
+ }, [filteredVideos, selectedFolder])
+
+ // Sort videos by recorded date or views
+ const sortedVideos = React.useMemo(() => {
+ if (!displayVideos) return []
+
+ return [...displayVideos].sort((a, b) => {
+ if (dateSortOrder.value === 'most_views') {
+ return (b.view_count || 0) - (a.view_count || 0)
+ } else if (dateSortOrder.value === 'least_views') {
+ return (a.view_count || 0) - (b.view_count || 0)
+ } else {
+ const dateA = a.recorded_at ? new Date(a.recorded_at) : new Date(0)
+ const dateB = b.recorded_at ? new Date(b.recorded_at) : new Date(0)
+ return dateSortOrder.value === 'newest' ? dateB - dateA : dateA - dateB
+ }
+ })
+ }, [displayVideos, dateSortOrder])
+
+ const handleEditModeToggle = () => {
+ setEditMode(!editMode)
+ if (editMode) {
+ setSelectedVideos(new Set())
+ }
+ }
+
+ const allSelected = sortedVideos.length > 0 && selectedVideos.size === sortedVideos.length
+
+ const handleSelectAllToggle = () => {
+ if (allSelected) {
+ setSelectedVideos(new Set())
+ } else {
+ setSelectedVideos(new Set(sortedVideos.map((v) => v.video_id)))
+ }
+ }
+
+ const handleVideoSelect = (videoId) => {
+ const newSelected = new Set(selectedVideos)
+ if (newSelected.has(videoId)) {
+ newSelected.delete(videoId)
+ } else {
+ newSelected.add(videoId)
+ }
+ setSelectedVideos(newSelected)
+ }
+
+ const handleDeleteClick = () => {
+ setDeleteDialogOpen(true)
+ }
+
+ const handleDeleteConfirm = async () => {
+ try {
+ const deletePromises = Array.from(selectedVideos).map((videoId) => VideoService.delete(videoId))
+ await Promise.all(deletePromises)
+
+ setAlert({
+ open: true,
+ type: 'success',
+ message: `Successfully deleted ${selectedVideos.size} video${selectedVideos.size > 1 ? 's' : ''}`,
+ })
+
+ // Refresh videos list
+ fetchVideos()
+
+ // Reset state
+ setSelectedVideos(new Set())
+ setDeleteDialogOpen(false)
+ setEditMode(false)
+ } catch (err) {
+ console.error('Error deleting videos:', err)
+ setAlert({
+ open: true,
+ type: 'error',
+ message: err.response?.data || 'Error deleting videos',
+ })
+ }
+ }
+
+ const handleDeleteCancel = () => {
+ setDeleteDialogOpen(false)
+ }
+
+ const handleLinkGameClick = async () => {
+ // Fetch games when opening dialog
+ try {
+ const res = await GameService.getGames()
+ setGames(res.data)
+ setLinkGameDialogOpen(true)
+ setShowAddNewGame(false)
+ setSelectedGame(null)
+ } catch (err) {
+ console.error('Error fetching games:', err)
+ setAlert({
+ open: true,
+ type: 'error',
+ message: err.response?.data || 'Error fetching games',
+ })
+ }
+ }
+
+ const handleNewGameCreated = async (game) => {
+ // Link all selected videos to the newly created game
+ try {
+ const linkPromises = Array.from(selectedVideos).map((videoId) =>
+ GameService.linkVideoToGame(videoId, game.id),
+ )
+ await Promise.all(linkPromises)
+
+ setAlert({
+ open: true,
+ type: 'success',
+ message: `Successfully linked ${selectedVideos.size} video${selectedVideos.size > 1 ? 's' : ''} to ${game.name}`,
+ })
+
+ // Reset state
+ setSelectedVideos(new Set())
+ setLinkGameDialogOpen(false)
+ setShowAddNewGame(false)
+ setEditMode(false)
+ } catch (err) {
+ console.error('Error linking videos to game:', err)
+ setAlert({
+ open: true,
+ type: 'error',
+ message: err.response?.data || 'Error linking videos to new game',
+ })
+ }
+ }
+
+ const handleLinkGameConfirm = async () => {
+ if (!selectedGame) return
+
+ try {
+ const linkPromises = Array.from(selectedVideos).map((videoId) =>
+ GameService.linkVideoToGame(videoId, selectedGame.id),
+ )
+ await Promise.all(linkPromises)
+
+ setAlert({
+ open: true,
+ type: 'success',
+ message: `Successfully linked ${selectedVideos.size} video${selectedVideos.size > 1 ? 's' : ''} to ${selectedGame.name}`,
+ })
+
+ // Reset state
+ setSelectedVideos(new Set())
+ setLinkGameDialogOpen(false)
+ setSelectedGame(null)
+ setEditMode(false)
+ } catch (err) {
+ console.error('Error linking videos to game:', err)
+ setAlert({
+ open: true,
+ type: 'error',
+ message: err.response?.data || 'Error linking videos to game',
+ })
+ }
+ }
+
+ const handleLinkGameCancel = () => {
+ setLinkGameDialogOpen(false)
+ setSelectedGame(null)
+ }
+
+ return (
+ <>
+ setAlert({ ...alert, open })}>
+ {alert.message}
+
+ {toolbarTarget && ReactDOM.createPortal(
+
+
+ ,
+ toolbarTarget,
+ )}
+
+
+
+
+
+
+
+
+ {/* Edit mode buttons */}
+ {authenticated && (
+
+ {editMode && (
+
+
+ }
+ onClick={handleLinkGameClick}
+ disabled={selectedVideos.size === 0}
+ sx={{
+ borderRadius: 0,
+ }}
+ >
+ Link to Game {selectedVideos.size > 0 && `(${selectedVideos.size})`}
+
+ }
+ onClick={handleDeleteClick}
+ disabled={selectedVideos.size === 0}
+ sx={{
+ borderRadius: '0 8px 8px 0',
+ }}
+ >
+ Delete {selectedVideos.size > 0 && `(${selectedVideos.size})`}
+
+
+ )}
+
+ {editMode ? : }
+
+
+ )}
+
+ {listStyle === 'list' && (
+ : null}
+ videos={displayVideos}
+ />
+ )}
+ {listStyle === 'card' && (
+
+ {!loading && (
+
+ )}
+
+ )}
+
+
+
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+ {/* Link to Game Dialog */}
+
+
+ {/* Release Notes Dialog */}
+
')
+ .replace(/\n/g, '
')
+ // Wrap in paragraph
+ .replace(/^(.*)$/, '
$1
')
+ : 'Check out the latest updates!',
+ }}
+ />
+ {releaseNotes?.html_url && (
+
+
+ View full release on GitHub
+
+
+ )}
+
+
+
+
+
+ >
+ )
+}
+
+export default FeedTesting