Public Access
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.
181 lines
8.3 KiB
React
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>
|
|
);
|
|
}
|