Merge pull request 'Portal game page with in-place play' (#7) from portal-phase2 into portal-phase1
Reviewed-on: branderson/flashpoint-web#7 Reviewed-by: branderson <brad@branderson.io>
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import BetterSqlite3 from 'better-sqlite3';
|
||||||
|
|
||||||
|
vi.mock('./DatabaseService', () => ({
|
||||||
|
DatabaseService: { all: vi.fn(), allAsync: vi.fn(), get: vi.fn(), getDatabase: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock('../utils/logger', () => ({
|
||||||
|
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock('../config', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../config')>();
|
||||||
|
return { config: { ...actual.config, flashpointPath: '/nonexistent-flashpoint' } };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { DatabaseService } from './DatabaseService';
|
||||||
|
import { GameService } from './GameService';
|
||||||
|
import { MatureContentService } from './MatureContentService';
|
||||||
|
import type { Game } from './GameService';
|
||||||
|
|
||||||
|
let db: BetterSqlite3.Database;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = new BetterSqlite3(':memory:');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE game (
|
||||||
|
id TEXT PRIMARY KEY, title TEXT, alternateTitles TEXT, developer TEXT, publisher TEXT,
|
||||||
|
series TEXT, platformName TEXT, platformsStr TEXT, library TEXT, orderTitle TEXT,
|
||||||
|
tagsStr TEXT, extreme INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE tag_category (id INTEGER PRIMARY KEY, name TEXT);
|
||||||
|
CREATE TABLE tag (id INTEGER PRIMARY KEY, categoryId INTEGER);
|
||||||
|
CREATE TABLE tag_alias (id INTEGER PRIMARY KEY, tagId INTEGER, name TEXT);
|
||||||
|
CREATE TABLE game_tags_tag (gameId TEXT, tagId INTEGER, PRIMARY KEY (gameId, tagId));
|
||||||
|
INSERT INTO tag_category VALUES (1, 'genre'), (2, 'meta'), (3, 'default');
|
||||||
|
INSERT INTO tag VALUES (10, 1), (11, 3), (12, 2), (13, 1);
|
||||||
|
INSERT INTO tag_alias VALUES (1, 10, 'Puzzle'), (2, 11, 'Cubes'), (3, 12, 'Auto-zipped'),
|
||||||
|
(4, 13, 'Gore');
|
||||||
|
`);
|
||||||
|
const games: [string, string, string, string, string][] = [
|
||||||
|
['src', 'Cube Logic', 'Ann', 'Cube Logic', 'arcade'],
|
||||||
|
['sequel', 'Cube Logic 2', 'Ann', 'Cube Logic', 'arcade'],
|
||||||
|
['cubes', 'Cube Stack', 'Bo', '', 'arcade'],
|
||||||
|
['puzzle', 'Any Puzzle', 'Cy', '', 'arcade'],
|
||||||
|
['zipped', 'Also Zipped', 'Di', '', 'arcade'],
|
||||||
|
['gory', 'Gory Cubes', 'Ed', '', 'arcade'],
|
||||||
|
['cartoon', 'Cube Cartoon', 'Fi', '', 'theatre'],
|
||||||
|
];
|
||||||
|
const insert = db.prepare(
|
||||||
|
"INSERT INTO game (id, title, developer, series, library, platformName) VALUES (?, ?, ?, ?, ?, 'Flash')"
|
||||||
|
);
|
||||||
|
for (const game of games) insert.run(...game);
|
||||||
|
const tag = db.prepare('INSERT INTO game_tags_tag VALUES (?, ?)');
|
||||||
|
for (const [gameId, tagId] of [
|
||||||
|
['src', 10],
|
||||||
|
['src', 11],
|
||||||
|
['src', 12],
|
||||||
|
['sequel', 10],
|
||||||
|
['cubes', 10],
|
||||||
|
['cubes', 11],
|
||||||
|
['puzzle', 10],
|
||||||
|
['zipped', 12],
|
||||||
|
['gory', 11],
|
||||||
|
['gory', 13],
|
||||||
|
['cartoon', 11],
|
||||||
|
] as const) {
|
||||||
|
tag.run(gameId, tagId);
|
||||||
|
}
|
||||||
|
// Puzzle is the common tag; give it more games so Cubes is the rarer, stronger match
|
||||||
|
for (let i = 0; i < 20; i++) tag.run(`filler-${i}`, 10);
|
||||||
|
|
||||||
|
vi.mocked(DatabaseService.all).mockImplementation(
|
||||||
|
(sql: string, params: unknown[] = []) => db.prepare(sql).all(...params) as unknown[]
|
||||||
|
);
|
||||||
|
vi.mocked(DatabaseService.allAsync).mockImplementation(
|
||||||
|
async (sql: string, params: unknown[] = []) => db.prepare(sql).all(...params) as unknown[]
|
||||||
|
);
|
||||||
|
MatureContentService.invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function serviceFor(id: string): GameService {
|
||||||
|
const service = new GameService();
|
||||||
|
const source = db.prepare('SELECT * FROM game WHERE id = ?').get(id) as Game;
|
||||||
|
vi.spyOn(service, 'getGameById').mockResolvedValue(source);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('related games', () => {
|
||||||
|
it('ranks the same series first, then games sharing rarer tags', async () => {
|
||||||
|
const related = await serviceFor('src').getRelatedGames('src', 10);
|
||||||
|
expect(related.map((game) => game.id)).toEqual(['sequel', 'cubes', 'puzzle']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores meta tags, other libraries and mature games unless asked', async () => {
|
||||||
|
const ids = (await serviceFor('src').getRelatedGames('src', 10)).map((game) => game.id);
|
||||||
|
expect(ids).not.toContain('zipped');
|
||||||
|
expect(ids).not.toContain('cartoon');
|
||||||
|
expect(ids).not.toContain('gory');
|
||||||
|
|
||||||
|
const withMature = await serviceFor('src').getRelatedGames('src', 10, true);
|
||||||
|
expect(withMature.map((game) => game.id)).toContain('gory');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -773,49 +773,67 @@ export class GameService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Games most like this one: ranked by shared descriptive tags (rarer tags count for more),
|
||||||
|
* then the same series, then the same developer.
|
||||||
|
*/
|
||||||
async getRelatedGames(gameId: string, limit: number = 10, showExtreme = false): Promise<Game[]> {
|
async getRelatedGames(gameId: string, limit: number = 10, showExtreme = false): Promise<Game[]> {
|
||||||
try {
|
try {
|
||||||
// First get the source game
|
|
||||||
const sourceGame = await this.getGameById(gameId);
|
const sourceGame = await this.getGameById(gameId);
|
||||||
if (!sourceGame) {
|
if (!sourceGame) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find games with same developer or platform
|
// Meta, presence, warning and language tags say little about what a game is like
|
||||||
const matureParams: unknown[] = [];
|
const tags = DatabaseService.all(
|
||||||
|
`SELECT gt.tagId, (SELECT COUNT(*) FROM game_tags_tag c WHERE c.tagId = gt.tagId) AS games
|
||||||
|
FROM game_tags_tag gt
|
||||||
|
JOIN tag t ON t.id = gt.tagId
|
||||||
|
JOIN tag_category tc ON tc.id = t.categoryId
|
||||||
|
WHERE gt.gameId = ? AND tc.name NOT IN ('meta', 'presence', 'warning', 'language')`,
|
||||||
|
[gameId]
|
||||||
|
) as { tagId: number; games: number }[];
|
||||||
|
|
||||||
|
const candidates: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
if (tags.length > 0) {
|
||||||
|
candidates.push(
|
||||||
|
`SELECT gameId AS id, CASE tagId ${tags.map(() => 'WHEN ? THEN ?').join(' ')} END AS score
|
||||||
|
FROM game_tags_tag WHERE tagId IN (${tags.map(() => '?').join(', ')})`
|
||||||
|
);
|
||||||
|
for (const tag of tags) params.push(tag.tagId, 1 / Math.log(2 + tag.games));
|
||||||
|
for (const tag of tags) params.push(tag.tagId);
|
||||||
|
}
|
||||||
|
if (sourceGame.series) {
|
||||||
|
candidates.push('SELECT id, 1.0 FROM game WHERE library = ? AND series = ?');
|
||||||
|
params.push(sourceGame.library, sourceGame.series);
|
||||||
|
}
|
||||||
|
if (sourceGame.developer) {
|
||||||
|
candidates.push('SELECT id, 0.5 FROM game WHERE library = ? AND developer = ?');
|
||||||
|
params.push(sourceGame.library, sourceGame.developer);
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(gameId, sourceGame.library);
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT
|
SELECT
|
||||||
g.id, g.title, g.alternateTitles, g.developer, g.publisher,
|
g.id, g.title, g.alternateTitles, g.developer, g.publisher,
|
||||||
g.platformName, g.platformsStr, g.library, g.tagsStr,
|
g.platformName, g.platformsStr, g.library, g.tagsStr,
|
||||||
g.orderTitle
|
g.orderTitle
|
||||||
FROM game g
|
FROM (
|
||||||
WHERE g.id != ?
|
SELECT id, SUM(score) AS score FROM (${candidates.join(' UNION ALL ')}) GROUP BY id
|
||||||
AND (
|
) r
|
||||||
g.developer = ? OR
|
JOIN game g ON g.id = r.id
|
||||||
g.platformName = ?
|
WHERE g.id != ? AND g.library = ?
|
||||||
)
|
${showExtreme ? '' : 'AND ' + MatureContentService.excludeCondition('g', params)}
|
||||||
${showExtreme ? '' : 'AND ' + MatureContentService.excludeCondition('g', matureParams)}
|
ORDER BY r.score DESC, RANDOM()
|
||||||
ORDER BY
|
|
||||||
CASE
|
|
||||||
WHEN g.developer = ? THEN 1
|
|
||||||
WHEN g.platformName = ? THEN 2
|
|
||||||
ELSE 3
|
|
||||||
END,
|
|
||||||
RANDOM()
|
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`;
|
`;
|
||||||
|
params.push(limit);
|
||||||
|
|
||||||
const games = DatabaseService.all(sql, [
|
return (await DatabaseService.allAsync(sql, params)) as Game[];
|
||||||
gameId,
|
|
||||||
sourceGame.developer,
|
|
||||||
sourceGame.platformName,
|
|
||||||
...matureParams,
|
|
||||||
sourceGame.developer,
|
|
||||||
sourceGame.platformName,
|
|
||||||
limit,
|
|
||||||
]) as Game[];
|
|
||||||
|
|
||||||
return games;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting related games:', error);
|
logger.error('Error getting related games:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -144,7 +144,9 @@ canPlayInBrowser, and downloading status.
|
|||||||
|
|
||||||
Query params: `limit` (default: 10, max: 50), `showExtreme` (default: false)
|
Query params: `limit` (default: 10, max: 50), `showExtreme` (default: false)
|
||||||
|
|
||||||
Returns array of related games based on developer/series/tags.
|
Returns games from the same library ranked by shared descriptive tags (meta,
|
||||||
|
presence, warning and language tags are ignored; rarer tags weigh more), then
|
||||||
|
the same series, then the same developer.
|
||||||
|
|
||||||
## Search Examples
|
## Search Examples
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,27 @@ body text uses Verdana.
|
|||||||
|
|
||||||
See the [Discovery API](../06-api-reference/discovery-api.md).
|
See the [Discovery API](../06-api-reference/discovery-api.md).
|
||||||
|
|
||||||
|
## Game Page
|
||||||
|
|
||||||
|
Game detail and play are one page (`views/GamePage.tsx`); the `/play` routes
|
||||||
|
open the same page with the game already started. Shared playlist links keep the
|
||||||
|
application's detail and player views.
|
||||||
|
|
||||||
|
- **Stage:** the game's screenshot with its logo and a Play button (or Enter).
|
||||||
|
Playing loads the game in place, starting a play session and fetching the game
|
||||||
|
data if needed. Saves, the Ruffle / original Flash Player switch and
|
||||||
|
Fullscreen sit under the game; in fullscreen the player's own header bar
|
||||||
|
returns. Platforms that can't run in the browser or be streamed point to the
|
||||||
|
Flashpoint Launcher.
|
||||||
|
- **About this game:** description, notes, and release date, developer,
|
||||||
|
publisher, series, platform, play mode and language; developer, publisher and
|
||||||
|
series link to browse.
|
||||||
|
- **Side boxes:** the signed-in user's plays of this game, the rating, and tags
|
||||||
|
linking to browse.
|
||||||
|
- **Favorite** and **Add to playlist** for signed-in users.
|
||||||
|
- **More like this:**
|
||||||
|
[related games](../06-api-reference/games-api.md#get-related-games).
|
||||||
|
|
||||||
## Default Ordering
|
## Default Ordering
|
||||||
|
|
||||||
Browse lists default to a shuffle instead of title order. The shuffle is seeded
|
Browse lists default to a shuffle instead of title order. The shuffle is seeded
|
||||||
|
|||||||
+11
-10
@@ -53,6 +53,7 @@ const AnimationsView = lazy(() =>
|
|||||||
const GameDetailView = lazy(() =>
|
const GameDetailView = lazy(() =>
|
||||||
import('./views/GameDetailView').then((m) => ({ default: m.GameDetailView }))
|
import('./views/GameDetailView').then((m) => ({ default: m.GameDetailView }))
|
||||||
);
|
);
|
||||||
|
const GamePage = lazy(() => import('./views/GamePage').then((m) => ({ default: m.GamePage })));
|
||||||
const GamePlayerView = lazy(() =>
|
const GamePlayerView = lazy(() =>
|
||||||
import('./views/GamePlayerView').then((m) => ({ default: m.GamePlayerView }))
|
import('./views/GamePlayerView').then((m) => ({ default: m.GamePlayerView }))
|
||||||
);
|
);
|
||||||
@@ -350,7 +351,7 @@ function App() {
|
|||||||
element={
|
element={
|
||||||
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
|
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
<GameDetailView />
|
<GamePage />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -360,7 +361,7 @@ function App() {
|
|||||||
element={
|
element={
|
||||||
<ProtectedRoute requirePermission="games.play">
|
<ProtectedRoute requirePermission="games.play">
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
<GamePlayerView />
|
<GamePage autoPlay />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -370,7 +371,7 @@ function App() {
|
|||||||
element={
|
element={
|
||||||
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
|
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
<GameDetailView />
|
<GamePage />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -380,7 +381,7 @@ function App() {
|
|||||||
element={
|
element={
|
||||||
<ProtectedRoute requirePermission="games.play">
|
<ProtectedRoute requirePermission="games.play">
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
<GamePlayerView />
|
<GamePage autoPlay />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -390,7 +391,7 @@ function App() {
|
|||||||
element={
|
element={
|
||||||
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
|
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
<GameDetailView />
|
<GamePage />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -400,7 +401,7 @@ function App() {
|
|||||||
element={
|
element={
|
||||||
<ProtectedRoute requirePermission="games.play">
|
<ProtectedRoute requirePermission="games.play">
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
<GamePlayerView />
|
<GamePage autoPlay />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -410,7 +411,7 @@ function App() {
|
|||||||
element={
|
element={
|
||||||
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
|
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
<GameDetailView />
|
<GamePage />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -420,7 +421,7 @@ function App() {
|
|||||||
element={
|
element={
|
||||||
<ProtectedRoute requirePermission="games.play">
|
<ProtectedRoute requirePermission="games.play">
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
<GamePlayerView />
|
<GamePage autoPlay />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -432,7 +433,7 @@ function App() {
|
|||||||
element={
|
element={
|
||||||
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
|
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
<GameDetailView />
|
<GamePage />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -444,7 +445,7 @@ function App() {
|
|||||||
element={
|
element={
|
||||||
<ProtectedRoute requirePermission="games.play">
|
<ProtectedRoute requirePermission="games.play">
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
<GamePlayerView />
|
<GamePage autoPlay />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Fragment, useState, useEffect, useRef, useMemo } from 'react';
|
import { Fragment, useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -18,6 +18,18 @@ import { useAuthStore } from '@/store/auth';
|
|||||||
import { PlayerErrorBoundary } from './PlayerErrorBoundary';
|
import { PlayerErrorBoundary } from './PlayerErrorBoundary';
|
||||||
import { PlatformIcon } from '@/components/ui/platform-icon';
|
import { PlatformIcon } from '@/components/ui/platform-icon';
|
||||||
|
|
||||||
|
/** Player actions, for pages that place the controls themselves */
|
||||||
|
export interface PlayerControls {
|
||||||
|
isFullscreen: boolean;
|
||||||
|
toggleFullscreen: () => void;
|
||||||
|
/** Whether the original Flash Player is in use instead of Ruffle */
|
||||||
|
preferStream: boolean;
|
||||||
|
/** Present when the game can switch between Ruffle and the original Flash Player */
|
||||||
|
togglePlayer?: () => void;
|
||||||
|
/** Present for signed-in players, whose saves are kept on their account */
|
||||||
|
openSaves?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GamePlayerProps {
|
export interface GamePlayerProps {
|
||||||
/** Required to stream games that can't run in a browser */
|
/** Required to stream games that can't run in a browser */
|
||||||
gameId?: string;
|
gameId?: string;
|
||||||
@@ -41,6 +53,8 @@ export interface GamePlayerProps {
|
|||||||
developer?: string;
|
developer?: string;
|
||||||
/** Callback for back button (shown in fullscreen mode) */
|
/** Callback for back button (shown in fullscreen mode) */
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
|
/** Renders the controls below the game instead of the header bar, outside fullscreen */
|
||||||
|
renderControls?: (controls: PlayerControls) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +80,7 @@ function GamePlayerInternal({
|
|||||||
logoUrl,
|
logoUrl,
|
||||||
developer,
|
developer,
|
||||||
onBack,
|
onBack,
|
||||||
|
renderControls,
|
||||||
}: GamePlayerProps) {
|
}: GamePlayerProps) {
|
||||||
const [isFullscreen, setIsFullscreen] = useState(initialFullscreen);
|
const [isFullscreen, setIsFullscreen] = useState(initialFullscreen);
|
||||||
// Streams a game Ruffle can play with the original player, for when Ruffle falls short
|
// Streams a game Ruffle can play with the original player, for when Ruffle falls short
|
||||||
@@ -181,7 +196,7 @@ function GamePlayerInternal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${containerClasses} ${isAspectVideo ? 'flex flex-col' : ''}`}>
|
<div className={`${containerClasses} ${isAspectVideo ? 'flex flex-col' : ''}`}>
|
||||||
{showControls ? (
|
{showControls && (isFullscreen || !renderControls) ? (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between bg-card/90 backdrop-blur-sm flex-shrink-0 border-b border-border ${
|
className={`flex items-center justify-between bg-card/90 backdrop-blur-sm flex-shrink-0 border-b border-border ${
|
||||||
isFullscreen ? 'px-4 py-3' : 'px-6 py-4'
|
isFullscreen ? 'px-4 py-3' : 'px-6 py-4'
|
||||||
@@ -421,6 +436,15 @@ function GamePlayerInternal({
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{renderControls && !isFullscreen
|
||||||
|
? renderControls({
|
||||||
|
isFullscreen,
|
||||||
|
toggleFullscreen,
|
||||||
|
preferStream,
|
||||||
|
togglePlayer: canSwitchPlayer ? () => setPreferStream((prev) => !prev) : undefined,
|
||||||
|
openSaves: isSignedIn && gameId ? () => setSavesOpen(true) : undefined,
|
||||||
|
})
|
||||||
|
: null}
|
||||||
{isSignedIn && gameId ? (
|
{isSignedIn && gameId ? (
|
||||||
<SavesDialog
|
<SavesDialog
|
||||||
gameId={gameId}
|
gameId={gameId}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export { RufflePlayer } from './RufflePlayer';
|
export { RufflePlayer } from './RufflePlayer';
|
||||||
export type { RufflePlayerProps } from './RufflePlayer';
|
export type { RufflePlayerProps } from './RufflePlayer';
|
||||||
export { GamePlayer } from './GamePlayer';
|
export { GamePlayer } from './GamePlayer';
|
||||||
export type { GamePlayerProps } from './GamePlayer';
|
export type { GamePlayerProps, PlayerControls } from './GamePlayer';
|
||||||
export { StreamPlayer } from './StreamPlayer';
|
export { StreamPlayer } from './StreamPlayer';
|
||||||
export type { StreamPlayerProps } from './StreamPlayer';
|
export type { StreamPlayerProps } from './StreamPlayer';
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { ReactNode } from 'react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface PortalBoxProps {
|
interface PortalBoxProps {
|
||||||
title: ReactNode;
|
/** Omit for a plain outlined box */
|
||||||
|
title?: ReactNode;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -12,10 +13,12 @@ interface PortalBoxProps {
|
|||||||
export function PortalBox({ title, action, className, children }: PortalBoxProps) {
|
export function PortalBox({ title, action, className, children }: PortalBoxProps) {
|
||||||
return (
|
return (
|
||||||
<section className={cn('portal-box', className)}>
|
<section className={cn('portal-box', className)}>
|
||||||
<div className="portal-box-title">
|
{title ? (
|
||||||
<h2>{title}</h2>
|
<div className="portal-box-title">
|
||||||
{action}
|
<h2>{title}</h2>
|
||||||
</div>
|
{action}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { GameStage } from './GameStage';
|
||||||
|
import type { Game } from '@/types/game';
|
||||||
|
|
||||||
|
vi.mock('@/components/player', () => ({
|
||||||
|
GamePlayer: ({ title }: { title: string }) => <div>playing {title}</div>,
|
||||||
|
}));
|
||||||
|
vi.mock('@/hooks/usePlayTracking', () => ({ usePlaySession: vi.fn() }));
|
||||||
|
vi.mock('@/hooks/useGames', () => ({
|
||||||
|
useGameLaunchData: () => ({
|
||||||
|
data: { platform: 'Flash', contentUrl: '/game.swf', canPlayInBrowser: true },
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const game = {
|
||||||
|
id: '1443f6b9-6bec-47cb-873e-72b9aedefccb',
|
||||||
|
title: '3D Logic',
|
||||||
|
developer: 'Alex Mateev',
|
||||||
|
publisher: 'Microgames',
|
||||||
|
platformName: 'Flash',
|
||||||
|
library: 'arcade',
|
||||||
|
orderTitle: '3d logic',
|
||||||
|
} satisfies Game;
|
||||||
|
|
||||||
|
describe('GameStage', () => {
|
||||||
|
it('waits for Play before loading the game', async () => {
|
||||||
|
render(<GameStage game={game} autoPlay={false} />);
|
||||||
|
expect(screen.queryByText('playing 3D Logic')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'PLAY' }));
|
||||||
|
expect(screen.getByText('playing 3D Logic')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts on Enter, and right away from a play link', async () => {
|
||||||
|
const { unmount } = render(<GameStage game={game} autoPlay={false} />);
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
expect(screen.getByText('playing 3D Logic')).toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(<GameStage game={game} autoPlay />);
|
||||||
|
expect(screen.getByText('playing 3D Logic')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('points launcher-only games to the Flashpoint Launcher', () => {
|
||||||
|
render(<GameStage game={{ ...game, platformName: 'Unity' }} autoPlay />);
|
||||||
|
expect(screen.queryByRole('button', { name: 'PLAY' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/only be played in the Flashpoint Launcher/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { History, Loader2, Maximize2, MonitorPlay } from 'lucide-react';
|
||||||
|
import { GamePlayer, type PlayerControls } from '@/components/player';
|
||||||
|
import { useGameLaunchData } from '@/hooks/useGames';
|
||||||
|
import { usePlaySession } from '@/hooks/usePlayTracking';
|
||||||
|
import { getGameLogoUrl, getGameScreenshotUrl } from '@/utils/gameUtils';
|
||||||
|
import type { Game } from '@/types/game';
|
||||||
|
|
||||||
|
const STAGE_HEIGHT = 'min(690px, calc(100vh - 140px))';
|
||||||
|
|
||||||
|
function canPlay(game: Game): boolean {
|
||||||
|
return game.platformName === 'Flash' || game.platformName === 'HTML5' || Boolean(game.streamable);
|
||||||
|
}
|
||||||
|
|
||||||
|
function howItPlays(game: Game): string {
|
||||||
|
if (game.platformName === 'Flash') return 'Plays in your browser with Ruffle';
|
||||||
|
if (game.platformName === 'HTML5') return 'Plays in your browser';
|
||||||
|
return `${game.platformName ?? 'This game'} streams from the server`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTyping(target: EventTarget | null): boolean {
|
||||||
|
return (
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
(target.isContentEditable ||
|
||||||
|
['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(target.tagName))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameStageProps {
|
||||||
|
game: Game;
|
||||||
|
autoPlay: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The game's screenshot with a Play button, replaced by the game itself once started */
|
||||||
|
export function GameStage({ game, autoPlay }: GameStageProps) {
|
||||||
|
const playable = canPlay(game);
|
||||||
|
const [playing, setPlaying] = useState(autoPlay && playable);
|
||||||
|
|
||||||
|
// Enter starts the game, as on the portals of old
|
||||||
|
const playingRef = useRef(playing);
|
||||||
|
useEffect(() => {
|
||||||
|
playingRef.current = playing;
|
||||||
|
}, [playing]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playable) return;
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Enter' || playingRef.current || isTyping(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
setPlaying(true);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [playable]);
|
||||||
|
|
||||||
|
if (!playing) {
|
||||||
|
return (
|
||||||
|
<div className="portal-stage" style={{ height: STAGE_HEIGHT }}>
|
||||||
|
<img
|
||||||
|
src={getGameScreenshotUrl(game.id)}
|
||||||
|
alt=""
|
||||||
|
className="portal-stage-backdrop"
|
||||||
|
onError={(event) => event.currentTarget.remove()}
|
||||||
|
/>
|
||||||
|
<div className="portal-stage-splash">
|
||||||
|
<span className="portal-tile-art">
|
||||||
|
<img
|
||||||
|
src={getGameLogoUrl(game.id)}
|
||||||
|
alt=""
|
||||||
|
onError={(event) => event.currentTarget.removeAttribute('src')}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{playable ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="portal-play w-64" onClick={() => setPlaying(true)}>
|
||||||
|
PLAY
|
||||||
|
</button>
|
||||||
|
<p className="text-[color:var(--p-meta)]">
|
||||||
|
{howItPlays(game)} · or press <kbd className="portal-kbd">Enter</kbd>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="portal-display max-w-md text-center text-lg">
|
||||||
|
{game.platformName ?? 'This game'} games can only be played in the Flashpoint
|
||||||
|
Launcher.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PlayingStage game={game} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayingStage({ game }: { game: Game }) {
|
||||||
|
const { data: launchData, isLoading, error } = useGameLaunchData(game.id);
|
||||||
|
usePlaySession(game.id, game.title);
|
||||||
|
|
||||||
|
if (isLoading || launchData?.downloading) {
|
||||||
|
return (
|
||||||
|
<div className="portal-stage" style={{ height: STAGE_HEIGHT }}>
|
||||||
|
<div className="portal-stage-splash">
|
||||||
|
<Loader2 size={32} className="animate-spin" aria-hidden="true" />
|
||||||
|
<p className="portal-display text-lg">
|
||||||
|
{launchData?.downloading ? 'Fetching the game...' : 'Loading...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !launchData) {
|
||||||
|
return (
|
||||||
|
<div className="portal-stage" style={{ height: STAGE_HEIGHT }}>
|
||||||
|
<div className="portal-stage-splash">
|
||||||
|
<p className="portal-display text-lg">This game failed to load.</p>
|
||||||
|
<p className="text-[color:var(--p-meta)]">{error?.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GamePlayer
|
||||||
|
gameId={game.id}
|
||||||
|
title={game.title}
|
||||||
|
platform={launchData.platform ?? game.platformName ?? ''}
|
||||||
|
contentUrl={launchData.contentUrl}
|
||||||
|
launchCommand={launchData.launchCommand}
|
||||||
|
canPlayInBrowser={launchData.canPlayInBrowser}
|
||||||
|
streamable={launchData.streamable ?? false}
|
||||||
|
logoUrl={getGameLogoUrl(game.id)}
|
||||||
|
developer={game.developer}
|
||||||
|
height={STAGE_HEIGHT}
|
||||||
|
className="portal-stage-player"
|
||||||
|
renderControls={(controls) => <StageToolbar {...controls} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StageToolbar({ toggleFullscreen, preferStream, togglePlayer, openSaves }: PlayerControls) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2 pt-2.5">
|
||||||
|
{openSaves ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="portal-capsule portal-capsule-sm"
|
||||||
|
onClick={openSaves}
|
||||||
|
title="Saves kept on your account"
|
||||||
|
>
|
||||||
|
<History size={14} aria-hidden="true" />
|
||||||
|
Saves
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{togglePlayer ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="portal-capsule portal-capsule-sm"
|
||||||
|
onClick={togglePlayer}
|
||||||
|
title={
|
||||||
|
preferStream
|
||||||
|
? 'Play in the browser with Ruffle'
|
||||||
|
: 'Play with the original Flash Player, streamed from the server'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MonitorPlay size={14} aria-hidden="true" />
|
||||||
|
{preferStream ? 'Use Ruffle' : 'Original Flash Player'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button type="button" className="portal-capsule portal-capsule-sm" onClick={toggleFullscreen}>
|
||||||
|
<Maximize2 size={14} aria-hidden="true" />
|
||||||
|
Fullscreen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -123,7 +123,8 @@ body.portal {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-capsule[aria-current='page'] {
|
.portal-capsule[aria-current='page'],
|
||||||
|
.portal-capsule[aria-pressed='true'] {
|
||||||
background: linear-gradient(180deg, var(--p-highlight), var(--p-box));
|
background: linear-gradient(180deg, var(--p-highlight), var(--p-box));
|
||||||
color: var(--p-header-ink);
|
color: var(--p-header-ink);
|
||||||
border-color: var(--p-chrome-1);
|
border-color: var(--p-chrome-1);
|
||||||
@@ -321,3 +322,30 @@ body.portal {
|
|||||||
.portal-meta-row svg {
|
.portal-meta-row svg {
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-stage {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-stage-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
filter: blur(3px) brightness(0.4);
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-stage-splash {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Building2,
|
||||||
|
CalendarDays,
|
||||||
|
Gamepad2,
|
||||||
|
Heart,
|
||||||
|
Languages,
|
||||||
|
ListPlus,
|
||||||
|
Users,
|
||||||
|
Wrench,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { GameRatingSection } from '@/components/game/GameRatingSection';
|
||||||
|
import { AddToPlaylistModal } from '@/components/playlist/AddToPlaylistModal';
|
||||||
|
import { GameTile } from '@/components/portal/GameTile';
|
||||||
|
import { PortalBox } from '@/components/portal/PortalBox';
|
||||||
|
import { GameStage } from '@/components/portal/game/GameStage';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useFavoriteGameIds, useToggleFavorite } from '@/hooks/useFavorites';
|
||||||
|
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
|
||||||
|
import { useGame, useRelatedGames } from '@/hooks/useGames';
|
||||||
|
import { useGameStats } from '@/hooks/usePlayTracking';
|
||||||
|
import { formatDuration } from '@/lib/cron-utils';
|
||||||
|
import { buildFilterSearchParams, type FilterUrlParams } from '@/lib/filterUrlCompression';
|
||||||
|
import { useAuthStore } from '@/store/auth';
|
||||||
|
import { getErrorMessage } from '@/types/api-error';
|
||||||
|
import type { BreadcrumbContext } from '@/components/common/Breadcrumbs';
|
||||||
|
|
||||||
|
function browseLink(params: FilterUrlParams): string {
|
||||||
|
return `/browse?${buildFilterSearchParams(params).toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetaRowProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaRow({ icon: Icon, label, value, href }: MetaRowProps) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<div className="portal-meta-row normal-case">
|
||||||
|
<Icon size={15} aria-hidden="true" />
|
||||||
|
<span className="w-24 flex-none uppercase text-[color:var(--p-meta)]">{label}</span>
|
||||||
|
{href ? (
|
||||||
|
<Link to={href} className="truncate hover:underline">
|
||||||
|
{value}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="truncate">{value}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A game's page: the game itself, what it is, and where to go next */
|
||||||
|
export function GamePage({ autoPlay = false }: { autoPlay?: boolean }) {
|
||||||
|
const { id = '' } = useParams<{ id: string }>();
|
||||||
|
const location = useLocation();
|
||||||
|
const from = (location.state as { breadcrumbContext?: BreadcrumbContext } | null)
|
||||||
|
?.breadcrumbContext;
|
||||||
|
const { data: game, isLoading, error } = useGame(id);
|
||||||
|
const { data: related } = useRelatedGames(id, 7);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-[18px]">
|
||||||
|
<Skeleton className="h-10 w-96" />
|
||||||
|
<Skeleton className="h-[600px] w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !game) {
|
||||||
|
return (
|
||||||
|
<PortalBox title="Game not found">
|
||||||
|
<p>
|
||||||
|
This game isn't in the archive. <Link to="/">Back to the homepage</Link>
|
||||||
|
</p>
|
||||||
|
</PortalBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = (game.tagsStr ?? '')
|
||||||
|
.split(';')
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-[18px]">
|
||||||
|
<div className="flex items-end justify-between gap-6">
|
||||||
|
<div className="min-w-0">
|
||||||
|
{from ? (
|
||||||
|
<Link
|
||||||
|
to={from.href}
|
||||||
|
className="text-xs uppercase tracking-wider text-[color:var(--p-meta)] hover:underline"
|
||||||
|
>
|
||||||
|
← {from.label}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<h1 className="portal-display truncate text-[32px] font-semibold leading-tight">
|
||||||
|
{game.title}
|
||||||
|
</h1>
|
||||||
|
{game.developer ? (
|
||||||
|
<p className="text-[color:var(--p-meta)]">by {game.developer}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<GameActions gameId={game.id} title={game.title} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PortalBox>
|
||||||
|
<GameStage key={game.id} game={game} autoPlay={autoPlay} />
|
||||||
|
</PortalBox>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-[18px]">
|
||||||
|
<PortalBox title="About this game">
|
||||||
|
{game.originalDescription ? (
|
||||||
|
<p className="mb-3 whitespace-pre-line leading-relaxed">{game.originalDescription}</p>
|
||||||
|
) : null}
|
||||||
|
{game.notes ? (
|
||||||
|
<p className="mb-3 whitespace-pre-line text-[color:var(--p-meta)]">{game.notes}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)] gap-1.5">
|
||||||
|
<MetaRow icon={CalendarDays} label="Released" value={game.releaseDate} />
|
||||||
|
<MetaRow
|
||||||
|
icon={Wrench}
|
||||||
|
label="Developer"
|
||||||
|
value={game.developer}
|
||||||
|
href={browseLink({ developers: game.developer, fo: 'Developer' })}
|
||||||
|
/>
|
||||||
|
<MetaRow
|
||||||
|
icon={Building2}
|
||||||
|
label="Publisher"
|
||||||
|
value={game.publisher}
|
||||||
|
href={browseLink({ publishers: game.publisher, fo: 'Publisher' })}
|
||||||
|
/>
|
||||||
|
<MetaRow
|
||||||
|
icon={BookOpen}
|
||||||
|
label="Series"
|
||||||
|
value={game.series}
|
||||||
|
href={browseLink({ series: game.series, fo: 'Series' })}
|
||||||
|
/>
|
||||||
|
<MetaRow icon={Gamepad2} label="Platform" value={game.platformName} />
|
||||||
|
<MetaRow icon={Users} label="Play mode" value={game.playMode} />
|
||||||
|
<MetaRow icon={Languages} label="Language" value={game.language} />
|
||||||
|
</div>
|
||||||
|
</PortalBox>
|
||||||
|
|
||||||
|
<div className="space-y-[18px]">
|
||||||
|
<YourPlays gameId={game.id} />
|
||||||
|
<RatingBox gameId={game.id} />
|
||||||
|
{tags.length > 0 ? (
|
||||||
|
<PortalBox title="Tags">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Link
|
||||||
|
key={tag}
|
||||||
|
to={browseLink({ tags: tag, fo: 'Tag' })}
|
||||||
|
className="portal-capsule portal-capsule-sm"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PortalBox>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{related && related.length > 0 ? (
|
||||||
|
<PortalBox title="More like this">
|
||||||
|
<div className="portal-tiles">
|
||||||
|
{related.map((relatedGame) => (
|
||||||
|
<GameTile key={relatedGame.id} id={relatedGame.id} title={relatedGame.title} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PortalBox>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GameActions({ gameId, title }: { gameId: string; title: string }) {
|
||||||
|
const signedIn = useAuthStore((state) => state.isAuthenticated && !state.isGuest);
|
||||||
|
const { enableFavorites, enablePlaylists } = useFeatureFlags();
|
||||||
|
const { data: favoriteIds } = useFavoriteGameIds();
|
||||||
|
const toggleFavorite = useToggleFavorite();
|
||||||
|
const [addingToPlaylist, setAddingToPlaylist] = useState(false);
|
||||||
|
const isFavorite = useMemo(() => favoriteIds?.includes(gameId) ?? false, [favoriteIds, gameId]);
|
||||||
|
|
||||||
|
if (!signedIn) return null;
|
||||||
|
|
||||||
|
const handleFavorite = () => {
|
||||||
|
toggleFavorite.mutate(gameId, {
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast.error(getErrorMessage(error) || 'Could not update favorites');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-none gap-2">
|
||||||
|
{enableFavorites ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="portal-capsule portal-capsule-sm"
|
||||||
|
aria-pressed={isFavorite}
|
||||||
|
onClick={handleFavorite}
|
||||||
|
disabled={toggleFavorite.isPending}
|
||||||
|
>
|
||||||
|
<Heart size={14} fill={isFavorite ? 'currentColor' : 'none'} aria-hidden="true" />
|
||||||
|
{isFavorite ? 'Favorited' : 'Favorite'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{enablePlaylists ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="portal-capsule portal-capsule-sm"
|
||||||
|
onClick={() => setAddingToPlaylist(true)}
|
||||||
|
>
|
||||||
|
<ListPlus size={14} aria-hidden="true" />
|
||||||
|
Add to playlist
|
||||||
|
</button>
|
||||||
|
<AddToPlaylistModal
|
||||||
|
isOpen={addingToPlaylist}
|
||||||
|
onClose={() => setAddingToPlaylist(false)}
|
||||||
|
gameId={gameId}
|
||||||
|
gameTitle={title}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function YourPlays({ gameId }: { gameId: string }) {
|
||||||
|
const { data } = useGameStats();
|
||||||
|
const stats = data?.data.find((entry) => entry.gameId === gameId);
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalBox title="Your plays">
|
||||||
|
<p>
|
||||||
|
Played {stats.totalPlays} {stats.totalPlays === 1 ? 'time' : 'times'}
|
||||||
|
{stats.totalPlaytimeSeconds > 0
|
||||||
|
? `, ${formatDuration(stats.totalPlaytimeSeconds)} in all`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
<p className="text-[color:var(--p-meta)]">
|
||||||
|
Last played {formatDistanceToNow(new Date(stats.lastPlayedAt), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</PortalBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RatingBox({ gameId }: { gameId: string }) {
|
||||||
|
const { enableRatings } = useFeatureFlags();
|
||||||
|
if (!enableRatings) return null;
|
||||||
|
return (
|
||||||
|
<PortalBox title="Rating">
|
||||||
|
<GameRatingSection gameId={gameId} className="mt-0 sm:flex-col sm:items-start sm:gap-2" />
|
||||||
|
</PortalBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user