@scrabble-solver/scrabble-solver 2.18.1 → 2.18.3

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 (196) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +14 -14
  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/812.js +1 -1
  29. package/.next/server/chunks/846.js +1 -1
  30. package/.next/server/chunks/886.js +1 -1
  31. package/.next/server/chunks/942.js +1 -0
  32. package/.next/server/chunks/953.js +1 -1
  33. package/.next/server/chunks/961.js +1 -1
  34. package/.next/server/dynamic-css-manifest.js +1 -1
  35. package/.next/server/middleware-build-manifest.js +1 -1
  36. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  37. package/.next/server/pages/404.html +3 -3
  38. package/.next/server/pages/500.html +3 -3
  39. package/.next/server/pages/_app.js +1 -1
  40. package/.next/server/pages/_app.js.nft.json +1 -1
  41. package/.next/server/pages/_document.js +2 -2
  42. package/.next/server/pages/_document.js.nft.json +1 -1
  43. package/.next/server/pages/_error.js +1 -1
  44. package/.next/server/pages/_error.js.nft.json +1 -1
  45. package/.next/server/pages/api/dictionary/[locale]/[word].js +1 -1
  46. package/.next/server/pages/api/dictionary/[locale]/[word].js.nft.json +1 -1
  47. package/.next/server/pages/api/dictionary/[locale].js +1 -1
  48. package/.next/server/pages/api/dictionary/[locale].js.nft.json +1 -1
  49. package/.next/server/pages/api/solve.js +1 -1
  50. package/.next/server/pages/api/solve.js.nft.json +1 -1
  51. package/.next/server/pages/api/verify.js +1 -1
  52. package/.next/server/pages/api/verify.js.nft.json +1 -1
  53. package/.next/server/pages/index.html +3 -3
  54. package/.next/server/pages/index.js +4 -4
  55. package/.next/server/pages/index.js.nft.json +1 -1
  56. package/.next/server/pages/index.json +1 -1
  57. package/.next/server/pages/not-found.html +3 -3
  58. package/.next/server/pages/not-found.js.nft.json +1 -1
  59. package/.next/server/pages-manifest.json +1 -1
  60. package/.next/static/{5KOWzYqfY1Z2rjrzptr8D → 8WnIUTe-yJvPD6stltvYI}/_buildManifest.js +1 -1
  61. package/.next/static/chunks/100.305bdf9c75b1758f.js +1 -0
  62. package/.next/static/chunks/256.6a20b23a766cfebd.js +1 -0
  63. package/.next/static/chunks/{307.67f52f13e3d60d4e.js → 307.89e6100f58443679.js} +1 -1
  64. package/.next/static/chunks/{363.01eed3233f92b6a4.js → 363.cec65b1f9668a75e.js} +1 -1
  65. package/.next/static/chunks/{422.1559ae6756912ca5.js → 422.3e55b4c94e328701.js} +1 -1
  66. package/.next/static/chunks/{663.d2c9ca36b26695b8.js → 663.bd2c45413fbca518.js} +1 -1
  67. package/.next/static/chunks/693.677c88e0eb1340b5.js +1 -0
  68. package/.next/static/chunks/{820.97b82f751ca49579.js → 820.823e9e885ebb66af.js} +1 -1
  69. package/.next/static/chunks/827.c81b92b383a748c9.js +1 -0
  70. package/.next/static/chunks/{865.a9b563a45848a83c.js → 865.59baba8296c9b116.js} +1 -1
  71. package/.next/static/chunks/912.077603e4f0bc6cca.js +1 -0
  72. package/.next/static/chunks/{939.19b33a3d3c351d73.js → 939.2ad86b50405c7897.js} +1 -1
  73. package/.next/static/chunks/980.7ce17464fc56648a.js +1 -0
  74. package/.next/static/chunks/{main-b92e08f3fc96dd8d.js → main-7e52da35a4beea0b.js} +1 -1
  75. package/.next/static/chunks/pages/_app-e3fe880e24c024ab.js +1 -0
  76. package/.next/static/chunks/pages/index-b073a1686818dd26.js +1 -0
  77. package/.next/static/chunks/webpack-d7138f1ef6b8c9d6.js +1 -0
  78. package/.next/static/css/65bcc9440e067113.css +1 -0
  79. package/.next/static/css/{4dbcacc1122ac6b9.css → 92f85ce358a0a468.css} +1 -1
  80. package/.next/static/css/a691348b788f8bc0.css +1 -0
  81. package/.next/static/css/cd4d7f2458487399.css +1 -0
  82. package/.next/trace +22 -21
  83. package/.next/trace-build +1 -1
  84. package/AGENTS.md +9 -0
  85. package/CLAUDE.md +1 -0
  86. package/package.json +14 -12
  87. package/public/llms.txt +1 -1
  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/modals/RemainingTilesModal/RemainingTilesModal.tsx +3 -1
  133. package/src/modals/RemainingTilesModal/components/Character/Character.tsx +8 -9
  134. package/src/modals/ResultsModal/ResultsModal.tsx +14 -10
  135. package/src/modals/WordsModal/WordsModal.module.scss +16 -27
  136. package/src/modals/WordsModal/WordsModal.module.scss.d.ts +3 -6
  137. package/src/modals/WordsModal/WordsModal.tsx +56 -42
  138. package/src/modals/WordsModal/components/WordsTable/WordRow.tsx +92 -0
  139. package/src/modals/WordsModal/components/WordsTable/WordsTable.module.scss +69 -0
  140. package/src/modals/WordsModal/components/WordsTable/WordsTable.module.scss.d.ts +16 -0
  141. package/src/modals/WordsModal/components/WordsTable/WordsTable.tsx +137 -0
  142. package/src/modals/WordsModal/components/WordsTable/index.ts +1 -0
  143. package/src/modals/WordsModal/components/index.ts +1 -0
  144. package/src/pages/api/verify.ts +7 -6
  145. package/src/pages/index.tsx +29 -4
  146. package/src/parameters/index.ts +2 -0
  147. package/src/sdk/verify.ts +3 -2
  148. package/src/solver-worker/client.test.ts +10 -3
  149. package/src/solver-worker/index.worker.ts +7 -7
  150. package/src/solver-worker/messages.ts +3 -3
  151. package/src/state/hoveredWord/index.ts +4 -0
  152. package/src/state/hoveredWord/initialState.ts +5 -0
  153. package/src/state/hoveredWord/selectors.ts +5 -0
  154. package/src/state/hoveredWord/slice.ts +18 -0
  155. package/src/state/hoveredWord/types.ts +5 -0
  156. package/src/state/index.ts +1 -0
  157. package/src/state/localStorage.ts +13 -2
  158. package/src/state/results/lib.ts +9 -26
  159. package/src/state/results/selectors.ts +9 -1
  160. package/src/state/results/slice.ts +4 -16
  161. package/src/state/sagas.ts +26 -1
  162. package/src/state/selectors.ts +18 -1
  163. package/src/state/store.ts +2 -0
  164. package/src/state/types.ts +2 -0
  165. package/src/state/verify/initialState.ts +7 -0
  166. package/src/state/verify/lib.test.ts +55 -0
  167. package/src/state/verify/lib.ts +49 -0
  168. package/src/state/verify/selectors.ts +37 -0
  169. package/src/state/verify/slice.ts +13 -2
  170. package/src/state/verify/types.ts +6 -2
  171. package/src/styles/_tokens.scss +2 -0
  172. package/src/styles/mixins.scss +5 -0
  173. package/src/styles/variables.module.scss +1 -0
  174. package/src/styles/variables.module.scss.d.ts +1 -0
  175. package/src/styles/variables.scss +1 -1
  176. package/src/types/index.ts +24 -11
  177. package/tsconfig.tsbuildinfo +1 -1
  178. package/.next/server/chunks/316.js +0 -1
  179. package/.next/server/chunks/916.js +0 -1
  180. package/.next/static/chunks/106.e414e88694add27f.js +0 -1
  181. package/.next/static/chunks/127.125d896a19061d71.js +0 -1
  182. package/.next/static/chunks/18.8b91e137d239e15c.js +0 -1
  183. package/.next/static/chunks/583.7a46807be14c377e.js +0 -1
  184. package/.next/static/chunks/711.ba7b8f900dfd40d0.js +0 -1
  185. package/.next/static/chunks/912.ff8037ba9d4db5c8.js +0 -1
  186. package/.next/static/chunks/pages/_app-2e607dcd4135f340.js +0 -1
  187. package/.next/static/chunks/pages/index-e0087247c995328d.js +0 -1
  188. package/.next/static/chunks/webpack-cfb34d175e081981.js +0 -1
  189. package/.next/static/css/b74cd7de82dabcdd.css +0 -1
  190. package/.next/static/css/c403ce739a9c0d83.css +0 -1
  191. package/.next/static/css/fbfdce70731d0d2b.css +0 -1
  192. package/src/components/Results/Cell.tsx +0 -32
  193. package/src/components/ResultsInput/ResultsInput.module.scss +0 -12
  194. package/src/components/ResultsInput/ResultsInput.module.scss.d.ts +0 -7
  195. package/src/components/ResultsInput/index.ts +0 -1
  196. /package/.next/static/{5KOWzYqfY1Z2rjrzptr8D → 8WnIUTe-yJvPD6stltvYI}/_ssgManifest.js +0 -0
@@ -18,10 +18,11 @@ import { type RemainingTile } from '@/types';
18
18
  import styles from './Character.module.scss';
19
19
 
20
20
  interface Props {
21
+ canHighlight: boolean;
21
22
  tile: RemainingTile;
22
23
  }
23
24
 
24
- export const Character: FunctionComponent<Props> = ({ tile }) => {
25
+ export const Character: FunctionComponent<Props> = ({ canHighlight, tile }) => {
25
26
  const dispatch = useDispatch();
26
27
  const locale = useTypedSelector(selectLocale);
27
28
  const hoveredCharacter = useTypedSelector(selectHoveredCharacter);
@@ -37,14 +38,12 @@ export const Character: FunctionComponent<Props> = ({ tile }) => {
37
38
  const current = direction === 'ltr' ? remainingCount : count;
38
39
  const total = direction === 'ltr' ? count : remainingCount;
39
40
  const isUsed = usedCount > 0;
40
- const isHovered = isUsed && hoveredCharacter === character;
41
+ const isHighlightable = canHighlight && isUsed;
42
+ const isHighlighted = isHighlightable && hoveredCharacter === character;
41
43
 
42
44
  const handleMouseEnter = useCallback(() => {
43
- if (!isUsed) {
44
- return;
45
- }
46
45
  dispatch(hoveredTileSlice.actions.set(character));
47
- }, [character, dispatch, isUsed]);
46
+ }, [character, dispatch]);
48
47
 
49
48
  const handleMouseLeave = useCallback(() => {
50
49
  dispatch(hoveredTileSlice.actions.clear());
@@ -64,15 +63,15 @@ export const Character: FunctionComponent<Props> = ({ tile }) => {
64
63
  [styles.unused]: !isUsed,
65
64
  })}
66
65
  data-testid={character === BLANK ? 'remaining-tile-blank' : `remaining-tile-${character}`}
67
- onMouseEnter={handleMouseEnter}
68
- onMouseLeave={handleMouseLeave}
66
+ onMouseEnter={isHighlightable ? handleMouseEnter : undefined}
67
+ onMouseLeave={isHighlightable ? handleMouseLeave : undefined}
69
68
  >
70
69
  <Tile
71
70
  aria-label={character}
72
71
  character={character}
73
72
  className={styles.tile}
74
73
  disabled
75
- highlighted={isHovered}
74
+ highlighted={isHighlighted}
76
75
  isBlank={character === BLANK}
77
76
  isValid={remainingCount >= 0}
78
77
  locale={locale}
@@ -6,10 +6,17 @@ 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 { useMediaQuery } from '@/hooks/useMediaQuery';
9
+ import { useIsCompactLayout } from '@/hooks/useIsCompactLayout';
10
+ import { useIsTouchDevice } from '@/hooks/useIsTouchDevice';
10
11
  import Check from '@/icons/Check.svg';
11
12
  import EyeFill from '@/icons/EyeFill.svg';
12
- import { resultsSlice, selectProcessedResults, selectResultCandidate, useTranslate, useTypedSelector } from '@/state';
13
+ import {
14
+ resultsSlice,
15
+ selectResultCandidate,
16
+ selectResultCandidateIndex,
17
+ useTranslate,
18
+ useTypedSelector,
19
+ } from '@/state';
13
20
 
14
21
  import styles from './ResultsModal.module.scss';
15
22
 
@@ -22,25 +29,22 @@ interface Props {
22
29
  const ResultsModalBase: FunctionComponent<Props> = ({ className, isOpen, onClose }) => {
23
30
  const dispatch = useDispatch();
24
31
  const translate = useTranslate();
25
- const showResultsInModal = useMediaQuery('<l');
26
- const results = useTypedSelector(selectProcessedResults);
32
+ const showResultsInModal = useIsCompactLayout();
33
+ const previewsOnRepeatedClick = useIsTouchDevice();
27
34
  const resultCandidate = useTypedSelector(selectResultCandidate);
28
- const index = results ? results.findIndex((result) => result.id === resultCandidate?.id) : -1;
29
- const highlightedIndex = index === -1 ? undefined : index;
35
+ const highlightedIndex = useTypedSelector(selectResultCandidateIndex);
30
36
 
31
37
  const callbacks = useMemo(
32
38
  () => ({
33
39
  onClick: (result: Result) => {
34
- const isSelected = result === resultCandidate;
35
-
36
- if (isSelected) {
40
+ if (previewsOnRepeatedClick && result === resultCandidate) {
37
41
  onClose();
38
42
  } else {
39
43
  dispatch(resultsSlice.actions.changeResultCandidate(result));
40
44
  }
41
45
  },
42
46
  }),
43
- [dispatch, onClose, resultCandidate],
47
+ [dispatch, onClose, previewsOnRepeatedClick, resultCandidate],
44
48
  );
45
49
 
46
50
  const handleInsert = () => {
@@ -1,35 +1,24 @@
1
- .title {
2
- display: flex;
3
- align-items: center;
4
- justify-content: space-between;
5
- flex-wrap: wrap;
6
- }
1
+ @use 'styles/mixins' as *;
7
2
 
8
- .badge {
9
- margin-inline-start: var(--spacing--m);
10
- }
11
-
12
- .word {
3
+ .content {
4
+ position: relative;
13
5
  display: flex;
14
- align-items: center;
15
-
16
- & + & {
17
- margin-top: var(--spacing--s);
18
- }
19
- }
20
-
21
- .invalid {
22
- color: var(--color--error);
6
+ flex-direction: column;
7
+ height: 100%;
8
+ gap: var(--spacing--l);
23
9
  }
24
10
 
25
- .valid {
26
- color: var(--color--success);
11
+ .words {
12
+ flex: 1 1 auto;
27
13
  }
28
14
 
29
- .icon {
30
- $size: 24px;
15
+ .dictionary {
16
+ flex: 0 0 var(--dictionary--height--mobile);
17
+ background-color: var(--color--background--element);
18
+ border: var(--border);
19
+ border-radius: var(--border--radius);
31
20
 
32
- width: $size;
33
- height: $size;
34
- margin-inline-end: var(--spacing--s);
21
+ @include media('desktop') {
22
+ display: none;
23
+ }
35
24
  }
@@ -1,11 +1,8 @@
1
1
  // Generated by scripts/generate-scss-types.ts - do not edit.
2
2
  declare const styles: {
3
- readonly badge: string;
4
- readonly icon: string;
5
- readonly invalid: string;
6
- readonly title: string;
7
- readonly valid: string;
8
- readonly word: string;
3
+ readonly content: string;
4
+ readonly dictionary: string;
5
+ readonly words: string;
9
6
  };
10
7
 
11
8
  export default styles;
@@ -1,59 +1,73 @@
1
- import classNames from 'classnames';
2
- import { type FunctionComponent, memo } from 'react';
1
+ import { type FunctionComponent, memo, useCallback, useEffect, useRef } from 'react';
2
+ import { useDispatch } from 'react-redux';
3
3
 
4
- import { Badge } from '@/components/Badge';
4
+ import { Button } from '@/components/Button';
5
+ import { Dictionary } from '@/components/Dictionary';
5
6
  import { Modal } from '@/components/Modal';
6
- import Check from '@/icons/Check.svg';
7
- import Cross from '@/icons/Cross.svg';
8
- import { selectInvalidWords, selectLocale, selectValidWords, useTranslate, useTypedSelector } from '@/state';
7
+ import { useIsCompactLayout } from '@/hooks/useIsCompactLayout';
8
+ import EyeFill from '@/icons/EyeFill.svg';
9
+ import { hoveredWordSlice, selectHoveredWord, useTranslate, useTypedSelector } from '@/state';
9
10
 
11
+ import { WordsTable } from './components';
10
12
  import styles from './WordsModal.module.scss';
11
13
 
12
14
  interface Props {
13
15
  className?: string;
14
16
  isOpen: boolean;
15
17
  onClose: () => void;
18
+ onPreview: () => void;
16
19
  }
17
20
 
18
- const WordsModalBase: FunctionComponent<Props> = ({ className, isOpen, onClose }) => {
21
+ const WordsModalBase: FunctionComponent<Props> = ({ className, isOpen, onClose, onPreview }) => {
22
+ const dispatch = useDispatch();
19
23
  const translate = useTranslate();
20
- const locale = useTypedSelector(selectLocale);
21
- const invalidWords = useTypedSelector(selectInvalidWords);
22
- const validWords = useTypedSelector(selectValidWords);
24
+ const hoveredWord = useTypedSelector(selectHoveredWord);
25
+ const showsPreviewButton = useIsCompactLayout();
26
+ const keepsHighlightOnClose = useRef(false);
27
+
28
+ const handlePreview = useCallback(() => {
29
+ keepsHighlightOnClose.current = true;
30
+ onPreview();
31
+ }, [onPreview]);
32
+
33
+ useEffect(() => {
34
+ if (!isOpen) {
35
+ return;
36
+ }
37
+
38
+ return () => {
39
+ if (!keepsHighlightOnClose.current) {
40
+ dispatch(hoveredWordSlice.actions.clear());
41
+ }
42
+
43
+ keepsHighlightOnClose.current = false;
44
+ };
45
+ }, [dispatch, isOpen]);
23
46
 
24
47
  return (
25
- <Modal className={className} isOpen={isOpen} title={translate('words')} onClose={onClose}>
26
- <Modal.Section
27
- label={translate('words.invalid')}
28
- title={
29
- <span className={styles.title}>
30
- <span>{translate('words.invalid')}</span>
31
- <Badge className={styles.badge}>{invalidWords.length.toLocaleString(locale)}</Badge>
32
- </span>
33
- }
34
- >
35
- {invalidWords.map((word, index) => (
36
- <div className={styles.word} key={index}>
37
- <Cross aria-hidden="true" className={classNames(styles.icon, styles.invalid)} role="img" /> {word}
38
- </div>
39
- ))}
40
- </Modal.Section>
41
-
42
- <Modal.Section
43
- label={translate('words.valid')}
44
- title={
45
- <span className={styles.title}>
46
- <span>{translate('words.valid')}</span>
47
- <Badge className={styles.badge}>{validWords.length.toLocaleString(locale)}</Badge>
48
- </span>
49
- }
50
- >
51
- {validWords.map((word, index) => (
52
- <div className={styles.word} key={index}>
53
- <Check aria-hidden="true" className={classNames(styles.icon, styles.valid)} role="img" /> {word}
54
- </div>
55
- ))}
56
- </Modal.Section>
48
+ <Modal
49
+ className={className}
50
+ footer={
51
+ showsPreviewButton && (
52
+ <Button
53
+ aria-label={translate('words.preview')}
54
+ disabled={!hoveredWord}
55
+ Icon={EyeFill}
56
+ tooltip={translate('words.preview')}
57
+ onClick={handlePreview}
58
+ >
59
+ {translate('words.preview')}
60
+ </Button>
61
+ )
62
+ }
63
+ isOpen={isOpen}
64
+ title={translate('words')}
65
+ onClose={onClose}
66
+ >
67
+ <div className={styles.content}>
68
+ <WordsTable className={styles.words} isOpen={isOpen} onPreview={handlePreview} />
69
+ <Dictionary className={styles.dictionary} />
70
+ </div>
57
71
  </Modal>
58
72
  );
59
73
  };
@@ -0,0 +1,92 @@
1
+ import classNames from 'classnames';
2
+ import { type ReactElement } from 'react';
3
+ import Highlighter from 'react-highlight-words';
4
+ import { useDispatch } from 'react-redux';
5
+ import { type RowComponentProps } from 'react-window';
6
+
7
+ import { Cell, Row } from '@/components/Table';
8
+ import Check from '@/icons/Check.svg';
9
+ import Cross from '@/icons/Cross.svg';
10
+ import {
11
+ hoveredWordSlice,
12
+ selectIsWordMatching,
13
+ selectWordCoordinates,
14
+ selectWordsQuery,
15
+ useTranslate,
16
+ useTypedSelector,
17
+ } from '@/state';
18
+ import { type VerifiedWord } from '@/types';
19
+
20
+ import styles from './WordsTable.module.scss';
21
+
22
+ export interface WordRowData {
23
+ highlightFollowsPointer: boolean;
24
+ highlightedIndex: number;
25
+ isTouchDevice: boolean;
26
+ words: VerifiedWord[];
27
+ onPreview: () => void;
28
+ }
29
+
30
+ export const WordRow = ({
31
+ highlightFollowsPointer,
32
+ highlightedIndex,
33
+ index,
34
+ isTouchDevice,
35
+ style,
36
+ words,
37
+ onPreview,
38
+ }: RowComponentProps<WordRowData>): ReactElement => {
39
+ const dispatch = useDispatch();
40
+ const translate = useTranslate();
41
+ const isSelected = index === highlightedIndex;
42
+ const word = words[index];
43
+ const coordinates = useTypedSelector((state) => selectWordCoordinates(state, index));
44
+ const isMatching = useTypedSelector((state) => selectIsWordMatching(state, index));
45
+ const query = useTypedSelector(selectWordsQuery);
46
+ const validityLabel = translate(word.isValid ? 'words.valid' : 'words.invalid');
47
+ const Icon = word.isValid ? Check : Cross;
48
+
49
+ const handleSet = () => {
50
+ dispatch(hoveredWordSlice.actions.set(word));
51
+ };
52
+
53
+ const handleClear = () => {
54
+ dispatch(hoveredWordSlice.actions.clear());
55
+ };
56
+
57
+ const handleClick = () => {
58
+ if (!isSelected) {
59
+ handleSet();
60
+ } else if (isTouchDevice) {
61
+ onPreview();
62
+ }
63
+ };
64
+
65
+ return (
66
+ <Row
67
+ aria-label={word.word}
68
+ data-testid={`word-${word.x}-${word.y}-${word.direction}`}
69
+ highlighted={isSelected}
70
+ inactive={!isMatching}
71
+ style={style}
72
+ onBlur={highlightFollowsPointer ? handleClear : undefined}
73
+ onClick={highlightFollowsPointer ? undefined : handleClick}
74
+ onFocus={highlightFollowsPointer ? handleSet : undefined}
75
+ onMouseEnter={highlightFollowsPointer ? handleSet : undefined}
76
+ >
77
+ <Cell className={styles.coordinates} translationKey="settings.showCoordinates" value={coordinates} />
78
+
79
+ <Cell primary translationKey="common.word" value={word.word}>
80
+ <Highlighter highlightClassName={styles.highlight} searchWords={[query]} textToHighlight={word.word} />
81
+ </Cell>
82
+
83
+ <Cell className={styles.stat} value={validityLabel}>
84
+ <Icon
85
+ aria-label={validityLabel}
86
+ className={classNames(styles.icon, word.isValid ? styles.valid : styles.invalid)}
87
+ role="img"
88
+ />
89
+ </Cell>
90
+ </Row>
91
+ );
92
+ };
@@ -0,0 +1,69 @@
1
+ @use 'styles/mixins' as *;
2
+
3
+ .words {
4
+ position: relative;
5
+ display: flex;
6
+ flex-direction: column;
7
+ height: 100%;
8
+ background: var(--color--background--element);
9
+ border: var(--border);
10
+ border-radius: var(--border--radius);
11
+ }
12
+
13
+ .content {
14
+ flex: 1;
15
+ position: relative;
16
+ height: 100%;
17
+ }
18
+
19
+ .listContainer {
20
+ position: absolute;
21
+ inset: 0;
22
+ }
23
+
24
+ .list {
25
+ @include scrollbars;
26
+ }
27
+
28
+ .stat {
29
+ $width: 55px;
30
+
31
+ flex: 0 0 $width;
32
+ max-width: $width;
33
+ justify-content: center;
34
+ }
35
+
36
+ .coordinates {
37
+ $width: 55px;
38
+
39
+ flex: 0 0 $width;
40
+ max-width: $width;
41
+ }
42
+
43
+ .invalid {
44
+ color: var(--color--error);
45
+ }
46
+
47
+ .valid {
48
+ color: var(--color--success);
49
+ }
50
+
51
+ .icon {
52
+ $size: 24px;
53
+
54
+ display: block;
55
+ width: $size;
56
+ height: $size;
57
+ }
58
+
59
+ .input {
60
+ border-top: var(--border);
61
+ border-bottom-left-radius: var(--border--radius);
62
+ border-bottom-right-radius: var(--border--radius);
63
+ }
64
+
65
+ .highlight {
66
+ background-color: var(--color--mark);
67
+ color: var(--color--foreground);
68
+ border-radius: var(--border--radius);
69
+ }
@@ -0,0 +1,16 @@
1
+ // Generated by scripts/generate-scss-types.ts - do not edit.
2
+ declare const styles: {
3
+ readonly content: string;
4
+ readonly coordinates: string;
5
+ readonly highlight: string;
6
+ readonly icon: string;
7
+ readonly input: string;
8
+ readonly invalid: string;
9
+ readonly list: string;
10
+ readonly listContainer: string;
11
+ readonly stat: string;
12
+ readonly valid: string;
13
+ readonly words: string;
14
+ };
15
+
16
+ export default styles;
@@ -0,0 +1,137 @@
1
+ import { isSameBoardWord } from '@scrabble-solver/types';
2
+ import classNames from 'classnames';
3
+ import { type FunctionComponent, useCallback, useEffect, useMemo } from 'react';
4
+ import { useDispatch } from 'react-redux';
5
+ import { List } from 'react-window';
6
+
7
+ import { EmptyState } from '@/components/EmptyState';
8
+ import { Header, HeaderButton, Search } from '@/components/Table';
9
+ import { useIsCompactLayout } from '@/hooks/useIsCompactLayout';
10
+ import { useIsTouchDevice } from '@/hooks/useIsTouchDevice';
11
+ import { LOCALE_FEATURES } from '@/i18n/constants';
12
+ import GeoAlt from '@/icons/GeoAlt.svg';
13
+ import QuestionSquare from '@/icons/QuestionSquare.svg';
14
+ import { RESULTS_ITEM_HEIGHT, RESULTS_OVERSCAN_COUNT } from '@/parameters';
15
+ import {
16
+ hoveredWordSlice,
17
+ selectHoveredWord,
18
+ selectLocale,
19
+ selectProcessedWords,
20
+ selectWordsQuery,
21
+ selectWordsSort,
22
+ useTranslate,
23
+ useTypedSelector,
24
+ verifySlice,
25
+ } from '@/state';
26
+ import { WordColumnId } from '@/types';
27
+
28
+ import { WordRow, type WordRowData } from './WordRow';
29
+ import styles from './WordsTable.module.scss';
30
+
31
+ interface Props {
32
+ className?: string;
33
+ isOpen: boolean;
34
+ onPreview: () => void;
35
+ }
36
+
37
+ export const WordsTable: FunctionComponent<Props> = ({ className, isOpen, onPreview }) => {
38
+ const dispatch = useDispatch();
39
+ const translate = useTranslate();
40
+ const isTouchDevice = useIsTouchDevice();
41
+ const isCompactLayout = useIsCompactLayout();
42
+ const highlightFollowsPointer = !isTouchDevice && !isCompactLayout;
43
+ const locale = useTypedSelector(selectLocale);
44
+ const { direction } = LOCALE_FEATURES[locale];
45
+ const words = useTypedSelector(selectProcessedWords);
46
+ const query = useTypedSelector(selectWordsQuery);
47
+ const sort = useTypedSelector(selectWordsSort);
48
+ const hoveredWord = useTypedSelector(selectHoveredWord);
49
+ const highlightedIndex = hoveredWord ? words.findIndex((word) => isSameBoardWord(word, hoveredWord)) : -1;
50
+ const rowProps = useMemo<WordRowData>(
51
+ () => ({ highlightFollowsPointer, highlightedIndex, isTouchDevice, words, onPreview }),
52
+ [highlightFollowsPointer, highlightedIndex, isTouchDevice, words, onPreview],
53
+ );
54
+
55
+ const handleSort = useCallback(
56
+ (columnId: WordColumnId) => {
57
+ dispatch(verifySlice.actions.sort(columnId));
58
+ },
59
+ [dispatch],
60
+ );
61
+
62
+ const handleQueryChange = useCallback(
63
+ (newQuery: string) => {
64
+ dispatch(verifySlice.actions.changeQuery(newQuery));
65
+ },
66
+ [dispatch],
67
+ );
68
+
69
+ const handleMouseLeave = useCallback(() => {
70
+ dispatch(hoveredWordSlice.actions.clear());
71
+ }, [dispatch]);
72
+
73
+ useEffect(() => {
74
+ if (isOpen && isCompactLayout && words.length > 0 && highlightedIndex === -1) {
75
+ dispatch(hoveredWordSlice.actions.set(words[0]));
76
+ }
77
+ }, [dispatch, highlightedIndex, isOpen, isCompactLayout, words]);
78
+
79
+ return (
80
+ <div
81
+ aria-label={translate('words')}
82
+ className={classNames(styles.words, className)}
83
+ data-testid="words"
84
+ role="region"
85
+ >
86
+ <Header>
87
+ <HeaderButton
88
+ className={styles.coordinates}
89
+ Icon={GeoAlt}
90
+ id={WordColumnId.Coordinates}
91
+ sort={sort}
92
+ translationKey="settings.showCoordinates"
93
+ onSort={handleSort}
94
+ />
95
+
96
+ <HeaderButton id={WordColumnId.Word} primary sort={sort} translationKey="common.word" onSort={handleSort} />
97
+
98
+ <HeaderButton
99
+ className={styles.stat}
100
+ Icon={QuestionSquare}
101
+ id={WordColumnId.Validity}
102
+ sort={sort}
103
+ translationKey="words.validity"
104
+ onSort={handleSort}
105
+ />
106
+ </Header>
107
+
108
+ <div aria-live="polite" className={styles.content}>
109
+ {words.length === 0 && <EmptyState variant="info">{translate('words.empty-state.no-words')}</EmptyState>}
110
+
111
+ {words.length > 0 && (
112
+ <div className={styles.listContainer}>
113
+ <List
114
+ className={styles.list}
115
+ dir={direction}
116
+ overscanCount={RESULTS_OVERSCAN_COUNT}
117
+ rowComponent={WordRow}
118
+ rowCount={words.length}
119
+ rowHeight={RESULTS_ITEM_HEIGHT}
120
+ rowProps={rowProps}
121
+ onMouseLeave={highlightFollowsPointer ? handleMouseLeave : undefined}
122
+ />
123
+ </div>
124
+ )}
125
+ </div>
126
+
127
+ {words.length > 0 && (
128
+ <Search
129
+ className={styles.input}
130
+ placeholder={translate('words.input.placeholder')}
131
+ value={query}
132
+ onChange={handleQueryChange}
133
+ />
134
+ )}
135
+ </div>
136
+ );
137
+ };
@@ -0,0 +1 @@
1
+ export { WordsTable } from './WordsTable';
@@ -0,0 +1 @@
1
+ export { WordsTable } from './WordsTable';
@@ -5,6 +5,7 @@ import { Board, type Config, type Game, type Locale, isBoardJson, isGame, isLoca
5
5
  import { type NextApiRequest, type NextApiResponse } from 'next';
6
6
 
7
7
  import { getServerLoggingData, isBoardValid } from '@/api';
8
+ import { type VerifiedWord } from '@/types';
8
9
 
9
10
  interface RequestData {
10
11
  board: Board;
@@ -31,15 +32,15 @@ const verify = async (request: NextApiRequest, response: NextApiResponse): Promi
31
32
  });
32
33
 
33
34
  const gaddag = await dictionaries.get(locale);
34
- const words = board.getWords().sort((a, b) => a.localeCompare(b, locale));
35
- const invalidWords: string[] = [];
36
- const validWords: string[] = [];
35
+ const words = board.getWords().sort((a, b) => a.word.localeCompare(b.word, locale));
36
+ const invalidWords: VerifiedWord[] = [];
37
+ const validWords: VerifiedWord[] = [];
37
38
 
38
39
  for (const word of words) {
39
- if (gaddag.has(word)) {
40
- validWords.push(word);
40
+ if (gaddag.has(word.word)) {
41
+ validWords.push({ ...word, isValid: true });
41
42
  } else {
42
- invalidWords.push(word);
43
+ invalidWords.push({ ...word, isValid: false });
43
44
  }
44
45
  }
45
46