feat(portal): arcade portal shell and discovery homepage

Chilled color scheme as swappable tokens, Fredoka wordmark with a
configurable tagline, genre categories, search and random hotkeys.
The homepage offers a Game of the Day, a Time Machine with a date
picker, recently played games and Flashpoint playlist shelves.
Mature games are hidden unless opted in from the user menu, stored
in the browser and the account. Browse lists default to a seeded
shuffle and accept release date ranges.
This commit is contained in:
2026-10-12 18:09:20 -07:00
parent d65f25aa2a
commit ee6fb4e951
38 changed files with 1586 additions and 344 deletions
+1
View File
@@ -19,6 +19,7 @@
"check-bundle": "VITE_ANALYZE=true vite build"
},
"dependencies": {
"@fontsource-variable/fredoka": "^5.3.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
+6 -2
View File
@@ -2,6 +2,7 @@ import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { useEffect, lazy, Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';
import { MainLayout } from './components/layout/MainLayout';
import { PortalLayout } from './components/portal/PortalLayout';
import { PublicLayout } from './components/layout/PublicLayout';
import { SharedLayout } from './components/layout/SharedLayout';
import { ErrorLayout } from './components/layout/ErrorLayout';
@@ -288,8 +289,8 @@ function App() {
{/* ==================== STANDALONE ==================== */}
<Route path="/maintenance" element={<MaintenancePage />} />
{/* ==================== MAIN LAYOUT ==================== */}
<Route element={<MainLayout />}>
{/* ==================== PORTAL LAYOUT ==================== */}
<Route element={<PortalLayout />}>
<Route
path="/"
element={
@@ -523,7 +524,10 @@ function App() {
</ProtectedRoute>
}
/>
</Route>
{/* ==================== MAIN LAYOUT (account and admin) ==================== */}
<Route element={<MainLayout />}>
{/* User management routes */}
<Route
path="/users"
@@ -1,61 +0,0 @@
import { useState } from 'react';
import { Star, Github } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { logger } from '@/lib/logger';
import { useMountEffect } from '@/hooks/useMountEffect';
import { githubApi } from '@/lib/api';
export function GitHubButton() {
const [stars, setStars] = useState<number>(0);
const [isLoading, setIsLoading] = useState(true);
const repoUrl = 'https://github.com/darkraise/flashpoint-web';
useMountEffect(() => {
const fetchStarCount = async () => {
try {
const result = await githubApi.getStarCount();
setStars(result.stars);
logger.debug(`GitHub stars fetched: ${result.stars}`);
} catch (error) {
logger.warn('Failed to fetch GitHub stars:', error);
} finally {
setIsLoading(false);
}
};
fetchStarCount();
});
const formatStarCount = (count: number): string => {
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}k`;
}
return count.toString();
};
const handleClick = () => {
window.open(repoUrl, '_blank', 'noopener,noreferrer');
};
return (
<Button
variant="outline"
size="sm"
onClick={handleClick}
className="h-10 gap-2 border-primary/30 hover:border-primary hover:bg-primary/5 transition-all"
title="Star on GitHub"
>
<Github className="h-4 w-4" />
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-primary/10 text-primary">
<Star className="h-3 w-3 fill-current" />
{isLoading ? (
<Skeleton className="h-3 w-6" />
) : (
<span className="text-xs font-semibold">{formatStarCount(stars)}</span>
)}
</div>
</Button>
);
}
@@ -1,97 +0,0 @@
import { Link } from 'react-router-dom';
import { Game } from '@/types/game';
import { GameCard } from '@/components/library/GameCard';
import { ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface HomeSectionProps {
title: string;
description: string;
games: Game[];
isLoading: boolean;
viewAllHref: string;
favoriteGameIds?: Set<string>;
}
export function HomeSection({
title,
description,
games,
isLoading,
viewAllHref,
favoriteGameIds,
}: HomeSectionProps) {
if (isLoading) {
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">{title}</h2>
<p className="text-muted-foreground text-sm mt-1">{description}</p>
</div>
</div>
<div className="flex gap-4 overflow-x-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent py-2">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex-shrink-0 w-48 h-72 bg-muted/50 rounded-xl animate-pulse" />
))}
</div>
</section>
);
}
if (games.length === 0) {
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">{title}</h2>
<p className="text-muted-foreground text-sm mt-1">{description}</p>
</div>
</div>
<div className="text-center py-16 border-2 border-dashed border-primary/50 rounded-xl">
<p className="text-muted-foreground">No games found in this time period</p>
<Link to="/browse">
<Button variant="link" className="mt-2">
Browse All Games
</Button>
</Link>
</div>
</section>
);
}
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">{title}</h2>
<p className="text-muted-foreground text-sm mt-1">{description}</p>
</div>
<Link to={viewAllHref}>
<Button variant="outline" size="sm" className="gap-2">
View All
<ChevronRight size={16} />
</Button>
</Link>
</div>
<div className="relative">
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent py-2">
{games.map((game) => (
<div key={game.id} className="flex-shrink-0 w-48">
<GameCard
game={game}
showFavoriteButton={true}
showFavoriteIndicator={true}
showAddToPlaylistButton={true}
favoriteGameIds={favoriteGameIds}
breadcrumbContext={{ label: 'Home', href: '/' }}
sectionKey="browse"
/>
</div>
))}
</div>
</div>
</section>
);
}
@@ -7,7 +7,6 @@ import { useAuth } from '@/contexts/AuthContext';
import { usePublicSettings } from '@/hooks/usePublicSettings';
import { SearchBar } from '../search/SearchBar';
import { ThemePicker } from '../theme/ThemePicker';
import { GitHubButton } from '../github/GitHubButton';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -113,7 +112,6 @@ export function Header({ hideSidebarToggle = false, hideSearch = false }: Header
) : null}
<div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
<GitHubButton />
<ThemePicker />
{(isAuthenticated || isGuest) && user ? (
@@ -76,8 +76,6 @@ export function Sidebar({ isOpen }: SidebarProps) {
const gameNavItems: NavItem[] = [
{ path: '/', icon: Home, label: 'Home' },
{ path: '/flash', iconImage: '/images/Flash.png', label: 'Flash Games' },
{ path: '/html5', iconImage: '/images/HTML5.png', label: 'HTML5 Games' },
{ path: '/animations', icon: Film, label: 'Animations' },
{ path: '/browse', icon: Gamepad2, label: 'Browse' },
];
@@ -23,9 +23,12 @@ import {
parseFilterParams,
buildFilterSearchParams,
hasLegacyParams,
defaultSortBy,
FilterUrlParams,
} from '@/lib/filterUrlCompression';
const newSeed = () => Math.floor(Math.random() * 2 ** 30);
interface GameBrowseLayoutProps {
title: string;
library?: 'arcade' | 'theatre';
@@ -71,6 +74,18 @@ export function GameBrowseLayout({
}
}, [searchParams, setSearchParams]);
// A shuffle needs a seed in the URL so paging, back and shared links keep the same order
useEffect(() => {
if (
(urlParams.sortBy ?? defaultSortBy(urlParams)) === 'random' &&
urlParams.seed === undefined
) {
setSearchParams(buildFilterSearchParams({ ...urlParams, seed: newSeed() }), {
replace: true,
});
}
}, [urlParams, setSearchParams]);
const { enableDownloadedFilterDefault } = useFeatureFlags();
const downloaded = useMemo(() => {
@@ -94,8 +109,11 @@ export function GameBrowseLayout({
tags: urlParams.tags,
yearFrom: urlParams.yearFrom,
yearTo: urlParams.yearTo,
sortBy: urlParams.sortBy ?? 'title',
releasedFrom: urlParams.releasedFrom,
releasedTo: urlParams.releasedTo,
sortBy: urlParams.sortBy ?? defaultSortBy(urlParams),
sortOrder: (urlParams.sortOrder as 'asc' | 'desc') ?? 'asc',
seed: urlParams.seed,
page: urlParams.page ?? 1,
limit: 50,
}),
@@ -160,10 +178,24 @@ export function GameBrowseLayout({
}
}
return parts.length > 0 ? parts.join(' ') : title;
}, [filters.search, filters.platform, filters.tags, title]);
if (parts.length === 0 && filters.releasedFrom) {
return `Released ${filters.releasedFrom} to ${filters.releasedTo ?? 'today'}`;
}
const { data, isLoading, error } = useGames(filters);
return parts.length > 0 ? parts.join(' ') : title;
}, [
filters.search,
filters.platform,
filters.tags,
filters.releasedFrom,
filters.releasedTo,
title,
]);
// The shuffle waits for its seed rather than fetching an order it is about to replace
const { data, isLoading, error } = useGames(filters, {
enabled: filters.sortBy !== 'random' || filters.seed !== undefined,
});
// Fetch batch rating aggregates for visible games
const gameIds = useMemo(() => data?.data.map((g) => g.id) ?? [], [data]);
@@ -314,6 +346,16 @@ export function GameBrowseLayout({
});
}
if (filters.releasedFrom || filters.releasedTo) {
chips.push({
id: 'releaseRange',
label: 'Released',
value: `${filters.releasedFrom ?? '...'} to ${filters.releasedTo ?? '...'}`,
category: 'Released',
order: getOrder('Released'),
});
}
// Sort chips by order to maintain hierarchy (application order)
return chips.sort((a, b) => a.order - b.order);
}, [filters, platform, filterOrder, forceDownloaded]);
@@ -348,6 +390,10 @@ export function GameBrowseLayout({
newParams.yearFrom = undefined;
newParams.yearTo = undefined;
categoryRemoved = 'Year';
} else if (chipId === 'releaseRange') {
newParams.releasedFrom = undefined;
newParams.releasedTo = undefined;
categoryRemoved = 'Released';
} else {
const paramMap: Record<
string,
@@ -407,6 +453,7 @@ export function GameBrowseLayout({
Language: ['languages'],
Tag: ['tags'],
Year: ['yearFrom', 'yearTo'],
Released: ['releasedFrom', 'releasedTo'],
};
const newParams: FilterUrlParams = { ...urlParams, page: undefined };
@@ -437,10 +484,17 @@ export function GameBrowseLayout({
const newParams: FilterUrlParams = {
sortBy: urlParams.sortBy,
sortOrder: urlParams.sortOrder,
seed: urlParams.seed,
downloaded: enableDownloadedFilterDefault ? '0' : undefined,
};
setSearchParams(buildFilterSearchParams(newParams));
}, [urlParams.sortBy, urlParams.sortOrder, enableDownloadedFilterDefault, setSearchParams]);
}, [
urlParams.sortBy,
urlParams.sortOrder,
urlParams.seed,
enableDownloadedFilterDefault,
setSearchParams,
]);
const handlePageChange = useCallback(
(newPage: number) => {
@@ -458,6 +512,10 @@ export function GameBrowseLayout({
[urlParams, setSearchParams]
);
const handleReshuffle = useCallback(() => {
setSearchParams(buildFilterSearchParams({ ...urlParams, seed: newSeed(), page: undefined }));
}, [urlParams, setSearchParams]);
return (
<div className="max-w-7xl mx-auto space-y-6">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
@@ -478,6 +536,7 @@ export function GameBrowseLayout({
sortBy={filters.sortBy}
sortOrder={filters.sortOrder}
onSortChange={handleSortChange}
onReshuffle={handleReshuffle}
/>
<ViewOptions />
</div>
@@ -0,0 +1,35 @@
import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { parseFilterParams } from '@/lib/filterUrlCompression';
import { GENRES } from './genres';
export function CategoryNav() {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const activeTags = pathname === '/browse' ? parseFilterParams(searchParams).tags : undefined;
const current = (active: boolean) => (active ? ('page' as const) : undefined);
return (
<nav id="navigation" aria-label="Categories" className="grid grid-cols-12 gap-2 my-4">
<Link to="/" className="portal-capsule" aria-current={current(pathname === '/')}>
Featured
</Link>
{GENRES.map(({ tag, label }) => (
<Link
key={tag}
to={`/browse?tags=${encodeURIComponent(tag)}`}
className="portal-capsule"
aria-current={current(activeTags === tag)}
>
{label ?? tag}
</Link>
))}
<Link
to="/animations"
className="portal-capsule"
aria-current={current(pathname === '/animations')}
>
Animations
</Link>
</nav>
);
}
@@ -0,0 +1,27 @@
import { memo, useState } from 'react';
import { Link } from 'react-router-dom';
import { getGameLogoUrl } from '@/utils/gameUtils';
interface GameTileProps {
id: string;
title: string;
}
/** A glossy logo tile linking to a game */
export const GameTile = memo(function GameTile({ id, title }: GameTileProps) {
const [broken, setBroken] = useState(false);
return (
<Link to={`/browse/${id}`} className="portal-tile" title={title}>
<span className="portal-tile-art">
{broken ? (
<span className="portal-tile-blank" aria-hidden="true" />
) : (
<img src={getGameLogoUrl(id)} alt="" loading="lazy" onError={() => setBroken(true)} />
)}
</span>
<span className="portal-tile-title">{title}</span>
</Link>
);
});
GameTile.displayName = 'GameTile';
@@ -0,0 +1,22 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface PortalBoxProps {
title: ReactNode;
action?: ReactNode;
className?: string;
children: ReactNode;
}
/** An outlined section with a filled header bar */
export function PortalBox({ title, action, className, children }: PortalBoxProps) {
return (
<section className={cn('portal-box', className)}>
<div className="portal-box-title">
<h2>{title}</h2>
{action}
</div>
{children}
</section>
);
}
@@ -0,0 +1,174 @@
import { forwardRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { ChevronDown, Search } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAuth } from '@/contexts/AuthContext';
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
import { usePublicSettings } from '@/hooks/usePublicSettings';
import { logger } from '@/lib/logger';
import { cn } from '@/lib/utils';
import { useAuthStore } from '@/store/auth';
import { useMatureStore } from '@/store/mature';
export const PortalHeader = forwardRef<HTMLInputElement>(function PortalHeader(_props, searchRef) {
const navigate = useNavigate();
const { data: publicSettings } = usePublicSettings();
const siteName = publicSettings?.app?.siteName ?? 'Flashpoint Archive';
const tagline = publicSettings?.app?.tagline;
const [query, setQuery] = useState('');
const handleSearch = (event: React.FormEvent) => {
event.preventDefault();
if (query.trim()) {
navigate(`/browse?search=${encodeURIComponent(query.trim())}`);
}
};
return (
<header className="flex items-center gap-6">
<Link to="/" className="portal-brand" title={`${siteName} home`}>
{siteName}
{tagline ? <small>{tagline}</small> : null}
</Link>
<form role="search" onSubmit={handleSearch} className="portal-search">
<Search size={16} aria-hidden="true" />
<input
ref={searchRef}
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search games..."
aria-label="Search games"
/>
<kbd className="portal-kbd" aria-hidden="true">
/
</kbd>
</form>
<UserMenu />
</header>
);
});
PortalHeader.displayName = 'PortalHeader';
function UserMenu() {
const user = useAuthStore((state) => state.user);
const isGuest = useAuthStore((state) => state.isGuest);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const hasPermission = useAuthStore((state) => state.hasPermission);
const showMature = useMatureStore((state) => state.showMature);
const setShowMature = useMatureStore((state) => state.setShowMature);
const { logout } = useAuth();
const { data: publicSettings } = usePublicSettings();
const {
enableFavorites,
enablePlaylists,
enableStatistics,
enableDownloadedPage,
enableDownloadedPageForGuests,
} = useFeatureFlags();
const signedIn = isAuthenticated && !isGuest;
const handleLogout = () => {
logout().catch((error) => logger.error('Logout failed:', error));
};
const links = [
signedIn && enableFavorites && { to: '/favorites', label: 'Favorites' },
signedIn && enablePlaylists && { to: '/playlists', label: 'My Playlists' },
signedIn && enablePlaylists && { to: '/flashpoint-playlists', label: 'Flashpoint Playlists' },
enableDownloadedPage &&
(signedIn || enableDownloadedPageForGuests) && { to: '/downloaded', label: 'Downloaded' },
signedIn && enableStatistics && { to: '/dashboard', label: 'Dashboard' },
signedIn && { to: '/settings', label: 'Settings' },
].filter((link): link is { to: string; label: string } => Boolean(link));
const adminLinks = [
hasPermission('users.read') && { to: '/users', label: 'Users' },
hasPermission('roles.read') && { to: '/roles', label: 'Roles' },
hasPermission('settings.update') && { to: '/jobs', label: 'Jobs' },
hasPermission('activities.read') && { to: '/activities', label: 'Activity Logs' },
].filter((link): link is { to: string; label: string } => Boolean(link));
return (
<DropdownMenu>
<DropdownMenuTrigger className="portal-display flex items-center gap-1 text-[15px] font-semibold text-[color:var(--p-meta)] hover:text-[color:var(--p-text)]">
{signedIn && user ? user.username : 'Guest'}
<ChevronDown size={16} aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{links.length > 0 ? (
<>
{links.map((link) => (
<DropdownMenuItem key={link.to} asChild>
<Link to={link.to}>{link.label}</Link>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuItem
role="menuitemcheckbox"
aria-checked={showMature}
onSelect={(event) => {
event.preventDefault();
setShowMature(!showMature);
}}
className="justify-between gap-3"
>
<span>
Show mature games
<span className="block text-xs text-muted-foreground">Adult, gory or hateful</span>
</span>
<span
aria-hidden="true"
className={cn(
'relative h-5 w-9 flex-none rounded-full border-2 border-transparent transition-colors',
showMature ? 'bg-primary' : 'bg-input'
)}
>
<span
className={cn(
'absolute top-0 left-0 h-4 w-4 rounded-full bg-background shadow transition-transform',
showMature && 'translate-x-4'
)}
/>
</span>
</DropdownMenuItem>
{adminLinks.length > 0 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>Admin</DropdownMenuLabel>
{adminLinks.map((link) => (
<DropdownMenuItem key={link.to} asChild>
<Link to={link.to}>{link.label}</Link>
</DropdownMenuItem>
))}
</>
) : null}
<DropdownMenuSeparator />
{signedIn ? (
<DropdownMenuItem onSelect={handleLogout}>Log out</DropdownMenuItem>
) : (
<>
<DropdownMenuItem asChild>
<Link to="/login">Log in</Link>
</DropdownMenuItem>
{publicSettings?.auth?.userRegistrationEnabled ? (
<DropdownMenuItem asChild>
<Link to="/register">Register</Link>
</DropdownMenuItem>
) : null}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from 'react';
import { Outlet, useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import '@fontsource-variable/fredoka';
import { MaintenanceGuard } from '@/components/common/MaintenanceGuard';
import { useMatureSync } from '@/hooks/useMatureSync';
import { gamesApi } from '@/lib/api';
import { logger } from '@/lib/logger';
import { useMatureStore } from '@/store/mature';
import { CategoryNav } from './CategoryNav';
import { PortalHeader } from './PortalHeader';
import { applyPortalScheme } from './schemes';
import './portal.css';
function isTyping(target: EventTarget | null): boolean {
return (
target instanceof HTMLElement &&
(target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName))
);
}
/** The arcade portal shell: framed page, wordmark and search, categories and key hints */
export function PortalLayout() {
const navigate = useNavigate();
const searchRef = useRef<HTMLInputElement>(null);
const [rolling, setRolling] = useState(false);
useEffect(() => applyPortalScheme('chilled'), []);
useMatureSync();
const playRandom = async () => {
setRolling(true);
try {
const game = await gamesApi.getRandom(
undefined,
undefined,
useMatureStore.getState().showMature
);
navigate(`/browse/${game.id}`);
} catch (error) {
logger.error('Failed to fetch random game:', error);
toast.error('Could not pick a random game. Please try again.');
} finally {
setRolling(false);
}
};
const playRandomRef = useRef(playRandom);
playRandomRef.current = playRandom;
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey || event.metaKey || event.altKey || isTyping(event.target)) return;
if (event.key === '/') {
event.preventDefault();
searchRef.current?.focus();
} else if (event.key === 'r' || event.key === 'R') {
event.preventDefault();
playRandomRef.current().catch(() => undefined);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
return (
<MaintenanceGuard>
<div className="portal-screen">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:bg-primary focus:text-primary-foreground focus:px-4 focus:py-2"
>
Skip to main content
</a>
<PortalHeader ref={searchRef} />
<CategoryNav />
<main id="main-content">
<Outlet />
</main>
<footer className="portal-footer portal-display flex justify-end gap-5 mt-4 text-xs uppercase tracking-wider text-[color:var(--p-meta)]">
<button type="button" onClick={() => searchRef.current?.focus()}>
<kbd>/</kbd>Search
</button>
<button type="button" onClick={() => playRandom()} disabled={rolling}>
<kbd>R</kbd>Random game
</button>
</footer>
</div>
</MaintenanceGuard>
);
}
+24
View File
@@ -0,0 +1,24 @@
/** Genre tags offered as categories, with shorter labels where the tag is long */
export const GENRES: readonly { tag: string; label?: string }[] = [
{ tag: 'Action' },
{ tag: 'Adventure' },
{ tag: 'Puzzle' },
{ tag: 'Shooter' },
{ tag: 'Platformer' },
{ tag: 'Sports' },
{ tag: 'Strategy' },
{ tag: 'Driving' },
{ tag: 'Escape the Room', label: 'Escape' },
{ tag: 'Dress Up' },
];
const GENRE_TAGS = new Set([...GENRES.map((genre) => genre.tag), 'Arcade', 'Simulation', 'Toy']);
/** The first genre among a game's semicolon-separated tags, else its first tag */
export function primaryGenre(tagsStr: string | undefined): string | undefined {
const tags = (tagsStr ?? '')
.split(';')
.map((tag) => tag.trim())
.filter(Boolean);
return tags.find((tag) => GENRE_TAGS.has(tag)) ?? tags[0];
}
@@ -0,0 +1,103 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { format, subDays } from 'date-fns';
import { CalendarDays, Gamepad2, Tag, Wrench } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { useGameOfTheDay } from '@/hooks/useDiscovery';
import { getGameScreenshotUrl } from '@/utils/gameUtils';
import { PortalBox } from '../PortalBox';
import { primaryGenre } from '../genres';
export function GameOfTheDay() {
const [daysAgo, setDaysAgo] = useState(0);
const date = format(subDays(new Date(), daysAgo), 'yyyy-MM-dd');
const { data, isLoading, isError } = useGameOfTheDay(date);
const game = data?.game;
const action =
daysAgo === 0 ? (
<button type="button" onClick={() => setDaysAgo(1)}>
Yesterday&apos;s pick →
</button>
) : (
<button type="button" onClick={() => setDaysAgo(0)}>
← Today&apos;s pick
</button>
);
const credits = [game?.developer, game?.publisher]
.filter((name, i, all): name is string => Boolean(name) && all.indexOf(name) === i)
.join(' / ');
const genre = primaryGenre(game?.tagsStr);
return (
<PortalBox title="Game of the Day" action={action}>
{isError ? (
<p className="text-[color:var(--p-meta)]">No pick today. Check back tomorrow.</p>
) : (
<div className="grid grid-cols-[minmax(0,1fr)_230px] items-start gap-3.5">
<div>
<div className="aspect-[4/3] overflow-hidden border-2 border-[color:var(--p-box)] bg-black">
{game ? (
<img
src={getGameScreenshotUrl(game.id)}
alt={`Screenshot of ${game.title}`}
className="h-full w-full object-cover"
/>
) : null}
</div>
{game?.originalDescription ? (
<p className="mt-3 line-clamp-4 whitespace-pre-line text-[color:var(--p-meta)]">
{game.originalDescription}
</p>
) : null}
</div>
{isLoading || !game ? (
<div className="space-y-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : (
<div className="flex min-w-0 flex-col self-stretch">
<h3 className="portal-display text-[26px] font-semibold leading-tight">
<Link to={`/browse/${game.id}`}>{game.title}</Link>
</h3>
{data.playlist ? (
<p className="text-[color:var(--p-meta)] mt-1">From {data.playlist.title}</p>
) : null}
<div className="grid grid-cols-[minmax(0,1fr)] gap-1.5 my-3">
{game.releaseDate ? (
<div className="portal-meta-row">
<CalendarDays size={15} aria-label="Released" />
{game.releaseDate}
</div>
) : null}
{credits ? (
<div className="portal-meta-row">
<Wrench size={15} aria-label="Made by" />
<span className="truncate">{credits}</span>
</div>
) : null}
{genre ? (
<div className="portal-meta-row">
<Tag size={15} aria-label="Genre" />
{genre}
</div>
) : null}
{game.platformName ? (
<div className="portal-meta-row">
<Gamepad2 size={15} aria-label="Platform" />
{game.platformName}
</div>
) : null}
</div>
<Link to={`/browse/${game.id}/play`} className="portal-play mt-auto">
PLAY
</Link>
</div>
)}
</div>
)}
</PortalBox>
);
}
@@ -0,0 +1,75 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import { usePlaylistShelves, useRecentlyPlayed } from '@/hooks/useDiscovery';
import { GameTile } from '../GameTile';
import { PortalBox } from '../PortalBox';
const PER_SHELF = 7;
function TileRowSkeleton() {
return (
<div className="portal-tiles">
{Array.from({ length: PER_SHELF }, (_, i) => (
<Skeleton key={i} className="h-[156px] w-[132px] rounded-[22px]" />
))}
</div>
);
}
export function JumpBackIn() {
const { data } = useRecentlyPlayed(PER_SHELF);
if (!data?.length) return null;
return (
<PortalBox title="Jump back in" action={<Link to="/dashboard">Your history →</Link>}>
<div className="portal-tiles">
{data.map((stat) => (
<GameTile key={stat.gameId} id={stat.gameId} title={stat.gameTitle} />
))}
</div>
</PortalBox>
);
}
export function PlaylistShelves() {
const [seed, setSeed] = useState(() => Math.floor(Math.random() * 2 ** 30));
const { data, isLoading } = usePlaylistShelves(seed, 3, PER_SHELF);
if (isLoading) {
return (
<PortalBox title="From the Flashpoint playlists">
<TileRowSkeleton />
</PortalBox>
);
}
return (
<>
{data?.map((shelf, i) => (
<PortalBox
key={shelf.id}
title={shelf.title}
action={
<span className="flex gap-3">
{i === 0 ? (
<button type="button" onClick={() => setSeed((current) => current + 1)}>
Different playlists
</button>
) : null}
<Link to={`/flashpoint-playlists/${shelf.id}`}>
See all {shelf.total.toLocaleString()} →
</Link>
</span>
}
>
<div className="portal-tiles">
{shelf.games.map((game) => (
<GameTile key={game.id} id={game.id} title={game.title} />
))}
</div>
</PortalBox>
))}
</>
);
}
@@ -0,0 +1,143 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { addDays, format } from 'date-fns';
import { Calendar } from '@/components/ui/calendar';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
import { useReleasedBetween } from '@/hooks/useDiscovery';
import { getGameLogoUrl } from '@/utils/gameUtils';
import { PortalBox } from '../PortalBox';
import { primaryGenre } from '../genres';
const ERA_START = new Date(2005, 0, 1).getTime();
const ERA_END = new Date(2014, 11, 31).getTime();
const EARLIEST = new Date(1990, 0, 1);
/** A random day in the heyday of Flash games */
function randomDay(): Date {
const date = new Date(ERA_START + Math.random() * (ERA_END - ERA_START));
date.setHours(0, 0, 0, 0);
return date;
}
export function TimeMachine() {
const [day, setDay] = useState(randomDay);
const [picking, setPicking] = useState(false);
const from = format(day, 'yyyy-MM-dd');
const to = format(addDays(day, 6), 'yyyy-MM-dd');
const { data, isLoading } = useReleasedBetween(from, to, 7);
const games = data?.data ?? [];
const seeAll = `/browse?releasedFrom=${from}&releasedTo=${to}&sortBy=releaseDate`;
return (
<PortalBox title="Time Machine">
<div className="flex items-center gap-1.5 mb-2.5">
<button
type="button"
className="portal-capsule portal-capsule-sm"
onClick={() => setDay((current) => addDays(current, -7))}
aria-label="Previous week"
>
◀ Week
</button>
<button
type="button"
className="portal-display flex-1 text-center leading-tight hover:underline"
onClick={() => setPicking(true)}
title="Pick a date"
>
<span className="block text-[11px] uppercase tracking-wider text-[color:var(--p-meta)]">
New the week of
</span>
<span className="text-base font-semibold">{format(day, 'MMMM d, yyyy')}</span>
</button>
<button
type="button"
className="portal-capsule portal-capsule-sm"
onClick={() => setDay((current) => addDays(current, 7))}
aria-label="Next week"
>
Week ▶
</button>
</div>
{isLoading ? (
<div className="grid gap-1.5">
{Array.from({ length: 7 }, (_, i) => (
<Skeleton key={i} className="h-[52px] w-full" />
))}
</div>
) : games.length === 0 ? (
<p className="py-8 text-center text-[color:var(--p-meta)]">
Nothing archived from this week. Try another.
</p>
) : (
<ul className="grid grid-cols-[minmax(0,1fr)] gap-1.5">
{games.map((game) => {
const genre = primaryGenre(game.tagsStr);
return (
<li key={game.id}>
<Link to={`/browse/${game.id}`} className="portal-list-item">
<img
src={getGameLogoUrl(game.id)}
alt=""
loading="lazy"
className="portal-thumb"
onError={(event) => event.currentTarget.removeAttribute('src')}
/>
<span className="min-w-0">
<b className="portal-display block truncate text-sm font-semibold">
{game.title}
</b>
<small className="text-[11px]">
{[game.releaseDate, genre].filter(Boolean).join(' · ')}
</small>
</span>
</Link>
</li>
);
})}
</ul>
)}
<div className="flex items-center justify-between mt-2.5">
<button
type="button"
className="portal-capsule portal-capsule-sm"
onClick={() => setDay(randomDay())}
>
🎲 Random day
</button>
{data && data.total > 0 ? (
<Link to={seeAll} className="portal-capsule portal-capsule-sm">
See all {data.total.toLocaleString()} →
</Link>
) : null}
</div>
<Dialog open={picking} onOpenChange={setPicking}>
<DialogContent className="w-auto">
<DialogHeader>
<DialogTitle className="portal-display">Travel to a date</DialogTitle>
</DialogHeader>
<Calendar
mode="single"
captionLayout="dropdown"
selected={day}
defaultMonth={day}
startMonth={EARLIEST}
endMonth={new Date()}
disabled={{ before: EARLIEST, after: new Date() }}
onSelect={(selected) => {
if (selected) {
setDay(selected);
setPicking(false);
}
}}
/>
</DialogContent>
</Dialog>
</PortalBox>
);
}
+323
View File
@@ -0,0 +1,323 @@
/* The app shell scrolls its own panes; the portal scrolls the page */
html:has(body.portal),
body.portal {
height: auto;
overflow: auto;
}
body.portal {
min-height: 100vh;
color: var(--p-text);
font:
13px/1.45 Verdana,
Tahoma,
sans-serif;
background:
radial-gradient(ellipse 1400px 900px at 50% 0%, var(--p-bg) 0%, var(--p-bg-mix) 100%) no-repeat,
var(--p-bg-mix);
}
.portal-display {
font-family: 'Fredoka Variable', Verdana, sans-serif;
}
.portal-screen {
width: 1180px;
margin: 18px auto;
padding: 18px 22px 10px;
border: 4px solid var(--p-box);
border-radius: 6px;
outline: 2px solid var(--p-box-end);
outline-offset: 4px;
}
.portal-brand {
font:
700 44px/1 'Fredoka Variable',
sans-serif;
letter-spacing: 0.5px;
white-space: nowrap;
background: linear-gradient(
180deg,
var(--p-highlight) 0%,
var(--p-box) 55%,
var(--p-box-end) 100%
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter: drop-shadow(0 2px 0 rgba(0, 0, 0, 0.45));
}
.portal-brand small {
display: block;
margin-top: 2px;
font:
500 13px 'Fredoka Variable',
sans-serif;
letter-spacing: 3px;
text-transform: uppercase;
-webkit-text-fill-color: var(--p-meta);
color: var(--p-meta);
}
.portal-search {
display: flex;
flex: 1;
align-items: center;
height: 38px;
padding: 0 16px;
gap: 8px;
border: 2px solid var(--p-box);
border-radius: 20px;
background: rgba(0, 0, 0, 0.25);
color: var(--p-meta);
}
.portal-search input {
flex: 1;
min-width: 0;
background: transparent;
color: var(--p-text);
outline: none;
}
.portal-search input::placeholder {
color: var(--p-meta);
}
.portal-search:focus-within {
box-shadow: 0 0 10px var(--p-select-glow);
}
.portal-kbd {
font:
11px Verdana,
sans-serif;
border: 1px solid var(--p-box-end);
border-radius: 4px;
padding: 1px 6px;
}
.portal-capsule {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 7px 0;
font:
600 14px 'Fredoka Variable',
sans-serif;
color: #eee;
border-radius: 18px;
background: linear-gradient(180deg, var(--p-tile-bottom), var(--p-tile-top) 60%);
border: 2px solid var(--p-chrome-2);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.35),
0 2px 0 rgba(0, 0, 0, 0.4);
}
.portal-capsule:hover,
.portal-capsule:focus-visible {
border-color: var(--p-chrome-1);
outline: none;
}
.portal-capsule[aria-current='page'] {
background: linear-gradient(180deg, var(--p-highlight), var(--p-box));
color: var(--p-header-ink);
border-color: var(--p-chrome-1);
}
.portal-capsule-sm {
padding: 4px 10px;
font-size: 13px;
border-radius: 14px;
}
.portal-box {
border: 2px solid var(--p-box);
border-radius: 4px;
padding: 12px;
background: rgba(0, 0, 0, 0.12);
}
.portal-box-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin: -12px -12px 12px;
padding: 5px 12px;
background: linear-gradient(180deg, var(--p-box-light), var(--p-box) 70%);
color: var(--p-header-ink);
font:
600 19px 'Fredoka Variable',
sans-serif;
}
.portal-box-title a,
.portal-box-title button {
font:
12px Verdana,
sans-serif;
color: var(--p-header-ink);
text-decoration: underline;
}
.portal-meta-row {
display: flex;
align-items: center;
gap: 10px;
border: 2px solid var(--p-box);
padding: 4px 8px;
font-size: 12px;
text-transform: uppercase;
}
.portal-play {
display: block;
text-align: center;
padding: 11px 0;
border-radius: 26px;
font:
700 22px 'Fredoka Variable',
sans-serif;
color: var(--p-header-ink);
background: linear-gradient(180deg, var(--p-highlight), var(--p-box) 55%, var(--p-box-end));
border: 3px solid var(--p-chrome-1);
box-shadow:
0 0 0 2px var(--p-chrome-2),
0 4px 10px rgba(0, 0, 0, 0.45),
inset 0 2px 0 rgba(255, 255, 255, 0.7);
}
.portal-play:hover,
.portal-play:focus-visible {
outline: none;
box-shadow:
0 0 0 2px var(--p-chrome-2),
0 0 18px var(--p-select-glow),
inset 0 2px 0 rgba(255, 255, 255, 0.7);
}
.portal-tiles {
display: grid;
grid-template-columns: repeat(7, 132px);
justify-content: space-between;
gap: 14px 0;
}
.portal-tile {
color: var(--p-text);
text-align: center;
outline: none;
}
.portal-tile-art {
position: relative;
display: block;
width: 132px;
height: 132px;
padding: 6px;
border-radius: 22px;
background: linear-gradient(
180deg,
var(--p-chrome-1),
var(--p-chrome-2) 50%,
var(--p-chrome-3) 51%,
var(--p-chrome-1)
);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);
}
.portal-tile-art img,
.portal-tile-art .portal-tile-blank {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 17px;
background: linear-gradient(180deg, var(--p-tile-bottom), var(--p-tile-top));
}
.portal-tile-art::after {
content: '';
position: absolute;
inset: 6px 6px 52%;
border-radius: 17px 17px 50% 50% / 17px 17px 14px 14px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.38), rgba(255, 255, 255, 0.04));
pointer-events: none;
}
.portal-tile:hover .portal-tile-art,
.portal-tile:focus-visible .portal-tile-art {
background: linear-gradient(
180deg,
var(--p-highlight),
var(--p-box) 50%,
var(--p-box-end) 51%,
var(--p-highlight)
);
box-shadow: 0 0 18px var(--p-select-glow);
}
.portal-tile-title {
display: block;
width: 132px;
margin-top: 6px;
font:
500 13px 'Fredoka Variable',
sans-serif;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.portal-thumb {
width: 44px;
height: 44px;
flex: none;
object-fit: cover;
border: 2px solid var(--p-chrome-2);
border-radius: 6px;
background: linear-gradient(180deg, var(--p-tile-bottom), var(--p-tile-top));
}
.portal-list-item {
display: flex;
gap: 10px;
align-items: center;
padding: 4px;
border-radius: 3px;
outline: none;
}
.portal-list-item:hover,
.portal-list-item:focus-visible {
background: var(--p-box);
color: var(--p-header-ink);
}
.portal-list-item small {
color: var(--p-meta);
}
.portal-list-item:hover small,
.portal-list-item:focus-visible small {
color: var(--p-header-ink-soft);
}
.portal-footer kbd {
font:
11px Verdana,
sans-serif;
border: 1px solid var(--p-meta);
border-radius: 50%;
padding: 0 5px;
margin-right: 4px;
}
.portal-meta-row svg {
flex: none;
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Portal color schemes. Each scheme is a set of CSS custom properties: `--p-*` drive the
* portal's own styles, the rest override the shadcn tokens so shared components match.
*/
export interface PortalScheme {
label: string;
tokens: Record<string, string>;
}
export const PORTAL_SCHEMES = {
chilled: {
label: 'Chilled',
tokens: {
'--p-bg': '#4c6878',
'--p-bg-mix': '#2f4553',
'--p-text': '#f1f5e9',
'--p-meta': '#d3e4c1',
'--p-box': '#c3dea2',
'--p-box-end': '#87a96e',
'--p-box-light': '#e6f3d3',
'--p-highlight': '#fbfff3',
'--p-header-ink': '#26371f',
'--p-header-ink-soft': '#3d5232',
'--p-select-glow': 'rgba(233, 246, 214, 0.55)',
'--p-tile-top': '#050505',
'--p-tile-bottom': '#3d3d3d',
'--p-chrome-1': '#f4f4f4',
'--p-chrome-2': '#8a8a8a',
'--p-chrome-3': '#555555',
'--background': '203 28% 25%',
'--foreground': '80 41% 94%',
'--card': '204 30% 21%',
'--card-foreground': '80 41% 94%',
'--popover': '204 30% 21%',
'--popover-foreground': '80 41% 94%',
'--primary': '87 48% 75%',
'--primary-foreground': '103 28% 17%',
'--secondary': '203 25% 33%',
'--secondary-foreground': '80 41% 94%',
'--muted': '203 25% 31%',
'--muted-foreground': '89 39% 83%',
'--accent': '203 25% 36%',
'--accent-foreground': '80 41% 94%',
'--destructive': '0 62% 45%',
'--destructive-foreground': '80 41% 94%',
'--border': '203 22% 40%',
'--input': '203 22% 40%',
'--ring': '87 48% 75%',
'--scrollbar-track': '203 28% 22%',
'--scrollbar-thumb': '95 26% 55%',
'--scrollbar-thumb-hover': '87 48% 75%',
},
},
} satisfies Record<string, PortalScheme>;
export type PortalSchemeName = keyof typeof PORTAL_SCHEMES;
/**
* Applies a scheme to <body>, which wins over the app theme on <html> and also reaches
* dialogs and menus portalled into the body. Returns a function that removes it.
*/
export function applyPortalScheme(name: PortalSchemeName): () => void {
const { tokens } = PORTAL_SCHEMES[name];
const style = document.body.style;
for (const [property, value] of Object.entries(tokens)) {
style.setProperty(property, value);
}
document.body.classList.add('portal');
return () => {
for (const property of Object.keys(tokens)) {
style.removeProperty(property);
}
document.body.classList.remove('portal');
};
}
+33 -13
View File
@@ -1,11 +1,13 @@
import { useMemo } from 'react';
import { ArrowUpDown } from 'lucide-react';
import { ArrowUpDown, Shuffle } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface SortControlsProps {
sortBy?: string;
sortOrder?: 'asc' | 'desc';
onSortChange: (key: 'sortBy' | 'sortOrder', value: string) => void;
/** New shuffle order; offered while sorting randomly */
onReshuffle?: () => void;
compact?: boolean;
}
@@ -15,6 +17,7 @@ export function SortControls({
sortBy = 'title',
sortOrder = 'asc',
onSortChange,
onReshuffle,
compact = false,
}: SortControlsProps) {
const handleSortOrderToggle = () => {
@@ -36,24 +39,41 @@ export function SortControls({
className={`${compact ? 'flex-1' : ''} px-3 py-2 bg-background border border-input rounded-lg text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-ring`}
aria-label="Sort by"
>
<option value="random">Shuffled</option>
<option value="title">Title</option>
<option value="releaseDate">Release Date</option>
<option value="dateAdded">Date Added</option>
<option value="dateModified">Date Modified</option>
<option value="developer">Developer</option>
</select>
<Button
variant="outline"
size="sm"
onClick={handleSortOrderToggle}
className={`h-10 ${compact ? 'px-2' : 'px-3'}`}
type="button"
title={sortOrderLabel}
aria-label={`Sort order: ${sortOrderLabel}`}
>
<ArrowUpDown size={16} aria-hidden="true" />
{!compact && <span className="ml-1 text-xs">{sortOrderLabel}</span>}
</Button>
{sortBy === 'random' ? (
onReshuffle ? (
<Button
variant="outline"
size="sm"
onClick={onReshuffle}
className={`h-10 ${compact ? 'px-2' : 'px-3'}`}
type="button"
aria-label="Reshuffle"
>
<Shuffle size={16} aria-hidden="true" />
{!compact && <span className="ml-1 text-xs">Reshuffle</span>}
</Button>
) : null
) : (
<Button
variant="outline"
size="sm"
onClick={handleSortOrderToggle}
className={`h-10 ${compact ? 'px-2' : 'px-3'}`}
type="button"
title={sortOrderLabel}
aria-label={`Sort order: ${sortOrderLabel}`}
>
<ArrowUpDown size={16} aria-hidden="true" />
{!compact && <span className="ml-1 text-xs">{sortOrderLabel}</span>}
</Button>
)}
</div>
);
}
@@ -56,6 +56,7 @@ export function AppSettingsTab({ tabContentVariants }: AppSettingsTabProps) {
const isAdmin = user?.permissions.includes('settings.update');
const [siteNameInput, setSiteNameInput] = useState('');
const [taglineInput, setTaglineInput] = useState('');
const [domainInput, setDomainInput] = useState('');
const [domainError, setDomainError] = useState<string | null>(null);
@@ -77,6 +78,10 @@ export function AppSettingsTab({ tabContentVariants }: AppSettingsTabProps) {
}
}, [appSettings?.siteName]);
useEffect(() => {
setTaglineInput(appSettings?.tagline ?? '');
}, [appSettings?.tagline]);
const updateSystemSettings = useMutation({
mutationFn: ({ category, settings }: { category: string; settings: Record<string, unknown> }) =>
systemSettingsApi.updateCategory(category, settings),
@@ -106,6 +111,13 @@ export function AppSettingsTab({ tabContentVariants }: AppSettingsTabProps) {
});
};
const handleSaveTagline = () => {
updateSystemSettings.mutate({
category: 'app',
settings: { tagline: taglineInput.trim() },
});
};
return (
<motion.div
key="app"
@@ -161,6 +173,44 @@ export function AppSettingsTab({ tabContentVariants }: AppSettingsTabProps) {
</div>
</div>
{/* Tagline */}
<div className="space-y-2">
<Label htmlFor="site-tagline" className="text-base">
Tagline
</Label>
<p className="text-sm text-muted-foreground">
A short line shown under the site name. Leave empty to hide it.
</p>
<div className="flex items-center gap-2">
<input
id="site-tagline"
type="text"
maxLength={120}
className="flex-1 px-3 py-2 bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
value={taglineInput}
onChange={(e) => setTaglineInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSaveTagline();
}
}}
disabled={updateSystemSettings.isPending}
placeholder="The Flash arcade that never closed"
/>
<Button
onClick={handleSaveTagline}
disabled={
updateSystemSettings.isPending ||
taglineInput.trim() === (appSettings?.tagline ?? '')
}
size="icon"
title="Save tagline"
>
<Check size={18} />
</Button>
</div>
</div>
{/* Maintenance Mode */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
+52
View File
@@ -0,0 +1,52 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { discoveryApi, gamesApi, playTrackingApi } from '@/lib/api';
import { useAuthStore } from '@/store/auth';
import { useMatureStore } from '@/store/mature';
export function useGameOfTheDay(date: string) {
const showExtreme = useMatureStore((state) => state.showMature);
return useQuery({
queryKey: ['discovery', 'daily', date, showExtreme],
queryFn: () => discoveryApi.getDaily(date, showExtreme),
staleTime: Infinity,
});
}
export function usePlaylistShelves(seed: number, count = 3, perShelf = 7) {
const showExtreme = useMatureStore((state) => state.showMature);
return useQuery({
queryKey: ['discovery', 'playlists', seed, count, perShelf, showExtreme],
queryFn: () => discoveryApi.getPlaylistShelves({ count, perShelf, seed, showExtreme }),
staleTime: Infinity,
placeholderData: keepPreviousData,
});
}
/** A shuffled sample of the games released between two ISO dates, inclusive */
export function useReleasedBetween(from: string, to: string, limit: number) {
const showExtreme = useMatureStore((state) => state.showMature);
return useQuery({
queryKey: ['discovery', 'released', from, to, limit, showExtreme],
queryFn: () =>
gamesApi.search({
releasedFrom: from,
releasedTo: to,
sortBy: 'random',
seed: 0,
limit,
page: 1,
showExtreme,
}),
staleTime: Infinity,
placeholderData: keepPreviousData,
});
}
export function useRecentlyPlayed(limit: number) {
const signedIn = useAuthStore((state) => state.isAuthenticated && !state.isGuest);
return useQuery({
queryKey: ['discovery', 'recently-played', limit],
queryFn: async () => (await playTrackingApi.getGameStats(limit)).data,
enabled: signedIn,
});
}
+29 -8
View File
@@ -5,6 +5,7 @@ import { ReactNode } from 'react';
import { useGames, useGame, useRelatedGames, useRandomGame } from './useGames';
import { mockGame } from '@/test/mocks/handlers';
import { gamesApi } from '@/lib/api';
import { useMatureStore } from '@/store/mature';
// Create a wrapper with QueryClient for hooks that use useQuery
function createWrapper() {
@@ -224,7 +225,7 @@ describe('useGames', () => {
expect(result.current.isSuccess).toBe(true);
});
expect(getRandomSpy).toHaveBeenCalledWith('arcade', undefined);
expect(getRandomSpy).toHaveBeenCalledWith('arcade', undefined, false);
getRandomSpy.mockRestore();
});
@@ -239,7 +240,7 @@ describe('useGames', () => {
expect(result.current.isSuccess).toBe(true);
});
expect(getRandomSpy).toHaveBeenCalledWith(undefined, platforms);
expect(getRandomSpy).toHaveBeenCalledWith(undefined, platforms, false);
getRandomSpy.mockRestore();
});
@@ -255,7 +256,7 @@ describe('useGames', () => {
expect(result.current.isSuccess).toBe(true);
});
expect(getRandomSpy).toHaveBeenCalledWith(library, platforms);
expect(getRandomSpy).toHaveBeenCalledWith(library, platforms, false);
getRandomSpy.mockRestore();
});
@@ -271,7 +272,7 @@ describe('useGames', () => {
});
// Verify the exact array is passed (order preserved)
expect(getRandomSpy).toHaveBeenCalledWith(undefined, ['HTML5', 'Flash']);
expect(getRandomSpy).toHaveBeenCalledWith(undefined, ['HTML5', 'Flash'], false);
getRandomSpy.mockRestore();
});
@@ -303,11 +304,17 @@ describe('useGames', () => {
});
// Check that the query is cached with the expected key
const cachedData = queryClient.getQueryData(['games', 'random', library, platforms]);
const cachedData = queryClient.getQueryData(['games', 'random', library, platforms, false]);
expect(cachedData).toBeDefined();
// Verify different parameters create different cache entries
const otherCachedData = queryClient.getQueryData(['games', 'random', 'arcade', platforms]);
const otherCachedData = queryClient.getQueryData([
'games',
'random',
'arcade',
platforms,
false,
]);
expect(otherCachedData).toBeUndefined();
});
@@ -348,11 +355,25 @@ describe('useGames', () => {
});
// Both should be cached separately
const cached1 = queryClient.getQueryData(['games', 'random', undefined, platforms1]);
const cached2 = queryClient.getQueryData(['games', 'random', undefined, platforms2]);
const cached1 = queryClient.getQueryData(['games', 'random', undefined, platforms1, false]);
const cached2 = queryClient.getQueryData(['games', 'random', undefined, platforms2, false]);
expect(cached1).toBeDefined();
expect(cached2).toBeDefined();
});
it('should include mature games only when the user opted in', async () => {
const getRandomSpy = vi.spyOn(gamesApi, 'getRandom');
useMatureStore.setState({ showMature: true });
const { result } = renderHook(() => useRandomGame('arcade'), { wrapper: createWrapper() });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(getRandomSpy).toHaveBeenCalledWith('arcade', undefined, true);
useMatureStore.setState({ showMature: false });
getRandomSpy.mockRestore();
});
});
});
+13 -7
View File
@@ -1,11 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { gamesApi } from '@/lib/api';
import { useMatureStore } from '@/store/mature';
import { GameFilters } from '@/types/game';
export function useGames(filters: GameFilters) {
export function useGames(filters: GameFilters, options?: { enabled?: boolean }) {
const showExtreme = useMatureStore((state) => state.showMature);
const withMature = { ...filters, showExtreme };
return useQuery({
queryKey: ['games', filters],
queryFn: ({ signal }) => gamesApi.search(filters, signal),
queryKey: ['games', withMature],
queryFn: ({ signal }) => gamesApi.search(withMature, signal),
enabled: options?.enabled !== false,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 30,
refetchOnWindowFocus: false,
@@ -22,17 +26,19 @@ export function useGame(id: string, options?: { enabled?: boolean }) {
}
export function useRelatedGames(id: string, limit = 10) {
const showExtreme = useMatureStore((state) => state.showMature);
return useQuery({
queryKey: ['games', id, 'related', limit],
queryFn: () => gamesApi.getRelated(id, limit),
queryKey: ['games', id, 'related', limit, showExtreme],
queryFn: () => gamesApi.getRelated(id, limit, showExtreme),
enabled: !!id,
});
}
export function useRandomGame(library?: string, platforms?: string[]) {
const showExtreme = useMatureStore((state) => state.showMature);
return useQuery({
queryKey: ['games', 'random', library, platforms],
queryFn: () => gamesApi.getRandom(library, platforms),
queryKey: ['games', 'random', library, platforms, showExtreme],
queryFn: () => gamesApi.getRandom(library, platforms, showExtreme),
});
}
+32
View File
@@ -0,0 +1,32 @@
import { useEffect } from 'react';
import { usersApi } from '@/lib/api';
import { logger } from '@/lib/logger';
import { useAuthStore } from '@/store/auth';
import { SHOW_MATURE_SETTING, useMatureStore } from '@/store/mature';
/** Adopts the signed-in account's mature content preference, or saves this browser's to it. */
export function useMatureSync(): void {
const userId = useAuthStore((state) =>
state.isAuthenticated && !state.isGuest ? state.user?.id : undefined
);
useEffect(() => {
if (userId === undefined) return;
let cancelled = false;
usersApi
.getAllSettings()
.then((settings) => {
if (cancelled) return;
const saved = settings[SHOW_MATURE_SETTING];
if (saved === undefined) {
if (useMatureStore.getState().showMature) useMatureStore.getState().setShowMature(true);
return;
}
useMatureStore.setState({ showMature: saved === '1' });
})
.catch((error) => logger.debug('Failed to load mature preference:', error));
return () => {
cancelled = true;
};
}, [userId]);
}
-13
View File
@@ -1,13 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { gamesApi } from '@/lib/api';
export function useMostPlayedGames(limit: number = 20) {
return useQuery({
queryKey: ['games', 'most-played', limit],
queryFn: () => gamesApi.getMostPlayed(limit),
staleTime: 10 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: false,
});
}
-27
View File
@@ -1,27 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { gamesApi } from '@/lib/api';
import { GameFilters } from '@/types/game';
export function useRecentGames(type: 'added' | 'modified', limit: number = 20, hours: number = 24) {
return useQuery({
queryKey: ['games', 'recent', type, limit, hours],
queryFn: async () => {
const sinceDate = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
const filters: GameFilters = {
[type === 'added' ? 'dateAddedSince' : 'dateModifiedSince']: sinceDate,
sortBy: type === 'added' ? 'dateAdded' : 'dateModified',
sortOrder: 'desc',
limit,
page: 1,
library: 'arcade',
};
return gamesApi.search(filters);
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: false,
});
}
+34
View File
@@ -0,0 +1,34 @@
import { apiClient } from './client';
import type { Game } from '@/types/game';
export interface DailyPick {
date: string;
game: Game;
playlist?: { id: string; title: string };
}
export interface PlaylistShelf {
id: string;
title: string;
total: number;
games: Game[];
}
export const discoveryApi = {
getDaily: async (date: string, showExtreme: boolean): Promise<DailyPick> => {
const { data } = await apiClient.get<DailyPick>('/discovery/daily', {
params: { date, showExtreme },
});
return data;
},
getPlaylistShelves: async (params: {
count: number;
perShelf: number;
seed: number;
showExtreme: boolean;
}): Promise<PlaylistShelf[]> => {
const { data } = await apiClient.get<PlaylistShelf[]>('/discovery/playlists', { params });
return data;
},
};
+8 -3
View File
@@ -26,7 +26,11 @@ export const gamesApi = {
yearTo: filters.yearTo,
dateAddedSince: filters.dateAddedSince,
dateModifiedSince: filters.dateModifiedSince,
releasedFrom: filters.releasedFrom,
releasedTo: filters.releasedTo,
showExtreme: filters.showExtreme,
sortBy: filters.sortBy,
seed: filters.seed,
sortOrder: filters.sortOrder,
page: filters.page,
limit: filters.limit,
@@ -66,18 +70,19 @@ export const gamesApi = {
return data;
},
getRelated: async (id: string, limit = 10): Promise<Game[]> => {
getRelated: async (id: string, limit = 10, showExtreme = false): Promise<Game[]> => {
const { data } = await apiClient.get<Game[]>(`/games/${id}/related`, {
params: { limit },
params: { limit, showExtreme },
});
return data;
},
getRandom: async (library?: string, platforms?: string[]): Promise<Game> => {
getRandom: async (library?: string, platforms?: string[], showExtreme = false): Promise<Game> => {
const { data } = await apiClient.get<Game>('/games/random', {
params: {
library,
platforms: platforms?.join(','),
showExtreme,
},
});
return data;
-10
View File
@@ -1,10 +0,0 @@
import { apiClient } from './client';
export const githubApi = {
getStarCount: async (): Promise<{ stars: number }> => {
const { data } = await apiClient.get<{ success: boolean; data: { stars: number } }>(
'/github/stars'
);
return data.data;
},
};
+2 -1
View File
@@ -5,6 +5,8 @@ export { apiClient } from './client';
export { authApi } from './auth';
export { gamesApi } from './games';
export { discoveryApi } from './discovery';
export type { DailyPick, PlaylistShelf } from './discovery';
export { gameSavesApi } from './gameSaves';
export { playlistsApi, communityPlaylistsApi } from './playlists';
@@ -39,7 +41,6 @@ export { jobsApi } from './jobs';
export { ruffleApi } from './ruffle';
export { githubApi } from './github';
export { updatesApi } from './updates';
export type {
@@ -4,6 +4,8 @@ import {
parseFilterParams,
compressFilters,
decompressFilters,
defaultSortBy,
type FilterUrlParams,
} from './filterUrlCompression';
describe('downloaded filter URL round-trip', () => {
@@ -31,3 +33,34 @@ describe('downloaded filter URL round-trip', () => {
expect(parsed.downloaded).toBeUndefined();
});
});
describe('release range and shuffle URL round-trip', () => {
const roundTrip = (params: FilterUrlParams) => parseFilterParams(buildFilterSearchParams(params));
it('round-trips a release date range', () => {
const parsed = roundTrip({ releasedFrom: '2008-06-03', releasedTo: '2008-06-09' });
expect(parsed.releasedFrom).toBe('2008-06-03');
expect(parsed.releasedTo).toBe('2008-06-09');
});
it('reads a release range from legacy params', () => {
const parsed = parseFilterParams(new URLSearchParams('releasedFrom=2008-06-03&sortBy=title'));
expect(parsed.releasedFrom).toBe('2008-06-03');
expect(parsed.sortBy).toBe('title');
});
it('shuffles by default, keeping the seed, but lists searches by title', () => {
const shuffled = buildFilterSearchParams({ tags: 'Puzzle', seed: 42 });
expect(shuffled.get('sortBy')).toBeNull();
expect(shuffled.get('seed')).toBe('42');
expect(parseFilterParams(shuffled).seed).toBe(42);
const byTitle = buildFilterSearchParams({ tags: 'Puzzle', sortBy: 'title', seed: 42 });
expect(byTitle.get('sortBy')).toBe('title');
expect(byTitle.get('seed')).toBeNull();
expect(buildFilterSearchParams({ search: 'cat', sortBy: 'title' }).get('sortBy')).toBeNull();
expect(defaultSortBy({ search: 'cat' })).toBe('title');
expect(defaultSortBy({})).toBe('random');
});
});
+29 -1
View File
@@ -14,8 +14,13 @@ export interface FilterUrlParams {
tags?: string;
yearFrom?: number;
yearTo?: number;
/** YYYY-MM-DD release date range */
releasedFrom?: string;
releasedTo?: string;
sortBy?: string;
sortOrder?: string;
/** Shuffle seed for the random sort, so pages stay stable */
seed?: number;
page?: number;
fo?: string; // filter order
/** '1' = downloaded only, '0' = explicitly off, undefined = use the admin default */
@@ -33,6 +38,7 @@ const CATEGORY_TO_ABBR: Record<string, string> = {
Language: 'L',
Tag: 'T',
Year: 'Y',
Released: 'N',
Downloaded: 'W',
};
@@ -107,6 +113,8 @@ function toUrlSafeFormat(params: FilterUrlParams): string | null {
if (params.tags) parts.push(`t.${escapeValue(params.tags)}`);
if (params.yearFrom !== undefined) parts.push(`f.${params.yearFrom}`);
if (params.yearTo !== undefined) parts.push(`e.${params.yearTo}`);
if (params.releasedFrom) parts.push(`a.${escapeValue(params.releasedFrom)}`);
if (params.releasedTo) parts.push(`z.${escapeValue(params.releasedTo)}`);
if (params.downloaded !== undefined) parts.push(`w.${params.downloaded}`);
if (params.fo) parts.push(`o.${encodeFilterOrder(params.fo)}`);
@@ -164,6 +172,12 @@ function fromUrlSafeFormat(encoded: string): FilterUrlParams | null {
if (!isNaN(parsed)) params.yearTo = parsed;
break;
}
case 'a':
params.releasedFrom = unescapeValue(value);
break;
case 'z':
params.releasedTo = unescapeValue(value);
break;
case 'o':
params.fo = decodeFilterOrder(value);
break;
@@ -353,6 +367,7 @@ export function parseFilterParams(searchParams: URLSearchParams): FilterUrlParam
...decompressed,
sortBy: searchParams.get('sortBy') ?? undefined,
sortOrder: searchParams.get('sortOrder') ?? undefined,
seed: safeParseInt(searchParams.get('seed')),
page: safeParseInt(searchParams.get('page')),
};
}
@@ -370,8 +385,11 @@ export function parseFilterParams(searchParams: URLSearchParams): FilterUrlParam
tags: searchParams.get('tags') ?? undefined,
yearFrom: safeParseInt(searchParams.get('yearFrom')),
yearTo: safeParseInt(searchParams.get('yearTo')),
releasedFrom: searchParams.get('releasedFrom') ?? undefined,
releasedTo: searchParams.get('releasedTo') ?? undefined,
sortBy: searchParams.get('sortBy') ?? undefined,
sortOrder: searchParams.get('sortOrder') ?? undefined,
seed: safeParseInt(searchParams.get('seed')),
page: safeParseInt(searchParams.get('page')),
fo: searchParams.get('fo') ?? undefined,
downloaded:
@@ -383,6 +401,11 @@ export function parseFilterParams(searchParams: URLSearchParams): FilterUrlParam
};
}
/** Searches list by title; everything else is shuffled */
export function defaultSortBy(params: Pick<FilterUrlParams, 'search'>): string {
return params.search ? 'title' : 'random';
}
/**
* Build URL search params from filter state
* Uses compressed format for filters, keeps sort/page as regular params
@@ -397,12 +420,15 @@ export function buildFilterSearchParams(params: FilterUrlParams): URLSearchParam
}
// Keep sort and page as regular params (for readability and SEO)
if (params.sortBy && params.sortBy !== 'title') {
if (params.sortBy && params.sortBy !== defaultSortBy(params)) {
newParams.set('sortBy', params.sortBy);
}
if (params.sortOrder && params.sortOrder !== 'asc') {
newParams.set('sortOrder', params.sortOrder);
}
if (params.seed !== undefined && (params.sortBy ?? defaultSortBy(params)) === 'random') {
newParams.set('seed', params.seed.toString());
}
if (params.page && params.page > 1) {
newParams.set('page', params.page.toString());
}
@@ -425,6 +451,8 @@ export function hasLegacyParams(searchParams: URLSearchParams): boolean {
'tags',
'yearFrom',
'yearTo',
'releasedFrom',
'releasedTo',
'fo',
'downloaded',
];
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { usersApi } from '@/lib/api';
import { useAuthStore } from './auth';
import { useMatureStore } from './mature';
vi.mock('./theme', () => ({
useThemeStore: { getState: () => ({ loadThemeFromServer: () => Promise.resolve() }) },
}));
describe('mature content preference', () => {
beforeEach(() => {
localStorage.clear();
useMatureStore.setState({ showMature: false });
useAuthStore.getState().clearAuth();
vi.restoreAllMocks();
});
it('is off by default and kept in the browser for guests', () => {
const update = vi.spyOn(usersApi, 'updateSettings').mockResolvedValue({});
useAuthStore.getState().setGuestMode();
expect(useMatureStore.getState().showMature).toBe(false);
useMatureStore.getState().setShowMature(true);
expect(useMatureStore.getState().showMature).toBe(true);
expect(JSON.parse(localStorage.getItem('flashpoint-mature-content') ?? '{}').state).toEqual({
showMature: true,
});
expect(update).not.toHaveBeenCalled();
});
it('is saved to the account of a signed-in user', () => {
const update = vi.spyOn(usersApi, 'updateSettings').mockResolvedValue({});
useAuthStore.getState().setAuth({
id: 7,
username: 'player',
email: 'player@example.com',
role: 'user',
permissions: ['games.read', 'games.play'],
});
useMatureStore.getState().setShowMature(true);
expect(update).toHaveBeenCalledWith({ show_mature: '1' });
});
});
+35
View File
@@ -0,0 +1,35 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { usersApi } from '@/lib/api';
import { logger } from '@/lib/logger';
import { useAuthStore } from './auth';
/** User setting holding the preference for signed-in users */
export const SHOW_MATURE_SETTING = 'show_mature';
interface MatureState {
showMature: boolean;
setShowMature: (show: boolean) => void;
}
/**
* Whether adult, gory and otherwise extreme games are shown. Off by default, kept in this
* browser, and copied to the account of a signed-in user.
*/
export const useMatureStore = create<MatureState>()(
persist(
(set) => ({
showMature: false,
setShowMature: (showMature) => {
set({ showMature });
const { isAuthenticated, isGuest } = useAuthStore.getState();
if (isAuthenticated && !isGuest) {
usersApi
.updateSettings({ [SHOW_MATURE_SETTING]: showMature ? '1' : '0' })
.catch((error) => logger.debug('Mature preference sync failed:', error));
}
},
}),
{ name: 'flashpoint-mature-content' }
)
);
+4
View File
@@ -67,8 +67,12 @@ export interface GameFilters {
yearTo?: number;
dateAddedSince?: string; // ISO 8601 timestamp - filter games added after this date
dateModifiedSince?: string; // ISO 8601 timestamp - filter games modified after this date
releasedFrom?: string; // YYYY-MM-DD, day-precise release dates only
releasedTo?: string;
downloaded?: boolean;
showExtreme?: boolean;
sortBy?: string;
seed?: number; // Shuffle seed for sortBy 'random'
sortOrder?: 'asc' | 'desc';
page?: number;
limit?: number;
+5 -1
View File
@@ -2,6 +2,7 @@ import { AuthSettings } from './auth';
export interface AppSettings {
siteName: string;
tagline?: string;
maintenanceMode: boolean;
maintenanceMessage: string;
defaultTheme?: string;
@@ -70,7 +71,10 @@ export interface Domain {
}
export interface PublicSettings {
app: Pick<AppSettings, 'siteName' | 'maintenanceMode' | 'maintenanceMessage' | 'homeRecentHours'>;
app: Pick<
AppSettings,
'siteName' | 'tagline' | 'maintenanceMode' | 'maintenanceMessage' | 'homeRecentHours'
>;
auth: Pick<AuthSettings, 'guestAccessEnabled' | 'userRegistrationEnabled'>;
features?: Partial<FeatureSettings>;
metadata?: {
+9 -91
View File
@@ -1,98 +1,16 @@
import { useMemo } from 'react';
import { useRecentGames } from '@/hooks/useRecentGames';
import { useMostPlayedGames } from '@/hooks/useMostPlayedGames';
import { useFavoriteGameIds } from '@/hooks/useFavorites';
import { usePublicSettings } from '@/hooks/usePublicSettings';
import { HomeSection } from '@/components/home/HomeSection';
import { useAuthStore } from '@/store/auth';
import { useTopRatedGames } from '@/hooks/useRatings';
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
import { GameOfTheDay } from '@/components/portal/home/GameOfTheDay';
import { JumpBackIn, PlaylistShelves } from '@/components/portal/home/Shelves';
import { TimeMachine } from '@/components/portal/home/TimeMachine';
export function HomeView() {
const { isAuthenticated } = useAuthStore();
const { data: publicSettings } = usePublicSettings();
const recentHours = publicSettings?.app?.homeRecentHours ?? 24;
const timePeriod = useMemo(() => {
if (recentHours < 24) {
return `${recentHours} hour${recentHours !== 1 ? 's' : ''}`;
} else if (recentHours === 24) {
return '24 hours';
} else if (recentHours % 24 === 0) {
const days = recentHours / 24;
return `${days} day${days !== 1 ? 's' : ''}`;
} else {
const days = Math.floor(recentHours / 24);
const hours = recentHours % 24;
return `${days} day${days !== 1 ? 's' : ''} and ${hours} hour${hours !== 1 ? 's' : ''}`;
}
}, [recentHours]);
const { data: recentAdded, isLoading: loadingAdded } = useRecentGames('added', 20, recentHours);
const { data: recentUpdated, isLoading: loadingUpdated } = useRecentGames(
'modified',
20,
recentHours
);
const { data: mostPlayed, isLoading: loadingMostPlayed } = useMostPlayedGames(20);
const { enableRatings } = useFeatureFlags();
const { data: topRated, isLoading: loadingTopRated } = useTopRatedGames(20);
const { data: favoriteGameIdsArray } = useFavoriteGameIds();
const favoriteGameIds = useMemo(
() => (favoriteGameIdsArray ? new Set(favoriteGameIdsArray) : undefined),
[favoriteGameIdsArray]
);
return (
<div className="max-w-7xl mx-auto space-y-12">
<div className="space-y-2">
<h1 className="text-4xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
Welcome to Flashpoint Archive
</h1>
<p className="text-muted-foreground text-lg">
Discover recently active games from the preservation archive
</p>
<div className="space-y-[18px]">
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-[18px]">
<GameOfTheDay />
<TimeMachine />
</div>
{enableRatings ? (
<HomeSection
title="Highest Rated Games"
description="Games with the highest community ratings"
games={topRated ?? []}
isLoading={loadingTopRated}
viewAllHref="/browse?sortBy=rating&sortOrder=desc"
favoriteGameIds={isAuthenticated ? favoriteGameIds : undefined}
/>
) : null}
<HomeSection
title="Most Played Games"
description="Games with the highest play counts from the community"
games={mostPlayed || []}
isLoading={loadingMostPlayed}
viewAllHref="/browse?sortBy=title&sortOrder=asc"
favoriteGameIds={isAuthenticated ? favoriteGameIds : undefined}
/>
<HomeSection
title="Recently Added Games"
description={`Games added in the last ${timePeriod}`}
games={recentAdded?.data || []}
isLoading={loadingAdded}
viewAllHref="/browse?sortBy=dateAdded&sortOrder=desc"
favoriteGameIds={isAuthenticated ? favoriteGameIds : undefined}
/>
<HomeSection
title="Recently Updated Games"
description={`Games updated in the last ${timePeriod}`}
games={recentUpdated?.data || []}
isLoading={loadingUpdated}
viewAllHref="/browse?sortBy=dateModified&sortOrder=desc"
favoriteGameIds={isAuthenticated ? favoriteGameIds : undefined}
/>
<JumpBackIn />
<PlaylistShelves />
</div>
);
}
+10
View File
@@ -2294,6 +2294,7 @@
"name": "flashpoint-web-frontend",
"version": "1.0.0",
"dependencies": {
"@fontsource-variable/fredoka": "^5.3.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
@@ -5309,6 +5310,15 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@fontsource-variable/fredoka": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/fredoka/-/fredoka-5.3.0.tgz",
"integrity": "sha512-13w98iw2RcmYfZQRZPLSu+0YJK3HHm5A2XrpU26moFIAePmfiKfvkmEJUgkc8r5GNR+pbDijOueS1KdGAMAWmQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",