diff --git a/.github/workflows/docker-publish-main-arm.yaml b/.github/workflows/docker-publish-main-arm.yaml deleted file mode 100644 index aa4e7e79..00000000 --- a/.github/workflows/docker-publish-main-arm.yaml +++ /dev/null @@ -1,52 +0,0 @@ -name: Publish Main Image (ARM) -on: - push: - paths: - - "app/**" - - "migrations/**" - - "Dockerfile" - - "entrypoint.sh" - tags: - - "v*" -jobs: - push_latest_to_registry: - name: Build & Push - runs-on: ubuntu-latest - steps: - - name: Check out the repo - uses: actions/checkout@v2 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - - name: Setup Docker buildx - uses: docker/setup-buildx-action@v1.6.0 - - - name: Log in to Docker Hub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Log in to the GitHub Container registry - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GIT_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v4 - with: - images: | - shaneisrael/fireshare - - - name: Build and push Docker images - uses: docker/build-push-action@v2 - with: - context: . - platforms: linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/docker-publish-main.yml b/.github/workflows/docker-publish-main.yml index c216e9a6..a2c960d0 100644 --- a/.github/workflows/docker-publish-main.yml +++ b/.github/workflows/docker-publish-main.yml @@ -8,28 +8,50 @@ on: - "entrypoint.sh" tags: - "v*" + workflow_dispatch: + inputs: + tag: + description: The build tag to push. + required: true + default: "latest" + +env: + REGISTRY_IMAGE: shaneisrael/fireshare + jobs: - push_latest_to_registry: - name: Build & Push + build: + if: ${{ github.event_name == 'push' }} + name: Build (${{ matrix.platform }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 steps: + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + - name: Check out the repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v3 - name: Setup Docker buildx - uses: docker/setup-buildx-action@v1.6.0 + uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - + - name: Log in to the GitHub Container registry - uses: docker/login-action@v1 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -37,16 +59,179 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: - images: | - shaneisrael/fireshare + images: ${{ env.REGISTRY_IMAGE }} - - name: Build and push Docker images - uses: docker/build-push-action@v2 + - name: Build and push by digest + id: build + uses: docker/build-push-action@v5 with: context: . - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} + platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + if: ${{ github.event_name == 'push' }} + name: Merge & Push Manifest + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Log in to the GitHub Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GIT_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY_IMAGE }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} + + manual_build: + if: ${{ github.event_name == 'workflow_dispatch' }} + name: Manual Build (${{ matrix.platform }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 + steps: + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Log in to the GitHub Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GIT_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v5 + with: + context: . + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: manual-digests-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + manual_merge: + if: ${{ github.event_name == 'workflow_dispatch' }} + name: Manual Merge & Push [${{ github.event.inputs.tag }}] + runs-on: ubuntu-latest + needs: + - manual_build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: manual-digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Log in to the GitHub Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GIT_TOKEN }} + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create -t ${{ env.REGISTRY_IMAGE }}:${{ github.event.inputs.tag }} \ + $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ github.event.inputs.tag }} diff --git a/app/client/src/App.js b/app/client/src/App.js index 1c431d66..3e7353db 100644 --- a/app/client/src/App.js +++ b/app/client/src/App.js @@ -8,6 +8,8 @@ import Dashboard from './views/Dashboard' import NotFound from './views/NotFound' import Settings from './views/Settings' import Feed from './views/Feed' +import Games from './views/Games' +import GameVideos from './views/GameVideos' import darkTheme from './common/darkTheme' import { ConfigService } from './services' import { getSetting, setSetting } from './common/utils' @@ -73,6 +75,26 @@ export default function App() { } /> + + + + + + } + /> + + + + + + } + /> svg': { marginTop: 2, - }, + }, '& svg:last-child': { marginLeft: 2, }, @@ -473,7 +473,7 @@ const theme = { main: '#DEA500', light: '#FFDC48', dark: '#AB6800', - contrastText: 'rgba(0, 0, 0, 0.87)', + contrastText: 'rgba(255, 255, 255, 0.87)', }, secondary: { main: '#ce93d8', diff --git a/app/client/src/components/modal/VideoModal.js b/app/client/src/components/modal/VideoModal.js index c10c2f0e..1dad9282 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 { Button, ButtonGroup, Grid, IconButton, InputAdornment, Modal, Paper, Slide, TextField } from '@mui/material' +import { Autocomplete, 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' @@ -7,9 +7,10 @@ import SaveIcon from '@mui/icons-material/Save' import CloseIcon from '@mui/icons-material/Close' import VisibilityOffIcon from '@mui/icons-material/VisibilityOff' import VisibilityIcon from '@mui/icons-material/Visibility' +import SportsEsportsIcon from '@mui/icons-material/SportsEsports' import { CopyToClipboard } from 'react-copy-to-clipboard' import { copyToClipboard, getPublicWatchUrl, getServedBy, getUrl, getVideoSources } from '../../common/utils' -import { ConfigService, VideoService } from '../../services' +import { ConfigService, VideoService, GameService } from '../../services' import SnackbarAlert from '../alert/SnackbarAlert' import VideoJSPlayer from '../misc/VideoJSPlayer' @@ -25,7 +26,10 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal const [vid, setVideo] = React.useState(null) const [viewAdded, setViewAdded] = React.useState(false) const [alert, setAlert] = React.useState({ open: false }) - const [autoplay, setAutoplay] = useState(false); + const [autoplay, setAutoplay] = useState(false) + const [selectedGame, setSelectedGame] = React.useState(null) + const [gameOptions, setGameOptions] = React.useState([]) + const [gameSearchLoading, setGameSearchLoading] = React.useState(false) const playerRef = React.useRef() @@ -69,6 +73,17 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal setDescription(details.info?.description) setPrivateView(details.info?.private) setUpdatable(false) + // Fetch linked game + try { + const gameData = (await GameService.getVideoGame(videoId)).data + if (gameData) { + setSelectedGame(gameData) + } else { + setSelectedGame(null) + } + } catch (err) { + setSelectedGame(null) + } } catch (err) { setAlert( setAlert({ @@ -84,6 +99,69 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal } }, [videoId]) + const searchGames = async (query) => { + if (!query || query.length < 2) return setGameOptions([]) + setGameSearchLoading(true) + try { + setGameOptions((await GameService.searchSteamGrid(query)).data || []) + } catch (err) { + setGameOptions([]) + } + setGameSearchLoading(false) + } + + const handleGameChange = async (event, newValue) => { + if (!authenticated) return + + if (newValue) { + 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) + + setSelectedGame(game) + setAlert({ + type: 'success', + message: `Linked to ${newValue.name}`, + open: true, + }) + } catch (err) { + console.error('Error linking game:', err) + setAlert({ + type: 'error', + message: 'Failed to link game', + open: true, + }) + } + } else { + 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) + } + } + } + const handleMouseDown = (e) => { if (e.button === 1) { window.open(`${PURL}${vid.video_id}`, '_blank') @@ -187,7 +265,7 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal - + )} + {/* Game ID search bar */} + {authenticated && ( + + searchGames(val)} + options={gameOptions} + getOptionLabel={(option) => option.name || ''} + loading={gameSearchLoading} + renderInput={(params) => ( + + + + ), + }} + /> + )} + 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, + }, + }} + /> +
    + )}
    diff --git a/app/client/src/components/nav/Navbar20.js b/app/client/src/components/nav/Navbar20.js index 15945200..923c07a9 100644 --- a/app/client/src/components/nav/Navbar20.js +++ b/app/client/src/components/nav/Navbar20.js @@ -31,6 +31,7 @@ import TableRowsIcon from '@mui/icons-material/TableRows' import BugReportIcon from '@mui/icons-material/BugReport' import StorageIcon from '@mui/icons-material/Storage' import SyncIcon from '@mui/icons-material/Sync' +import SportsEsportsIcon from '@mui/icons-material/SportsEsports' import { Grid, ToggleButton, ToggleButtonGroup } from '@mui/material' import { useNavigate } from 'react-router-dom' @@ -51,6 +52,7 @@ const CARD_SIZE_MULTIPLIER = 2 const pages = [ { title: 'My Videos', icon: , href: '/', private: true }, { title: 'Public Videos', icon: , href: '/feed', private: false }, + { title: 'Games', icon: , href: '/games', private: true }, { title: 'Settings', icon: , href: '/settings', private: true }, ] diff --git a/app/client/src/services/GameService.js b/app/client/src/services/GameService.js new file mode 100644 index 00000000..39dc8a69 --- /dev/null +++ b/app/client/src/services/GameService.js @@ -0,0 +1,36 @@ +import Api from './Api' + +const service = { + searchSteamGrid(query) { + return Api().get('/api/steamgrid/search', { + params: { + query, + }, + }) + }, + getGameAssets(gameId) { + return Api().get(`/api/steamgrid/game/${gameId}/assets`) + }, + getGames() { + return Api().get('/api/games') + }, + getGameVideos(gameId) { + return Api().get(`/api/games/${gameId}/videos`) + }, + createGame(gameData) { + return Api().post('/api/games', gameData) + }, + linkVideoToGame(videoId, gameId) { + return Api().post(`/api/videos/${videoId}/game`, { + game_id: gameId, + }) + }, + getVideoGame(videoId) { + return Api().get(`/api/videos/${videoId}/game`) + }, + unlinkVideoFromGame(videoId) { + return Api().delete(`/api/videos/${videoId}/game`) + }, +} + +export default service diff --git a/app/client/src/services/index.js b/app/client/src/services/index.js index 49e3e183..16455d37 100644 --- a/app/client/src/services/index.js +++ b/app/client/src/services/index.js @@ -2,3 +2,4 @@ export { default as AuthService } from './AuthService' export { default as VideoService } from './VideoService' export { default as ConfigService } from './ConfigService' export { default as StatsService } from './StatsService' +export { default as GameService } from './GameService' diff --git a/app/client/src/views/GameVideos.js b/app/client/src/views/GameVideos.js new file mode 100644 index 00000000..fb2ac8ff --- /dev/null +++ b/app/client/src/views/GameVideos.js @@ -0,0 +1,83 @@ +import React from 'react' +import { Box, Divider } from '@mui/material' +import { useParams } from 'react-router-dom' +import { GameService } from '../services' +import VideoCards from '../components/admin/VideoCards' +import VideoList from '../components/admin/VideoList' +import LoadingSpinner from '../components/misc/LoadingSpinner' +import SnackbarAlert from '../components/alert/SnackbarAlert' + +const GameVideos = ({ cardSize, listStyle }) => { + const { gameId } = useParams() + const [videos, setVideos] = React.useState([]) + const [game, setGame] = React.useState(null) + const [loading, setLoading] = React.useState(true) + const [alert, setAlert] = React.useState({ open: false }) + + React.useEffect(() => { + Promise.all([ + GameService.getGames(), + GameService.getGameVideos(gameId) + ]) + .then(([gamesRes, videosRes]) => { + const foundGame = gamesRes.data.find(g => g.id === parseInt(gameId)) + setGame(foundGame) + setVideos(videosRes.data) + setLoading(false) + }) + .catch((err) => { + console.error('Error fetching game videos:', err) + setLoading(false) + }) + }, [gameId]) + + function fetchVideos() { + GameService.getGameVideos(gameId) + .then((res) => setVideos(res.data)) + .catch((err) => console.error(err)) + } + + if (loading) return + + return ( + + + + {game?.logo_url && ( + + + + + )} + {listStyle === 'list' ? ( + + ) : ( + + )} + + + ) +} + +export default GameVideos diff --git a/app/client/src/views/Games.js b/app/client/src/views/Games.js new file mode 100644 index 00000000..9ec6a042 --- /dev/null +++ b/app/client/src/views/Games.js @@ -0,0 +1,112 @@ +import React from 'react' +import { Box, Grid, Typography } from '@mui/material' +import { useNavigate } from 'react-router-dom' +import { GameService } from '../services' +import LoadingSpinner from '../components/misc/LoadingSpinner' + +const Games = () => { + const [games, setGames] = React.useState([]) + const [loading, setLoading] = React.useState(true) + const [hoveredGame, setHoveredGame] = React.useState(null) + const [mousePos, setMousePos] = React.useState({ x: 0, y: 0 }) + const navigate = useNavigate() + + React.useEffect(() => { + GameService.getGames() + .then((res) => { + setGames(res.data) + setLoading(false) + }) + .catch((err) => { + console.error('Error fetching games:', err) + setLoading(false) + }) + }, []) + + const handleMouseMove = (e, gameId) => { + const rect = e.currentTarget.getBoundingClientRect() + const x = (e.clientX - rect.left) / rect.width - 0.5 + const y = (e.clientY - rect.top) / rect.height - 0.5 + setMousePos({ x, y }) + setHoveredGame(gameId) + } + + const handleMouseLeave = () => { + setHoveredGame(null) + setMousePos({ x: 0, y: 0 }) + } + + if (loading) return + + return ( + + + {games.map((game) => { + const isHovered = hoveredGame === game.id + const heroTransform = isHovered + ? `translate(${mousePos.x * -15}px, ${mousePos.y * -15}px) scale(1.1)` + : 'translate(0, 0) scale(1)' + const logoTransform = isHovered + ? `translate(calc(-50% + ${mousePos.x * 8}px), calc(-50% + ${mousePos.y * 8}px)) scale(1.05)` + : 'translate(-50%, -50%) scale(1)' + + return ( + + navigate(`/games/${game.id}`)} + onMouseMove={(e) => handleMouseMove(e, game.id)} + onMouseLeave={handleMouseLeave} + sx={{ + position: 'relative', + height: 170, + borderRadius: 2, + overflow: 'hidden', + cursor: 'pointer', + transition: 'box-shadow 0.3s ease', + '&:hover': { + boxShadow: '0 0 20px rgba(255, 255, 255, 0.5)', + }, + }} + > + {game.hero_url && ( + + )} + {game.logo_url && ( + + )} + + + ) + })} + + + ) +} + +export default Games diff --git a/app/client/src/views/Settings.js b/app/client/src/views/Settings.js index 762f28a6..5f3cfe48 100644 --- a/app/client/src/views/Settings.js +++ b/app/client/src/views/Settings.js @@ -33,6 +33,7 @@ const Settings = ({ authenticated }) => { const [updatedConfig, setUpdatedConfig] = React.useState({}) const [updateable, setUpdateable] = React.useState(false) const [discordUrl, setDiscordUrl] = React.useState('') + const [showSteamGridKey, setShowSteamGridKey] = React.useState(false) const isDiscordUsed = discordUrl.trim() !== '' @@ -94,11 +95,35 @@ const Settings = ({ authenticated }) => { return; for (const warning of warnings.data) { - setAlert({ - open: true, - type: 'warning', - message: warning, - }); + // Check if this is the SteamGridDB warning + if (warning.includes('SteamGridDB API key not configured')) { + setAlert({ + open: true, + type: 'warning', + message: ( + + {warning.replace('Click here to set it up.', '')} + { + e.preventDefault(); + document.getElementById('steamgrid-api-key-field')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + document.getElementById('steamgrid-api-key-field')?.focus(); + }} + style={{ color: '#2684FF', textDecoration: 'underline', cursor: 'pointer', marginLeft: '4px' }} + > + Click here to set it up. + + + ), + }); + } else { + setAlert({ + open: true, + type: 'warning', + message: warning, + }); + } await new Promise(r => setTimeout(r, 2000)); //Without this a second Warning would instantly overwrite the first... } } @@ -298,6 +323,45 @@ const Settings = ({ authenticated }) => { })) }} /> + + Get a free API key at{' '} + + SteamGridDB + + + } + onChange={(e) => { + setUpdatedConfig((prev) => ({ + ...prev, + integrations: { + ...prev.integrations, + steamgriddb_api_key: e.target.value, + }, + })) + }} + InputProps={{ + endAdornment: ( + setShowSteamGridKey(!showSteamGridKey)} + > + {showSteamGridKey ? : } + + ), + }} + />