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