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

+17 -4
View File
@@ -57,10 +57,12 @@ To Be Played is split into three repositories:
finished games per year of all users. finished games per year of all users.
- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set counts on each of its sources), "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest - **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set counts on each of its sources), "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
in library, rating or priority, paging. Each row shows the unlocked achievements. in library, rating or priority, paging. Each row shows the unlocked achievements.
- **Editions**: an auditor links the editions of one game (Steam and PS5 copy, remaster, port) for every user. A user - **Editions**: the editions of one game (Steam and PS5 copy, remaster, port) are linked automatically from IGDB's
who owns two or more of a linked set sees "Also in your library" on the details page, an "x2" badge in the library "same game" key; an auditor can also link or unlink them for every user. A user who owns two or more of a linked
and the filter "Hide duplicates"; the numbers count the set once. "Same game as..." on the details page asks the set sees "Also in your library" on the details page, an "x2" badge in the library and the filter "Hide
auditors for a link. duplicates"; the numbers count the set once. The editions of a set share one status, rating and notes (the page
reloads after a save to show it). "Not the same game" splits the game off its set for this user only, and it stays
split. "Same game as..." on the details page asks the auditors for a link.
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, - **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several
hits to add them at once through the import review. hits to add them at once through the import review.
@@ -257,6 +259,17 @@ flowchart LR
which question is open; `src/utils/questions.js` only loads the list and sends the answer. A yes to Finished? gets which question is open; `src/utils/questions.js` only loads the list and sends the answer. A yes to Finished? gets
the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `BacklogCard`. the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `BacklogCard`.
```mermaid
flowchart LR
Detail["GameDetail: EditionsCard"] -->|"Not the same game"| Split["Core DELETE /api/library/games/{appid}/set"]
Detail -->|"Same game as... (request)"| Request["Core POST /api/library/games/{appid}/link-requests"]
Detail -->|"Unlink (auditor)"| Unlink["Core DELETE /api/library/games/{appid}/link"]
Split --> Reload["reload the detail"]
Backlog["BacklogCard save"] -->|"game has editions"| Reload
```
- `EditionsCard` shows "Not the same game" on every game that has other editions in the own library. The call goes
through `src/utils/links.js` (`splitGame`); `404 NOT_LINKED` shows the generic error toast.
```mermaid ```mermaid
flowchart LR flowchart LR
Header["AppHeader badge"] --> Count["Core /api/library/questions/count"] Header["AppHeader badge"] --> Count["Core /api/library/questions/count"]
+3
View File
@@ -809,6 +809,9 @@
"REQUEST": "Anfrage senden", "REQUEST": "Anfrage senden",
"LINK": "Verknüpfen", "LINK": "Verknüpfen",
"UNLINK": "Verknüpfung lösen", "UNLINK": "Verknüpfung lösen",
"SPLIT": "Nicht dasselbe Spiel",
"SPLIT_DONE": "Spiel getrennt. Es steht jetzt allein in deiner Bibliothek.",
"SHARED_STATUS": "Status, Bewertung und Notizen gelten für alle diese Ausgaben.",
"DISMISS": "Nicht dasselbe", "DISMISS": "Nicht dasselbe",
"REQUESTED": "Anfrage gesendet", "REQUESTED": "Anfrage gesendet",
"LINKED": "Spiele verknüpft", "LINKED": "Spiele verknüpft",
+3
View File
@@ -809,6 +809,9 @@
"REQUEST": "Send request", "REQUEST": "Send request",
"LINK": "Link", "LINK": "Link",
"UNLINK": "Unlink", "UNLINK": "Unlink",
"SPLIT": "Not the same game",
"SPLIT_DONE": "Game split off. It stands alone in your library now.",
"SHARED_STATUS": "Status, rating and notes are shared by these editions.",
"DISMISS": "Not the same", "DISMISS": "Not the same",
"REQUESTED": "Request sent", "REQUESTED": "Request sent",
"LINKED": "Games linked", "LINKED": "Games linked",
+18 -9
View File
@@ -1,37 +1,38 @@
import {useState} from 'react'; import {useState} from 'react';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Layers, Link2Off} from 'lucide-react'; import {Layers, Link2Off, Unlink} from 'lucide-react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import GameStatusBadge from './GameStatusBadge.jsx'; import GameStatusBadge from './GameStatusBadge.jsx';
import FitText from './FitText.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'; 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 * The other editions of this game in the own library, and the buttons to ask for a link (everyone), to split the game
* (auditor). Shown on every game: a request must be possible from any 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}) { export default function EditionsCard({game, auditor, onRequest, onChanged}) {
const {t} = useTranslation(); const {t} = useTranslation();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const unlink = async () => { const run = async (call, doneKey) => {
setBusy(true); setBusy(true);
try { try {
const response = await unlinkGame(game.appid); const response = await call(game.appid);
if (response.status === 401) return; if (response.status === 401) return;
if (response.ok) { if (response.ok) {
toast.success(t('LINKS.UNLINKED')); toast.success(t(doneKey));
onChanged(); onChanged();
} else { } else {
toast.error(t('LINKS.ERROR')); toast.error(t('LINKS.ERROR'));
} }
} catch (err) { } catch (err) {
console.error('Unlink error:', err); console.error('Edition change error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE')); toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally { } finally {
setBusy(false); setBusy(false);
@@ -56,14 +57,22 @@ export default function EditionsCard({game, auditor, onRequest, onChanged}) {
</li> </li>
))} ))}
</ul> </ul>
<p className="text-muted-foreground">{t('LINKS.SHARED_STATUS')}</p>
</div> </div>
)} )}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={onRequest}> <Button type="button" variant="outline" size="sm" onClick={onRequest}>
<Layers/>{t(auditor ? 'LINKS.LINK_WITH' : 'LINKS.SAME_AS')} <Layers/>{t(auditor ? 'LINKS.LINK_WITH' : 'LINKS.SAME_AS')}
</Button> </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 && ( {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')} <Link2Off/>{t('LINKS.UNLINK')}
</Button> </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}))}/> <CompletedHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
<BacklogCard key={data.appid} game={data} <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}/> <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'}); 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. * DELETE: 204, 404 PAIR_NOT_OPEN.
*/ */