@scrabble-solver/scrabble-solver 2.18.3 → 2.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +15 -15
  3. package/.next/cache/.previewinfo +1 -1
  4. package/.next/cache/.rscinfo +1 -1
  5. package/.next/cache/.tsbuildinfo +1 -1
  6. package/.next/cache/webpack/client-production/0.pack +0 -0
  7. package/.next/cache/webpack/client-production/index.pack +0 -0
  8. package/.next/cache/webpack/client-production/index.pack.old +0 -0
  9. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  10. package/.next/cache/webpack/edge-server-production/index.pack.old +0 -0
  11. package/.next/cache/webpack/server-production/0.pack +0 -0
  12. package/.next/cache/webpack/server-production/index.pack +0 -0
  13. package/.next/cache/webpack/server-production/index.pack.old +0 -0
  14. package/.next/diagnostics/framework.json +1 -1
  15. package/.next/dynamic-css-manifest.json +1 -1
  16. package/.next/prerender-manifest.json +4 -4
  17. package/.next/react-loadable-manifest.json +6 -6
  18. package/.next/routes-manifest.json +1 -1
  19. package/.next/server/chunks/546.js +1 -1
  20. package/.next/server/chunks/612.js +5 -0
  21. package/.next/server/chunks/712.js +1 -1
  22. package/.next/server/chunks/715.js +1 -0
  23. package/.next/server/chunks/961.js +1 -1
  24. package/.next/server/dynamic-css-manifest.js +1 -1
  25. package/.next/server/middleware-build-manifest.js +1 -1
  26. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  27. package/.next/server/pages/404.html +2 -2
  28. package/.next/server/pages/500.html +2 -2
  29. package/.next/server/pages/_document.js +2 -2
  30. package/.next/server/pages/api/dictionary/[locale]/[word].js +2 -2
  31. package/.next/server/pages/api/dictionary/[locale]/[word].js.nft.json +1 -1
  32. package/.next/server/pages/api/dictionary/[locale].js +1 -1
  33. package/.next/server/pages/api/dictionary/[locale].js.nft.json +1 -1
  34. package/.next/server/pages/api/solve.js +1 -1
  35. package/.next/server/pages/api/solve.js.nft.json +1 -1
  36. package/.next/server/pages/api/verify.js +1 -1
  37. package/.next/server/pages/api/verify.js.nft.json +1 -1
  38. package/.next/server/pages/api/visit.js +1 -1
  39. package/.next/server/pages/api/visit.js.nft.json +1 -1
  40. package/.next/server/pages/index.html +2 -2
  41. package/.next/server/pages/index.js +1 -1
  42. package/.next/server/pages/index.json +1 -1
  43. package/.next/server/pages/not-found.html +2 -2
  44. package/.next/static/{8WnIUTe-yJvPD6stltvYI → TAqjh9l5VJQOfJ4SzgPk2}/_buildManifest.js +1 -1
  45. package/.next/static/chunks/101-f341f70c10d71212.js +3 -0
  46. package/.next/static/chunks/693.d295da7c7b873986.js +1 -0
  47. package/.next/static/chunks/980.dd35e807b18063c1.js +1 -0
  48. package/.next/static/chunks/{main-7e52da35a4beea0b.js → main-6e9ac37e28d2b899.js} +1 -1
  49. package/.next/static/chunks/pages/_app-9340d51f5a6787fc.js +1 -0
  50. package/.next/static/chunks/pages/index-ea21d96a88f25000.js +1 -0
  51. package/.next/static/chunks/{webpack-d7138f1ef6b8c9d6.js → webpack-d73aa4aae49cd800.js} +1 -1
  52. package/.next/static/css/{92f85ce358a0a468.css → 4dbcacc1122ac6b9.css} +1 -1
  53. package/.next/static/css/8b8087461767360a.css +1 -0
  54. package/.next/static/css/{65bcc9440e067113.css → 98051ed25076b896.css} +1 -1
  55. package/.next/trace +22 -22
  56. package/.next/trace-build +1 -1
  57. package/package.json +11 -11
  58. package/src/api/BadRequestError.ts +6 -0
  59. package/src/api/index.ts +2 -1
  60. package/src/api/withApiLog.ts +85 -0
  61. package/src/components/Button/Button.module.scss +1 -1
  62. package/src/components/Solver/Solver.module.scss +4 -4
  63. package/src/components/Solver/Solver.tsx +2 -2
  64. package/src/hooks/useColumns.ts +5 -7
  65. package/src/modals/ResultsModal/ResultsModal.tsx +2 -2
  66. package/src/modals/WordsModal/WordsModal.module.scss +1 -1
  67. package/src/modals/WordsModal/WordsModal.tsx +2 -2
  68. package/src/modals/WordsModal/components/WordsTable/WordsTable.tsx +2 -2
  69. package/src/pages/api/dictionary/[locale]/[word].ts +22 -30
  70. package/src/pages/api/dictionary/[locale]/index.ts +36 -42
  71. package/src/pages/api/solve.ts +32 -42
  72. package/src/pages/api/verify.ts +35 -43
  73. package/src/pages/api/visit.ts +38 -13
  74. package/src/pages/index.tsx +2 -2
  75. package/src/parameters/index.ts +0 -2
  76. package/src/sdk/solve.ts +3 -9
  77. package/src/sdk/verify.ts +2 -10
  78. package/src/sdk/visit.ts +8 -3
  79. package/src/state/sagas.ts +7 -1
  80. package/src/styles/_tokens.scss +0 -2
  81. package/src/styles/mixins.scss +0 -5
  82. package/src/styles/variables.module.scss +0 -1
  83. package/src/styles/variables.module.scss.d.ts +0 -1
  84. package/src/styles/variables.scss +1 -1
  85. package/src/types/api.ts +7 -0
  86. package/tsconfig.tsbuildinfo +1 -1
  87. package/.next/server/chunks/392.js +0 -1
  88. package/.next/static/chunks/101-829ab8a8d310e7a0.js +0 -3
  89. package/.next/static/chunks/693.677c88e0eb1340b5.js +0 -1
  90. package/.next/static/chunks/980.7ce17464fc56648a.js +0 -1
  91. package/.next/static/chunks/pages/_app-e3fe880e24c024ab.js +0 -1
  92. package/.next/static/chunks/pages/index-b073a1686818dd26.js +0 -1
  93. package/.next/static/css/a691348b788f8bc0.css +0 -1
  94. package/src/api/getServerLoggingData.ts +0 -17
  95. package/src/hooks/useIsCompactLayout.ts +0 -14
  96. /package/.next/static/{8WnIUTe-yJvPD6stltvYI → TAqjh9l5VJQOfJ4SzgPk2}/_ssgManifest.js +0 -0
package/.next/trace-build CHANGED
@@ -1 +1 @@
1
- [{"name":"run-typescript","duration":160243,"timestamp":6454978890,"id":13,"parentId":1,"tags":{},"startTime":1787605800486,"traceId":"f92ac34b43b8b857"},{"name":"run-webpack","duration":5760453,"timestamp":6455141586,"id":15,"parentId":1,"tags":{},"startTime":1787605800649,"traceId":"f92ac34b43b8b857"},{"name":"static-check","duration":171530,"timestamp":6460973542,"id":2106,"parentId":1,"tags":{},"startTime":1787605806481,"traceId":"f92ac34b43b8b857"},{"name":"static-generation","duration":249530,"timestamp":6461155014,"id":2124,"parentId":1,"tags":{},"startTime":1787605806662,"traceId":"f92ac34b43b8b857"},{"name":"collect-build-traces","duration":4270405,"timestamp":6461145430,"id":2121,"parentId":1,"tags":{},"startTime":1787605806652,"traceId":"f92ac34b43b8b857"},{"name":"telemetry-flush","duration":19,"timestamp":6465417121,"id":2131,"parentId":1,"tags":{},"startTime":1787605810924,"traceId":"f92ac34b43b8b857"},{"name":"next-build","duration":10674125,"timestamp":6454743019,"id":1,"tags":{"buildMode":"default","version":"16.3.2","bundler":"webpack","has-custom-webpack-config":"true","use-build-worker":"false"},"startTime":1787605800250,"traceId":"f92ac34b43b8b857"}]
1
+ [{"name":"run-typescript","duration":168729,"timestamp":5383943044,"id":13,"parentId":1,"tags":{},"startTime":1787864119994,"traceId":"29d4e0b0fa58c2a5"},{"name":"run-webpack","duration":5660378,"timestamp":5384114003,"id":15,"parentId":1,"tags":{},"startTime":1787864120165,"traceId":"29d4e0b0fa58c2a5"},{"name":"static-check","duration":171566,"timestamp":5389837607,"id":2109,"parentId":1,"tags":{},"startTime":1787864125889,"traceId":"29d4e0b0fa58c2a5"},{"name":"static-generation","duration":249480,"timestamp":5390019708,"id":2127,"parentId":1,"tags":{},"startTime":1787864126071,"traceId":"29d4e0b0fa58c2a5"},{"name":"collect-build-traces","duration":4113081,"timestamp":5390009545,"id":2124,"parentId":1,"tags":{},"startTime":1787864126061,"traceId":"29d4e0b0fa58c2a5"},{"name":"telemetry-flush","duration":19,"timestamp":5394123827,"id":2134,"parentId":1,"tags":{},"startTime":1787864130175,"traceId":"29d4e0b0fa58c2a5"},{"name":"next-build","duration":10414286,"timestamp":5383709563,"id":1,"tags":{"buildMode":"default","version":"16.3.3","bundler":"webpack","has-custom-webpack-config":"true","use-build-worker":"false"},"startTime":1787864119761,"traceId":"29d4e0b0fa58c2a5"}]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrabble-solver/scrabble-solver",
3
- "version": "2.18.3",
3
+ "version": "2.18.4",
4
4
  "description": "Scrabble Solver web app: free word finder and board solver for Scrabble, Super Scrabble, Literaki, and more.",
5
5
  "keywords": [
6
6
  "scrabble",
@@ -57,18 +57,18 @@
57
57
  "@floating-ui/react": "^0.27.20",
58
58
  "@kamilmielnik/gaddag": "^2.0.0",
59
59
  "@reduxjs/toolkit": "^2.12.0",
60
- "@scrabble-solver/configs": "^2.18.3",
61
- "@scrabble-solver/constants": "^2.18.3",
62
- "@scrabble-solver/dictionaries": "^2.18.3",
63
- "@scrabble-solver/logger": "^2.18.3",
64
- "@scrabble-solver/solver": "^2.18.3",
65
- "@scrabble-solver/types": "^2.18.3",
66
- "@scrabble-solver/word-definitions": "^2.18.3",
60
+ "@scrabble-solver/configs": "^2.18.4",
61
+ "@scrabble-solver/constants": "^2.18.4",
62
+ "@scrabble-solver/dictionaries": "^2.18.4",
63
+ "@scrabble-solver/logger": "^2.18.4",
64
+ "@scrabble-solver/solver": "^2.18.4",
65
+ "@scrabble-solver/types": "^2.18.4",
66
+ "@scrabble-solver/word-definitions": "^2.18.4",
67
67
  "classnames": "^2.5.1",
68
68
  "env-cmd": "^11.0.0",
69
69
  "include-media": "^2.0.0",
70
70
  "include-media-query-builder": "^1.1.0",
71
- "next": "^16.3.2",
71
+ "next": "^16.3.3",
72
72
  "normalize.css": "^8.0.1",
73
73
  "react": "^19.2.8",
74
74
  "react-cool-onclickoutside": "^1.7.0",
@@ -89,12 +89,12 @@
89
89
  "devDependencies": {
90
90
  "@svgr/webpack": "^8.1.0",
91
91
  "@types/react": "^19.2.18",
92
- "@types/react-dom": "^19.2.4",
92
+ "@types/react-dom": "^19.2.5",
93
93
  "@types/react-highlight-words": "^0.20.1",
94
94
  "@types/react-modal": "^3.16.3",
95
95
  "@types/react-portal": "^4.0.7",
96
96
  "postcss": "^8.5.26",
97
97
  "sass": "^1.103.1"
98
98
  },
99
- "gitHead": "a1b4a5735a1a48fbb5dfe7f214a7df2158309202"
99
+ "gitHead": "ff030ba9e63eba57579e45d6698bfb81d88c8bd5"
100
100
  }
@@ -0,0 +1,6 @@
1
+ export class BadRequestError extends Error {
2
+ constructor(message: string) {
3
+ super(message);
4
+ this.name = 'BadRequestError';
5
+ }
6
+ }
package/src/api/index.ts CHANGED
@@ -1,5 +1,6 @@
1
- export { getServerLoggingData } from './getServerLoggingData';
1
+ export { BadRequestError } from './BadRequestError';
2
2
  export { isBoardValid } from './isBoardValid';
3
3
  export { isCellValid } from './isCellValid';
4
4
  export { isCharacterValid } from './isCharacterValid';
5
5
  export { isRowValid } from './isRowValid';
6
+ export { type ApiContext, withApiLog } from './withApiLog';
@@ -0,0 +1,85 @@
1
+ import { type Operation, logError, logEvent } from '@scrabble-solver/logger';
2
+ import { isObject } from '@scrabble-solver/types';
3
+ import { type NextApiHandler, type NextApiRequest, type NextApiResponse } from 'next';
4
+
5
+ import { BadRequestError } from './BadRequestError';
6
+
7
+ export interface ApiContext {
8
+ ip: string | undefined;
9
+ ua: string | undefined;
10
+ getElapsedMs: () => number;
11
+ }
12
+
13
+ type ApiHandler = (request: NextApiRequest, response: NextApiResponse, context: ApiContext) => Promise<void> | void;
14
+
15
+ interface ErrorResponse {
16
+ error: string;
17
+ message: string;
18
+ }
19
+
20
+ const INPUT_EXCERPT_LENGTH = 1024;
21
+ const UA_MAX_LENGTH = 512;
22
+
23
+ export function withApiLog(operation: Operation, handler: ApiHandler): NextApiHandler {
24
+ return async (request, response) => {
25
+ const startedAt = performance.now();
26
+ const context: ApiContext = {
27
+ ip: getClientIp(request),
28
+ ua: request.headers['user-agent']?.slice(0, UA_MAX_LENGTH),
29
+ getElapsedMs: () => Math.round(performance.now() - startedAt),
30
+ };
31
+
32
+ try {
33
+ await handler(request, response, context);
34
+ } catch (error) {
35
+ const { ip, ua } = context;
36
+ const input = getInputExcerpt(request);
37
+
38
+ if (error instanceof BadRequestError) {
39
+ logEvent({ type: 'error', level: 'warn', operation, ip, ua, message: error.message, input });
40
+ respond(response, 400, { error: 'Bad request', message: error.message });
41
+ } else {
42
+ logError(operation, error, { ip, ua, input });
43
+ respond(response, 500, {
44
+ error: 'Server error',
45
+ message: error instanceof Error ? error.message : 'Unknown error',
46
+ });
47
+ }
48
+ }
49
+ };
50
+ }
51
+
52
+ function respond(response: NextApiResponse, status: number, body: ErrorResponse): void {
53
+ if (!response.headersSent) {
54
+ response.status(status).send(body);
55
+ }
56
+ }
57
+
58
+ function getClientIp(request: NextApiRequest): string | undefined {
59
+ const forwardedFor = request.headers['x-forwarded-for'];
60
+ const header = Array.isArray(forwardedFor) ? forwardedFor.at(-1) : forwardedFor;
61
+ const ip = header?.split(',').at(-1)?.trim() || request.socket.remoteAddress;
62
+ return ip?.replace(/^::ffff:/, '');
63
+ }
64
+
65
+ function getInputExcerpt(request: NextApiRequest): string {
66
+ const input: unknown = request.method === 'GET' ? request.query : request.body;
67
+
68
+ try {
69
+ return JSON.stringify(sortFieldsBySize(input) ?? null).slice(0, INPUT_EXCERPT_LENGTH);
70
+ } catch (error) {
71
+ return `(unserializable: ${String(error)})`;
72
+ }
73
+ }
74
+
75
+ function sortFieldsBySize(input: unknown): unknown {
76
+ if (!isObject(input) || Array.isArray(input)) {
77
+ return input;
78
+ }
79
+
80
+ return Object.fromEntries(Object.entries(input).sort(([, a], [, b]) => getJsonSize(a) - getJsonSize(b)));
81
+ }
82
+
83
+ function getJsonSize(value: unknown): number {
84
+ return JSON.stringify(value ?? null).length;
85
+ }
@@ -64,7 +64,7 @@
64
64
  gap: var(--spacing--l);
65
65
  line-height: var(--button--icon--size);
66
66
 
67
- @include media('compact') {
67
+ @include media('<l') {
68
68
  gap: var(--spacing--m);
69
69
  }
70
70
  }
@@ -8,7 +8,7 @@
8
8
  display: flex;
9
9
  flex-direction: column;
10
10
 
11
- @include media('compact', 'touch') {
11
+ @include media('<l', 'touch') {
12
12
  margin-bottom: calc(3 * var(--spacing--l) + var(--button--icon--size));
13
13
  }
14
14
 
@@ -31,7 +31,7 @@
31
31
  height: 100%;
32
32
  margin: 0 auto;
33
33
 
34
- @include media('compact') {
34
+ @include media('<l') {
35
35
  grid-template-columns: 1fr;
36
36
  }
37
37
  }
@@ -54,7 +54,7 @@
54
54
  flex: 0 0 var(--solver-column--width);
55
55
  gap: var(--spacing);
56
56
 
57
- @include media('compact') {
57
+ @include media('<l') {
58
58
  display: none;
59
59
  }
60
60
  }
@@ -106,7 +106,7 @@
106
106
  width: 100%;
107
107
  max-width: var(--controls-max-width);
108
108
 
109
- @include media('desktop') {
109
+ @include media('>=l') {
110
110
  display: none;
111
111
  }
112
112
  }
@@ -3,8 +3,8 @@ import classNames from 'classnames';
3
3
  import { type FunctionComponent, memo, type SyntheticEvent, useEffect, useMemo } from 'react';
4
4
  import { useDispatch } from 'react-redux';
5
5
 
6
- import { useIsCompactLayout } from '@/hooks/useIsCompactLayout';
7
6
  import { useIsTouchDevice } from '@/hooks/useIsTouchDevice';
7
+ import { useMediaQuery } from '@/hooks/useMediaQuery';
8
8
  import {
9
9
  resultsSlice,
10
10
  selectAreResultsOutdated,
@@ -37,7 +37,7 @@ const SolverBase: FunctionComponent<Props> = ({ className, onShowResults }) => {
37
37
  const dispatch = useDispatch();
38
38
  const translate = useTranslate();
39
39
  const isTouchDevice = useIsTouchDevice();
40
- const showCompactControls = useIsCompactLayout();
40
+ const showCompactControls = useMediaQuery('<l');
41
41
  const error = useTypedSelector(selectSolveError);
42
42
  const isOutdated = useTypedSelector(selectAreResultsOutdated);
43
43
  const results = useTypedSelector(selectProcessedResults);
@@ -5,7 +5,6 @@ import { LOCALE_FEATURES } from '@/i18n/constants';
5
5
  import { selectLocale, useTypedSelector } from '@/state';
6
6
  import { ResultColumnId } from '@/types';
7
7
 
8
- import { useIsCompactLayout } from './useIsCompactLayout';
9
8
  import { useMediaQueries } from './useMediaQueries';
10
9
 
11
10
  const COLUMNS_XS = [ResultColumnId.Coordinates, ResultColumnId.Word, ResultColumnId.Points];
@@ -14,7 +13,7 @@ const COLUMNS_S = [...COLUMNS_XS, ResultColumnId.BlanksCount, ResultColumnId.Wor
14
13
 
15
14
  const COLUMNS_M = [...COLUMNS_XS];
16
15
 
17
- const COLUMNS_COMPACT = [...COLUMNS_XS];
16
+ const COLUMNS_L = [...COLUMNS_XS];
18
17
 
19
18
  const selectColumns = createSelector([selectLocale], (locale) => {
20
19
  const { consonants, vowels } = LOCALE_FEATURES[locale];
@@ -40,8 +39,7 @@ const selectColumns = createSelector([selectLocale], (locale) => {
40
39
 
41
40
  export const useColumns = (): Partial<Record<ResultColumnId, boolean>> => {
42
41
  const columns = useTypedSelector(selectColumns);
43
- const { isLessThanXs, isLessThanS, isLessThanM } = useMediaQueries();
44
- const isCompactLayout = useIsCompactLayout();
42
+ const { isLessThanXs, isLessThanS, isLessThanM, isLessThanL } = useMediaQueries();
45
43
 
46
44
  const filteredColumns = useMemo(() => {
47
45
  if (isLessThanXs) {
@@ -56,12 +54,12 @@ export const useColumns = (): Partial<Record<ResultColumnId, boolean>> => {
56
54
  return columns.filter((columnId) => COLUMNS_M.includes(columnId));
57
55
  }
58
56
 
59
- if (isCompactLayout) {
60
- return columns.filter((columnId) => COLUMNS_COMPACT.includes(columnId));
57
+ if (isLessThanL) {
58
+ return columns.filter((columnId) => COLUMNS_L.includes(columnId));
61
59
  }
62
60
 
63
61
  return columns;
64
- }, [columns, isLessThanXs, isLessThanS, isLessThanM, isCompactLayout]);
62
+ }, [columns, isLessThanXs, isLessThanS, isLessThanM, isLessThanL]);
65
63
 
66
64
  const columnsMap = useMemo(() => {
67
65
  return Object.fromEntries(filteredColumns.map((column) => [column, true]));
@@ -6,8 +6,8 @@ import { Button } from '@/components/Button';
6
6
  import { Dictionary } from '@/components/Dictionary';
7
7
  import { Modal } from '@/components/Modal';
8
8
  import { Results } from '@/components/Results';
9
- import { useIsCompactLayout } from '@/hooks/useIsCompactLayout';
10
9
  import { useIsTouchDevice } from '@/hooks/useIsTouchDevice';
10
+ import { useMediaQuery } from '@/hooks/useMediaQuery';
11
11
  import Check from '@/icons/Check.svg';
12
12
  import EyeFill from '@/icons/EyeFill.svg';
13
13
  import {
@@ -29,7 +29,7 @@ interface Props {
29
29
  const ResultsModalBase: FunctionComponent<Props> = ({ className, isOpen, onClose }) => {
30
30
  const dispatch = useDispatch();
31
31
  const translate = useTranslate();
32
- const showResultsInModal = useIsCompactLayout();
32
+ const showResultsInModal = useMediaQuery('<l');
33
33
  const previewsOnRepeatedClick = useIsTouchDevice();
34
34
  const resultCandidate = useTypedSelector(selectResultCandidate);
35
35
  const highlightedIndex = useTypedSelector(selectResultCandidateIndex);
@@ -18,7 +18,7 @@
18
18
  border: var(--border);
19
19
  border-radius: var(--border--radius);
20
20
 
21
- @include media('desktop') {
21
+ @include media('>=l') {
22
22
  display: none;
23
23
  }
24
24
  }
@@ -4,7 +4,7 @@ import { useDispatch } from 'react-redux';
4
4
  import { Button } from '@/components/Button';
5
5
  import { Dictionary } from '@/components/Dictionary';
6
6
  import { Modal } from '@/components/Modal';
7
- import { useIsCompactLayout } from '@/hooks/useIsCompactLayout';
7
+ import { useMediaQuery } from '@/hooks/useMediaQuery';
8
8
  import EyeFill from '@/icons/EyeFill.svg';
9
9
  import { hoveredWordSlice, selectHoveredWord, useTranslate, useTypedSelector } from '@/state';
10
10
 
@@ -22,7 +22,7 @@ const WordsModalBase: FunctionComponent<Props> = ({ className, isOpen, onClose,
22
22
  const dispatch = useDispatch();
23
23
  const translate = useTranslate();
24
24
  const hoveredWord = useTypedSelector(selectHoveredWord);
25
- const showsPreviewButton = useIsCompactLayout();
25
+ const showsPreviewButton = useMediaQuery('<l');
26
26
  const keepsHighlightOnClose = useRef(false);
27
27
 
28
28
  const handlePreview = useCallback(() => {
@@ -6,8 +6,8 @@ import { List } from 'react-window';
6
6
 
7
7
  import { EmptyState } from '@/components/EmptyState';
8
8
  import { Header, HeaderButton, Search } from '@/components/Table';
9
- import { useIsCompactLayout } from '@/hooks/useIsCompactLayout';
10
9
  import { useIsTouchDevice } from '@/hooks/useIsTouchDevice';
10
+ import { useMediaQuery } from '@/hooks/useMediaQuery';
11
11
  import { LOCALE_FEATURES } from '@/i18n/constants';
12
12
  import GeoAlt from '@/icons/GeoAlt.svg';
13
13
  import QuestionSquare from '@/icons/QuestionSquare.svg';
@@ -38,7 +38,7 @@ export const WordsTable: FunctionComponent<Props> = ({ className, isOpen, onPrev
38
38
  const dispatch = useDispatch();
39
39
  const translate = useTranslate();
40
40
  const isTouchDevice = useIsTouchDevice();
41
- const isCompactLayout = useIsCompactLayout();
41
+ const isCompactLayout = useMediaQuery('<l');
42
42
  const highlightFollowsPointer = !isTouchDevice && !isCompactLayout;
43
43
  const locale = useTypedSelector(selectLocale);
44
44
  const { direction } = LOCALE_FEATURES[locale];
@@ -1,12 +1,12 @@
1
1
  import { games } from '@scrabble-solver/configs';
2
2
  import { COMMA_ARABIC, COMMA_LATIN } from '@scrabble-solver/constants';
3
3
  import { dictionaries } from '@scrabble-solver/dictionaries';
4
- import { logger } from '@scrabble-solver/logger';
4
+ import { logEvent } from '@scrabble-solver/logger';
5
5
  import { type Locale, isLocale } from '@scrabble-solver/types';
6
6
  import { getWordDefinition } from '@scrabble-solver/word-definitions';
7
7
  import { type NextApiRequest, type NextApiResponse } from 'next';
8
8
 
9
- import { getServerLoggingData } from '@/api';
9
+ import { type ApiContext, BadRequestError, withApiLog } from '@/api';
10
10
 
11
11
  interface RequestData {
12
12
  locale: Locale;
@@ -16,39 +16,33 @@ interface RequestData {
16
16
  const MAXIMUM_COLLISIONS_COUNT = Object.values(games).reduce((result, game) => Math.max(result, game.rackSize), 0);
17
17
  const MAXIMUM_WORDS_COUNT = MAXIMUM_COLLISIONS_COUNT + 1;
18
18
 
19
- const dictionary = async (request: NextApiRequest, response: NextApiResponse): Promise<void> => {
20
- const meta = getServerLoggingData(request);
19
+ export default withApiLog('definition', dictionary);
21
20
 
22
- try {
23
- const { locale, words } = parseRequest(request);
21
+ async function dictionary(request: NextApiRequest, response: NextApiResponse, { ip, getElapsedMs }: ApiContext) {
22
+ const { locale, words } = parseRequest(request);
23
+ const gaddag = await dictionaries.get(locale);
24
+ const results = await Promise.all(words.map((word) => getWordDefinition(locale, word, gaddag.has(word))));
25
+ response.status(200).send(results.map((result) => result.toJson()));
24
26
 
25
- logger.info('dictionary - request', {
26
- meta,
27
- payload: {
28
- locale,
29
- words,
30
- },
31
- });
32
-
33
- const gaddag = await dictionaries.get(locale);
34
- const results = await Promise.all(words.map((word) => getWordDefinition(locale, word, gaddag.has(word))));
35
- response.status(200).send(results.map((result) => result.toJson()));
36
- } catch (error) {
37
- const message = error instanceof Error ? error.message : 'Unknown error';
38
- logger.error('dictionary - error', { error, meta });
39
- response.status(500).send({ error: 'Server error', message });
40
- }
41
- };
27
+ logEvent({
28
+ type: 'definition',
29
+ ip,
30
+ ms: getElapsedMs(),
31
+ locale,
32
+ words: words.join(','),
33
+ found: results.filter((result) => result.definitions.length > 0).length,
34
+ });
35
+ }
42
36
 
43
- const parseRequest = (request: NextApiRequest): RequestData => {
37
+ function parseRequest(request: NextApiRequest): RequestData {
44
38
  const { locale, word } = request.query;
45
39
 
46
40
  if (!isLocale(locale)) {
47
- throw new Error('Invalid "locale" parameter');
41
+ throw new BadRequestError('Invalid "locale" parameter');
48
42
  }
49
43
 
50
44
  if (typeof word !== 'string' || word.length === 0) {
51
- throw new Error('Invalid "word" parameter');
45
+ throw new BadRequestError('Invalid "word" parameter');
52
46
  }
53
47
 
54
48
  const words = Array.from(
@@ -62,13 +56,11 @@ const parseRequest = (request: NextApiRequest): RequestData => {
62
56
  );
63
57
 
64
58
  if (words.length > MAXIMUM_WORDS_COUNT) {
65
- throw new Error('Invalid "word" parameter');
59
+ throw new BadRequestError('Invalid "word" parameter');
66
60
  }
67
61
 
68
62
  return {
69
63
  locale,
70
64
  words,
71
65
  };
72
- };
73
-
74
- export default dictionary;
66
+ }
@@ -1,65 +1,61 @@
1
1
  import { type Gaddag } from '@kamilmielnik/gaddag';
2
2
  import { dictionaries } from '@scrabble-solver/dictionaries';
3
- import { logger } from '@scrabble-solver/logger';
3
+ import { logEvent } from '@scrabble-solver/logger';
4
4
  import { isLocale, type Locale } from '@scrabble-solver/types';
5
5
  import { type NextApiRequest, type NextApiResponse } from 'next';
6
6
  import { promisify } from 'util';
7
7
  import { gzip } from 'zlib';
8
8
 
9
- import { getServerLoggingData } from '@/api';
9
+ import { type ApiContext, BadRequestError, withApiLog } from '@/api';
10
10
 
11
11
  interface RequestData {
12
12
  locale: Locale;
13
13
  }
14
14
 
15
- const dictionary = async (request: NextApiRequest, response: NextApiResponse): Promise<void> => {
16
- const meta = getServerLoggingData(request);
15
+ export default withApiLog('download', dictionary);
17
16
 
18
- try {
19
- const { locale } = parseRequest(request);
17
+ async function dictionary(request: NextApiRequest, response: NextApiResponse, { ip, getElapsedMs }: ApiContext) {
18
+ const { locale } = parseRequest(request);
19
+ const gaddag = await dictionaries.get(locale);
20
+ const encoding = acceptsGzip(request) ? 'gzip' : 'identity';
21
+ const body = encoding === 'gzip' ? await getCompressedDictionary(gaddag) : getSerializedDictionary(gaddag);
20
22
 
21
- logger.info('dictionary - request', {
22
- meta,
23
- payload: {
24
- locale,
25
- },
26
- });
23
+ // no-cache makes clients store the payload but revalidate it, so unchanged
24
+ // re-downloads become 304s, answered by Next's built-in ETag handling.
25
+ response.setHeader('Cache-Control', 'no-cache');
26
+ response.setHeader('Content-Type', 'application/octet-stream');
27
+ response.setHeader('Vary', 'Accept-Encoding');
27
28
 
28
- const gaddag = await dictionaries.get(locale);
29
- const isGzip = acceptsGzip(request);
30
- const body = isGzip ? await getCompressedDictionary(gaddag) : getSerializedDictionary(gaddag);
29
+ if (encoding === 'gzip') {
30
+ response.setHeader('Content-Encoding', 'gzip');
31
+ }
31
32
 
32
- // no-cache makes clients store the payload but revalidate it, so unchanged
33
- // re-downloads become 304s, answered by Next's built-in ETag handling.
34
- response.setHeader('Cache-Control', 'no-cache');
35
- response.setHeader('Content-Type', 'application/octet-stream');
36
- response.setHeader('Vary', 'Accept-Encoding');
33
+ response.status(200).send(body);
37
34
 
38
- if (isGzip) {
39
- response.setHeader('Content-Encoding', 'gzip');
40
- }
41
-
42
- response.status(200).send(body);
43
- } catch (error) {
44
- const message = error instanceof Error ? error.message : 'Unknown error';
45
- logger.error('dictionary - error', { error, meta });
46
- response.status(500).send({ error: 'Server error', message });
47
- }
48
- };
35
+ logEvent({
36
+ type: 'download',
37
+ ip,
38
+ ms: getElapsedMs(),
39
+ locale,
40
+ status: response.statusCode,
41
+ encoding,
42
+ bytes: response.statusCode === 304 ? 0 : body.byteLength,
43
+ });
44
+ }
49
45
 
50
- const parseRequest = (request: NextApiRequest): RequestData => {
46
+ function parseRequest(request: NextApiRequest): RequestData {
51
47
  const { locale } = request.query;
52
48
 
53
49
  if (!isLocale(locale)) {
54
- throw new Error('Invalid "locale" parameter');
50
+ throw new BadRequestError('Invalid "locale" parameter');
55
51
  }
56
52
 
57
53
  return {
58
54
  locale,
59
55
  };
60
- };
56
+ }
61
57
 
62
- const acceptsGzip = (request: NextApiRequest): boolean => {
58
+ function acceptsGzip(request: NextApiRequest): boolean {
63
59
  const acceptEncoding = request.headers['accept-encoding'];
64
60
  const header = Array.isArray(acceptEncoding) ? acceptEncoding.join(',') : acceptEncoding;
65
61
 
@@ -86,14 +82,14 @@ const acceptsGzip = (request: NextApiRequest): boolean => {
86
82
  }
87
83
 
88
84
  return wildcardAccepts;
89
- };
85
+ }
90
86
 
91
87
  // Dictionaries rebuild at most daily, so each response variant is produced
92
88
  // once and lives exactly as long as the in-memory dictionary it was made from.
93
89
  const serializedCache = new WeakMap<Gaddag, Buffer>();
94
90
  const compressedCache = new WeakMap<Gaddag, Promise<Buffer>>();
95
91
 
96
- const getSerializedDictionary = (gaddag: Gaddag): Buffer => {
92
+ function getSerializedDictionary(gaddag: Gaddag): Buffer {
97
93
  let serialized = serializedCache.get(gaddag);
98
94
 
99
95
  if (!serialized) {
@@ -104,11 +100,11 @@ const getSerializedDictionary = (gaddag: Gaddag): Buffer => {
104
100
  }
105
101
 
106
102
  return serialized;
107
- };
103
+ }
108
104
 
109
105
  const gzipAsync = promisify(gzip);
110
106
 
111
- const getCompressedDictionary = (gaddag: Gaddag): Promise<Buffer> => {
107
+ function getCompressedDictionary(gaddag: Gaddag): Promise<Buffer> {
112
108
  let compressed = compressedCache.get(gaddag);
113
109
 
114
110
  if (!compressed) {
@@ -118,12 +114,10 @@ const getCompressedDictionary = (gaddag: Gaddag): Promise<Buffer> => {
118
114
  }
119
115
 
120
116
  return compressed;
121
- };
117
+ }
122
118
 
123
119
  export const config = {
124
120
  api: {
125
121
  responseLimit: '50mb',
126
122
  },
127
123
  };
128
-
129
- export default dictionary;