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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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's pick →
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => setDaysAgo(0)}>
|
||||
← Today'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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
};
|
||||
}
|
||||
@@ -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,12 +39,28 @@ 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>
|
||||
{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"
|
||||
@@ -54,6 +73,7 @@ export function SortControls({
|
||||
<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">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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' }
|
||||
)
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+10
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user