@scrabble-solver/scrabble-solver 2.18.0 → 2.18.2

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 (203) 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 +2 -2
  16. package/.next/prerender-manifest.json +4 -4
  17. package/.next/react-loadable-manifest.json +37 -37
  18. package/.next/routes-manifest.json +1 -1
  19. package/.next/server/chunks/309.js +1 -1
  20. package/.next/server/chunks/344.js +1 -1
  21. package/.next/server/chunks/371.js +1 -1
  22. package/.next/server/chunks/392.js +1 -1
  23. package/.next/server/chunks/465.js +1 -1
  24. package/.next/server/chunks/546.js +1 -1
  25. package/.next/server/chunks/641.js +1 -1
  26. package/.next/server/chunks/67.js +1 -0
  27. package/.next/server/chunks/712.js +1 -1
  28. package/.next/server/chunks/846.js +1 -1
  29. package/.next/server/chunks/886.js +1 -1
  30. package/.next/server/chunks/942.js +1 -0
  31. package/.next/server/chunks/953.js +1 -1
  32. package/.next/server/chunks/961.js +1 -1
  33. package/.next/server/dynamic-css-manifest.js +1 -1
  34. package/.next/server/middleware-build-manifest.js +1 -1
  35. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  36. package/.next/server/pages/404.html +3 -2
  37. package/.next/server/pages/500.html +3 -2
  38. package/.next/server/pages/_app.js +1 -1
  39. package/.next/server/pages/_app.js.nft.json +1 -1
  40. package/.next/server/pages/_document.js +2 -2
  41. package/.next/server/pages/_document.js.nft.json +1 -1
  42. package/.next/server/pages/_error.js +2 -2
  43. package/.next/server/pages/_error.js.nft.json +1 -1
  44. package/.next/server/pages/api/dictionary/[locale]/[word].js +1 -1
  45. package/.next/server/pages/api/dictionary/[locale]/[word].js.nft.json +1 -1
  46. package/.next/server/pages/api/dictionary/[locale].js +1 -1
  47. package/.next/server/pages/api/dictionary/[locale].js.nft.json +1 -1
  48. package/.next/server/pages/api/solve.js +1 -1
  49. package/.next/server/pages/api/solve.js.nft.json +1 -1
  50. package/.next/server/pages/api/verify.js +1 -1
  51. package/.next/server/pages/api/verify.js.nft.json +1 -1
  52. package/.next/server/pages/index.html +3 -2
  53. package/.next/server/pages/index.js +4 -4
  54. package/.next/server/pages/index.js.nft.json +1 -1
  55. package/.next/server/pages/index.json +1 -1
  56. package/.next/server/pages/not-found.html +3 -2
  57. package/.next/server/pages/not-found.js.nft.json +1 -1
  58. package/.next/static/chunks/100.305bdf9c75b1758f.js +1 -0
  59. package/.next/static/chunks/256.66ff9fb6d7db9ec7.js +1 -0
  60. package/.next/static/chunks/{307.67f52f13e3d60d4e.js → 307.89e6100f58443679.js} +1 -1
  61. package/.next/static/chunks/{363.01eed3233f92b6a4.js → 363.cec65b1f9668a75e.js} +1 -1
  62. package/.next/static/chunks/{422.1559ae6756912ca5.js → 422.3e55b4c94e328701.js} +1 -1
  63. package/.next/static/chunks/{663.d2c9ca36b26695b8.js → 663.bd2c45413fbca518.js} +1 -1
  64. package/.next/static/chunks/693.677c88e0eb1340b5.js +1 -0
  65. package/.next/static/chunks/{820.97b82f751ca49579.js → 820.823e9e885ebb66af.js} +1 -1
  66. package/.next/static/chunks/827.c81b92b383a748c9.js +1 -0
  67. package/.next/static/chunks/{865.a9b563a45848a83c.js → 865.59baba8296c9b116.js} +1 -1
  68. package/.next/static/chunks/912.077603e4f0bc6cca.js +1 -0
  69. package/.next/static/chunks/{939.19b33a3d3c351d73.js → 939.2ad86b50405c7897.js} +1 -1
  70. package/.next/static/chunks/980.7ce17464fc56648a.js +1 -0
  71. package/.next/static/chunks/{main-b92e08f3fc96dd8d.js → main-7e52da35a4beea0b.js} +1 -1
  72. package/.next/static/chunks/pages/_app-f9ec81278ffa7b1a.js +1 -0
  73. package/.next/static/chunks/pages/index-b5bf018100015277.js +1 -0
  74. package/.next/static/chunks/pages/not-found-a59f9deeeff63d17.js +1 -0
  75. package/.next/static/chunks/webpack-9f5c9d275348829b.js +1 -0
  76. package/.next/static/css/65bcc9440e067113.css +1 -0
  77. package/.next/static/css/{abc2425d3ba607bc.css → 92f85ce358a0a468.css} +2 -1
  78. package/.next/static/css/a691348b788f8bc0.css +1 -0
  79. package/.next/static/css/cd4d7f2458487399.css +1 -0
  80. package/.next/static/{FL82obwzFHRzvV6z76I-U → nvGZh1gPuX98L07YmxlYD}/_buildManifest.js +1 -1
  81. package/.next/trace +21 -21
  82. package/.next/trace-build +1 -1
  83. package/AGENTS.md +9 -0
  84. package/CLAUDE.md +1 -0
  85. package/package.json +23 -14
  86. package/public/robots.txt +1 -0
  87. package/public/site.webmanifest +14 -0
  88. package/src/components/Board/Board.tsx +3 -1
  89. package/src/components/Board/BoardPure.tsx +26 -20
  90. package/src/components/Board/components/Cell/Cell.tsx +4 -4
  91. package/src/components/Board/lib.test.ts +106 -0
  92. package/src/components/Board/lib.ts +34 -3
  93. package/src/components/Board/selectors.ts +17 -5
  94. package/src/components/Button/Button.module.scss +1 -1
  95. package/src/components/Modal/Modal.module.scss +0 -4
  96. package/src/components/Results/Header.tsx +30 -5
  97. package/src/components/Results/Result.tsx +45 -55
  98. package/src/components/Results/Results.module.scss +0 -126
  99. package/src/components/Results/Results.module.scss.d.ts +0 -10
  100. package/src/components/Results/Results.tsx +25 -5
  101. package/src/components/Solver/Solver.module.scss +4 -4
  102. package/src/components/Solver/Solver.tsx +5 -3
  103. package/src/components/Solver/components/ResultCandidatePicker/ResultCandidatePicker.module.scss +5 -1
  104. package/src/components/Solver/components/ResultCandidatePicker/ResultCandidatePicker.module.scss.d.ts +1 -0
  105. package/src/components/Solver/components/ResultCandidatePicker/ResultCandidatePicker.tsx +5 -2
  106. package/src/components/Table/Cell.tsx +42 -0
  107. package/src/components/Table/Header.tsx +11 -0
  108. package/src/components/{Results → Table}/HeaderButton.tsx +22 -14
  109. package/src/components/Table/Row.tsx +59 -0
  110. package/src/components/{ResultsInput/ResultsInput.tsx → Table/Search.tsx} +9 -11
  111. package/src/components/Table/Table.module.scss +147 -0
  112. package/src/components/Table/Table.module.scss.d.ts +19 -0
  113. package/src/components/Table/index.ts +5 -0
  114. package/src/components/Tile/Tile.module.scss +2 -1
  115. package/src/hooks/useColumns.ts +7 -5
  116. package/src/hooks/useIsCompactLayout.ts +14 -0
  117. package/src/i18n/languages/english.json +6 -1
  118. package/src/i18n/languages/french.json +6 -1
  119. package/src/i18n/languages/german.json +6 -1
  120. package/src/i18n/languages/persian.json +6 -1
  121. package/src/i18n/languages/polish.json +6 -1
  122. package/src/i18n/languages/romanian.json +6 -1
  123. package/src/i18n/languages/spanish.json +6 -1
  124. package/src/i18n/languages/turkish.json +6 -1
  125. package/src/icons/QuestionSquare.svg +5 -0
  126. package/src/lib/createCoordinatesComparator.ts +23 -0
  127. package/src/lib/createKeyComparator.ts +1 -4
  128. package/src/lib/createSortComparator.ts +22 -0
  129. package/src/lib/getCoordinates.test.ts +21 -0
  130. package/src/lib/getCoordinates.ts +16 -0
  131. package/src/lib/getNextSort.ts +12 -0
  132. package/src/lib/waitForIdleOrFirstIntent.ts +19 -8
  133. package/src/modals/RemainingTilesModal/RemainingTilesModal.tsx +3 -1
  134. package/src/modals/RemainingTilesModal/components/Character/Character.tsx +8 -9
  135. package/src/modals/ResultsModal/ResultsModal.tsx +14 -10
  136. package/src/modals/WordsModal/WordsModal.module.scss +16 -27
  137. package/src/modals/WordsModal/WordsModal.module.scss.d.ts +3 -6
  138. package/src/modals/WordsModal/WordsModal.tsx +56 -42
  139. package/src/modals/WordsModal/components/WordsTable/WordRow.tsx +92 -0
  140. package/src/modals/WordsModal/components/WordsTable/WordsTable.module.scss +69 -0
  141. package/src/modals/WordsModal/components/WordsTable/WordsTable.module.scss.d.ts +16 -0
  142. package/src/modals/WordsModal/components/WordsTable/WordsTable.tsx +137 -0
  143. package/src/modals/WordsModal/components/WordsTable/index.ts +1 -0
  144. package/src/modals/WordsModal/components/index.ts +1 -0
  145. package/src/pages/_app.tsx +40 -76
  146. package/src/pages/_document.tsx +21 -19
  147. package/src/pages/api/verify.ts +7 -6
  148. package/src/pages/index.tsx +58 -6
  149. package/src/pages/not-found.tsx +13 -2
  150. package/src/parameters/index.ts +9 -0
  151. package/src/sdk/verify.ts +3 -2
  152. package/src/solver-worker/client.test.ts +10 -3
  153. package/src/solver-worker/index.worker.ts +7 -7
  154. package/src/solver-worker/messages.ts +3 -3
  155. package/src/state/hoveredWord/index.ts +4 -0
  156. package/src/state/hoveredWord/initialState.ts +5 -0
  157. package/src/state/hoveredWord/selectors.ts +5 -0
  158. package/src/state/hoveredWord/slice.ts +18 -0
  159. package/src/state/hoveredWord/types.ts +5 -0
  160. package/src/state/index.ts +1 -0
  161. package/src/state/localStorage.ts +13 -2
  162. package/src/state/results/lib.ts +9 -26
  163. package/src/state/results/selectors.ts +9 -1
  164. package/src/state/results/slice.ts +4 -16
  165. package/src/state/sagas.ts +30 -5
  166. package/src/state/selectors.ts +18 -1
  167. package/src/state/store.ts +2 -0
  168. package/src/state/types.ts +2 -0
  169. package/src/state/verify/initialState.ts +7 -0
  170. package/src/state/verify/lib.test.ts +55 -0
  171. package/src/state/verify/lib.ts +49 -0
  172. package/src/state/verify/selectors.ts +37 -0
  173. package/src/state/verify/slice.ts +13 -2
  174. package/src/state/verify/types.ts +6 -2
  175. package/src/styles/_tokens.scss +2 -0
  176. package/src/styles/mixins.scss +5 -0
  177. package/src/styles/variables.module.scss +1 -0
  178. package/src/styles/variables.module.scss.d.ts +1 -0
  179. package/src/styles/variables.scss +1 -1
  180. package/src/types/index.ts +24 -11
  181. package/tsconfig.tsbuildinfo +1 -1
  182. package/.next/server/chunks/316.js +0 -1
  183. package/.next/server/chunks/916.js +0 -1
  184. package/.next/static/chunks/106.e414e88694add27f.js +0 -1
  185. package/.next/static/chunks/127.125d896a19061d71.js +0 -1
  186. package/.next/static/chunks/18.8b91e137d239e15c.js +0 -1
  187. package/.next/static/chunks/583.7a46807be14c377e.js +0 -1
  188. package/.next/static/chunks/711.ba7b8f900dfd40d0.js +0 -1
  189. package/.next/static/chunks/912.ff8037ba9d4db5c8.js +0 -1
  190. package/.next/static/chunks/pages/_app-ba5d628e81a15461.js +0 -1
  191. package/.next/static/chunks/pages/index-5f9fa6ac3cd3d97b.js +0 -1
  192. package/.next/static/chunks/pages/not-found-23d61fd3ed791cad.js +0 -1
  193. package/.next/static/chunks/webpack-e34c3ae12275e978.js +0 -1
  194. package/.next/static/css/b74cd7de82dabcdd.css +0 -1
  195. package/.next/static/css/c403ce739a9c0d83.css +0 -1
  196. package/.next/static/css/fbfdce70731d0d2b.css +0 -1
  197. package/src/components/Results/Cell.tsx +0 -32
  198. package/src/components/ResultsInput/ResultsInput.module.scss +0 -12
  199. package/src/components/ResultsInput/ResultsInput.module.scss.d.ts +0 -7
  200. package/src/components/ResultsInput/index.ts +0 -1
  201. package/src/components/SeoMessage/SeoMessage.tsx +0 -18
  202. package/src/components/SeoMessage/index.ts +0 -1
  203. /package/.next/static/{FL82obwzFHRzvV6z76I-U → nvGZh1gPuX98L07YmxlYD}/_ssgManifest.js +0 -0
@@ -16,7 +16,7 @@ export const selectResultCandidate = createSelector([selectResults], (results) =
16
16
 
17
17
  export const selectResultsQuery = createSelector([selectResults], (results) => results.query);
18
18
 
19
- const selectResultsResults = createSelector([selectResults], (results) => results.results);
19
+ export const selectResultsResults = createSelector([selectResults], (results) => results.results);
20
20
 
21
21
  export const selectResultsSort = createSelector([selectResults], (results) => results.sort);
22
22
 
@@ -44,6 +44,14 @@ export const selectProcessedResults = createSelector([selectGroupedSortedResults
44
44
  return results ? [...results.matching, ...results.other] : undefined;
45
45
  });
46
46
 
47
+ export const selectResultCandidateIndex = createSelector(
48
+ [selectProcessedResults, selectResultCandidate],
49
+ (results, candidate) => {
50
+ const index = results && candidate ? results.findIndex((result) => result.id === candidate.id) : -1;
51
+ return index === -1 ? undefined : index;
52
+ },
53
+ );
54
+
47
55
  export const selectIsResultMatching = createSelector(
48
56
  [selectProcessedResults, selectResultsQuery, selectCellFilters, selectResultIndex],
49
57
  (results, query, cellFilters, index) => {
@@ -1,14 +1,11 @@
1
1
  import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
2
2
  import { type Result } from '@scrabble-solver/types';
3
3
 
4
- import { type ResultColumnId, SortDirection } from '@/types';
4
+ import { getNextSort } from '@/lib/getNextSort';
5
+ import { type ResultColumnId } from '@/types';
5
6
 
6
7
  import { resultsInitialState } from './initialState';
7
8
 
8
- const toggleDirection = (direction: SortDirection): SortDirection => {
9
- return direction === SortDirection.Ascending ? SortDirection.Descending : SortDirection.Ascending;
10
- };
11
-
12
9
  export const resultsSlice = createSlice({
13
10
  initialState: resultsInitialState,
14
11
  name: 'results',
@@ -24,7 +21,7 @@ export const resultsSlice = createSlice({
24
21
 
25
22
  changeResultCandidate: (state, action: PayloadAction<Result | null>) => {
26
23
  const candidate = action.payload;
27
- return { ...state, candidate };
24
+ return candidate === state.candidate ? state : { ...state, candidate };
28
25
  },
29
26
 
30
27
  changeResults: (state, action: PayloadAction<Result[]>) => {
@@ -38,16 +35,7 @@ export const resultsSlice = createSlice({
38
35
  },
39
36
 
40
37
  sort: (state, action: PayloadAction<ResultColumnId>) => {
41
- const columndId = action.payload;
42
- const { column, direction } = state.sort;
43
-
44
- return {
45
- ...state,
46
- sort: {
47
- column: columndId,
48
- direction: column === columndId ? toggleDirection(direction) : direction,
49
- },
50
- };
38
+ return { ...state, sort: getNextSort(state.sort, action.payload) };
51
39
  },
52
40
 
53
41
  reset: () => resultsInitialState,
@@ -11,15 +11,17 @@ import { call, delay, put, select, spawn, takeEvery, takeLatest } from 'redux-sa
11
11
  import { LOCALE_FEATURES } from '@/i18n/constants';
12
12
  import { loadTranslations } from '@/i18n/i18n';
13
13
  import { memoize } from '@/lib/memoize';
14
- import { waitForIdleOrFirstIntent } from '@/lib/waitForIdleOrFirstIntent';
14
+ import { waitForFirstIntent, waitForIdleOrFirstIntent } from '@/lib/waitForIdleOrFirstIntent';
15
15
  import { findWordDefinitions, solve, verify, visit } from '@/sdk';
16
16
  import { prefetchDictionary } from '@/solver-worker';
17
+ import { type VerifiedWord } from '@/types';
17
18
 
18
19
  import { initialize, reset } from './actions';
19
20
  import { appSlice, selectVersion } from './app';
20
21
  import { boardSlice, selectBoard } from './board';
21
22
  import { cellFiltersSlice, selectCellFilter } from './cellFilters';
22
23
  import { dictionarySlice, selectDictionary } from './dictionary';
24
+ import { hoveredWordSlice } from './hoveredWord';
23
25
  import { i18nSlice, selectLoadedTranslations } from './i18n';
24
26
  import { localStorage } from './localStorage';
25
27
  import { rackSlice, selectCharacters, selectRack } from './rack';
@@ -51,6 +53,7 @@ export function* rootSaga(): AnyGenerator {
51
53
  yield takeEvery([rackSlice.actions.changeCharacter.type, rackSlice.actions.changeCharacters.type], onRackValueChange);
52
54
  yield takeEvery(resultsSlice.actions.applyResult.type, onApplyResult);
53
55
  yield takeLatest(resultsSlice.actions.changeResultCandidate.type, onResultCandidateChange);
56
+ yield takeLatest([hoveredWordSlice.actions.set.type, hoveredWordSlice.actions.clear.type], onHoveredWordChange);
54
57
  yield takeEvery(settingsSlice.actions.changeGame.type, onGameChange);
55
58
  yield takeEvery(settingsSlice.actions.changeLocale.type, onLocaleChange);
56
59
  yield takeLatest(dictionarySlice.actions.submit.type, onDictionarySubmit);
@@ -68,6 +71,7 @@ function* onCellValueChange({ payload }: PayloadAction<{ value: string; x: numbe
68
71
  }
69
72
 
70
73
  yield put(resultsSlice.actions.changeResultCandidate(null));
74
+ yield put(hoveredWordSlice.actions.clear());
71
75
  yield put(verifySlice.actions.submit());
72
76
  }
73
77
 
@@ -99,6 +103,7 @@ function* onGameChange(): AnyGenerator {
99
103
  }
100
104
 
101
105
  yield put(resultsSlice.actions.reset());
106
+ yield put(hoveredWordSlice.actions.clear());
102
107
  yield* resetRack();
103
108
  yield put(verifySlice.actions.submit());
104
109
  }
@@ -139,7 +144,7 @@ function* onInitialize({ payload }: PayloadAction<{ version: string }>): AnyGene
139
144
  const board = yield select(selectBoard);
140
145
  const locale = yield select(selectLocale);
141
146
 
142
- yield spawn(prefetchDictionaryWhenIdle);
147
+ yield spawn(prefetchDictionaryOnFirstIntent);
143
148
  yield spawn(loadLocaleTranslations, locale);
144
149
  yield spawn(preloadTranslationsWhenIdle);
145
150
  yield spawn(visitWhenIdle);
@@ -202,8 +207,8 @@ function* visitWhenIdle(): AnyGenerator {
202
207
  yield call(visit);
203
208
  }
204
209
 
205
- function* prefetchDictionaryWhenIdle(): AnyGenerator {
206
- yield call(waitForIdleOrFirstIntent);
210
+ function* prefetchDictionaryOnFirstIntent(): AnyGenerator {
211
+ yield call(waitForFirstIntent);
207
212
  const locale = yield select(selectLocale);
208
213
  yield call(prefetchDictionary, locale);
209
214
  }
@@ -250,6 +255,7 @@ function* onReset(): AnyGenerator {
250
255
  yield put(boardSlice.actions.init(Board.create(config.boardWidth, config.boardHeight)));
251
256
  yield put(cellFiltersSlice.actions.reset());
252
257
  yield put(dictionarySlice.actions.reset());
258
+ yield put(hoveredWordSlice.actions.clear());
253
259
  yield put(rackSlice.actions.reset());
254
260
  yield put(resultsSlice.actions.reset());
255
261
  yield put(solveSlice.actions.reset());
@@ -280,6 +286,7 @@ function* onLocaleChange({ payload: locale }: PayloadAction<Locale>): AnyGenerat
280
286
  }
281
287
 
282
288
  yield put(dictionarySlice.actions.reset());
289
+ yield put(hoveredWordSlice.actions.clear());
283
290
  yield put(resultsSlice.actions.changeResultCandidate(null));
284
291
  yield put(verifySlice.actions.submit());
285
292
  }
@@ -289,8 +296,26 @@ function* onResultCandidateChange({ payload: result }: PayloadAction<Result | nu
289
296
  return;
290
297
  }
291
298
 
299
+ yield put(hoveredWordSlice.actions.clear());
300
+ yield* searchDictionary(result.words);
301
+ }
302
+
303
+ function* onHoveredWordChange({ payload: word }: PayloadAction<VerifiedWord | undefined>): AnyGenerator {
304
+ if (!word) {
305
+ return;
306
+ }
307
+
308
+ yield put(resultsSlice.actions.changeResultCandidate(null));
309
+
310
+ const board: Board = yield select(selectBoard);
311
+ const collidingWords = board.getCollidingWords(word);
312
+
313
+ yield* searchDictionary([word.word, ...collidingWords.map((collidingWord) => collidingWord.word)]);
314
+ }
315
+
316
+ function* searchDictionary(words: string[]): AnyGenerator {
292
317
  const locale: Locale = yield select(selectLocale);
293
- const uniqueWords = Array.from(new Set(result.words));
318
+ const uniqueWords = Array.from(new Set(words));
294
319
  const input = uniqueWords.join(LOCALE_FEATURES[locale].separator);
295
320
 
296
321
  if (!memoizedFindWordDefinitions.hasCache(locale, input)) {
@@ -3,10 +3,13 @@ import { createSelector } from '@reduxjs/toolkit';
3
3
  import { unorderedArraysEqual } from '@/lib/unorderedArraysEqual';
4
4
 
5
5
  import { selectBoard } from './board';
6
+ import { selectHoveredWord } from './hoveredWord';
6
7
  import { getRemainingTiles } from './lib';
7
8
  import { selectCharacters } from './rack';
9
+ import { selectResultsResults } from './results';
8
10
  import { selectConfig, selectLocale } from './settings';
9
- import { selectLastSolvedParameters } from './solve';
11
+ import { selectLastSolvedParameters, selectSolveError } from './solve';
12
+ import { selectLastVerifiedBoard } from './verify';
10
13
 
11
14
  const selectHasBoardChanged = createSelector(
12
15
  [selectLastSolvedParameters, selectBoard],
@@ -29,6 +32,20 @@ export const selectAreResultsOutdated = createSelector(
29
32
  },
30
33
  );
31
34
 
35
+ export const selectUpToDateResults = createSelector(
36
+ [selectResultsResults, selectAreResultsOutdated, selectSolveError],
37
+ (results, areResultsOutdated, solveError) => {
38
+ return results && !areResultsOutdated && !solveError ? results : null;
39
+ },
40
+ );
41
+
42
+ export const selectUpToDateHoveredWord = createSelector(
43
+ [selectHoveredWord, selectLastVerifiedBoard, selectBoard],
44
+ (hoveredWord, lastVerifiedBoard, board) => {
45
+ return hoveredWord && lastVerifiedBoard.equals(board) ? hoveredWord : null;
46
+ },
47
+ );
48
+
32
49
  export const selectRemainingTiles = createSelector(
33
50
  [selectConfig, selectBoard, selectCharacters, selectLocale],
34
51
  getRemainingTiles,
@@ -6,6 +6,7 @@ import { boardSlice } from './board';
6
6
  import { cellFiltersSlice } from './cellFilters';
7
7
  import { dictionarySlice } from './dictionary';
8
8
  import { hoveredTileSlice } from './hoveredTile';
9
+ import { hoveredWordSlice } from './hoveredWord';
9
10
  import { i18nSlice } from './i18n';
10
11
  import { rackSlice } from './rack';
11
12
  import { resultsSlice } from './results';
@@ -23,6 +24,7 @@ export const store = configureStore({
23
24
  cellFilters: cellFiltersSlice.reducer,
24
25
  dictionary: dictionarySlice.reducer,
25
26
  hoveredTile: hoveredTileSlice.reducer,
27
+ hoveredWord: hoveredWordSlice.reducer,
26
28
  i18n: i18nSlice.reducer,
27
29
  rack: rackSlice.reducer,
28
30
  results: resultsSlice.reducer,
@@ -3,6 +3,7 @@ import type { BoardState } from './board';
3
3
  import type { CellFiltersState } from './cellFilters';
4
4
  import type { DictionaryState } from './dictionary';
5
5
  import type { HoveredTileState } from './hoveredTile';
6
+ import type { HoveredWordState } from './hoveredWord';
6
7
  import type { I18nState } from './i18n';
7
8
  import type { RackState } from './rack';
8
9
  import type { ResultsState } from './results';
@@ -16,6 +17,7 @@ export type RootState = {
16
17
  cellFilters: CellFiltersState;
17
18
  dictionary: DictionaryState;
18
19
  hoveredTile: HoveredTileState;
20
+ hoveredWord: HoveredWordState;
19
21
  i18n: I18nState;
20
22
  rack: RackState;
21
23
  results: ResultsState;
@@ -1,3 +1,5 @@
1
+ import { SortDirection, WordColumnId } from '@/types';
2
+
1
3
  import { boardInitialState } from '../board';
2
4
 
3
5
  import type { VerifyState } from './types';
@@ -8,5 +10,10 @@ export const verifyInitialState: VerifyState = {
8
10
  board: boardInitialState,
9
11
  },
10
12
  invalidWords: [],
13
+ query: '',
14
+ sort: {
15
+ column: WordColumnId.Word,
16
+ direction: SortDirection.Ascending,
17
+ },
11
18
  validWords: [],
12
19
  };
@@ -0,0 +1,55 @@
1
+ import { SortDirection, type VerifiedWord, WordColumnId } from '../../types';
2
+
3
+ import { getWordCoordinates, sortWords } from './lib';
4
+
5
+ describe('getWordCoordinates', () => {
6
+ it('locates a horizontal word by its start cell', () => {
7
+ expect(getWordCoordinates(createWord({ x: 3, y: 3 }), 'original')).toBe('4D');
8
+ });
9
+
10
+ it('locates a vertical word by its start cell', () => {
11
+ expect(getWordCoordinates(createWord({ direction: 'vertical', x: 3, y: 3 }), 'original')).toBe('D4');
12
+ });
13
+ });
14
+
15
+ describe('sortWords', () => {
16
+ const cat = createWord({ word: 'cat', x: 3, y: 3 });
17
+ const zvq = createWord({ isValid: false, word: 'zvq', x: 3, y: 5 });
18
+
19
+ it('sorts by word', () => {
20
+ const sort = { column: WordColumnId.Word, direction: SortDirection.Ascending };
21
+
22
+ expect(sortWords([zvq, cat], sort, 'en-US', 'original')).toEqual([cat, zvq]);
23
+ });
24
+
25
+ it('sorts by coordinates', () => {
26
+ const sort = { column: WordColumnId.Coordinates, direction: SortDirection.Ascending };
27
+
28
+ expect(sortWords([zvq, cat], sort, 'en-US', 'original')).toEqual([cat, zvq]);
29
+ });
30
+
31
+ it('sorts invalid words first by validity', () => {
32
+ const sort = { column: WordColumnId.Validity, direction: SortDirection.Ascending };
33
+
34
+ expect(sortWords([cat, zvq], sort, 'en-US', 'original')).toEqual([zvq, cat]);
35
+ });
36
+
37
+ it('reverses the order when sorting descending', () => {
38
+ const sort = { column: WordColumnId.Word, direction: SortDirection.Descending };
39
+
40
+ expect(sortWords([cat, zvq], sort, 'en-US', 'original')).toEqual([zvq, cat]);
41
+ });
42
+
43
+ it('does not modify the input', () => {
44
+ const sort = { column: WordColumnId.Word, direction: SortDirection.Ascending };
45
+ const words = [zvq, cat];
46
+
47
+ sortWords(words, sort, 'en-US', 'original');
48
+
49
+ expect(words).toEqual([zvq, cat]);
50
+ });
51
+ });
52
+
53
+ function createWord(overrides: Partial<VerifiedWord>): VerifiedWord {
54
+ return { direction: 'horizontal', isValid: true, word: 'cat', x: 0, y: 0, ...overrides };
55
+ }
@@ -0,0 +1,49 @@
1
+ import { type BoardWord, type ShowCoordinates } from '@scrabble-solver/types';
2
+
3
+ import { createCoordinatesComparator } from '@/lib/createCoordinatesComparator';
4
+ import { createKeyComparator } from '@/lib/createKeyComparator';
5
+ import { createRegExp } from '@/lib/createRegExp';
6
+ import { createSortComparator } from '@/lib/createSortComparator';
7
+ import { getCoordinates } from '@/lib/getCoordinates';
8
+ import { numberComparator } from '@/lib/numberComparator';
9
+ import { type ComparatorFactory, type GroupedWords, type Sort, type VerifiedWord, WordColumnId } from '@/types';
10
+
11
+ export const getWordCoordinates = (word: BoardWord, showCoordinates: ShowCoordinates): string => {
12
+ return getCoordinates({ isHorizontal: word.direction === 'horizontal', x: word.x, y: word.y }, showCoordinates);
13
+ };
14
+
15
+ export const groupWords = (words: VerifiedWord[], query: string): GroupedWords => {
16
+ const regExp = createRegExp(query);
17
+
18
+ return words.reduce<GroupedWords>(
19
+ (groupedWords, word) => {
20
+ if (word.word.match(regExp)) {
21
+ groupedWords.matching.push(word);
22
+ } else {
23
+ groupedWords.other.push(word);
24
+ }
25
+
26
+ return groupedWords;
27
+ },
28
+ { matching: [], other: [] },
29
+ );
30
+ };
31
+
32
+ const comparators: Record<WordColumnId, ComparatorFactory<VerifiedWord>> = {
33
+ [WordColumnId.Coordinates]: (locale: string, showCoordinates: ShowCoordinates) => {
34
+ return createCoordinatesComparator({ getItemCoordinates: getWordCoordinates, locale, showCoordinates });
35
+ },
36
+ [WordColumnId.Validity]: () => (a, b) => {
37
+ return numberComparator(Number(a.isValid), Number(b.isValid));
38
+ },
39
+ [WordColumnId.Word]: (locale: string) => createKeyComparator('word', locale),
40
+ };
41
+
42
+ export const sortWords = (
43
+ words: VerifiedWord[],
44
+ sort: Sort<WordColumnId>,
45
+ locale: string,
46
+ showCoordinates: ShowCoordinates,
47
+ ): VerifiedWord[] => {
48
+ return [...words].sort(createSortComparator({ comparators, locale, showCoordinates, sort }));
49
+ };
@@ -1,9 +1,46 @@
1
1
  import { createSelector } from '@reduxjs/toolkit';
2
2
 
3
+ import { createRegExp } from '@/lib/createRegExp';
4
+
5
+ import { selectLocale, selectShowCoordinates } from '../settings';
3
6
  import type { RootState } from '../types';
4
7
 
8
+ import { getWordCoordinates, groupWords, sortWords } from './lib';
9
+
10
+ const selectWordIndex = (_: unknown, index: number): number => index;
11
+
5
12
  export const selectVerify = (state: RootState) => state.verify;
6
13
 
14
+ export const selectLastVerifiedBoard = createSelector([selectVerify], (verify) => verify.lastSolvedParameters.board);
15
+
7
16
  export const selectInvalidWords = createSelector([selectVerify], (verify) => verify.invalidWords);
8
17
 
9
18
  export const selectValidWords = createSelector([selectVerify], (verify) => verify.validWords);
19
+
20
+ export const selectWordsQuery = createSelector([selectVerify], (verify) => verify.query);
21
+
22
+ export const selectWordsSort = createSelector([selectVerify], (verify) => verify.sort);
23
+
24
+ export const selectCreatedWords = createSelector([selectInvalidWords, selectValidWords], (invalidWords, validWords) => {
25
+ return [...invalidWords, ...validWords];
26
+ });
27
+
28
+ const selectSortedWords = createSelector(
29
+ [selectCreatedWords, selectWordsSort, selectLocale, selectShowCoordinates],
30
+ sortWords,
31
+ );
32
+
33
+ export const selectProcessedWords = createSelector([selectSortedWords, selectWordsQuery], (words, query) => {
34
+ const { matching, other } = groupWords(words, query);
35
+ return [...matching, ...other];
36
+ });
37
+
38
+ export const selectIsWordMatching = createSelector(
39
+ [selectProcessedWords, selectWordsQuery, selectWordIndex],
40
+ (words, query, index) => createRegExp(query).test(words[index].word),
41
+ );
42
+
43
+ export const selectWordCoordinates = createSelector(
44
+ [selectProcessedWords, selectShowCoordinates, selectWordIndex],
45
+ (words, showCoordinates, index) => getWordCoordinates(words[index], showCoordinates),
46
+ );
@@ -1,12 +1,15 @@
1
1
  import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
2
2
  import { type Board } from '@scrabble-solver/types';
3
3
 
4
+ import { getNextSort } from '@/lib/getNextSort';
5
+ import { type VerifiedWord, type WordColumnId } from '@/types';
6
+
4
7
  import { verifyInitialState } from './initialState';
5
8
 
6
9
  interface VerifyParameters {
7
10
  board: Board;
8
- invalidWords: string[];
9
- validWords: string[];
11
+ invalidWords: VerifiedWord[];
12
+ validWords: VerifiedWord[];
10
13
  }
11
14
 
12
15
  export const verifySlice = createSlice({
@@ -17,10 +20,18 @@ export const verifySlice = createSlice({
17
20
  return { ...state, isLoading: true };
18
21
  },
19
22
 
23
+ changeQuery: (state, action: PayloadAction<string>) => {
24
+ return { ...state, query: action.payload };
25
+ },
26
+
20
27
  submitFailure: (state) => {
21
28
  return { ...state, isLoading: false };
22
29
  },
23
30
 
31
+ sort: (state, action: PayloadAction<WordColumnId>) => {
32
+ return { ...state, sort: getNextSort(state.sort, action.payload) };
33
+ },
34
+
24
35
  submitSuccess: (state, action: PayloadAction<VerifyParameters>) => {
25
36
  const { board, invalidWords, validWords } = action.payload;
26
37
  return { ...state, isLoading: false, lastSolvedParameters: { board }, invalidWords, validWords };
@@ -1,3 +1,5 @@
1
+ import type { Sort, VerifiedWord, WordColumnId } from '@/types';
2
+
1
3
  import type { BoardState } from '../board';
2
4
 
3
5
  export interface VerifyState {
@@ -5,6 +7,8 @@ export interface VerifyState {
5
7
  lastSolvedParameters: {
6
8
  board: BoardState;
7
9
  };
8
- invalidWords: string[];
9
- validWords: string[];
10
+ invalidWords: VerifiedWord[];
11
+ query: string;
12
+ sort: Sort<WordColumnId>;
13
+ validWords: VerifiedWord[];
10
14
  }
@@ -10,6 +10,8 @@ $breakpoint-m: 992px;
10
10
  $breakpoint-l: 1200px;
11
11
  $breakpoint-xl: 1400px;
12
12
 
13
+ $breakpoint-height-l: 860px;
14
+
13
15
  $border-color: #cdcdcd;
14
16
  $border-color-light: #d9d9d9;
15
17
  $border-radius: 5px;
@@ -10,6 +10,11 @@
10
10
  ),
11
11
  $media-expressions: (
12
12
  touch: '(hover: none)',
13
+ /**
14
+ * Keep in sync with useIsCompactLayout
15
+ */
16
+ compact: '(max-width: #{$breakpoint-l - 1px}), (max-height: #{$breakpoint-height-l - 1px})',
17
+ desktop: '(min-width: #{$breakpoint-l}) and (min-height: #{$breakpoint-height-l})',
13
18
  )
14
19
  );
15
20
 
@@ -6,6 +6,7 @@
6
6
  breakpointM: strip-unit($breakpoint-m);
7
7
  breakpointL: strip-unit($breakpoint-l);
8
8
  breakpointXl: strip-unit($breakpoint-xl);
9
+ breakpointHeightL: strip-unit($breakpoint-height-l);
9
10
 
10
11
  borderColorLight: $border-color-light;
11
12
  borderWidth: strip-unit($border-width);
@@ -2,6 +2,7 @@
2
2
  declare const styles: {
3
3
  readonly borderColorLight: string;
4
4
  readonly borderWidth: string;
5
+ readonly breakpointHeightL: string;
5
6
  readonly breakpointL: string;
6
7
  readonly breakpointM: string;
7
8
  readonly breakpointS: string;
@@ -153,7 +153,7 @@ $easeOutSine: cubic-bezier(0.61, 1, 0.88, 1);
153
153
  --components-spacing: var(--spacing--l);
154
154
  }
155
155
 
156
- @include media('<l') {
156
+ @include media('compact') {
157
157
  --dictionary--height: var(--dictionary--height--mobile);
158
158
  --logo--height: #{$logo-height-small};
159
159
  --solver-column-total: 0px;
@@ -1,8 +1,9 @@
1
- import { type Result } from '@scrabble-solver/types';
2
- import { type FunctionComponent, type SVGAttributes } from 'react';
1
+ import { type BoardWord, type Result, type ShowCoordinates } from '@scrabble-solver/types';
3
2
 
4
3
  export type Comparator<T> = (a: T, B: T) => number;
5
4
 
5
+ export type ComparatorFactory<T> = (locale: string, showCoordinates: ShowCoordinates) => Comparator<T>;
6
+
6
7
  export type AutoGroupTiles = 'left' | 'right' | null;
7
8
 
8
9
  export type CellFilterType = 'include' | 'exclude';
@@ -26,8 +27,8 @@ export interface Point {
26
27
 
27
28
  export type Rack = (string | null)[];
28
29
 
29
- export interface Sort {
30
- column: ResultColumnId;
30
+ export interface Sort<Id extends string = ResultColumnId> {
31
+ column: Id;
31
32
  direction: SortDirection;
32
33
  }
33
34
 
@@ -61,12 +62,10 @@ export enum ResultColumnId {
61
62
  WordsCount = 'words-count',
62
63
  }
63
64
 
64
- export interface ResultColumn {
65
- className: string;
66
- Icon?: FunctionComponent<SVGAttributes<SVGElement>>;
67
- id: ResultColumnId;
68
- translationKey: TranslationKey;
69
- width?: number;
65
+ export enum WordColumnId {
66
+ Coordinates = 'coordinates',
67
+ Validity = 'validity',
68
+ Word = 'word',
70
69
  }
71
70
 
72
71
  export interface GroupedResults {
@@ -74,6 +73,15 @@ export interface GroupedResults {
74
73
  other: Result[];
75
74
  }
76
75
 
76
+ export interface GroupedWords {
77
+ matching: VerifiedWord[];
78
+ other: VerifiedWord[];
79
+ }
80
+
81
+ export interface VerifiedWord extends BoardWord {
82
+ isValid: boolean;
83
+ }
84
+
77
85
  export type TranslationKey =
78
86
  | 'cell.enter-word'
79
87
  | 'cell.filter-cell.exclude'
@@ -135,6 +143,7 @@ export type TranslationKey =
135
143
  | 'results.input.placeholder'
136
144
  | 'results.insert'
137
145
  | 'results.preview'
146
+ | 'results.select'
138
147
  | 'results.solve'
139
148
  | 'settings'
140
149
  | 'settings.autoGroupTiles'
@@ -154,8 +163,12 @@ export type TranslationKey =
154
163
  | 'settings.showCoordinates.alternative'
155
164
  | 'settings.showCoordinates.original'
156
165
  | 'words'
166
+ | 'words.empty-state.no-words'
167
+ | 'words.input.placeholder'
157
168
  | 'words.invalid'
158
- | 'words.valid';
169
+ | 'words.preview'
170
+ | 'words.valid'
171
+ | 'words.validity';
159
172
 
160
173
  export type Translate = (key: TranslationKey, replacements?: Record<string, string>) => string;
161
174