Skip to content

Commit 77fc9bc

Browse files
committed
Added advanced filters to library + bookmarked filter + bookmark on game card
1 parent 1205287 commit 77fc9bc

9 files changed

Lines changed: 150 additions & 41 deletions

File tree

src/components/GameCard.tsx

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { useAuth } from "@/context/AuthContext";
44
import { useDownloads } from "@/context/DownloadContext";
55
import { getGameCoverMediaId } from "@/hooks/useGames";
66
import { CloudArrowDownIcon } from "@heroicons/react/16/solid";
7+
import { StarIcon as StarSolid } from "@heroicons/react/24/solid";
8+
import { StarIcon as StarOutline } from "@heroicons/react/24/outline";
79
import { Button } from "@tw/button";
810
import {
911
Dropdown,
@@ -13,13 +15,46 @@ import {
1315
DropdownMenu,
1416
} from "@tw/dropdown";
1517
import clsx from "clsx";
16-
import { useCallback } from "react";
18+
import { useCallback, useMemo, useState } from "react";
1719
import { useNavigate } from "react-router";
1820

1921
export function GameCard({ game }: { game: GamevaultGame }) {
20-
const coverId = getGameCoverMediaId(game);
21-
const { serverUrl } = useAuth();
22-
const { startDownload } = useDownloads();
22+
const coverId = getGameCoverMediaId(game) as number | string | null;
23+
const { serverUrl, user, authFetch } = useAuth();
24+
// Derive initial bookmarked state from raw API shape (bookmarked_users or bookmarkedUsers)
25+
const currentUserId = (user as any)?.id ?? (user as any)?.ID;
26+
const initialBookmarked = useMemo(() => {
27+
if (!currentUserId) return false;
28+
const raw = (game as any).bookmarked_users || (game as any).bookmarkedUsers;
29+
if (!Array.isArray(raw)) return false;
30+
return raw.some((u: any) => (u?.id ?? u?.ID) === currentUserId);
31+
}, [game, currentUserId]);
32+
const [bookmarked, setBookmarked] = useState<boolean>(initialBookmarked);
33+
const [bookmarkBusy, setBookmarkBusy] = useState(false);
34+
35+
const toggleBookmark = useCallback(
36+
async (e: React.MouseEvent) => {
37+
e.preventDefault();
38+
e.stopPropagation();
39+
if (!serverUrl || !currentUserId || bookmarkBusy) return;
40+
const base = serverUrl.replace(/\/+$/, "");
41+
const url = `${base}/api/users/me/bookmark/${game.id}`;
42+
const next = !bookmarked;
43+
setBookmarked(next); // optimistic
44+
setBookmarkBusy(true);
45+
try {
46+
const res = await authFetch(url, { method: next ? "POST" : "DELETE" });
47+
if (!res.ok) throw new Error(`Bookmark toggle failed (${res.status})`);
48+
} catch (err) {
49+
// rollback on error
50+
setBookmarked(!next);
51+
} finally {
52+
setBookmarkBusy(false);
53+
}
54+
},
55+
[serverUrl, currentUserId, bookmarkBusy, authFetch, game.id, bookmarked],
56+
);
57+
const { startDownload } = useDownloads() as any;
2358

2459
const filename = (() => {
2560
return `${game.title}.zip`;
@@ -86,7 +121,11 @@ export function GameCard({ game }: { game: GamevaultGame }) {
86121
<div className="relative aspect-[3/4] w-full bg-bg-muted flex items-center justify-center overflow-hidden">
87122
{coverId ? (
88123
<Media
89-
media={{ id: coverId }}
124+
media={{
125+
id: typeof coverId === 'number' ? coverId : Number(coverId) || 0,
126+
created_at: new Date(0),
127+
entity_version: 0,
128+
} as any}
90129
size={300}
91130
className="h-full w-full object-contain rounded-none"
92131
square
@@ -98,6 +137,27 @@ export function GameCard({ game }: { game: GamevaultGame }) {
98137
No Cover
99138
</div>
100139
)}
140+
{/* Top-right bookmark toggle */}
141+
<button
142+
type="button"
143+
onClick={toggleBookmark}
144+
aria-label={bookmarked ? "Remove bookmark" : "Add bookmark"}
145+
aria-pressed={bookmarked}
146+
disabled={!currentUserId || bookmarkBusy}
147+
className={clsx(
148+
"absolute top-1 right-1 h-8 w-8 flex items-center justify-center rounded-md border shadow-sm backdrop-blur-sm transition-colors",
149+
"disabled:opacity-50 disabled:cursor-not-allowed",
150+
bookmarked
151+
? "bg-yellow-400/20 border-yellow-400"
152+
: "bg-zinc-900/40 dark:bg-zinc-700/50 border-white/20 hover:bg-zinc-800/60 dark:hover:bg-zinc-600/60",
153+
)}
154+
>
155+
{bookmarked ? (
156+
<StarSolid className="h-5 w-5 text-yellow-400" />
157+
) : (
158+
<StarOutline className="h-5 w-5 text-white" />
159+
)}
160+
</button>
101161
{/* Bottom-right download actions */}
102162
<div className="absolute bottom-0 right-0 p-1 z-10 flex justify-end opacity-85">
103163
<Dropdown>
@@ -124,7 +184,7 @@ export function GameCard({ game }: { game: GamevaultGame }) {
124184
<h3 className="text-sm font-medium truncate" title={game.title}>
125185
{game.metadata?.title || game.title}
126186
</h3>
127-
{game.sortTitle && game.sortTitle !== game.title && (
187+
{(game as any).sort_title && (game as any).sort_title !== game.title && (
128188
<p
129189
className="mt-0.5 text-xs text-fg-muted truncate"
130190
title={game.title}

src/components/Sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function Sidebar() {
6767
setBadgeVisible(false);
6868
};
6969

70-
const roleVal = user.role;
70+
const roleVal = user?.role;
7171
const isAdmin = roleVal === GamevaultUserRoleEnum.NUMBER_3;
7272

7373
return (

src/components/admin/RegisterUserModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { GamevaultUser } from "../../api";
1515

1616
interface Props {
1717
onClose: () => void;
18-
onRegistered: (u: User) => void;
18+
onRegistered: (u: GamevaultUser) => void;
1919
}
2020

2121
export function RegisterUserModal({ onClose, onRegistered }: Props) {

src/components/admin/UserEditorModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ interface Props {
2424
password?: string;
2525
birth_date: string | null;
2626
}) => Promise<{ ok: boolean; message?: string }>;
27-
onUserUpdated?: (u: User) => void;
27+
onUserUpdated?: (u: GamevaultUser) => void;
2828
/** If true, treat this editor as editing the current logged-in user (affects endpoints) */
2929
self?: boolean;
3030
}

src/guards/ProtectedRoute.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export default function ProtectedRoute({
2222
if (!auth) return <Navigate to="/" replace />;
2323

2424
if (requiredRole !== undefined) {
25-
const roleVal = user.role;
25+
const roleVal = user?.role;
2626
if (roleVal == null || roleVal < requiredRole) {
2727
return <Navigate to="/library" replace />;
2828
}

src/hooks/useAdminUsers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useAuth } from "@/context/AuthContext";
22
import { useCallback, useEffect, useRef, useState } from "react";
3+
// Local helper to normalize user objects if backend changes shape; currently identity
4+
function normalizeUser<T>(u: T): T { return u; }
35
import { GamevaultUser, GamevaultUserRoleEnum } from "../api";
46

57
export interface UseAdminUsersResult {

src/hooks/useGames.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,18 @@ export interface UseGamesOptions {
1313
sortBy: string; // e.g. sort_title, size
1414
order: "ASC" | "DESC";
1515
limit?: number;
16+
// If true, only return games bookmarked by the current user
17+
bookmarkedOnly?: boolean;
1618
}
1719

1820
export function useGames({
1921
search,
2022
sortBy,
2123
order,
2224
limit = 50,
25+
bookmarkedOnly = false,
2326
}: UseGamesOptions) {
24-
const { serverUrl, authFetch } = useAuth();
27+
const { serverUrl, authFetch, user } = useAuth();
2528
const [count, setCount] = useState(0);
2629
const [games, setGames] = useState<GamevaultGame[]>([]);
2730
const [next, setNext] = useState<string | null>(null);
@@ -42,6 +45,12 @@ export function useGames({
4245
if (search) params.set("search", search);
4346
if (sortBy) params.set("sortBy", `${sortBy}:${order}`);
4447
if (limit) params.set("limit", String(limit));
48+
// Advanced filter: bookmarked by current user
49+
const userId = (user as any)?.id ?? (user as any)?.ID;
50+
if (bookmarkedOnly && userId != null) {
51+
// Backend expects: filter.bookmarked_users.id=$eq:<userId>
52+
params.set("filter.bookmarked_users.id", `$eq:${userId}`);
53+
}
4554
const url = `${base}/api/games?${params.toString()}`;
4655
const res = await authFetch(url, { method: "GET", signal: ac.signal });
4756
if (!res.ok) throw new Error(`Games fetch failed (${res.status})`);
@@ -55,7 +64,7 @@ export function useGames({
5564
} finally {
5665
setLoading(false);
5766
}
58-
}, [serverUrl, authFetch, search, sortBy, order, limit]);
67+
}, [serverUrl, authFetch, search, sortBy, order, limit, bookmarkedOnly, user]);
5968

6069
const loadMore = useCallback(async () => {
6170
if (!serverUrl || !next || loading) return;

src/pages/Administration.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,11 @@ export default function Administration() {
397397
user={currentEditingUser}
398398
onClose={() => setEditingUserId(null)}
399399
onSave={async (payload) =>
400-
updateUser(currentEditingUser, payload)
400+
updateUser(currentEditingUser, {
401+
...payload,
402+
// Convert birth_date string|null to Date if present, otherwise undefined to satisfy typing
403+
birth_date: payload.birth_date ? new Date(payload.birth_date) : undefined,
404+
})
401405
}
402406
onUserUpdated={(updated) => {
403407
setUsers((prev) =>

src/pages/Library.tsx

Lines changed: 62 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { Heading } from "@tw/heading";
66
import { Input } from "@tw/input";
77
import { Listbox, ListboxLabel, ListboxOption } from "@tw/listbox";
88
import { useDeferredValue, useEffect, useRef, useState } from "react";
9+
import { StarIcon } from "@heroicons/react/24/solid";
10+
import { StarIcon as StarOutlineIcon } from "@heroicons/react/24/outline";
11+
import Card from "@/components/Card";
12+
import { Button } from "@/components/tailwind/button";
913
import { Badge } from "../components/tailwind/badge";
1014

1115
const SORT_BY: { label: string; value: string }[] = [
@@ -28,34 +32,12 @@ const LIB_SORT_KEY = 'app_library_sort';
2832
const LIB_ORDER_KEY = 'app_library_order';
2933

3034
export default function Library() {
31-
const { serverUrl } = useAuth();
35+
const { serverUrl, user } = useAuth();
3236
const [search, setSearch] = useState("");
33-
const [sortBy, setSortBy] = useState(() => {
34-
try {
35-
if (localStorage.getItem(RETAIN_KEY) === '1') {
36-
return localStorage.getItem(LIB_SORT_KEY) || 'sort_title';
37-
}
38-
} catch {}
39-
return 'sort_title';
40-
});
41-
const [order, setOrder] = useState<"ASC" | "DESC">(() => {
42-
try {
43-
if (localStorage.getItem(RETAIN_KEY) === '1') {
44-
const v = localStorage.getItem(LIB_ORDER_KEY) as 'ASC' | 'DESC' | null;
45-
if (v === 'ASC' || v === 'DESC') return v;
46-
}
47-
} catch {}
48-
return 'ASC';
49-
});
50-
// Persist when changed if retention enabled
51-
useEffect(() => {
52-
try {
53-
if (localStorage.getItem(RETAIN_KEY) === '1') {
54-
localStorage.setItem(LIB_SORT_KEY, sortBy);
55-
localStorage.setItem(LIB_ORDER_KEY, order);
56-
}
57-
} catch {}
58-
}, [sortBy, order]);
37+
const [sortBy, setSortBy] = useState("sort_title");
38+
const [order, setOrder] = useState<"ASC" | "DESC">("ASC");
39+
const [showAdvanced, setShowAdvanced] = useState(false);
40+
const [bookmarkedOnly, setBookmarkedOnly] = useState(false);
5941
// Defer search to avoid spamming requests while user types quickly
6042
const deferredSearch = useDeferredValue(search);
6143
const { count, games, loading, error, loadMore, hasMore, refetch } = useGames(
@@ -64,13 +46,32 @@ export default function Library() {
6446
sortBy,
6547
order,
6648
limit: 50,
49+
bookmarkedOnly,
6750
},
6851
);
6952

7053
// Reset to first page when filters change (search, sortBy, order)
7154
useEffect(() => {
7255
refetch();
73-
}, [deferredSearch, sortBy, order, refetch]);
56+
}, [deferredSearch, sortBy, order, bookmarkedOnly, refetch]);
57+
58+
// Sync bookmark filter in URL search params for shareable links
59+
useEffect(() => {
60+
const url = new URL(window.location.href);
61+
if (bookmarkedOnly) {
62+
url.searchParams.set("bookmarked", "1");
63+
} else {
64+
url.searchParams.delete("bookmarked");
65+
}
66+
// We intentionally do not push to history each keystroke of search for cleanliness
67+
window.history.replaceState({}, "", url.toString());
68+
}, [bookmarkedOnly]);
69+
70+
// Initialize from URL (first render)
71+
useEffect(() => {
72+
const params = new URL(window.location.href).searchParams;
73+
if (params.get("bookmarked") === "1") setBookmarkedOnly(true);
74+
}, []);
7475

7576
const sentinelRef = useRef<HTMLDivElement | null>(null);
7677
useEffect(() => {
@@ -144,7 +145,40 @@ export default function Library() {
144145
))}
145146
</Listbox>
146147
</div>
148+
<div className="flex flex-col w-40">
149+
<label className="block text-xs font-medium text-fg-muted mb-1 invisible">
150+
Advanced Filters
151+
</label>
152+
<Button
153+
outline
154+
className="w-full justify-center h-9 text-sm items-center"
155+
onClick={() => setShowAdvanced((s) => !s)}
156+
>
157+
{showAdvanced ? "Hide Filters" : "Advanced Filters"}
158+
</Button>
159+
</div>
147160
</div>
161+
{showAdvanced && (
162+
<Card title="Advanced Filters" className="gap-4 !mb-6">
163+
<div className="flex items-center gap-3 flex-wrap text-xs">
164+
<Button
165+
aria-pressed={bookmarkedOnly}
166+
disabled={!user}
167+
onClick={() => user && setBookmarkedOnly((v) => !v)}
168+
{...(bookmarkedOnly ? { color: "yellow" } : { outline: true })}
169+
className="text-sm h-9 px-3 flex items-center gap-1"
170+
>
171+
{bookmarkedOnly ? (
172+
<StarIcon data-slot="icon" className="h-4 w-4" />
173+
) : (
174+
<StarOutlineIcon data-slot="icon" className="h-4 w-4" />
175+
)}
176+
Bookmarked
177+
</Button>
178+
</div>
179+
{/* Placeholder for future filters */}
180+
</Card>
181+
)}
148182
<div className="flex-1 overflow-auto">
149183
{!serverUrl && (
150184
<div className="p-8 text-sm text-fg-muted">

0 commit comments

Comments
 (0)