diff --git a/README.md b/README.md index 6b3297f..e1c4612 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,10 @@ flowchart LR ``` - **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and finished games per year of all users. -- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set matches 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. A game you own on several +- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set matches on each of its sources), "short games" filter (up to 10 h), search, sorting by playtime, newest + in library, rating, priority, name, last played or time to beat (games never played or without a time to beat come last), page + numbers with Previous / Next. Each row shows the unlocked achievements, and its status badge is a menu: choosing a status saves at once + (with an Undo in the toast and the points check). While sorted by rating or priority the row shows them. A game you own on several platforms is one row (Core's counted edition) with a small badge per platform next to the title (`PlatformIcons`: ST, PS or the platform name, from `platforms[]`; lucide has no brand icons). - **Editions**: the editions of one game (Steam and PS5 copy, remaster, port) are linked automatically from IGDB's @@ -314,7 +316,7 @@ flowchart LR - `/questions` (`QuestionsPage`, nav item with the open count; the badge is hidden on the page, whose title shows the live count) lists one card per game with an open question (Start it? / Finished? / Dropped? / Rate it?). Core decides 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 `GameHeroCard`. Below the cards, a closed "Removed games" section (`RemovedGamesSection`, `GET /api/library/removed-games`) has a Restore button per game (`POST /api/library/games/{appid}/restore`). The toast after "Remove from library" on a manual game has an Undo that restores it the same way. @@ -339,10 +341,12 @@ flowchart LR 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 + Backlog["GameHeroCard / BacklogCard save"] -->|"game has editions"| Reload ``` +- The game page edits at once: status (menu with Undo) and rating in `GameHeroCard`, priority in `BacklogCard`; only the + notes there have a Save button. Background, banner, profile art, hide (with Undo) and the auditor tools sit in the "..." menu of the hero card. - `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. + through `src/utils/links.js` (`splitGame`) after a confirm dialog (unlink asks too); `404 NOT_LINKED` shows the generic error toast. ```mermaid flowchart LR diff --git a/public/i18n/de.json b/public/i18n/de.json index 8a8e654..1245056 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -289,7 +289,10 @@ "SORT_BY": "Sortieren:", "SORT_PLAYTIME_DESC": "Meistgespielt", "SORT_PLAYTIME_ASC": "Am wenigsten gespielt", - "SORT_LAST_SEEN": "Zuletzt synchronisiert", + "SORT_NAME": "Name A–Z", + "SORT_LAST_PLAYED": "Zuletzt gespielt", + "SORT_TIME_TO_BEAT": "Kürzeste Spieldauer", + "PAGINATION": "Seiten", "SORT_FIRST_SEEN": "Neueste in Bibliothek", "PREV": "Zurück", "NEXT": "Weiter", @@ -318,6 +321,7 @@ "EDITIONS": "{{count}} Editionen in deiner Bibliothek" }, "GAME_DETAIL": { + "MORE": "Weitere Aktionen", "BACK": "Zurück", "BACK_TO_LIBRARY": "Zur Bibliothek", "LOADING": "Lade Spiel …", @@ -840,6 +844,10 @@ "NEVER": "nie" }, "LINKS": { + "SPLIT_CONFIRM_TITLE": "Nicht dasselbe Spiel?", + "SPLIT_CONFIRM_TEXT": "Das Spiel wird ein eigener Eintrag in deiner Bibliothek mit eigenem Status, eigener Bewertung und eigenen Notizen. Die Verknüpfung für andere Nutzer bleibt.", + "UNLINK_CONFIRM_TITLE": "Für alle trennen?", + "UNLINK_CONFIRM_TEXT": "Entfernt die Verknüpfung dieser Spiele für alle Nutzer.", "ALSO_IN_LIBRARY": "Auch in deiner Bibliothek:", "SAME_AS": "Dasselbe Spiel wie...", "LINK_WITH": "Verknüpfen mit...", diff --git a/public/i18n/en.json b/public/i18n/en.json index 8d892b2..bacd5fb 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -289,7 +289,10 @@ "SORT_BY": "Sort by:", "SORT_PLAYTIME_DESC": "Most played", "SORT_PLAYTIME_ASC": "Least played", - "SORT_LAST_SEEN": "Last synced", + "SORT_NAME": "Name A–Z", + "SORT_LAST_PLAYED": "Last played", + "SORT_TIME_TO_BEAT": "Shortest to beat", + "PAGINATION": "Pages", "SORT_FIRST_SEEN": "Newest in library", "PREV": "Previous", "NEXT": "Next", @@ -318,6 +321,7 @@ "EDITIONS": "{{count}} editions in your library" }, "GAME_DETAIL": { + "MORE": "More actions", "BACK": "Back", "BACK_TO_LIBRARY": "Back to library", "LOADING": "Loading game …", @@ -840,6 +844,10 @@ "NEVER": "never" }, "LINKS": { + "SPLIT_CONFIRM_TITLE": "Not the same game?", + "SPLIT_CONFIRM_TEXT": "The game becomes a separate entry in your library with its own status, rating and notes. The link for other users stays.", + "UNLINK_CONFIRM_TITLE": "Unlink for everyone?", + "UNLINK_CONFIRM_TEXT": "Removes the link between these games for every user.", "ALSO_IN_LIBRARY": "Also in your library:", "SAME_AS": "Same game as...", "LINK_WITH": "Link with...", diff --git a/src/components/BacklogCard.jsx b/src/components/BacklogCard.jsx index 74bdb4f..1c038c8 100644 --- a/src/components/BacklogCard.jsx +++ b/src/components/BacklogCard.jsx @@ -1,6 +1,5 @@ -import {useEffect, useState} from 'react'; +import {useState} from 'react'; import {useTranslation} from 'react-i18next'; -import {toast} from 'sonner'; import {ListTodo, Loader2} from 'lucide-react'; import {Button} from '@/components/ui/button'; @@ -8,58 +7,25 @@ import {Card} from '@/components/ui/card'; import {Label} from '@/components/ui/label'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import {Textarea} from '@/components/ui/textarea'; -import RatingStars from './RatingStars.jsx'; -import {STATUS_META} from './GameStatusBadge.jsx'; -import {changedFields, NO_PRIORITY, saveBacklogEntry} from '../utils/backlog.js'; -import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js'; +import {changedFields, NO_PRIORITY} from '../utils/backlog.js'; +import {GAME_PRIORITIES, MAX_NOTES_LENGTH} from '../utils/limits.js'; +import useSaveBacklogField from '../hooks/useSaveBacklogField.js'; import useUnsavedChanges from '../hooks/useUnsavedChanges.js'; -const toForm = (entry) => ({ - status: entry.status ?? 'BACKLOG', - rating: entry.rating ?? 0, - notes: entry.notes ?? '', - priority: entry.priority ?? NO_PRIORITY, -}); - +/** + * Priority (saved on change) and notes (saved with the Save button). Status and rating live in the hero card. + * + * @param onSaved gets the saved backlog entry + */ export default function BacklogCard({game, onSaved}) { const {t} = useTranslation(); - const [saved, setSaved] = useState(() => toForm(game)); - const [form, setForm] = useState(saved); + const [notes, setNotes] = useState(game.notes ?? ''); + const {save, pending, busy} = useSaveBacklogField(game.appid, {onSaved}); - // The PlayingHint can change the status from outside; keep the other unsaved edits. - useEffect(() => { - setSaved((current) => (current.status === game.status ? current : {...current, status: game.status})); - setForm((current) => (current.status === game.status ? current : {...current, status: game.status})); - }, [game.status]); - const [busy, setBusy] = useState(false); - - const changes = changedFields(saved, form); - const dirty = Object.keys(changes).length > 0; + const savingNotes = pending?.field === 'notes'; + const dirty = 'notes' in changedFields({notes: game.notes}, {notes}); useUnsavedChanges(dirty); - const set = (field) => (value) => setForm((f) => ({...f, [field]: value})); - - const save = async () => { - setBusy(true); - try { - const response = await saveBacklogEntry(game.appid, changes); - if (response.status === 401) return; - if (!response.ok) { - toast.error(t('BACKLOG.SAVE_FAILED')); - return; - } - const entry = await response.json(); - const next = toForm(entry); - setSaved(next); - onSaved?.(entry); - setForm(next); - toast.success(t('BACKLOG.SAVED')); - } catch (err) { - console.error('backlog save error:', err); - toast.error(t('INFO.ERROR.NETWORK_FAILURE')); - } finally { - setBusy(false); - } - }; + const priority = pending?.field === 'priority' ? pending.value : (game.priority ?? NO_PRIORITY); return ( @@ -68,68 +34,42 @@ export default function BacklogCard({game, onSaved}) { {t('BACKLOG.TITLE')} - {/* Locked while saving: the answer replaces the form, so an edit made meanwhile would be lost. */} -
-
-
- - -
+
+ + +
-
- - -
- -
- - -
+
+
+ + {notes.length} / {MAX_NOTES_LENGTH}
- -
-
- - {form.notes.length} / {MAX_NOTES_LENGTH} -
-