From 37747c2a33e997735bb7041a788b3077527e7c84 Mon Sep 17 00:00:00 2001 From: Alper Alkan Date: Thu, 18 Dec 2025 13:05:20 +0100 Subject: [PATCH 01/12] Bump version from 16.0.0 to 16.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7c4ffbc..e89fc8d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gamevault-frontend", - "version": "16.0.0", + "version": "16.2.0", "description": "the self-hosted gaming platform for drm-free games", "author": "Alkan Alper, Schäfer Philip GbR / Phalcode", "private": true, From 80a4c122870e892e8e2d969449426917735e3a51 Mon Sep 17 00:00:00 2001 From: Alper Alkan Date: Thu, 18 Dec 2025 13:10:19 +0100 Subject: [PATCH 02/12] Update CHANGELOG for version 16.2.0 Updated versioning and added new features for 16.2.0. --- CHANGELOG.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 776ff16..9396694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,21 @@ # GameVault Frontend Changelog -## 1.1.0 -Recommended Gamevault Server Version: `v16.1.2` +## 16.2.0 + ### Changes - Added GameSettings - Extended library filters - Bug fix: Youtube player error 153 -## 1.0.0 +## 16.1.0 + +### Changes +- Added GameView Layout +- Added Support for SSO +- Added Sorting + Filtering + + +## 16.0.0 ### Changes From e41d2a882af841eb3d2e28d8b3b870b14cafd363 Mon Sep 17 00:00:00 2001 From: Alper Alkan Date: Wed, 31 Dec 2025 00:22:03 +0100 Subject: [PATCH 03/12] Update CHANGELOG for version 16.2.1 and refactor GameCard and Community components to use Link for navigation --- CHANGELOG.md | 6 +++ src/components/GameCard.tsx | 22 +++------ src/pages/Community.tsx | 93 ++++++++++++++++++++----------------- 3 files changed, 62 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9396694..b526cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # GameVault Frontend Changelog +## 16.2.1 + +### Changes + +- [#6](https://github.com/Phalcode/gamevault-frontend/issues/6) - Use proper href links on game cards and community progress list for better accessibility (middle-click/right-click to open in new tab) + ## 16.2.0 ### Changes diff --git a/src/components/GameCard.tsx b/src/components/GameCard.tsx index 8418602..5b50215 100644 --- a/src/components/GameCard.tsx +++ b/src/components/GameCard.tsx @@ -17,7 +17,7 @@ import { } from "@tw/dropdown"; import clsx from "clsx"; import { useCallback, useMemo, useState, useEffect } from "react"; -import { useNavigate } from "react-router"; +import { Link } from "react-router"; export function GameCard({ game }: { game: GamevaultGame }) { const { serverUrl, user, authFetch } = useAuth(); @@ -107,16 +107,7 @@ export function GameCard({ game }: { game: GamevaultGame }) { [game.id], ); - const navigate = useNavigate(); - - const handleOpenGameView = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - navigate(`/library/${game.id}`); - }, - [navigate, game.id], - ); + const gameViewUrl = `/library/${game.id}`; const handleOpenSettings = useCallback( (e: React.MouseEvent) => { @@ -129,12 +120,12 @@ export function GameCard({ game }: { game: GamevaultGame }) { return ( <> -
{coverId ? ( @@ -148,10 +139,9 @@ export function GameCard({ game }: { game: GamevaultGame }) { className="h-full w-full object-contain rounded-none" square alt={localGame.title} - onClick={handleOpenGameView} /> ) : ( -
+
No Cover
)} @@ -226,7 +216,7 @@ export function GameCard({ game }: { game: GamevaultGame }) {

)}
-
+ {settingsOpen && ( ([]); const [currentUsername, setCurrentUsername] = useState(""); const [loading, setLoading] = useState(false); @@ -282,52 +281,60 @@ export default function Community() { const coverId = game ? getGameCoverMediaId(game) : null; const key = p.id ?? `${(game && game.id) || "g"}-${idx}`; const hours = minutes / 60; - const openGame = () => { - if (!game?.id) return; - const base = (serverUrl || "").replace(/\/+$/, ""); - if (base) { - window.location.href = `${base}/library/${game.id}`; - } else { - navigate(`/library/${game.id}`); - } - }; - const onKey: React.KeyboardEventHandler = (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - openGame(); - } - }; + const gameUrl = game?.id ? `/library/${game.id}` : undefined; return (
-
- {coverId ? ( - - ) : ( -
- No Cover -
- )} -
+ {gameUrl ? ( + + {coverId ? ( + + ) : ( +
+ No Cover +
+ )} + + ) : ( +
+ {coverId ? ( + + ) : ( +
+ No Cover +
+ )} +
+ )}
{title} From 91d541591eaddf5c6c1deb5c60f70e0636715b51 Mon Sep 17 00:00:00 2001 From: Alper Alkan Date: Wed, 31 Dec 2025 00:29:48 +0100 Subject: [PATCH 04/12] improve URL parameter syncing with debouncing in Library component --- CHANGELOG.md | 1 + src/pages/Library.tsx | 103 ++++++++++++++++++++++-------------------- 2 files changed, 56 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b526cd4..ebbf7dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changes - [#6](https://github.com/Phalcode/gamevault-frontend/issues/6) - Use proper href links on game cards and community progress list for better accessibility (middle-click/right-click to open in new tab) +- [#9](https://github.com/Phalcode/gamevault-frontend/issues/9) - Fix search with special characters (apostrophes) causing blank page due to excessive history API calls (added debounce and duplicate URL check) ## 16.2.0 diff --git a/src/pages/Library.tsx b/src/pages/Library.tsx index 45c38ff..c0ff069 100644 --- a/src/pages/Library.tsx +++ b/src/pages/Library.tsx @@ -350,68 +350,75 @@ export default function Library() { urlInitializedRef.current = true; }, [getParamValues, isBookmark, isEarlyAccess, isGameType, isProgressState]); - // Sync all filters into URL search params for shareable links + // Sync all filters into URL search params for shareable links (debounced) useEffect(() => { if (typeof window === "undefined") return; if (!urlInitializedRef.current) return; - const url = new URL(window.location.href); - const params = url.searchParams; + const timeoutId = setTimeout(() => { + const url = new URL(window.location.href); + const params = url.searchParams; - if (search.trim().length > 0) params.set("q", search.trim()); - else params.delete("q"); + if (search.trim().length > 0) params.set("q", search.trim()); + else params.delete("q"); - if (sortBy !== "sort_title") params.set("sort", sortBy); - else params.delete("sort"); + if (sortBy !== "sort_title") params.set("sort", sortBy); + else params.delete("sort"); - if (order !== "ASC") params.set("order", order); - else params.delete("order"); + if (order !== "ASC") params.set("order", order); + else params.delete("order"); - if (bookmarkFilter !== "all") params.set("bookmarked", bookmarkFilter); - else params.delete("bookmarked"); + if (bookmarkFilter !== "all") params.set("bookmarked", bookmarkFilter); + else params.delete("bookmarked"); - setParamValues( - params, - "types", - selectedGameTypes - .map((i) => String(i.id)) - .filter((v): v is GamevaultGameTypeEnum => isGameType(v)), - ); - setParamValues( - params, - "tags", - selectedTags.map((t) => t.name), - ); - setParamValues( - params, - "genres", - selectedGenres.map((g) => g.name), - ); - setParamValues( - params, - "developers", - selectedDevelopers.map((d) => d.name), - ); - setParamValues( - params, - "publishers", - selectedPublishers.map((p) => p.name), - ); + setParamValues( + params, + "types", + selectedGameTypes + .map((i) => String(i.id)) + .filter((v): v is GamevaultGameTypeEnum => isGameType(v)), + ); + setParamValues( + params, + "tags", + selectedTags.map((t) => t.name), + ); + setParamValues( + params, + "genres", + selectedGenres.map((g) => g.name), + ); + setParamValues( + params, + "developers", + selectedDevelopers.map((d) => d.name), + ); + setParamValues( + params, + "publishers", + selectedPublishers.map((p) => p.name), + ); + + if (selectedGameState) params.set("state", selectedGameState); + else params.delete("state"); - if (selectedGameState) params.set("state", selectedGameState); - else params.delete("state"); + if (releaseDateFrom) params.set("releasedAfter", releaseDateFrom); + else params.delete("releasedAfter"); - if (releaseDateFrom) params.set("releasedAfter", releaseDateFrom); - else params.delete("releasedAfter"); + if (releaseDateTo) params.set("releasedBefore", releaseDateTo); + else params.delete("releasedBefore"); - if (releaseDateTo) params.set("releasedBefore", releaseDateTo); - else params.delete("releasedBefore"); + if (earlyAccess !== "all") params.set("earlyAccess", earlyAccess); + else params.delete("earlyAccess"); - if (earlyAccess !== "all") params.set("earlyAccess", earlyAccess); - else params.delete("earlyAccess"); + // Only update URL if it actually changed to avoid rate-limiting errors + const newUrl = url.toString(); + if (newUrl !== window.location.href) { + window.history.replaceState({}, "", newUrl); + } + }, 300); - // We intentionally do not push to history each keystroke of search for cleanliness - window.history.replaceState({}, "", url.toString()); + return () => clearTimeout(timeoutId); }, [ bookmarkFilter, earlyAccess, From 8aa8cbefc739b5aeed331698dc2aaed678696ac7 Mon Sep 17 00:00:00 2001 From: Alper Alkan Date: Wed, 31 Dec 2025 01:25:38 +0100 Subject: [PATCH 05/12] Update registration and login components --- CHANGELOG.md | 1 + src/components/Login.tsx | 314 ++++++++++---- src/components/Register.tsx | 513 +++++++++++++---------- src/hooks/useRegistrationRequirements.ts | 16 +- src/pages/GameView.tsx | 2 - 5 files changed, 530 insertions(+), 316 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbf7dd..7b6361a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changes +- [#5](https://github.com/Phalcode/gamevault-frontend/issues/5) - Dynamically show or hide Basic Auth and SSO options on Login and Register pages based on server configuration - [#6](https://github.com/Phalcode/gamevault-frontend/issues/6) - Use proper href links on game cards and community progress list for better accessibility (middle-click/right-click to open in new tab) - [#9](https://github.com/Phalcode/gamevault-frontend/issues/9) - Fix search with special characters (apostrophes) causing blank page due to excessive history API calls (added debounce and duplicate URL check) diff --git a/src/components/Login.tsx b/src/components/Login.tsx index e18bf27..de33404 100644 --- a/src/components/Login.tsx +++ b/src/components/Login.tsx @@ -9,15 +9,25 @@ import { Strong, Text, TextLink } from "@tw/text"; import { FormEvent, useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router"; import ThemeSwitch from "./ThemeSwitch"; -import { RegisterUserDtoFromJSON } from "../api"; +import { Status } from "../api"; export function Login() { const { loginBasic, loginWithTokens, loading, error } = useAuth(); const navigate = useNavigate(); const [server, setServer] = useState(window.location.origin); + const [confirmedServer, setConfirmedServer] = useState(null); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [useSso, setUseSso] = useState(false); + const [serverStatus, setServerStatus] = useState(null); + const [statusLoading, setStatusLoading] = useState(false); + const [statusError, setStatusError] = useState(false); + + const basicAuthAvailable = + serverStatus?.available_authentication_methods?.includes("basic") ?? true; + const ssoAvailable = + serverStatus?.available_authentication_methods?.includes("sso") ?? true; + const noAuthAvailable = !basicAuthAvailable && !ssoAvailable; // Refs for focus trap const serverRef = useRef(null); @@ -26,9 +36,11 @@ export function Login() { const submitRef = useRef(null); useEffect(() => { - // Auto-focus server field on mount - serverRef.current?.focus(); - }, []); + // Auto-focus server field on mount if not confirmed + if (!confirmedServer) { + serverRef.current?.focus(); + } + }, [confirmedServer]); const normalizeServer = useCallback((raw: string) => { if (!raw) return raw; @@ -42,12 +54,72 @@ export function Login() { return s; }, []); + useEffect(() => { + if (!confirmedServer) { + setServerStatus(null); + setStatusError(false); + return; + } + const normalized = normalizeServer(confirmedServer); + if (!normalized) { + setServerStatus(null); + return; + } + + setStatusLoading(true); + setStatusError(false); + const controller = new AbortController(); + + (async () => { + try { + const res = await fetch(`${normalized}/api/status`, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }); + if (res.ok) { + const data = await res.json(); + setServerStatus(data); + } else { + setServerStatus(null); + setStatusError(true); + } + } catch (e: any) { + if (e.name !== "AbortError") { + setServerStatus(null); + setStatusError(true); + } + } finally { + if (!controller.signal.aborted) { + setStatusLoading(false); + } + } + })(); + + return () => { + controller.abort(); + }; + }, [confirmedServer, normalizeServer]); + + useEffect(() => { + if (serverStatus) { + if (ssoAvailable && !basicAuthAvailable) { + setUseSso(true); + } else if (!ssoAvailable && basicAuthAvailable) { + setUseSso(false); + } + } + }, [serverStatus, ssoAvailable, basicAuthAvailable]); + // Parse SSO redirect style: {server}/access_token=...&refresh_token=... useEffect(() => { try { const loc = window.location; - const search = loc.search.startsWith("?") ? loc.search.substring(1) : loc.search; - const path = loc.pathname.startsWith("/") ? loc.pathname.slice(1) : loc.pathname; + const search = loc.search.startsWith("?") + ? loc.search.substring(1) + : loc.search; + const path = loc.pathname.startsWith("/") + ? loc.pathname.slice(1) + : loc.pathname; const hash = loc.hash.startsWith("#") ? loc.hash.slice(1) : loc.hash; // Priority order: query string (?access_token=...), then path style, then hash fragment. @@ -65,10 +137,13 @@ export function Login() { const base = window.location.origin; // assume same origin the user entered for SSO (async () => { try { - await loginWithTokens(base, { access_token: access, refresh_token: refresh }); + await loginWithTokens(base, { + access_token: access, + refresh_token: refresh, + }); // Scrub sensitive tokens from URL: go to /login (or /library directly after navigation) without query/hash. const cleanUrl = base + "/login"; - window.history.replaceState({}, document.title, cleanUrl); + window.history.replaceState({}, document.title, cleanUrl); navigate("/library", { replace: true }); } catch { // ignore - context will show error @@ -79,10 +154,24 @@ export function Login() { } }, [loginWithTokens, navigate]); + const handleContinue = (e: React.FormEvent) => { + e.preventDefault(); + if (!server.trim()) return; + let normalized = server.trim(); + if (!/^https?:\/\//i.test(normalized)) normalized = `https://${normalized}`; + setServer(normalized); + setConfirmedServer(normalized); + }; + + const handleChangeServer = () => { + setConfirmedServer(null); + setServerStatus(null); + }; + const onSubmit = async (e: FormEvent) => { e.preventDefault(); try { - const normalized = normalizeServer(server); + const normalized = normalizeServer(confirmedServer || server); if (useSso) { window.location.href = `${normalized}/api/auth/oauth2/login`; return; @@ -130,7 +219,7 @@ export function Login() { return (
@@ -138,81 +227,138 @@ export function Login() {
Sign in to your account - - - setServer(e.target.value)} - // Only trim whitespace on blur; do NOT auto-inject protocol into the visible field - onBlur={() => setServer((s) => s.trim())} - autoComplete="url" - ref={serverRef} - tabIndex={1} - /> - - - - setUsername(e.target.value)} - ref={userRef} - tabIndex={2} - disabled={useSso} - /> - - - - setPassword(e.target.value)} - ref={passRef} - tabIndex={3} - disabled={useSso} - /> - - - setUseSso(!!checked)} - /> - - - {error && ( -
- {error} -
+ + {!confirmedServer && ( + <> + + + setServer(e.target.value)} + // Only trim whitespace on blur; do NOT auto-inject protocol into the visible field + onBlur={() => setServer((s) => s.trim())} + autoComplete="url" + ref={serverRef} + tabIndex={1} + autoFocus + /> + + + )} - + + {confirmedServer && ( + <> + + +
+ + +
+
+ + {statusLoading && Connecting to server...} + + {statusError && ( + + Failed to connect to server. Please check the URL. + + )} + + {!statusLoading && !statusError && ( + <> + {basicAuthAvailable && !useSso && ( + + + setUsername(e.target.value)} + ref={userRef} + tabIndex={2} + /> + + )} + {basicAuthAvailable && !useSso && ( + + + setPassword(e.target.value)} + ref={passRef} + tabIndex={3} + /> + + )} + {ssoAvailable && basicAuthAvailable && ( + + setUseSso(!!checked)} + /> + + + )} + {noAuthAvailable && ( +
+ No authentication methods are currently available on this server. +
+ )} + {error && ( +
+ {error} +
+ )} + {!noAuthAvailable && ( + + )} + + )} + + )} +