@scrabble-solver/scrabble-solver 2.18.2 → 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.
- package/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +15 -15
- package/.next/cache/.previewinfo +1 -1
- package/.next/cache/.rscinfo +1 -1
- package/.next/cache/.tsbuildinfo +1 -1
- package/.next/cache/webpack/client-production/0.pack +0 -0
- package/.next/cache/webpack/client-production/index.pack +0 -0
- package/.next/cache/webpack/client-production/index.pack.old +0 -0
- package/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/.next/cache/webpack/edge-server-production/index.pack.old +0 -0
- package/.next/cache/webpack/server-production/0.pack +0 -0
- package/.next/cache/webpack/server-production/index.pack +0 -0
- package/.next/cache/webpack/server-production/index.pack.old +0 -0
- package/.next/diagnostics/framework.json +1 -1
- package/.next/dynamic-css-manifest.json +1 -1
- package/.next/prerender-manifest.json +4 -4
- package/.next/react-loadable-manifest.json +6 -6
- package/.next/routes-manifest.json +1 -1
- package/.next/server/chunks/546.js +1 -1
- package/.next/server/chunks/612.js +5 -0
- package/.next/server/chunks/67.js +1 -1
- package/.next/server/chunks/712.js +1 -1
- package/.next/server/chunks/715.js +1 -0
- package/.next/server/chunks/812.js +1 -1
- package/.next/server/chunks/961.js +1 -1
- package/.next/server/dynamic-css-manifest.js +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/404.html +3 -3
- package/.next/server/pages/500.html +3 -3
- package/.next/server/pages/_app.js.nft.json +1 -1
- package/.next/server/pages/_document.js +2 -2
- package/.next/server/pages/_document.js.nft.json +1 -1
- package/.next/server/pages/api/dictionary/[locale]/[word].js +2 -2
- package/.next/server/pages/api/dictionary/[locale]/[word].js.nft.json +1 -1
- package/.next/server/pages/api/dictionary/[locale].js +1 -1
- package/.next/server/pages/api/dictionary/[locale].js.nft.json +1 -1
- package/.next/server/pages/api/solve.js +1 -1
- package/.next/server/pages/api/solve.js.nft.json +1 -1
- package/.next/server/pages/api/verify.js +1 -1
- package/.next/server/pages/api/verify.js.nft.json +1 -1
- package/.next/server/pages/api/visit.js +1 -1
- package/.next/server/pages/api/visit.js.nft.json +1 -1
- package/.next/server/pages/index.html +3 -3
- package/.next/server/pages/index.js +1 -1
- package/.next/server/pages/index.js.nft.json +1 -1
- package/.next/server/pages/index.json +1 -1
- package/.next/server/pages/not-found.html +3 -3
- package/.next/server/pages/not-found.js.nft.json +1 -1
- package/.next/static/{nvGZh1gPuX98L07YmxlYD → TAqjh9l5VJQOfJ4SzgPk2}/_buildManifest.js +1 -1
- package/.next/static/chunks/101-f341f70c10d71212.js +3 -0
- package/.next/static/chunks/256.6a20b23a766cfebd.js +1 -0
- package/.next/static/chunks/693.d295da7c7b873986.js +1 -0
- package/.next/static/chunks/980.dd35e807b18063c1.js +1 -0
- package/.next/static/chunks/{main-7e52da35a4beea0b.js → main-6e9ac37e28d2b899.js} +1 -1
- package/.next/static/chunks/pages/_app-9340d51f5a6787fc.js +1 -0
- package/.next/static/chunks/pages/index-ea21d96a88f25000.js +1 -0
- package/.next/static/chunks/{webpack-9f5c9d275348829b.js → webpack-d73aa4aae49cd800.js} +1 -1
- package/.next/static/css/{92f85ce358a0a468.css → 4dbcacc1122ac6b9.css} +1 -1
- package/.next/static/css/8b8087461767360a.css +1 -0
- package/.next/static/css/{65bcc9440e067113.css → 98051ed25076b896.css} +1 -1
- package/.next/trace +22 -21
- package/.next/trace-build +1 -1
- package/package.json +11 -11
- package/public/llms.txt +1 -1
- package/src/api/BadRequestError.ts +6 -0
- package/src/api/index.ts +2 -1
- package/src/api/withApiLog.ts +85 -0
- package/src/components/Button/Button.module.scss +1 -1
- package/src/components/Solver/Solver.module.scss +4 -4
- package/src/components/Solver/Solver.tsx +2 -2
- package/src/hooks/useColumns.ts +5 -7
- package/src/modals/ResultsModal/ResultsModal.tsx +2 -2
- package/src/modals/WordsModal/WordsModal.module.scss +1 -1
- package/src/modals/WordsModal/WordsModal.tsx +2 -2
- package/src/modals/WordsModal/components/WordsTable/WordsTable.tsx +2 -2
- package/src/pages/api/dictionary/[locale]/[word].ts +22 -30
- package/src/pages/api/dictionary/[locale]/index.ts +36 -42
- package/src/pages/api/solve.ts +32 -42
- package/src/pages/api/verify.ts +35 -43
- package/src/pages/api/visit.ts +38 -13
- package/src/pages/index.tsx +3 -3
- package/src/parameters/index.ts +0 -2
- package/src/sdk/solve.ts +3 -9
- package/src/sdk/verify.ts +2 -10
- package/src/sdk/visit.ts +8 -3
- package/src/state/sagas.ts +7 -1
- package/src/styles/_tokens.scss +0 -2
- package/src/styles/mixins.scss +0 -5
- package/src/styles/variables.module.scss +0 -1
- package/src/styles/variables.module.scss.d.ts +0 -1
- package/src/styles/variables.scss +1 -1
- package/src/types/api.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/.next/server/chunks/392.js +0 -1
- package/.next/static/chunks/101-829ab8a8d310e7a0.js +0 -3
- package/.next/static/chunks/256.66ff9fb6d7db9ec7.js +0 -1
- package/.next/static/chunks/693.677c88e0eb1340b5.js +0 -1
- package/.next/static/chunks/980.7ce17464fc56648a.js +0 -1
- package/.next/static/chunks/pages/_app-f9ec81278ffa7b1a.js +0 -1
- package/.next/static/chunks/pages/index-b5bf018100015277.js +0 -1
- package/.next/static/css/a691348b788f8bc0.css +0 -1
- package/src/api/getServerLoggingData.ts +0 -17
- package/src/hooks/useIsCompactLayout.ts +0 -14
- /package/.next/static/{nvGZh1gPuX98L07YmxlYD → TAqjh9l5VJQOfJ4SzgPk2}/_ssgManifest.js +0 -0
package/.next/trace-build
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
[{"name":"run-typescript","duration":
|
|
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
|
+
"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.
|
|
61
|
-
"@scrabble-solver/constants": "^2.18.
|
|
62
|
-
"@scrabble-solver/dictionaries": "^2.18.
|
|
63
|
-
"@scrabble-solver/logger": "^2.18.
|
|
64
|
-
"@scrabble-solver/solver": "^2.18.
|
|
65
|
-
"@scrabble-solver/types": "^2.18.
|
|
66
|
-
"@scrabble-solver/word-definitions": "^2.18.
|
|
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.
|
|
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.
|
|
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": "
|
|
99
|
+
"gitHead": "ff030ba9e63eba57579e45d6698bfb81d88c8bd5"
|
|
100
100
|
}
|
package/public/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Scrabble Solver
|
|
2
2
|
|
|
3
|
-
> Free and open-source analysis tool for Scrabble, Super Scrabble, Scrabble Duel, Letter League, Crossplay, Literaki, and
|
|
3
|
+
> Free and open-source analysis tool for Scrabble, Super Scrabble, Scrabble Duel, Letter League, Crossplay, Literaki, Kelimelik, and Wyrazy. Quickly finds top-scoring words for a given board and rack in English, French, German, Persian, Polish, Romanian, Spanish, and Turkish.
|
|
4
4
|
|
|
5
5
|
Scrabble Solver is a single-page web app. Enter the board state and your tiles to get every legal move ranked by score, check whether a word is allowed in the selected language's dictionary, and look up word definitions. Once a dictionary is cached, solving also works offline.
|
|
6
6
|
|
package/src/api/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export {
|
|
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
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
display: flex;
|
|
9
9
|
flex-direction: column;
|
|
10
10
|
|
|
11
|
-
@include media('
|
|
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('
|
|
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('
|
|
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('
|
|
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 =
|
|
40
|
+
const showCompactControls = useMediaQuery('<l');
|
|
41
41
|
const error = useTypedSelector(selectSolveError);
|
|
42
42
|
const isOutdated = useTypedSelector(selectAreResultsOutdated);
|
|
43
43
|
const results = useTypedSelector(selectProcessedResults);
|
package/src/hooks/useColumns.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
60
|
-
return columns.filter((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,
|
|
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 =
|
|
32
|
+
const showResultsInModal = useMediaQuery('<l');
|
|
33
33
|
const previewsOnRepeatedClick = useIsTouchDevice();
|
|
34
34
|
const resultCandidate = useTypedSelector(selectResultCandidate);
|
|
35
35
|
const highlightedIndex = useTypedSelector(selectResultCandidateIndex);
|
|
@@ -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 {
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
20
|
-
const meta = getServerLoggingData(request);
|
|
19
|
+
export default withApiLog('definition', dictionary);
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
+
function parseRequest(request: NextApiRequest): RequestData {
|
|
44
38
|
const { locale, word } = request.query;
|
|
45
39
|
|
|
46
40
|
if (!isLocale(locale)) {
|
|
47
|
-
throw new
|
|
41
|
+
throw new BadRequestError('Invalid "locale" parameter');
|
|
48
42
|
}
|
|
49
43
|
|
|
50
44
|
if (typeof word !== 'string' || word.length === 0) {
|
|
51
|
-
throw new
|
|
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
|
|
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 {
|
|
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 {
|
|
9
|
+
import { type ApiContext, BadRequestError, withApiLog } from '@/api';
|
|
10
10
|
|
|
11
11
|
interface RequestData {
|
|
12
12
|
locale: Locale;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
const meta = getServerLoggingData(request);
|
|
15
|
+
export default withApiLog('download', dictionary);
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
if (encoding === 'gzip') {
|
|
30
|
+
response.setHeader('Content-Encoding', 'gzip');
|
|
31
|
+
}
|
|
31
32
|
|
|
32
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
46
|
+
function parseRequest(request: NextApiRequest): RequestData {
|
|
51
47
|
const { locale } = request.query;
|
|
52
48
|
|
|
53
49
|
if (!isLocale(locale)) {
|
|
54
|
-
throw new
|
|
50
|
+
throw new BadRequestError('Invalid "locale" parameter');
|
|
55
51
|
}
|
|
56
52
|
|
|
57
53
|
return {
|
|
58
54
|
locale,
|
|
59
55
|
};
|
|
60
|
-
}
|
|
56
|
+
}
|
|
61
57
|
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|