feat(library): add not the same game button to editions

Part of Gitea #34 slice 1 of 3: one entry per game across platforms.
Status, rating and notes are shared, so the detail page reloads after a save.
This commit is contained in:
lobo committed 2026-10-09 10:57:23 +02:00
1 parent e136a7abc1
commit 25cc5ff2d8
7 files changed
+107 -14

No files matched your search

+18 -9
View File
@@ -1,37 +1,38 @@
import {useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Layers, Link2Off} from 'lucide-react';
import {Layers, Link2Off, Unlink} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameStatusBadge from './GameStatusBadge.jsx';
import FitText from './FitText.jsx';
import {unlinkGame} from '../utils/links.js';
import {splitGame, unlinkGame} from '../utils/links.js';
import {platformLabel} from '../utils/platforms.js';
/**
* The other editions of this game in the own library, and the buttons to ask for a link (everyone) or to unlink
* (auditor). Shown on every game: a request must be possible from any game.
* The other editions of this game in the own library, and the buttons to ask for a link (everyone), to split the game
* off its set (everyone, for the own library) or to unlink (auditor). Shown on every game: a request must be possible
* from any game.
*/
export default function EditionsCard({game, auditor, onRequest, onChanged}) {
const {t} = useTranslation();
const [busy, setBusy] = useState(false);
const unlink = async () => {
const run = async (call, doneKey) => {
setBusy(true);
try {
const response = await unlinkGame(game.appid);
const response = await call(game.appid);
if (response.status === 401) return;
if (response.ok) {
toast.success(t('LINKS.UNLINKED'));
toast.success(t(doneKey));
onChanged();
} else {
toast.error(t('LINKS.ERROR'));
}
} catch (err) {
console.error('Unlink error:', err);
console.error('Edition change error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
@@ -56,14 +57,22 @@ export default function EditionsCard({game, auditor, onRequest, onChanged}) {
</li>
))}
</ul>
<p className="text-muted-foreground">{t('LINKS.SHARED_STATUS')}</p>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={onRequest}>
<Layers/>{t(auditor ? 'LINKS.LINK_WITH' : 'LINKS.SAME_AS')}
</Button>
{game.editions?.length > 0 && (
<Button type="button" variant="outline" size="sm" disabled={busy}
onClick={() => run(splitGame, 'LINKS.SPLIT_DONE')}>
<Unlink/>{t('LINKS.SPLIT')}
</Button>
)}
{auditor && game.linked && (
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={unlink}>
<Button type="button" variant="outline" size="sm" disabled={busy}
onClick={() => run(unlinkGame, 'LINKS.UNLINKED')}>
<Link2Off/>{t('LINKS.UNLINK')}
</Button>
)}
+56
View File
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import EditionsCard from './EditionsCard.jsx';
import {splitGame} from '../utils/links.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('../utils/links.js', () => ({splitGame: vi.fn(), unlinkGame: vi.fn()}));
const edition = {appid: 221, name: 'Game (PS5)', platform: 'PS5', source: 'PSN', status: 'PLAYING'};
const renderCard = (game, onChanged = vi.fn()) => {
render(<MemoryRouter><EditionsCard game={game} auditor={false} onRequest={vi.fn()} onChanged={onChanged}/></MemoryRouter>);
return onChanged;
};
describe('EditionsCard', () => {
beforeEach(() => vi.clearAllMocks());
afterEach(cleanup);
it('showsTheSplitButtonAndTheSharedHintOnlyWithEditions', () => {
renderCard({appid: 220, editions: []});
expect(screen.queryByText('LINKS.SPLIT')).toBeNull();
expect(screen.queryByText('LINKS.SHARED_STATUS')).toBeNull();
cleanup();
renderCard({appid: 220, editions: [edition]});
expect(screen.getByText('LINKS.SPLIT')).toBeTruthy();
expect(screen.getByText('LINKS.SHARED_STATUS')).toBeTruthy();
});
it('splitsTheGameAndReloads', async () => {
splitGame.mockResolvedValue({ok: true, status: 204});
const onChanged = renderCard({appid: 220, editions: [edition]});
fireEvent.click(screen.getByText('LINKS.SPLIT'));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(splitGame).toHaveBeenCalledWith(220);
expect(toast.success).toHaveBeenCalledWith('LINKS.SPLIT_DONE');
});
it('showsAnErrorAndDoesNotReloadWhenTheCallFails', async () => {
splitGame.mockResolvedValue({ok: false, status: 404});
const onChanged = renderCard({appid: 220, editions: [edition]});
fireEvent.click(screen.getByText('LINKS.SPLIT'));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('LINKS.ERROR'));
expect(onChanged).not.toHaveBeenCalled();
});
});
+5 -1
View File
@@ -104,7 +104,11 @@ export default function GameDetail() {
<CompletedHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
<BacklogCard key={data.appid} game={data}
onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
onSaved={(entry) => {
setData((d) => ({...d, ...entry}));
// The other editions share status, rating and notes.
if (data.editions?.length) reload();
}}/>
<EditionsCard game={data} auditor={auditor} onRequest={() => setLinking(true)} onChanged={reload}/>
+5
View File
@@ -21,6 +21,11 @@ export const linkGames = (appid, otherAppid) =>
*/
export const unlinkGame = (appid) => apiFetch(`${base()}/games/${appid}/link`, {method: 'DELETE'});
/**
* DELETE: the game stands alone in the own library from now on. 204, 404 NOT_LINKED.
*/
export const splitGame = (appid) => apiFetch(`${base()}/games/${appid}/set`, {method: 'DELETE'});
/**
* DELETE: 204, 404 PAIR_NOT_OPEN.
*/