{
+ const [selectedGame, setSelectedGame] = React.useState(null)
+ const [gameOptions, setGameOptions] = React.useState([])
+ const [gameSearchLoading, setGameSearchLoading] = React.useState(false)
+ const [gameLinkLoading, setGameLinkLoading] = React.useState(false)
+
+ const searchGames = async (query) => {
+ if (!query || query.length < 2) {
+ setGameOptions([])
+ return
+ }
+ setGameSearchLoading(true)
+ try {
+ const results = (await GameService.searchSteamGrid(query)).data || []
+ setGameOptions(results)
+ } catch (err) {
+ setGameOptions([])
+ }
+ setGameSearchLoading(false)
+ }
+
+ const handleGameChange = async (_, newValue) => {
+ if (!newValue) {
+ setSelectedGame(null)
+ return
+ }
+
+ setSelectedGame(newValue)
+ setGameLinkLoading(true)
+
+ try {
+ // Check if game already exists
+ const allGames = (await GameService.getGames()).data
+ let game = allGames.find((g) => g.steamgriddb_id === newValue.id)
+
+ if (!game) {
+ // Create the game
+ const assets = (await GameService.getGameAssets(newValue.id)).data
+ const gameData = {
+ steamgriddb_id: newValue.id,
+ name: newValue.name,
+ release_date: newValue.release_date
+ ? new Date(newValue.release_date * 1000).toISOString().split('T')[0]
+ : null,
+ hero_url: assets.hero_url,
+ logo_url: assets.logo_url,
+ icon_url: assets.icon_url,
+ }
+ game = (await GameService.createGame(gameData)).data
+ }
+
+ // Call the callback with the game
+ if (onGameLinked) {
+ onGameLinked(game)
+ }
+
+ // Reset the autocomplete
+ setSelectedGame(null)
+ setGameOptions([])
+ } catch (err) {
+ console.error('Error creating/linking game:', err)
+ if (onError) {
+ onError(err)
+ }
+ setSelectedGame(null)
+ } finally {
+ setGameLinkLoading(false)
+ }
+ }
+
+ return (
+
searchGames(val)}
+ options={gameOptions}
+ getOptionLabel={(option) => option.name || ''}
+ loading={gameSearchLoading}
+ disabled={disabled || gameLinkLoading}
+ renderInput={(params) => (
+
+
+
+ ),
+ endAdornment: (
+ <>
+ {gameLinkLoading && (
+
+
+
+ )}
+ {params.InputProps.endAdornment}
+ >
+ ),
+ }}
+ />
+ )}
+ renderOption={(props, option) => (
+
+ {option.name}
+ {option.release_date && ` (${new Date(option.release_date * 1000).getFullYear()})`}
+
+ )}
+ />
+ )
+}
+
+export default GameSearch
diff --git a/app/client/src/components/modal/VideoModal.js b/app/client/src/components/modal/VideoModal.js
index f43a83c4..261947c7 100644
--- a/app/client/src/components/modal/VideoModal.js
+++ b/app/client/src/components/modal/VideoModal.js
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'
-import { Autocomplete, Box, Button, ButtonGroup, Grid, IconButton, InputAdornment, Modal, Paper, Slide, TextField } from '@mui/material'
+import { Box, Button, ButtonGroup, Grid, IconButton, InputAdornment, Modal, Paper, Slide, TextField } from '@mui/material'
import LinkIcon from '@mui/icons-material/Link'
import AccessTimeIcon from '@mui/icons-material/AccessTime'
import ShuffleIcon from '@mui/icons-material/Shuffle'
@@ -13,6 +13,7 @@ import { copyToClipboard, getPublicWatchUrl, getServedBy, getUrl, getVideoSource
import { ConfigService, VideoService, GameService } from '../../services'
import SnackbarAlert from '../alert/SnackbarAlert'
import VideoJSPlayer from '../misc/VideoJSPlayer'
+import GameSearch from '../game/GameSearch'
const URL = getUrl()
const PURL = getPublicWatchUrl()
@@ -28,9 +29,6 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal
const [alert, setAlert] = React.useState({ open: false })
const [autoplay, setAutoplay] = useState(false)
const [selectedGame, setSelectedGame] = React.useState(null)
- const [gameOptions, setGameOptions] = React.useState([])
- const [gameSearchLoading, setGameSearchLoading] = React.useState(false)
- const [gameLinkLoading, setGameLinkLoading] = React.useState(false)
const playerRef = React.useRef()
@@ -100,77 +98,41 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal
}
}, [videoId])
- const searchGames = async (query) => {
- if (!query || query.length < 2) return setGameOptions([])
- setGameSearchLoading(true)
+ const handleGameLinked = async (game) => {
try {
- setGameOptions((await GameService.searchSteamGrid(query)).data || [])
+ await GameService.linkVideoToGame(vid.video_id, game.id)
+ setSelectedGame(game)
+ setAlert({
+ type: 'success',
+ message: `Linked to ${game.name}`,
+ open: true,
+ })
} catch (err) {
- setGameOptions([])
+ console.error('Error linking game:', err)
+ setAlert({
+ type: 'error',
+ message: 'Failed to link game',
+ open: true,
+ })
}
- setGameSearchLoading(false)
}
- const handleGameChange = async (event, newValue) => {
- if (!authenticated) return
-
- if (newValue) {
- // Set the selected game immediately so it stays visible during loading
- setSelectedGame(newValue)
- setGameLinkLoading(true)
- try {
- const allGames = (await GameService.getGames()).data
- let game = allGames.find(g => g.steamgriddb_id === newValue.id)
-
- if (!game) {
- const assets = (await GameService.getGameAssets(newValue.id)).data
- const gameData = {
- steamgriddb_id: newValue.id,
- name: newValue.name,
- release_date: newValue.release_date ? new Date(newValue.release_date * 1000).toISOString().split('T')[0] : null,
- hero_url: assets.hero_url,
- logo_url: assets.logo_url,
- icon_url: assets.icon_url,
- }
- game = (await GameService.createGame(gameData)).data
- }
-
- await GameService.linkVideoToGame(vid.video_id, game.id)
-
- // Update with the full game object from the database
- setSelectedGame(game)
- setAlert({
- type: 'success',
- message: `Linked to ${newValue.name}`,
- open: true,
- })
- } catch (err) {
- console.error('Error linking game:', err)
- // Revert selection on error
- setSelectedGame(null)
- setAlert({
- type: 'error',
- message: 'Failed to link game',
- open: true,
- })
- } finally {
- setGameLinkLoading(false)
- }
- } else {
- setGameLinkLoading(true)
- try {
- await GameService.unlinkVideoFromGame(vid.video_id)
- setSelectedGame(null)
- setAlert({
- type: 'info',
- message: 'Game link removed',
- open: true,
- })
- } catch (err) {
- console.error('Error unlinking game:', err)
- } finally {
- setGameLinkLoading(false)
- }
+ const handleUnlinkGame = async () => {
+ try {
+ await GameService.unlinkVideoFromGame(vid.video_id)
+ setSelectedGame(null)
+ setAlert({
+ type: 'info',
+ message: 'Game link removed',
+ open: true,
+ })
+ } catch (err) {
+ console.error('Error unlinking game:', err)
+ setAlert({
+ type: 'error',
+ message: 'Failed to unlink game',
+ open: true,
+ })
}
}
@@ -420,75 +382,58 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal
/>
)}
- {/* Game ID search bar */}
+ {/* Game linking */}
{authenticated && (
- searchGames(val)}
- options={gameOptions}
- getOptionLabel={(option) => option.name || ''}
- loading={gameSearchLoading}
- disabled={gameLinkLoading}
- renderInput={(params) => (
-
+
+
+
+ {selectedGame.name}
+
+
+
-
-
- ),
- endAdornment: (
- <>
- {gameLinkLoading && (
-
-
-
-
-
- )}
- {params.InputProps.endAdornment}
- >
- ),
+ sx={{
+ color: 'rgba(255, 255, 255, 0.5)',
+ '&:hover': {
+ color: 'rgba(255, 255, 255, 0.9)',
+ },
}}
- />
- )}
- renderOption={(props, option) => (
-
- {option.name}
- {option.release_date && ` (${new Date(option.release_date * 1000).getFullYear()})`}
-
- )}
- sx={{
- '& .MuiAutocomplete-input, & .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator': {
- color: '#fff',
- opacity: 0.7,
- },
- '& .Mui-disabled': {
- WebkitTextFillColor: '#fff !important',
- opacity: '0.7 !important',
- },
- }}
- />
+ >
+
+
+
+ ) : (
+
+ setAlert({
+ open: true,
+ type: 'error',
+ message: err.response?.data || 'Error linking game',
+ })
+ }
+ placeholder="Search for a game..."
+ />
+ )}
)}
diff --git a/app/client/src/views/Dashboard.js b/app/client/src/views/Dashboard.js
index dbaedb37..6bb15c5b 100644
--- a/app/client/src/views/Dashboard.js
+++ b/app/client/src/views/Dashboard.js
@@ -1,8 +1,26 @@
import React from 'react'
-import { Box, Grid, Stack } from '@mui/material'
+import {
+ Box,
+ Grid,
+ Stack,
+ 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 VideoCards from '../components/admin/VideoCards'
import VideoList from '../components/admin/VideoList'
-import { VideoService } from '../services'
+import GameSearch from '../components/game/GameSearch'
+import { VideoService, GameService } from '../services'
import LoadingSpinner from '../components/misc/LoadingSpinner'
import { getSetting, setSetting } from '../common/utils'
import Select from 'react-select'
@@ -32,6 +50,15 @@ const Dashboard = ({ authenticated, searchText, cardSize, listStyle }) => {
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)
+
if (searchText !== search) {
setSearch(searchText)
setFilteredVideos(videos.filter((v) => v.info.title.search(new RegExp(searchText, 'i')) >= 0))
@@ -88,6 +115,141 @@ const Dashboard = ({ authenticated, searchText, cardSize, listStyle }) => {
setSelectedSort(sortOption)
}
+ const handleEditModeToggle = () => {
+ setEditMode(!editMode)
+ if (editMode) {
+ setSelectedVideos(new Set())
+ }
+ }
+
+ 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 })}>
@@ -120,6 +282,53 @@ const Dashboard = ({ authenticated, searchText, cardSize, listStyle }) => {
+ {/* Edit mode buttons */}
+ {authenticated && (
+
+ {editMode && (
+ <>
+ }
+ onClick={handleLinkGameClick}
+ disabled={selectedVideos.size === 0}
+ sx={{
+ borderRadius: '8px',
+ }}
+ >
+ Link to Game {selectedVideos.size > 0 && `(${selectedVideos.size})`}
+
+ }
+ onClick={handleDeleteClick}
+ disabled={selectedVideos.size === 0}
+ sx={{
+ borderRadius: '8px',
+ }}
+ >
+ Delete {selectedVideos.size > 0 && `(${selectedVideos.size})`}
+
+ >
+ )}
+
+ {editMode ? : }
+
+
+ )}
{listStyle === 'list' && (
{
size={cardSize}
showUploadCard={selectedFolder.value === 'All Videos'}
fetchVideos={fetchVideos}
+ editMode={editMode}
+ selectedVideos={selectedVideos}
+ onVideoSelect={handleVideoSelect}
videos={
selectedFolder.value === 'All Videos'
? filteredVideos
@@ -162,6 +374,101 @@ const Dashboard = ({ authenticated, searchText, cardSize, listStyle }) => {
+
+ {/* Delete Confirmation Dialog */}
+
+
+ {/* Link to Game Dialog */}
+
>
)
}
diff --git a/app/client/src/views/Games.js b/app/client/src/views/Games.js
index 27b55386..8398d6d7 100644
--- a/app/client/src/views/Games.js
+++ b/app/client/src/views/Games.js
@@ -136,9 +136,8 @@ const Games = ({ authenticated }) => {
sx={{
bgcolor: editMode ? 'primary.main' : 'rgba(255, 255, 255, 0.1)',
borderRadius: '8px',
- width: '36.5px',
- height: '36.5px',
- padding: '8px',
+ width: '40px',
+ height: '40px',
'&:hover': {
bgcolor: editMode ? 'primary.dark' : 'rgba(255, 255, 255, 0.2)',
},