From ee6fb4e951d4864e60562f80cca70829c7470423 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 12 Oct 2026 18:09:12 -0700 Subject: [PATCH] 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. --- frontend/package.json | 1 + frontend/src/App.tsx | 8 +- .../src/components/github/GitHubButton.tsx | 61 ---- frontend/src/components/home/HomeSection.tsx | 97 ------ frontend/src/components/layout/Header.tsx | 2 - frontend/src/components/layout/Sidebar.tsx | 2 - .../components/library/GameBrowseLayout.tsx | 69 +++- .../src/components/portal/CategoryNav.tsx | 35 ++ frontend/src/components/portal/GameTile.tsx | 27 ++ frontend/src/components/portal/PortalBox.tsx | 22 ++ .../src/components/portal/PortalHeader.tsx | 174 ++++++++++ .../src/components/portal/PortalLayout.tsx | 90 +++++ frontend/src/components/portal/genres.ts | 24 ++ .../components/portal/home/GameOfTheDay.tsx | 103 ++++++ .../src/components/portal/home/Shelves.tsx | 75 ++++ .../components/portal/home/TimeMachine.tsx | 143 ++++++++ frontend/src/components/portal/portal.css | 323 ++++++++++++++++++ frontend/src/components/portal/schemes.ts | 76 +++++ .../src/components/search/SortControls.tsx | 46 ++- .../components/settings/AppSettingsTab.tsx | 50 +++ frontend/src/hooks/useDiscovery.ts | 52 +++ frontend/src/hooks/useGames.test.tsx | 37 +- frontend/src/hooks/useGames.ts | 20 +- frontend/src/hooks/useMatureSync.ts | 32 ++ frontend/src/hooks/useMostPlayedGames.ts | 13 - frontend/src/hooks/useRecentGames.ts | 27 -- frontend/src/lib/api/discovery.ts | 34 ++ frontend/src/lib/api/games.ts | 11 +- frontend/src/lib/api/github.ts | 10 - frontend/src/lib/api/index.ts | 3 +- frontend/src/lib/filterUrlCompression.test.ts | 33 ++ frontend/src/lib/filterUrlCompression.ts | 30 +- frontend/src/store/mature.test.ts | 45 +++ frontend/src/store/mature.ts | 35 ++ frontend/src/types/game.ts | 4 + frontend/src/types/settings.ts | 6 +- frontend/src/views/HomeView.tsx | 100 +----- package-lock.json | 10 + 38 files changed, 1586 insertions(+), 344 deletions(-) delete mode 100644 frontend/src/components/github/GitHubButton.tsx delete mode 100644 frontend/src/components/home/HomeSection.tsx create mode 100644 frontend/src/components/portal/CategoryNav.tsx create mode 100644 frontend/src/components/portal/GameTile.tsx create mode 100644 frontend/src/components/portal/PortalBox.tsx create mode 100644 frontend/src/components/portal/PortalHeader.tsx create mode 100644 frontend/src/components/portal/PortalLayout.tsx create mode 100644 frontend/src/components/portal/genres.ts create mode 100644 frontend/src/components/portal/home/GameOfTheDay.tsx create mode 100644 frontend/src/components/portal/home/Shelves.tsx create mode 100644 frontend/src/components/portal/home/TimeMachine.tsx create mode 100644 frontend/src/components/portal/portal.css create mode 100644 frontend/src/components/portal/schemes.ts create mode 100644 frontend/src/hooks/useDiscovery.ts create mode 100644 frontend/src/hooks/useMatureSync.ts delete mode 100644 frontend/src/hooks/useMostPlayedGames.ts delete mode 100644 frontend/src/hooks/useRecentGames.ts create mode 100644 frontend/src/lib/api/discovery.ts delete mode 100644 frontend/src/lib/api/github.ts create mode 100644 frontend/src/store/mature.test.ts create mode 100644 frontend/src/store/mature.ts diff --git a/frontend/package.json b/frontend/package.json index f244a5b..ad2775d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e1d8a17..f62d90e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ==================== */} } /> - {/* ==================== MAIN LAYOUT ==================== */} - }> + {/* ==================== PORTAL LAYOUT ==================== */} + }> } /> + + {/* ==================== MAIN LAYOUT (account and admin) ==================== */} + }> {/* User management routes */} (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 ( - - ); -} diff --git a/frontend/src/components/home/HomeSection.tsx b/frontend/src/components/home/HomeSection.tsx deleted file mode 100644 index 98e2462..0000000 --- a/frontend/src/components/home/HomeSection.tsx +++ /dev/null @@ -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; -} - -export function HomeSection({ - title, - description, - games, - isLoading, - viewAllHref, - favoriteGameIds, -}: HomeSectionProps) { - if (isLoading) { - return ( -
-
-
-

{title}

-

{description}

-
-
-
- {Array.from({ length: 10 }).map((_, i) => ( -
- ))} -
-
- ); - } - - if (games.length === 0) { - return ( -
-
-
-

{title}

-

{description}

-
-
-
-

No games found in this time period

- - - -
-
- ); - } - - return ( -
-
-
-

{title}

-

{description}

-
- - - -
- -
-
- {games.map((game) => ( -
- -
- ))} -
-
-
- ); -} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 570f7a4..3576ef3 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -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}
- {(isAuthenticated || isGuest) && user ? ( diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 922ecc2..dc3e8f8 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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' }, ]; diff --git a/frontend/src/components/library/GameBrowseLayout.tsx b/frontend/src/components/library/GameBrowseLayout.tsx index 3d62e47..7bfbdc3 100644 --- a/frontend/src/components/library/GameBrowseLayout.tsx +++ b/frontend/src/components/library/GameBrowseLayout.tsx @@ -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 (
@@ -478,6 +536,7 @@ export function GameBrowseLayout({ sortBy={filters.sortBy} sortOrder={filters.sortOrder} onSortChange={handleSortChange} + onReshuffle={handleReshuffle} />
diff --git a/frontend/src/components/portal/CategoryNav.tsx b/frontend/src/components/portal/CategoryNav.tsx new file mode 100644 index 0000000..39213bc --- /dev/null +++ b/frontend/src/components/portal/CategoryNav.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/components/portal/GameTile.tsx b/frontend/src/components/portal/GameTile.tsx new file mode 100644 index 0000000..ccaff47 --- /dev/null +++ b/frontend/src/components/portal/GameTile.tsx @@ -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 ( + + + {broken ? ( + + {title} + + ); +}); + +GameTile.displayName = 'GameTile'; diff --git a/frontend/src/components/portal/PortalBox.tsx b/frontend/src/components/portal/PortalBox.tsx new file mode 100644 index 0000000..1fa1d3d --- /dev/null +++ b/frontend/src/components/portal/PortalBox.tsx @@ -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 ( +
+
+

{title}

+ {action} +
+ {children} +
+ ); +} diff --git a/frontend/src/components/portal/PortalHeader.tsx b/frontend/src/components/portal/PortalHeader.tsx new file mode 100644 index 0000000..5a94b1a --- /dev/null +++ b/frontend/src/components/portal/PortalHeader.tsx @@ -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(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 ( +
+ + {siteName} + {tagline ? {tagline} : null} + +
+
+ ); +}); + +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 ( + + + {signedIn && user ? user.username : 'Guest'} + + + {links.length > 0 ? ( + <> + {links.map((link) => ( + + {link.label} + + ))} + + + ) : null} + { + event.preventDefault(); + setShowMature(!showMature); + }} + className="justify-between gap-3" + > + + Show mature games + Adult, gory or hateful + + + {adminLinks.length > 0 ? ( + <> + + Admin + {adminLinks.map((link) => ( + + {link.label} + + ))} + + ) : null} + + {signedIn ? ( + Log out + ) : ( + <> + + Log in + + {publicSettings?.auth?.userRegistrationEnabled ? ( + + Register + + ) : null} + + )} + + + ); +} diff --git a/frontend/src/components/portal/PortalLayout.tsx b/frontend/src/components/portal/PortalLayout.tsx new file mode 100644 index 0000000..bd10467 --- /dev/null +++ b/frontend/src/components/portal/PortalLayout.tsx @@ -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(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 ( + +
+ + Skip to main content + + + +
+ +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/portal/genres.ts b/frontend/src/components/portal/genres.ts new file mode 100644 index 0000000..2a3afb2 --- /dev/null +++ b/frontend/src/components/portal/genres.ts @@ -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]; +} diff --git a/frontend/src/components/portal/home/GameOfTheDay.tsx b/frontend/src/components/portal/home/GameOfTheDay.tsx new file mode 100644 index 0000000..b87275b --- /dev/null +++ b/frontend/src/components/portal/home/GameOfTheDay.tsx @@ -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 ? ( + + ) : ( + + ); + + 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 ( + + {isError ? ( +

No pick today. Check back tomorrow.

+ ) : ( +
+
+
+ {game ? ( + {`Screenshot + ) : null} +
+ {game?.originalDescription ? ( +

+ {game.originalDescription} +

+ ) : null} +
+ {isLoading || !game ? ( +
+ + +
+ ) : ( +
+

+ {game.title} +

+ {data.playlist ? ( +

From {data.playlist.title}

+ ) : null} +
+ {game.releaseDate ? ( +
+ + {game.releaseDate} +
+ ) : null} + {credits ? ( +
+ + {credits} +
+ ) : null} + {genre ? ( +
+ + {genre} +
+ ) : null} + {game.platformName ? ( +
+ + {game.platformName} +
+ ) : null} +
+ + PLAY + +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/portal/home/Shelves.tsx b/frontend/src/components/portal/home/Shelves.tsx new file mode 100644 index 0000000..86d5fab --- /dev/null +++ b/frontend/src/components/portal/home/Shelves.tsx @@ -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 ( +
+ {Array.from({ length: PER_SHELF }, (_, i) => ( + + ))} +
+ ); +} + +export function JumpBackIn() { + const { data } = useRecentlyPlayed(PER_SHELF); + if (!data?.length) return null; + + return ( + Your history →}> +
+ {data.map((stat) => ( + + ))} +
+
+ ); +} + +export function PlaylistShelves() { + const [seed, setSeed] = useState(() => Math.floor(Math.random() * 2 ** 30)); + const { data, isLoading } = usePlaylistShelves(seed, 3, PER_SHELF); + + if (isLoading) { + return ( + + + + ); + } + + return ( + <> + {data?.map((shelf, i) => ( + + {i === 0 ? ( + + ) : null} + + See all {shelf.total.toLocaleString()} → + +
+ } + > +
+ {shelf.games.map((game) => ( + + ))} +
+ + ))} + + ); +} diff --git a/frontend/src/components/portal/home/TimeMachine.tsx b/frontend/src/components/portal/home/TimeMachine.tsx new file mode 100644 index 0000000..cbee500 --- /dev/null +++ b/frontend/src/components/portal/home/TimeMachine.tsx @@ -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 ( + +
+ + + +
+ + {isLoading ? ( +
+ {Array.from({ length: 7 }, (_, i) => ( + + ))} +
+ ) : games.length === 0 ? ( +

+ Nothing archived from this week. Try another. +

+ ) : ( +
    + {games.map((game) => { + const genre = primaryGenre(game.tagsStr); + return ( +
  • + + event.currentTarget.removeAttribute('src')} + /> + + + {game.title} + + + {[game.releaseDate, genre].filter(Boolean).join(' · ')} + + + +
  • + ); + })} +
+ )} + +
+ + {data && data.total > 0 ? ( + + See all {data.total.toLocaleString()} → + + ) : null} +
+ + + + + Travel to a date + + { + if (selected) { + setDay(selected); + setPicking(false); + } + }} + /> + + +
+ ); +} diff --git a/frontend/src/components/portal/portal.css b/frontend/src/components/portal/portal.css new file mode 100644 index 0000000..427e3f0 --- /dev/null +++ b/frontend/src/components/portal/portal.css @@ -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; +} diff --git a/frontend/src/components/portal/schemes.ts b/frontend/src/components/portal/schemes.ts new file mode 100644 index 0000000..ec3de44 --- /dev/null +++ b/frontend/src/components/portal/schemes.ts @@ -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; +} + +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; + +export type PortalSchemeName = keyof typeof PORTAL_SCHEMES; + +/** + * Applies a scheme to , which wins over the app theme on 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'); + }; +} diff --git a/frontend/src/components/search/SortControls.tsx b/frontend/src/components/search/SortControls.tsx index 4ae5e91..a75a23c 100644 --- a/frontend/src/components/search/SortControls.tsx +++ b/frontend/src/components/search/SortControls.tsx @@ -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" > + - + {sortBy === 'random' ? ( + onReshuffle ? ( + + ) : null + ) : ( + + )}
); } diff --git a/frontend/src/components/settings/AppSettingsTab.tsx b/frontend/src/components/settings/AppSettingsTab.tsx index 612fa45..f684a82 100644 --- a/frontend/src/components/settings/AppSettingsTab.tsx +++ b/frontend/src/components/settings/AppSettingsTab.tsx @@ -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(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 }) => systemSettingsApi.updateCategory(category, settings), @@ -106,6 +111,13 @@ export function AppSettingsTab({ tabContentVariants }: AppSettingsTabProps) { }); }; + const handleSaveTagline = () => { + updateSystemSettings.mutate({ + category: 'app', + settings: { tagline: taglineInput.trim() }, + }); + }; + return (
+ {/* Tagline */} +
+ +

+ A short line shown under the site name. Leave empty to hide it. +

+
+ setTaglineInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSaveTagline(); + } + }} + disabled={updateSystemSettings.isPending} + placeholder="The Flash arcade that never closed" + /> + +
+
+ {/* Maintenance Mode */}
diff --git a/frontend/src/hooks/useDiscovery.ts b/frontend/src/hooks/useDiscovery.ts new file mode 100644 index 0000000..3d469a5 --- /dev/null +++ b/frontend/src/hooks/useDiscovery.ts @@ -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, + }); +} diff --git a/frontend/src/hooks/useGames.test.tsx b/frontend/src/hooks/useGames.test.tsx index 33a686a..361d953 100644 --- a/frontend/src/hooks/useGames.test.tsx +++ b/frontend/src/hooks/useGames.test.tsx @@ -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(); + }); }); }); diff --git a/frontend/src/hooks/useGames.ts b/frontend/src/hooks/useGames.ts index 6d1089c..157016c 100644 --- a/frontend/src/hooks/useGames.ts +++ b/frontend/src/hooks/useGames.ts @@ -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), }); } diff --git a/frontend/src/hooks/useMatureSync.ts b/frontend/src/hooks/useMatureSync.ts new file mode 100644 index 0000000..fd85121 --- /dev/null +++ b/frontend/src/hooks/useMatureSync.ts @@ -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]); +} diff --git a/frontend/src/hooks/useMostPlayedGames.ts b/frontend/src/hooks/useMostPlayedGames.ts deleted file mode 100644 index d723d7b..0000000 --- a/frontend/src/hooks/useMostPlayedGames.ts +++ /dev/null @@ -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, - }); -} diff --git a/frontend/src/hooks/useRecentGames.ts b/frontend/src/hooks/useRecentGames.ts deleted file mode 100644 index a1c6e55..0000000 --- a/frontend/src/hooks/useRecentGames.ts +++ /dev/null @@ -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, - }); -} diff --git a/frontend/src/lib/api/discovery.ts b/frontend/src/lib/api/discovery.ts new file mode 100644 index 0000000..0bec052 --- /dev/null +++ b/frontend/src/lib/api/discovery.ts @@ -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 => { + const { data } = await apiClient.get('/discovery/daily', { + params: { date, showExtreme }, + }); + return data; + }, + + getPlaylistShelves: async (params: { + count: number; + perShelf: number; + seed: number; + showExtreme: boolean; + }): Promise => { + const { data } = await apiClient.get('/discovery/playlists', { params }); + return data; + }, +}; diff --git a/frontend/src/lib/api/games.ts b/frontend/src/lib/api/games.ts index 4316a1f..a4b93ad 100644 --- a/frontend/src/lib/api/games.ts +++ b/frontend/src/lib/api/games.ts @@ -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 => { + getRelated: async (id: string, limit = 10, showExtreme = false): Promise => { const { data } = await apiClient.get(`/games/${id}/related`, { - params: { limit }, + params: { limit, showExtreme }, }); return data; }, - getRandom: async (library?: string, platforms?: string[]): Promise => { + getRandom: async (library?: string, platforms?: string[], showExtreme = false): Promise => { const { data } = await apiClient.get('/games/random', { params: { library, platforms: platforms?.join(','), + showExtreme, }, }); return data; diff --git a/frontend/src/lib/api/github.ts b/frontend/src/lib/api/github.ts deleted file mode 100644 index 3a67158..0000000 --- a/frontend/src/lib/api/github.ts +++ /dev/null @@ -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; - }, -}; diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index eeaf959..c6ffc61 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -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 { diff --git a/frontend/src/lib/filterUrlCompression.test.ts b/frontend/src/lib/filterUrlCompression.test.ts index bea5ab5..6834315 100644 --- a/frontend/src/lib/filterUrlCompression.test.ts +++ b/frontend/src/lib/filterUrlCompression.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/filterUrlCompression.ts b/frontend/src/lib/filterUrlCompression.ts index f3482e8..9e74276 100644 --- a/frontend/src/lib/filterUrlCompression.ts +++ b/frontend/src/lib/filterUrlCompression.ts @@ -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 = { 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): 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', ]; diff --git a/frontend/src/store/mature.test.ts b/frontend/src/store/mature.test.ts new file mode 100644 index 0000000..a27dbcf --- /dev/null +++ b/frontend/src/store/mature.test.ts @@ -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' }); + }); +}); diff --git a/frontend/src/store/mature.ts b/frontend/src/store/mature.ts new file mode 100644 index 0000000..15d28f9 --- /dev/null +++ b/frontend/src/store/mature.ts @@ -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()( + 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' } + ) +); diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index d73ed66..7611a91 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -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; diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts index 7d741e6..2798333 100644 --- a/frontend/src/types/settings.ts +++ b/frontend/src/types/settings.ts @@ -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; + app: Pick< + AppSettings, + 'siteName' | 'tagline' | 'maintenanceMode' | 'maintenanceMessage' | 'homeRecentHours' + >; auth: Pick; features?: Partial; metadata?: { diff --git a/frontend/src/views/HomeView.tsx b/frontend/src/views/HomeView.tsx index fd7c212..58c6683 100644 --- a/frontend/src/views/HomeView.tsx +++ b/frontend/src/views/HomeView.tsx @@ -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 ( -
-
-

- Welcome to Flashpoint Archive -

-

- Discover recently active games from the preservation archive -

+
+
+ +
- - {enableRatings ? ( - - ) : null} - - - - - - + +
); } diff --git a/package-lock.json b/package-lock.json index bcce49d..22ff965 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",