Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions client/__tests__/AddGameModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,36 @@ describe("AddGameModal", () => {
expect(searchCallsAfter).toBe(searchCallsBefore);
});

it("seeds a single discovered platform and persists its IGDB pair", async () => {
setupFetch({
searchResults: [
{
...makeSearchResult("God of War", "2005-03-22"),
platforms: ["PlayStation 2"],
platformOptions: [{ id: 8, name: "PlayStation 2" }],
},
],
});
renderModal({ initialQuery: "God of War" });
fireEvent.click(screen.getByTestId("open-btn"));

expect(await screen.findByLabelText("Target platform for God of War")).toHaveTextContent(
"PlayStation 2"
);
fireEvent.click(screen.getByTestId("button-add-igdb-1"));

await waitFor(() => {
const postCall = vi
.mocked(globalThis.fetch)
.mock.calls.find(([url, init]) => String(url) === "/api/games" && init?.method === "POST");
expect(postCall).toBeDefined();
expect(JSON.parse(String(postCall?.[1]?.body))).toMatchObject({
targetPlatformId: 8,
targetPlatformName: "PlayStation 2",
});
});
});

it("shows the mobile configuration prompt when IGDB is not configured", async () => {
mockIsMobile = true;
setupFetch({ configured: false });
Expand Down
40 changes: 40 additions & 0 deletions client/__tests__/GameDetailsModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,46 @@ describe("GameDetailsModal", () => {
expect(screen.getByTestId("badge-platform-ps5")).toBeInTheDocument();
});

it("updates and clears the automatic download target", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(
makeFetchMock({
"/api/igdb/platforms": [
{ id: 8, name: "PlayStation 2" },
{ id: 48, name: "PlayStation 4" },
],
})
);
renderComponent();

const targetSelect = await screen.findByLabelText("Automatic download target");
await screen.findByRole("option", { name: "PlayStation 2" });
fireEvent.change(targetSelect, { target: { value: "8" } });

await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/games/1/target-platform",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
targetPlatformId: 8,
targetPlatformName: "PlayStation 2",
}),
})
);
});

fireEvent.change(targetSelect, { target: { value: "" } });
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/games/1/target-platform",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ targetPlatformId: null, targetPlatformName: null }),
})
);
});
});

it("renders screenshots in Media tab", () => {
renderComponent();
// Media tab uses forceMount so screenshots are always in the DOM (hidden until tab activated)
Expand Down
68 changes: 66 additions & 2 deletions client/src/components/AddGameModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ import { Link } from "wouter";
import { apiFetch, apiRequest } from "@/lib/queryClient";
import { getAddGamePendingQuery, clearAddGamePendingQuery } from "@/lib/add-game-store";
import { useIsMobile } from "@/hooks/use-mobile";
import { resolveTargetPlatform } from "@shared/title-utils";

interface SearchResult extends Game {
inCollection?: boolean;
platformOptions?: IGDBPlatform[];
}

interface AddGameModalProps {
Expand All @@ -58,6 +60,7 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
const [showUndatedGames, setShowUndatedGames] = useState(false);
const [selectedPlatform, setSelectedPlatform] = useState("all");
const [releaseYear, setReleaseYear] = useState("");
const [targetPlatforms, setTargetPlatforms] = useState<Record<string, string>>({});
const { toast } = useToast();
const queryClient = useQueryClient();
const isMobile = useIsMobile();
Expand Down Expand Up @@ -86,6 +89,11 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
return () => clearTimeout(timer);
}, [searchQuery]);

// Per-result target overrides only apply to the current discovery result set.
useEffect(() => {
setTargetPlatforms({});
}, [searchQuery, selectedPlatform, releaseYear, showUndatedGames]);

// Pre-fill search when modal opens (from prop or from the dashboard store)
useEffect(() => {
if (open) {
Expand All @@ -102,6 +110,7 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
setShowUndatedGames(false);
setSelectedPlatform("all");
setReleaseYear("");
setTargetPlatforms({});
}
}, [open, initialQuery]);

Expand Down Expand Up @@ -225,10 +234,61 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
</div>
</div>
);
const getSupportedTargetOptions = (game: SearchResult) =>
game.platformOptions?.filter(({ id, name }) => resolveTargetPlatform(id, name)) ?? [];

const getTargetPlatformValue = (game: SearchResult) => {
const supportedOptions = getSupportedTargetOptions(game);
const key = String(game.igdbId ?? game.id);
const explicitValue = targetPlatforms[key];
if (explicitValue && supportedOptions.some(({ id }) => String(id) === explicitValue)) {
return explicitValue;
}
if (
selectedPlatform !== "all" &&
supportedOptions.some(({ id }) => String(id) === selectedPlatform)
) {
return selectedPlatform;
}
return supportedOptions.length === 1 ? String(supportedOptions[0].id) : "default";
};

const renderTargetPlatformSelect = (game: SearchResult) => {
const supportedOptions = getSupportedTargetOptions(game);
if (game.inCollection || !supportedOptions?.length) return null;
const key = String(game.igdbId ?? game.id);
return (
<Select
value={getTargetPlatformValue(game)}
onValueChange={(value) => setTargetPlatforms((current) => ({ ...current, [key]: value }))}
>
<SelectTrigger
className="h-8 w-full max-w-64 text-xs"
aria-label={`Target platform for ${game.title}`}
>
<SelectValue placeholder="Account default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Account default</SelectItem>
{supportedOptions.map((platform) => (
<SelectItem key={platform.id} value={String(platform.id)}>
{platform.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
const handleAddGame = (searchResult: SearchResult) => {
// Map to InsertGame to filter out client-only fields before sending to server
const gameData = mapGameToInsertGame(searchResult);
const selectedTarget = getTargetPlatformValue(searchResult);
const targetPlatform = searchResult.platformOptions?.find(
({ id }) => String(id) === selectedTarget
);
const gameData = mapGameToInsertGame({
...searchResult,
targetPlatformId: targetPlatform?.id ?? null,
targetPlatformName: targetPlatform?.name ?? null,
});
addGameMutation.mutate(gameData);
};

Expand Down Expand Up @@ -397,6 +457,8 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
{game.summary}
</p>
)}

{renderTargetPlatformSelect(game)}
</div>
</div>
))}
Expand Down Expand Up @@ -527,6 +589,8 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
))}
</div>

{renderTargetPlatformSelect(game)}

<div className="flex items-center justify-between">
<div className="flex flex-wrap gap-1">
{game.platforms?.slice(0, 3).map((platform) => (
Expand Down
96 changes: 96 additions & 0 deletions client/src/components/GameDetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import { useToast } from "@/hooks/use-toast";
import { useHiddenMutation } from "@/hooks/use-hidden-mutation";
import { useIsMobile } from "@/hooks/use-mobile";
import { type Game, type GameDownload, type ScannedGameFile } from "@shared/schema";
import { resolveTargetPlatform } from "@shared/title-utils";
import StatusBadge, { getStatusLabel } from "./StatusBadge";
import { apiRequest } from "@/lib/queryClient";
import { cn, safeUrl, formatBytes, isDiscoveryId } from "@/lib/utils";
Expand All @@ -99,6 +100,11 @@ type FileDeletionResult =
| { deleted: true; path: string | null }
| { deleted: false; reason: "outside-library-root" | "delete-failed"; path: string };

interface IgdbPlatformOption {
id: number;
name: string;
}

interface NexusMod {
mod_id: number;
name: string;
Expand Down Expand Up @@ -356,6 +362,7 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai
const [downloadOpen, setDownloadOpen] = useState(false);
const [isSummaryExpanded, setIsSummaryExpanded] = useState(false);
const [notesValue, setNotesValue] = useState<string>("");
const [targetPlatformValue, setTargetPlatformValue] = useState("");
const [isEditingNotes, setIsEditingNotes] = useState(false);
// Tracks the live notesValue so the async save's onSuccess (below) can tell
// whether the user kept typing after blur, instead of seeing the stale
Expand Down Expand Up @@ -409,6 +416,7 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai
useEffect(() => {
setIsSummaryExpanded(false);
setNotesValue(game?.notes ?? "");
setTargetPlatformValue(game?.targetPlatformId ? String(game.targetPlatformId) : "");
setIsEditingNotes(false);
Comment thread
Doezer marked this conversation as resolved.
queuedNotesSaveRef.current = null;
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand All @@ -423,6 +431,12 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai
setNotesValue(game?.notes ?? "");
}, [game?.notes, isMobile]);

// Keep targetPlatformValue in sync with the server value for the same game
// (e.g. after the target-platform mutation's invalidateQueries refetch lands).
useEffect(() => {
setTargetPlatformValue(game?.targetPlatformId ? String(game.targetPlatformId) : "");
}, [game?.targetPlatformId]);

useEffect(() => {
setSelectedScreenshotIndex(null);
}, [game?.id]);
Expand Down Expand Up @@ -509,6 +523,29 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai
};
}, [open, game?.id, queryClient]);

const { data: targetPlatformOptions = [] } = useQuery<IgdbPlatformOption[]>({
queryKey: ["/api/igdb/platforms"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/igdb/platforms");
return res.json();
},
enabled: open && !!game?.id && !isDiscoveryId(game.id),
staleTime: 24 * 60 * 60 * 1000,
});

const supportedTargetPlatformOptions = useMemo(() => {
const options = targetPlatformOptions.filter(({ id, name }) => resolveTargetPlatform(id, name));
if (
game?.targetPlatformId &&
game.targetPlatformName &&
resolveTargetPlatform(game.targetPlatformId, game.targetPlatformName) &&
!options.some(({ id }) => id === game.targetPlatformId)
) {
return [{ id: game.targetPlatformId, name: game.targetPlatformName }, ...options];
}
return options;
}, [targetPlatformOptions, game?.targetPlatformId, game?.targetPlatformName]);

const { data: gameDownloads = [], isLoading: downloadsLoading } = useQuery<
GameDownloadWithDownloader[]
>({
Expand Down Expand Up @@ -614,6 +651,23 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai
},
});

const targetPlatformMutation = useMutation({
mutationFn: async (target: {
targetPlatformId: number | null;
targetPlatformName: string | null;
}) => {
await apiRequest("PATCH", `/api/games/${game?.id}/target-platform`, target);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/games"] });
toast({ description: "Download target updated" });
},
onError: () => {
setTargetPlatformValue(game?.targetPlatformId ? String(game.targetPlatformId) : "");
toast({ description: "Failed to update download target", variant: "destructive" });
},
});

const notesMutation = useMutation({
mutationFn: async ({ gameId, notes }: { gameId: string; notes: string | null }) => {
await apiRequest("PATCH", `/api/games/${gameId}/notes`, { notes });
Expand Down Expand Up @@ -1146,6 +1200,48 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai
/>
</div>
)}
{!isDiscoveryId(game.id) && (
<div>
<label
htmlFor="target-platform"
className="font-semibold mb-2 flex items-center gap-2"
>
<Gamepad2 className="w-4 h-4" />
Automatic download target
</label>
<select
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
id="target-platform"
value={targetPlatformValue}
disabled={targetPlatformMutation.isPending}
onChange={(event) => {
const value = event.target.value;
setTargetPlatformValue(value);
const selected = supportedTargetPlatformOptions.find(
({ id }) => String(id) === value
);
targetPlatformMutation.mutate(
selected
? {
targetPlatformId: selected.id,
targetPlatformName: selected.name,
}
: { targetPlatformId: null, targetPlatformName: null }
);
}}
className="flex h-9 w-full max-w-sm rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Use account default</option>
{supportedTargetPlatformOptions.map((platform) => (
<option key={platform.id} value={platform.id}>
{platform.name}
</option>
))}
</select>
<p className="mt-1 text-xs text-muted-foreground">
Overrides the account platform for automatic release matching.
</p>
</div>
)}
</div>
</div>
</ScrollArea>
Expand Down
2 changes: 2 additions & 0 deletions client/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export function mapGameToInsertGame(game: Game): InsertGame {
releaseDate: game.releaseDate || null,
rating: game.rating,
platforms: game.platforms,
targetPlatformId: game.targetPlatformId,
targetPlatformName: game.targetPlatformName,
genres: game.genres,
themes: game.themes,
screenshots: game.screenshots,
Expand Down
2 changes: 2 additions & 0 deletions migrations/0035_add-target-platform-columns.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE `games` ADD `target_platform_id` integer;--> statement-breakpoint
ALTER TABLE `games` ADD `target_platform_name` text;
Loading