diff --git a/src/components/GameSearch.jsx b/src/components/GameSearch.jsx
index 9b644ca..fdc07cd 100644
--- a/src/components/GameSearch.jsx
+++ b/src/components/GameSearch.jsx
@@ -7,9 +7,9 @@ import {Input} from '@/components/ui/input';
import GameCover from './GameCover.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {searchGames} from '../utils/manualGames.js';
+import {MAX_GAME_SEARCH_LENGTH, MIN_GAME_SEARCH_LENGTH} from '../utils/limits.js';
const SEARCH_DEBOUNCE_MS = 400;
-const MIN_QUERY = 2;
/**
* The search field and the hits of Core's game search. The parent keeps the query, so it stays when the user goes
@@ -29,7 +29,7 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f
<>
- onQueryChange(e.target.value)}/>
@@ -87,7 +87,7 @@ function useGameSearch(query) {
useEffect(() => {
const trimmed = query.trim();
- if (trimmed.length < MIN_QUERY) {
+ if (trimmed.length < MIN_GAME_SEARCH_LENGTH) {
setSearchState('idle');
setResults([]);
return undefined;
diff --git a/src/components/ManualGameCard.jsx b/src/components/ManualGameCard.jsx
index 37a124d..8fc3c79 100644
--- a/src/components/ManualGameCard.jsx
+++ b/src/components/ManualGameCard.jsx
@@ -10,6 +10,7 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js';
+import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
/**
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
@@ -70,7 +71,7 @@ export default function ManualGameCard({game, onSaved}) {
- setPlatform(e.target.value)}/>
diff --git a/src/components/PileOfShameCard.jsx b/src/components/PileOfShameCard.jsx
index ae36641..27820f5 100644
--- a/src/components/PileOfShameCard.jsx
+++ b/src/components/PileOfShameCard.jsx
@@ -7,7 +7,8 @@ import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import PileTrend from './stats/PileTrend.jsx';
import {STATUS_META} from './GameStatusBadge.jsx';
-import {backlogShare, GAME_STATUSES} from '../utils/backlog.js';
+import {backlogShare} from '../utils/backlog.js';
+import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.js';
/**
diff --git a/src/components/stats/OverviewStats.jsx b/src/components/stats/OverviewStats.jsx
index ad73173..e72bd0f 100644
--- a/src/components/stats/OverviewStats.jsx
+++ b/src/components/stats/OverviewStats.jsx
@@ -3,7 +3,7 @@ import {Gamepad2} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import StatTile from './StatTile.jsx';
-import {GAME_STATUSES} from '../../utils/backlog.js';
+import {GAME_STATUSES} from '../../utils/limits.js';
import {formatPlaytime} from '../../utils/games.js';
export default function OverviewStats({overview}) {
diff --git a/src/pages/AdminHintsPage.jsx b/src/pages/AdminHintsPage.jsx
index 8431aa5..9511a1b 100644
--- a/src/pages/AdminHintsPage.jsx
+++ b/src/pages/AdminHintsPage.jsx
@@ -15,8 +15,8 @@ import PageContainer from '../components/PageContainer.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {hasSession} from '../utils/auth.js';
import {createHint, endHint, fetchAdminHints} from '../utils/hints.js';
+import {MAX_HINT_LENGTH} from '../utils/limits.js';
-const MAX_TEXT = 500;
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
// The value of a datetime-local input: local time, no seconds.
@@ -129,11 +129,11 @@ export default function AdminHintsPage() {
diff --git a/src/pages/ImportPage.jsx b/src/pages/ImportPage.jsx
index 29f35c0..f35e717 100644
--- a/src/pages/ImportPage.jsx
+++ b/src/pages/ImportPage.jsx
@@ -15,11 +15,11 @@ import PageContainer from '../components/PageContainer.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import useStartImport from '../hooks/useStartImport.js';
import {hasSession} from '../utils/auth.js';
-import {GAME_STATUSES} from '../utils/backlog.js';
import {
- checkUndo, getImport, listImports, MAX_IMPORT_ROWS, parseCsv, parseTextList, retryImport, saveImport, undoImport,
+ checkUndo, getImport, listImports, parseCsv, parseTextList, retryImport, saveImport, undoImport,
} from '../utils/imports.js';
-import {hoursToMinutes, MAX_PLATFORM_LENGTH, toHours} from '../utils/manualGames.js';
+import {GAME_STATUSES, MAX_IMPORT_ROWS, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
+import {hoursToMinutes, toHours} from '../utils/manualGames.js';
import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js';
const POLL_MS = 2000;
diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx
index 225554b..ce55d51 100644
--- a/src/pages/PublicProfilePage.jsx
+++ b/src/pages/PublicProfilePage.jsx
@@ -9,7 +9,7 @@ import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx';
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
-import {GAME_STATUSES} from '../utils/backlog.js';
+import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.js';
import {hasSession} from '../utils/auth.js';
import {normalizeProfileStyle} from '../utils/profileStyle.js';
diff --git a/src/pages/RegistrationPage.jsx b/src/pages/RegistrationPage.jsx
index ead87e3..9204c4d 100644
--- a/src/pages/RegistrationPage.jsx
+++ b/src/pages/RegistrationPage.jsx
@@ -13,6 +13,7 @@ import FeedbackAlert from '../components/FeedbackAlert.jsx';
import FormField from '../components/FormField.jsx';
import {registrationSchema} from '../utils/validation.js';
import {clientFetch} from '../utils/api.js';
+import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
const BACKEND_ERROR_KEY = {
EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE',
@@ -68,7 +69,7 @@ export default function RegistrationPage() {
? {minutes: Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60)}
: undefined;
setFeedback({
- message: mappedKey ? t(mappedKey, options) : data.message || t('REGISTRATION.ERROR_GENERIC'),
+ message: t(mappedKey ?? 'REGISTRATION.ERROR_GENERIC', options),
isError: true,
});
} catch (err) {
@@ -131,7 +132,7 @@ export default function RegistrationPage() {
diff --git a/src/pages/RegistrationPage.test.jsx b/src/pages/RegistrationPage.test.jsx
index ce8f779..eca6d32 100644
--- a/src/pages/RegistrationPage.test.jsx
+++ b/src/pages/RegistrationPage.test.jsx
@@ -12,12 +12,12 @@ vi.mock('react-i18next', () => ({
}),
}));
-function errorResponse(status, code, headers = {}) {
+function errorResponse(status, code, headers = {}, message = undefined) {
return {
ok: false,
status,
headers: new Headers(headers),
- json: async () => ({code}),
+ json: async () => ({code, message}),
};
}
@@ -60,4 +60,13 @@ describe('RegistrationPage', () => {
expect(await screen.findByText('REGISTRATION.ERROR_INVALID_INVITE_CODE')).toBeTruthy();
});
+
+ it('shows its own text, not the English one of the server, for a refused input', async () => {
+ globalThis.fetch = vi.fn(async () => errorResponse(400, 'VALIDATION_FAILED', {}, 'The request is not valid'));
+
+ submitValidForm();
+
+ expect(await screen.findByText('REGISTRATION.ERROR_GENERIC')).toBeTruthy();
+ expect(screen.queryByText('The request is not valid')).toBeNull();
+ });
});
diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx
index bcabf72..8609741 100644
--- a/src/pages/SettingsPage.jsx
+++ b/src/pages/SettingsPage.jsx
@@ -32,6 +32,7 @@ import {apiFetch, refreshSession} from '../utils/api.js';
import {hasSession, markSession} from '../utils/auth.js';
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js';
+import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
const BACKEND_ERROR_KEY = {
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
@@ -158,7 +159,7 @@ export default function SettingsPage() {
if (!response.ok) {
const data = await response.json().catch(() => ({}));
const mappedKey = BACKEND_ERROR_KEY[data.code];
- toast.error(mappedKey ? t(mappedKey) : data.message || t('SETTINGS.ERROR_SAVE'));
+ toast.error(t(mappedKey ?? 'SETTINGS.ERROR_SAVE'));
return {ok: false};
}
@@ -393,7 +394,7 @@ export default function SettingsPage() {
`${import.meta.env.VITE_CORE_API_URL}/api/library/imports`;
const JSON_HEADERS = {'Content-Type': 'application/json'};
-// Core's limits: lines per import, title length of the game search, playtime.
-export const MAX_IMPORT_ROWS = 1000;
-const MAX_TITLE = 100;
-const MAX_MINUTES = 6_000_000;
-
// The calls return the raw response; the caller handles the status.
export const createImport = (source, rows) =>
apiFetch(IMPORTS_URL(), {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({source, rows})});
@@ -31,7 +27,7 @@ export const undoImport = (id) => apiFetch(`${IMPORTS_URL()}/${id}/games`, {meth
* One title per line; empty lines are left out.
*/
export const parseTextList = (text) => text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
- .map((line) => ({title: line.slice(0, MAX_TITLE)}));
+ .map((line) => ({title: line.slice(0, MAX_IMPORT_TITLE_LENGTH)}));
// Header names, lower case, per field.
const COLUMNS = {
@@ -65,9 +61,9 @@ export const parseCsv = (text) => {
const minutes = at.minutes >= 0 ? Math.round(Number(value(record, 'minutes'))) || 0
: hoursToMinutes(value(record, 'hours'));
return {
- title: value(record, 'title').slice(0, MAX_TITLE),
+ title: value(record, 'title').slice(0, MAX_IMPORT_TITLE_LENGTH),
platform: platform && platform.length <= MAX_PLATFORM_LENGTH ? platform : null,
- playtimeMinutes: minutes > 0 ? Math.min(minutes, MAX_MINUTES) : null,
+ playtimeMinutes: minutes > 0 ? Math.min(minutes, MAX_PLAYTIME_MINUTES) : null,
status: parseStatus(value(record, 'status').toUpperCase()),
};
});
diff --git a/src/utils/limits.js b/src/utils/limits.js
new file mode 100644
index 0000000..2ffbb51
--- /dev/null
+++ b/src/utils/limits.js
@@ -0,0 +1,32 @@
+// Limits and enums that Core or Auth check. Each copy names its server source: change both together.
+
+// Core GameStatus
+export const GAME_STATUSES = ['BACKLOG', 'PLAYING', 'FINISHED', 'COMPLETED', 'DROPPED'];
+// Core GamePriority, in the order the UI shows it
+export const GAME_PRIORITIES = ['HIGH', 'MEDIUM', 'LOW'];
+
+// Core ManualGameUpdate.MAX_PLATFORM_LENGTH, Auth FavoritePlatformRequest @Size(max = 32)
+export const MAX_PLATFORM_LENGTH = 32;
+// Core ManualGameUpdate.MAX_MINUTES
+export const MAX_PLAYTIME_MINUTES = 6_000_000;
+// Core BacklogEntryDto.notes @Size(max = 2000)
+export const MAX_NOTES_LENGTH = 2000;
+
+// Core ImportRequest.MAX_ROWS
+export const MAX_IMPORT_ROWS = 1000;
+// Core ImportRequest.Row.title @Size(max = 100)
+export const MAX_IMPORT_TITLE_LENGTH = 100;
+// Core ManualGameController.search @Size(min = 2, max = 100)
+export const MIN_GAME_SEARCH_LENGTH = 2;
+export const MAX_GAME_SEARCH_LENGTH = 100;
+// Frontend only: Core's library search takes any length.
+export const MAX_LIBRARY_SEARCH_LENGTH = 100;
+// Core GameCorrectionService.MAX_TEXT
+export const MAX_CORRECTION_LENGTH = 512;
+// Core GameCorrectionService.MAX_GENRES
+export const MAX_CORRECTION_GENRES = 10;
+
+// Auth DisplayNameFormat.PATTERN (3 to 24 characters)
+export const MAX_DISPLAY_NAME_LENGTH = 24;
+// Auth CreateHintRequest @Size(max = 500)
+export const MAX_HINT_LENGTH = 500;
diff --git a/src/utils/manualGames.js b/src/utils/manualGames.js
index 887a0a2..cc53ebb 100644
--- a/src/utils/manualGames.js
+++ b/src/utils/manualGames.js
@@ -7,9 +7,6 @@ const JSON_HEADERS = {'Content-Type': 'application/json'};
// positive keys; Auth also takes only those as background or banner.
export const isSteamKey = (appid) => Number(appid) > 0;
-// Core's limit of a game's platform (ManualGameUpdate.MAX_PLATFORM_LENGTH).
-export const MAX_PLATFORM_LENGTH = 32;
-
// Two decimals: then hoursToMinutes gives back the same minute.
export const toHours = (minutes) => (minutes > 0 ? String(Math.round((minutes / 60) * 100) / 100) : '');