Files
ToBePlayed-Frontend/src/components/AddGamePanel.jsx
T
lobo 5be0d42bca
Build, Test & Push Frontend / quality-check (push) Successful in 37s
Build, Test & Push Frontend / docker (push) Successful in 1m23s
feat(follow): add feed links, following page and tile (#52, #51)
Feed links go to the game and to the profile view of that game.
Add /following page, nav item and an opt-in Following tile.
Games not in your library show a catalog view with an add button.
2026-10-14 16:23:11 +02:00

181 lines
8.3 KiB
React

import {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {ArrowLeft, Loader2, Plus, X} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import GameSearch from './GameSearch.jsx';
import usePointsCheck from '../hooks/usePointsCheck.js';
import useStartImport from '../hooks/useStartImport.js';
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
import {addGame, hoursToMinutes} from '../utils/manualGames.js';
import {preselectPlatform} from '../utils/platforms.js';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
/**
* Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch; or check several and review
* them on the import page.
*
* @param onAdded called with the new appid before the navigation, for a page that shows the same address
* @param initialQuery the search text to start with, e.g. the name of a game seen elsewhere
*/
export default function AddGamePanel({onClose, onAdded, initialQuery = ''}) {
const {t} = useTranslation();
const navigate = useNavigate();
const [query, setQuery] = useState(initialQuery);
const [picked, setPicked] = useState(null);
// igdbId -> hit, over several searches.
const [selected, setSelected] = useState(() => new Map());
const [busy, setBusy] = useState(false);
const favorite = useCurrentUserContext().user?.settings?.favoritePlatform ?? null;
const startImport = useStartImport();
const toggle = (hit) => {
const next = new Map(selected);
if (!next.delete(hit.igdbId)) next.set(hit.igdbId, hit);
setSelected(next);
};
// Several games go through a SEARCH import: its review picks the platforms, and it can be undone.
const reviewSelected = async () => {
setBusy(true);
const id = await startImport('SEARCH', [...selected.values()]
.map((hit) => ({igdbId: hit.igdbId, title: hit.name.slice(0, 100)})));
setBusy(false);
if (id) navigate(`/import?id=${id}`);
};
return (
<Card className="p-5 gap-4">
<div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-semibold">
<Plus className="size-4 text-primary"/>
{t('MANUAL_GAME.ADD_TITLE')}
</h2>
<Button variant="ghost" size="icon" onClick={onClose} aria-label={t('MANUAL_GAME.CLOSE')}>
<X/>
</Button>
</div>
{picked ? (
<AddForm hit={picked} favorite={favorite} onBack={() => setPicked(null)} navigate={navigate}
onAdded={onAdded}/>
) : (
<>
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.ADD_HINT')}</p>
<GameSearch query={query} onQueryChange={setQuery} onPick={setPicked} blockInLibrary
selected={selected} onToggle={toggle}/>
{selected.size > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">{t('MANUAL_GAME.SELECTED', {count: selected.size})}</span>
<Button onClick={reviewSelected} disabled={busy}>
{busy ? <Loader2 className="animate-spin"/> : <Plus/>}{t('MANUAL_GAME.ADD_SELECTED')}
</Button>
</div>
)}
</>
)}
</Card>
);
}
function AddForm({hit, favorite, onBack, navigate, onAdded}) {
const {t} = useTranslation();
const [platform, setPlatform] = useState(() => preselectPlatform(null, hit.platforms, favorite));
// The favorite can arrive after the pick; it fills only a platform the user has not picked yet.
useEffect(() => {
setPlatform((current) => current || preselectPlatform(null, hit.platforms, favorite));
}, [favorite, hit.platforms]);
const [status, setStatus] = useState('BACKLOG');
const [hours, setHours] = useState('');
const [busy, setBusy] = useState(false);
const check = usePointsCheck();
const submit = async (e) => {
e.preventDefault();
setBusy(true);
try {
const response = await addGame({
igdbId: hit.igdbId, platform: platform.trim(), status, playtimeMinutes: hoursToMinutes(hours),
});
if (response.status === 401) return;
if (response.status === 409) {
toast.error(t('MANUAL_GAME.ALREADY_IN_LIBRARY'));
return;
}
if (!response.ok) {
toast.error(t('MANUAL_GAME.ADD_FAILED'));
return;
}
const {appid} = await response.json();
toast.success(t('MANUAL_GAME.ADDED', {name: hit.name}));
check(appid, {status, tell: true});
onAdded?.(appid);
navigate(`/library/${appid}`);
} catch (err) {
console.error('add game error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
return (
<form onSubmit={submit} className="space-y-4">
<div className="flex items-center gap-3">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-24 shrink-0"/>
<div className="@container min-w-0 flex-1 font-medium">
<FitText text={`${hit.name}${hit.year ? ` (${hit.year})` : ''}`}/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="add-platform">{t('MANUAL_GAME.PLATFORM')}</Label>
{hit.platforms.length > 0 ? (
<Select value={platform} onValueChange={setPlatform}>
<SelectTrigger id="add-platform" className="w-full">
<SelectValue placeholder={t('IMPORT.PICK_PLATFORM')}/>
</SelectTrigger>
<SelectContent>
{hit.platforms.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input id="add-platform" value={platform} maxLength={MAX_PLATFORM_LENGTH} onChange={(e) => setPlatform(e.target.value)}/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="add-status">{t('BACKLOG.STATUS_LABEL')}</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger id="add-status" className="w-full"><SelectValue/></SelectTrigger>
<SelectContent>
{GAME_STATUSES.map((s) => <SelectItem key={s} value={s}>{t(`BACKLOG.STATUS.${s}`)}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="add-hours">{t('MANUAL_GAME.HOURS')}</Label>
<Input id="add-hours" type="number" min="0" step="0.5" inputMode="decimal" value={hours}
onChange={(e) => setHours(e.target.value)}/>
</div>
</div>
<div className="flex justify-between gap-3">
<Button type="button" variant="ghost" onClick={onBack} disabled={busy}>
<ArrowLeft/>{t('MANUAL_GAME.BACK_TO_SEARCH')}
</Button>
<Button type="submit" disabled={busy || !platform.trim()}>
{busy && <Loader2 className="animate-spin"/>}
<Plus/>{t('MANUAL_GAME.ADD')}
</Button>
</div>
</form>
);
}