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.
- **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.
- **Editions**: an auditor links the editions of one game (Steam and PS5 copy, remaster, port) for every user. A user
who owns two or more of a linked set sees "Also in your library" on the details page, an "x2" badge in the library
and the filter "Hide duplicates"; the numbers count the set once. "Same game as..." on the details page asks the
auditors for a link.
- **Editions**: the editions of one game (Steam and PS5 copy, remaster, port) are linked automatically from IGDB's
"same game" key; an auditor can also link or unlink them for every user. A user who owns two or more of a linked
set sees "Also in your library" on the details page, an "x2" badge in the library and the filter "Hide
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,
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.
@@ -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
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
flowchart LR
Header["AppHeader badge"] --> Count["Core /api/library/questions/count"]
+3
View File
@@ -809,6 +809,9 @@
"REQUEST": "Anfrage senden",
"LINK": "Verknüpfen",
"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",
"REQUESTED": "Anfrage gesendet",
"LINKED": "Spiele verknüpft",
+3
View File
@@ -809,6 +809,9 @@
"REQUEST": "Send request",
"LINK": "Link",
"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",
"REQUESTED": "Request sent",
"LINKED": "Games linked",
+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.
*/