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",