Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions app/client/src/components/admin/CompactVideoCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import VideoService from '../../services/VideoService'
import _ from 'lodash'
import UpdateDetailsModal from '../modal/UpdateDetailsModal'
import LightTooltip from '../misc/LightTooltip'
import GameDetectionCard from '../game/GameDetectionCard'

const URL = getUrl()
const PURL = getPublicWatchUrl()
Expand All @@ -36,6 +37,8 @@ const CompactVideoCard = ({
const [privateView, setPrivateView] = React.useState(video.info?.private)

const [detailsModalOpen, setDetailsModalOpen] = React.useState(false)
const [gameSuggestion, setGameSuggestion] = React.useState(null)
const [showSuggestion, setShowSuggestion] = React.useState(true)

const previousVideoRef = React.useRef()
const previousVideo = previousVideoRef.current
Expand All @@ -51,6 +54,25 @@ const CompactVideoCard = ({
previousVideoRef.current = video
})

React.useEffect(() => {
// Fetch game suggestion when component mounts
VideoService.getGameSuggestion(video.video_id)
.then((response) => {
if (response.data) {
setGameSuggestion(response.data)
setShowSuggestion(true)
}
})
.catch(() => {
// No suggestion or error - that's fine
})
}, [video.video_id])

const handleSuggestionComplete = () => {
setShowSuggestion(false)
setGameSuggestion(null)
}

const debouncedMouseEnter = React.useRef(
_.debounce(() => {
setHover(true)
Expand Down Expand Up @@ -304,8 +326,8 @@ const CompactVideoCard = ({
width: cardWidth,
minHeight: previewVideoHeight,
border: '1px solid #3399FFAE',
borderBottomRightRadius: '6px',
borderBottomLeftRadius: '6px',
borderBottomRightRadius: (authenticated && gameSuggestion && showSuggestion && !editMode) ? 0 : '6px',
borderBottomLeftRadius: (authenticated && gameSuggestion && showSuggestion && !editMode) ? 0 : '6px',
borderTop: 'none',
background: 'repeating-linear-gradient(45deg,#606dbc,#606dbc 10px,#465298 10px,#465298 20px)',
overflow: 'hidden'
Expand All @@ -326,8 +348,8 @@ const CompactVideoCard = ({
WebkitAnimationDuration: '1.5s',
WebkitAnimationFillMode: 'both',
border: '1px solid #3399FFAE',
borderBottomRightRadius: '6px',
borderBottomLeftRadius: '6px',
borderBottomRightRadius: (authenticated && gameSuggestion && showSuggestion && !editMode) ? 0 : '6px',
borderBottomLeftRadius: (authenticated && gameSuggestion && showSuggestion && !editMode) ? 0 : '6px',
borderTop: 'none',
overflow: 'hidden'
}}
Expand Down Expand Up @@ -398,6 +420,16 @@ const CompactVideoCard = ({
</Box>
</div>
</Box>

{/* Game Detection Suggestion Card */}
{authenticated && gameSuggestion && showSuggestion && !editMode && (
<GameDetectionCard
videoId={video.video_id}
suggestion={gameSuggestion}
onComplete={handleSuggestionComplete}
cardWidth={cardWidth}
/>
)}
</Box>
</>
)
Expand Down
181 changes: 181 additions & 0 deletions app/client/src/components/game/GameDetectionCard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import React, { useState } from 'react'
import { Box, IconButton, Typography, Fade } from '@mui/material'
import CheckIcon from '@mui/icons-material/Check'
import CloseIcon from '@mui/icons-material/Close'
import SportsEsportsIcon from '@mui/icons-material/SportsEsports'
import VideoService from '../../services/VideoService'
import GameService from '../../services/GameService'

/**
* GameDetectionCard - Shows automatic game detection suggestions
* Appears below video thumbnails when a game is detected from the filename
*/
export default function GameDetectionCard({ videoId, suggestion, onComplete, cardWidth }) {
const [loading, setLoading] = useState(false)
const [status, setStatus] = useState('pending') // 'pending', 'accepted', 'rejected'

const handleAccept = async (e) => {
e.stopPropagation() // Prevent triggering video card click
setLoading(true)

try {
let gameId = suggestion.game_id

// If game doesn't exist in our DB (came from SteamGridDB), create it first
if (!gameId && suggestion.steamgriddb_id) {
// Reuse the same logic as GameSearch.js
const assets = (await GameService.getGameAssets(suggestion.steamgriddb_id)).data
const gameData = {
steamgriddb_id: suggestion.steamgriddb_id,
name: suggestion.game_name,
hero_url: assets.hero_url,
logo_url: assets.logo_url,
icon_url: assets.icon_url,
}
const createdGame = (await GameService.createGame(gameData)).data
gameId = createdGame.id
}

// Link video to game using existing service
await GameService.linkVideoToGame(videoId, gameId)

// Remove the suggestion from cache
await VideoService.rejectGameSuggestion(videoId)

setStatus('accepted')
// Auto-hide after showing success
setTimeout(() => {
onComplete?.()
}, 2000)
} catch (err) {
console.error('Failed to accept game suggestion:', err)
setLoading(false)
}
}

const handleReject = async (e) => {
e.stopPropagation() // Prevent triggering video card click
setLoading(true)

try {
await VideoService.rejectGameSuggestion(videoId)
setStatus('rejected')
// Hide immediately
setTimeout(() => {
onComplete?.()
}, 300)
} catch (err) {
console.error('Failed to reject game suggestion:', err)
setLoading(false)
}
}

if (status === 'accepted') {
return (
<Fade in timeout={500}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
p: 1,
width: cardWidth,
background: 'rgba(76, 175, 80, 0.2)',
borderLeft: '1px solid rgba(76, 175, 80, 0.8)',
borderRight: '1px solid rgba(76, 175, 80, 0.8)',
borderBottom: '1px solid rgba(76, 175, 80, 0.8)',
borderBottomLeftRadius: '6px',
borderBottomRightRadius: '6px',
lineHeight: 0,
}}
>
<CheckIcon sx={{ color: '#4caf50', fontSize: 20 }} />
<Typography variant="body2" sx={{ color: '#4caf50', fontWeight: 600 }}>
Linked to {suggestion.game_name}
</Typography>
</Box>
</Fade>
)
}

if (status === 'rejected') {
return null
}

return (
<Fade in timeout={500}>
<Box
onClick={(e) => e.stopPropagation()} // Prevent triggering video card click
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 1,
width: cardWidth,
background: 'rgba(50, 50, 50, 0.95)',
borderLeft: '1px solid #3399FFAE',
borderRight: '1px solid #3399FFAE',
borderBottom: '1px solid #3399FFAE',
borderBottomLeftRadius: '6px',
borderBottomRightRadius: '6px',
lineHeight: 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, minWidth: 0 }}>
<SportsEsportsIcon sx={{ color: '#3399FF', fontSize: 20, flexShrink: 0 }} />
<Typography
variant="body2"
sx={{
color: 'rgba(255, 255, 255, 0.95)',
fontSize: '0.875rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
Detected: <strong>{suggestion.game_name}</strong>
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
<IconButton
size="small"
onClick={handleAccept}
disabled={loading}
sx={{
color: '#4caf50',
bgcolor: 'rgba(76, 175, 80, 0.1)',
'&:hover': {
bgcolor: 'rgba(76, 175, 80, 0.2)',
transform: 'scale(1.1)',
},
transition: 'all 0.2s',
width: 28,
height: 28,
}}
>
<CheckIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={handleReject}
disabled={loading}
sx={{
color: '#f44336',
bgcolor: 'rgba(244, 67, 54, 0.1)',
'&:hover': {
bgcolor: 'rgba(244, 67, 54, 0.2)',
transform: 'scale(1.1)',
},
transition: 'all 0.2s',
width: 28,
height: 28,
}}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
</Box>
</Fade>
)
}
10 changes: 9 additions & 1 deletion app/client/src/components/nav/Navbar20.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const minimizedDrawerWidth = 57
const CARD_SIZE_DEFAULT = 375
const CARD_SIZE_MULTIPLIER = 2

const pages = [
const allPages = [
{ title: 'My Videos', icon: <VideoLibraryIcon />, href: '/', private: true },
{ title: 'Public Videos', icon: <PublicIcon />, href: '/feed', private: false },
{ title: 'Games', icon: <SportsEsportsIcon />, href: '/games', private: false },
Expand Down Expand Up @@ -149,6 +149,14 @@ function Navbar20({
const [alert, setAlert] = React.useState({ open: false })
const navigate = useNavigate()

const uiConfig = getSetting('ui_config') || {}
const pages = allPages.filter((p) => {
if (p.href === '/' && uiConfig.show_my_videos === false) return false
if (p.href === '/feed' && uiConfig.show_public_videos === false) return false
if (p.href === '/games' && uiConfig.show_games === false) return false
return true
})

const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen)
}
Expand Down
9 changes: 9 additions & 0 deletions app/client/src/services/VideoService.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,15 @@ const service = {
scan() {
return Api().get('/api/manual/scan')
},
scanGames() {
return Api().get('/api/manual/scan-games')
},
getGameSuggestion(videoId) {
return Api().get(`/api/videos/${videoId}/game/suggestion`)
},
rejectGameSuggestion(videoId) {
return Api().delete(`/api/videos/${videoId}/game/suggestion`)
},
}

export default service
12 changes: 3 additions & 9 deletions app/client/src/views/Dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -396,13 +396,10 @@ const Dashboard = ({ authenticated, searchText, cardSize, listStyle }) => {

{/* Link to Game Dialog */}
<Dialog open={linkGameDialogOpen} onClose={handleLinkGameCancel} maxWidth="sm" fullWidth>
<DialogTitle>Link Videos to Game</DialogTitle>
<DialogContent>
<DialogTitle>Link {selectedVideos.size} Clip{selectedVideos.size !== 1 ? 's' : ''} to Game</DialogTitle>
<DialogContent sx={{ pt: 3 }}>
{!showAddNewGame ? (
<>
<Typography sx={{ mb: 2 }}>
Select a game to link {selectedVideos.size} video{selectedVideos.size > 1 ? 's' : ''} to:
</Typography>
<Autocomplete
options={[...games, { id: 'add-new', name: 'Add a new game...', isAddNew: true }]}
getOptionLabel={(option) => option.name || ''}
Expand All @@ -415,7 +412,7 @@ const Dashboard = ({ authenticated, searchText, cardSize, listStyle }) => {
setSelectedGame(newValue)
}
}}
renderInput={(params) => <TextField {...params} label="Select Game" />}
renderInput={(params) => <TextField {...params} placeholder="Select a game..." />}
renderOption={(props, option) => (
<Box
component="li"
Expand All @@ -438,9 +435,6 @@ const Dashboard = ({ authenticated, searchText, cardSize, listStyle }) => {
</>
) : (
<>
<Typography sx={{ mb: 2 }}>
Search for a game to add and link {selectedVideos.size} video{selectedVideos.size > 1 ? 's' : ''} to:
</Typography>
<GameSearch
onGameLinked={handleNewGameCreated}
onError={(err) =>
Expand Down
Loading