From 9b62b2f3dbdaa3e4e390f725c55195ddd36144ff Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Fri, 6 Feb 2026 19:48:29 -0800 Subject: [PATCH] Added BetaVideoCards Component, Testing page hidden from public view --- app/client/src/App.js | 11 + .../src/components/admin/BetaVideoCards.js | 176 ++++++ .../components/admin/CompactBetaVideoCard.js | 256 ++++++++ app/client/src/views/FeedTesting.js | 576 ++++++++++++++++++ 4 files changed, 1019 insertions(+) create mode 100644 app/client/src/components/admin/BetaVideoCards.js create mode 100644 app/client/src/components/admin/CompactBetaVideoCard.js create mode 100644 app/client/src/views/FeedTesting.js 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 && ( + + + + )} + + )} + {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 && ( + + + {/* Info section below thumbnail */} + + {/* Game icon */} + + {game?.icon_url ? ( + {game.name} + ) : ( + + )} + + + {/* 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( + + + + + {/* Edit mode buttons */} + {authenticated && ( + + {editMode && ( + + + + + + )} + + {editMode ? : } + + + )} + + {listStyle === 'list' && ( + : null} + videos={displayVideos} + /> + )} + {listStyle === 'card' && ( + + {!loading && ( + + )} + + )} + + + + + + {/* Delete Confirmation Dialog */} + + + Delete {selectedVideos.size} Video{selectedVideos.size > 1 ? 's' : ''}? + + + + Are you sure you want to delete the selected video{selectedVideos.size > 1 ? 's' : ''}? This will + permanently delete the video file{selectedVideos.size > 1 ? 's' : ''}. + + + + + + + + + {/* Link to Game Dialog */} + + Link {selectedVideos.size} Clip{selectedVideos.size !== 1 ? 's' : ''} to Game + + {!showAddNewGame ? ( + <> + option.name || ''} + value={selectedGame} + onChange={(_, newValue) => { + if (newValue?.isAddNew) { + setShowAddNewGame(true) + setSelectedGame(null) + } else { + setSelectedGame(newValue) + } + }} + renderInput={(params) => } + renderOption={(props, option) => ( + + {option.icon_url && ( + {option.name} + )} + {option.name} + + )} + /> + + ) : ( + <> + + setAlert({ + open: true, + type: 'error', + message: err.response?.data || 'Error adding game', + }) + } + placeholder="Search SteamGridDB..." + /> + + )} + + + {showAddNewGame && ( + + )} + + {!showAddNewGame && ( + + )} + + + + {/* Release Notes Dialog */} + + + {releaseNotes?.name || `Update ${releaseNotes?.version}`} + + + /g, '>') + // Remove @username mentions + .replace(/@[\w-]+/g, '') + // Headers + .replace(/^## (.+)$/gm, '$1') + .replace(/^### (.+)$/gm, '$1') + // Bold + .replace(/\*\*(.+?)\*\*/g, '$1') + // Links + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + .replace(/(https?:\/\/[^\s<]+)/g, '$1') + // Line breaks + .replace(/\n\n/g, '

') + .replace(/\n/g, '
') + // Wrap in paragraph + .replace(/^(.*)$/, '

$1

') + : 'Check out the latest updates!', + }} + /> + {releaseNotes?.html_url && ( + + + View full release on GitHub + + + )} +
+ + + +
+ + ) +} + +export default FeedTesting