Extra Practice

Practice your current level's Radicals and Kanji with standard, english -> Kanji, and combination mode!

  1. (function () {
  2. 'use strict';
  3.  
  4. // ==UserScript==
  5. // @name Extra Practice
  6. // @namespace https://github.com/mrpassiontea/Extra-Practice
  7. // @version 2.0.0
  8. // @description Practice your current level's Radicals and Kanji with standard, english -> Kanji, and combination mode!
  9. // @author @mrpassiontea
  10. // @match https://www.wanikani.com/
  11. // @match *://*.wanikani.com/dashboard
  12. // @match *://*.wanikani.com/dashboard?*
  13. // @copyright 2025, mrpassiontea
  14. // @grant none
  15. // @grant window.onurlchange
  16. // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js
  17. // @require https://unpkg.com/wanakana@5.3.1/wanakana.min.js
  18. // @license MIT; http://opensource.org/licenses/MIT
  19. // @run-at document-end
  20. // ==/UserScript==
  21.  
  22.  
  23. const SELECTORS = {
  24. DIV_LEVEL_PROGRESS_CONTENT: "div.wk-panel__content div.level-progress-dashboard",
  25. DIV_CONTENT_WRAPPER: "div.level-progress-dashboard__content",
  26. DIV_CONTENT_TITLE: "div.level-progress-dashboard__content-title"
  27. };
  28.  
  29. const DB_VALUES = {
  30. DB_NAME: "wkof.file_cache",
  31. USER_RECORD: "Apiv2.user",
  32. SUBJECT_RECORD: "Apiv2.subjects",
  33. FILE_STORE: "files"
  34. };
  35.  
  36. const DB_ERRORS = {
  37. OPEN: "Failed to open database",
  38. USER_LEVEL: "Failed to retrieve user level",
  39. SUBJECT_DATA: "Failed to retrieve subjects data"
  40. };
  41.  
  42. const PRACTICE_MODES = {
  43. STANDARD: 'standard',
  44. ENGLISH_TO_KANJI: 'englishToKanji',
  45. COMBINED: 'combined'
  46. };
  47.  
  48. const modalTemplate = `
  49. <div id="ep-practice-modal">
  50. <div id="ep-practice-modal-content">
  51. <div id="ep-practice-modal-welcome">
  52. <h1>Hello, <span id="username"></span></h1>
  53. <h2>Please select all the Radicals that you would like to include in your practice session</h2>
  54. </div>
  55. <button id="ep-practice-modal-select-all">Select All</button>
  56. <div id="ep-practice-modal-grid"></div>
  57. <div id="ep-practice-modal-footer">
  58. <button id="ep-practice-modal-start" disabled>Start Review (0 Selected)</button>
  59. <button id="ep-practice-modal-close">Exit</button>
  60. </div>
  61. </div>
  62. </div>
  63. `;
  64.  
  65. const reviewModalTemplate = `
  66. <div id="ep-review-modal">
  67. <div id="ep-review-modal-wrapper">
  68. <div id="ep-review-modal-header">
  69. <div id="ep-review-progress">
  70. <span id="ep-review-progress-correct">0</span>
  71. </div>
  72. <button id="ep-review-exit">End Review</button>
  73. </div>
  74.  
  75. <div id="ep-review-content">
  76. <div id="ep-review-character"></div>
  77.  
  78. <div id="ep-review-input-section">
  79. <input type="text" id="ep-review-answer" placeholder="Enter meaning..." tabindex="1" autofocus />
  80. <button id="ep-review-submit" tabindex="2">Submit</button>
  81. </div>
  82.  
  83. <div id="ep-review-result" style="display: none;">
  84. <div id="ep-review-result-message"></div>
  85. <button id="ep-review-show-hint" style="display: none;">Show Answer</button>
  86. </div>
  87.  
  88. <div id="ep-review-explanation" style="display: none;">
  89. <h3>
  90. <span id="ep-review-meaning-label">Meaning:</span>
  91. <span id="ep-review-meaning"></span>
  92. </h3>
  93. <div class="mnemonic-container">
  94. <span id="ep-review-mnemonic-label">Mnemonic:</span>
  95. <div id="ep-review-mnemonic"></div>
  96. </div>
  97. </div>
  98. </div>
  99. </div>
  100. </div>
  101. `;
  102.  
  103. // Theme constants for consistent values across the application
  104. const theme = {
  105. colors: {
  106. radical: "#0598e4",
  107. kanji: "#eb019c",
  108. white: "#FFFFFF",
  109. black: "#000000",
  110. gray: {
  111. 100: "#F3F4F6",
  112. 200: "#E5E7EB",
  113. 300: "#D1D5DB",
  114. 400: "#9CA3AF",
  115. 600: "#4B5563",
  116. 700: "#374151",
  117. 800: "#1F2937"},
  118. overlay: {
  119. dark: "rgba(0, 0, 0, 0.9)"},
  120. success: "#10B981",
  121. error: "#EF4444",
  122. info: "#3B82F6"
  123. },
  124. spacing: {
  125. xs: "0.5rem", // 8px
  126. sm: "0.75rem", // 12px
  127. md: "1rem", // 16px
  128. lg: "1.5rem", // 24px
  129. xl: "2rem"},
  130. typography: {
  131. fontSize: {
  132. xs: "0.875rem", // 14px
  133. sm: "1rem", // 16px
  134. md: "1.25rem", // 20px
  135. lg: "1.5rem", // 24px
  136. xl: "2rem", // 32px
  137. "2xl": "6rem" // 96px (for the big character display)
  138. },
  139. fontWeight: {
  140. normal: "400",
  141. medium: "500",
  142. bold: "700"
  143. }
  144. },
  145. borderRadius: {
  146. sm: "3px",
  147. md: "4px",
  148. lg: "8px"
  149. },
  150. zIndex: {
  151. modal: 99999
  152. }
  153. };
  154.  
  155. // Common style mixins for reusable patterns
  156. const mixins = {
  157. modalBackdrop: {
  158. position: "fixed",
  159. top: "0",
  160. left: "0",
  161. width: "100%",
  162. height: "100%",
  163. zIndex: theme.zIndex.modal
  164. }};
  165.  
  166. // Component-specific styles
  167. const styles = {
  168. layout: {
  169. contentTitle: {
  170. display: "flex",
  171. justifyContent: "space-between",
  172. alignItems: "center"
  173. }
  174. },
  175.  
  176. buttons: {
  177. practice: {
  178. radical: {
  179. marginBottom: theme.spacing.md,
  180. backgroundColor: theme.colors.radical,
  181. padding: theme.spacing.sm,
  182. borderRadius: theme.borderRadius.sm,
  183. color: theme.colors.white,
  184. fontWeight: theme.typography.fontWeight.medium,
  185. cursor: "pointer"
  186. },
  187. kanji: {
  188. marginBottom: theme.spacing.md,
  189. backgroundColor: theme.colors.kanji,
  190. padding: theme.spacing.sm,
  191. borderRadius: theme.borderRadius.sm,
  192. color: theme.colors.white,
  193. fontWeight: theme.typography.fontWeight.medium,
  194. cursor: "pointer"
  195. }
  196. }
  197. },
  198.  
  199. practiceModal: {
  200. backdrop: {
  201. ...mixins.modalBackdrop,
  202. backgroundColor: theme.colors.overlay.dark,
  203. display: "flex",
  204. flexDirection: "column",
  205. justifyContent: "center",
  206. alignItems: "center"
  207. },
  208. contentWrapper: {
  209. width: "100%",
  210. maxWidth: "800px",
  211. padding: `0 ${theme.spacing.xl}`,
  212. display: "flex",
  213. flexDirection: "column",
  214. alignItems: "center"
  215. },
  216. welcomeText: {
  217. container: {
  218. color: theme.colors.white,
  219. textAlign: "center",
  220. fontSize: theme.typography.fontSize.sm,
  221. marginBottom: theme.spacing.md,
  222. display: "flex",
  223. flexDirection: "column",
  224. alignItems: "center",
  225. maxWidth: "750px"
  226. },
  227. username: {
  228. fontSize: theme.typography.fontSize.xl,
  229. marginBottom: theme.spacing.md
  230. }
  231. },
  232. grid: {
  233. display: "grid",
  234. gridTemplateColumns: "repeat(5, minmax(100px, 1fr))",
  235. gap: theme.spacing.md,
  236. padding: `${theme.spacing.md} ${theme.spacing.xl}`,
  237. maxHeight: "50vh",
  238. maxWidth: "600px",
  239. margin: "0 auto",
  240. justifyContent: "center"
  241. },
  242. radical: {
  243. base: {
  244. background: "rgba(255, 255, 255, 0.1)",
  245. border: "2px solid rgba(255, 255, 255, 0.2)",
  246. borderRadius: theme.borderRadius.lg,
  247. padding: theme.spacing.md,
  248. cursor: "pointer",
  249. display: "flex",
  250. flexDirection: "column",
  251. alignItems: "center",
  252. transition: "all 0.2s ease"
  253. },
  254. selected: {
  255. background: "rgba(5, 152, 228, 0.3)",
  256. border: `2px solid ${theme.colors.radical}`
  257. },
  258. character: {
  259. fontSize: theme.typography.fontSize.xl,
  260. color: theme.colors.white
  261. }},
  262. buttons: {
  263. start: {
  264. base: {
  265. padding: `${theme.spacing.sm} ${theme.spacing.lg}`,
  266. borderRadius: theme.borderRadius.md,
  267. border: "none",
  268. fontWeight: theme.typography.fontWeight.medium,
  269. transition: "all 0.2s ease",
  270. cursor: "pointer",
  271. color: theme.colors.white
  272. },
  273. radical: {
  274. backgroundColor: theme.colors.radical,
  275. '&:hover': {
  276. backgroundColor: theme.colors.radical,
  277. opacity: 0.9
  278. }
  279. },
  280. kanji: {
  281. backgroundColor: theme.colors.kanji,
  282. '&:hover': {
  283. backgroundColor: theme.colors.kanji,
  284. opacity: 0.9
  285. }
  286. }
  287. },
  288. selectAll: {
  289. color: theme.colors.white,
  290. background: "transparent",
  291. border: `1px solid ${theme.colors.white}`,
  292. cursor: "pointer",
  293. fontSize: theme.typography.fontSize.xs,
  294. marginBottom: theme.spacing.md,
  295. padding: theme.spacing.sm,
  296. borderRadius: theme.borderRadius.sm,
  297. fontWeight: theme.typography.fontWeight.bold,
  298. transition: "all 0.2s ease"
  299. },
  300. exit: {
  301. border: `1px solid ${theme.colors.white}`,
  302. backgroundColor: "rgba(255, 255, 255, 0.9)",
  303. padding: `${theme.spacing.sm} ${theme.spacing.md}`,
  304. color: theme.colors.black,
  305. fontWeight: theme.typography.fontWeight.medium,
  306. borderRadius: theme.borderRadius.sm,
  307. cursor: "pointer",
  308. transition: "all 0.2s ease"
  309. }
  310. },
  311. footer: {
  312. padding: `${theme.spacing.md} ${theme.spacing.xl}`,
  313. display: "flex",
  314. justifyContent: "center",
  315. width: "100%",
  316. maxWidth: "600px",
  317. gap: theme.spacing.md
  318. },
  319. modeSelector: {
  320. container: {
  321. display: "flex",
  322. flexDirection: "column",
  323. alignItems: "center",
  324. marginBottom: theme.spacing.xl,
  325. width: "100%",
  326. maxWidth: "600px"
  327. },
  328. label: {
  329. color: theme.colors.white,
  330. fontSize: theme.typography.fontSize.md,
  331. marginBottom: theme.spacing.md
  332. },
  333. options: {
  334. display: "flex",
  335. gap: theme.spacing.md,
  336. justifyContent: "center",
  337. width: "100%"
  338. },
  339. option: {
  340. base: {
  341. padding: `${theme.spacing.sm} ${theme.spacing.md}`,
  342. borderRadius: theme.borderRadius.md,
  343. border: `2px solid ${theme.colors.gray[400]}`,
  344. backgroundColor: "transparent",
  345. color: theme.colors.white,
  346. cursor: "pointer",
  347. transition: "all 0.2s ease",
  348. fontSize: theme.typography.fontSize.sm,
  349. fontWeight: theme.typography.fontWeight.medium,
  350. '&:hover': {
  351. borderColor: theme.colors.kanji,
  352. backgroundColor: "rgba(235, 1, 156, 0.1)"
  353. }
  354. },
  355. selected: {
  356. borderColor: theme.colors.kanji,
  357. backgroundColor: "rgba(235, 1, 156, 0.2)"
  358. }
  359. }
  360. }},
  361.  
  362. reviewModal: {
  363. container: {
  364. backgroundColor: theme.colors.white,
  365. borderRadius: theme.borderRadius.lg,
  366. boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
  367. maxWidth: "600px",
  368. width: "100%",
  369. display: "flex",
  370. flexDirection: "column"
  371. },
  372. header: {
  373. display: "flex",
  374. justifyContent: "space-between",
  375. alignItems: "center",
  376. padding: theme.spacing.lg,
  377. borderBottom: `1px solid ${theme.colors.gray[200]}`,
  378. gap: theme.spacing.md
  379. },
  380. progress: {
  381. fontWeight: theme.typography.fontWeight.bold,
  382. fontSize: theme.typography.fontSize.md,
  383. color: theme.colors.gray[800]
  384. },
  385. content: {
  386. padding: theme.spacing.xl,
  387. display: "flex",
  388. flexDirection: "column",
  389. width: "100%",
  390. gap: theme.spacing.xl,
  391. },
  392. character: {
  393. fontSize: theme.typography.fontSize["2xl"],
  394. color: theme.colors.gray[800],
  395. marginBottom: theme.spacing.xl,
  396. textAlign: "center"
  397. },
  398. inputSection: {
  399. width: "100%",
  400. display: "flex",
  401. gap: theme.spacing.md,
  402. marginBottom: theme.spacing.xl
  403. },
  404. input: {
  405. flex: "1",
  406. padding: theme.spacing.sm,
  407. fontSize: theme.typography.fontSize.sm,
  408. borderRadius: theme.borderRadius.md,
  409. border: `1px solid ${theme.colors.gray[300]}`
  410. },
  411. buttons: {
  412. submit: {
  413. backgroundColor: theme.colors.info,
  414. color: theme.colors.white,
  415. padding: `${theme.spacing.sm} ${theme.spacing.lg}`,
  416. borderRadius: theme.borderRadius.md,
  417. border: "none",
  418. fontWeight: theme.typography.fontWeight.medium,
  419. cursor: "pointer",
  420. transition: "background-color 0.2s ease",
  421. "&:hover": {
  422. backgroundColor: "#2563EB"
  423. }
  424. },
  425. exit: {
  426. backgroundColor: "transparent",
  427. color: theme.colors.kanji,
  428. border: `1px solid ${theme.colors.kanji}`,
  429. borderRadius: theme.borderRadius.md,
  430. padding: `${theme.spacing.sm} ${theme.spacing.lg}`,
  431. fontWeight: theme.typography.fontWeight.medium,
  432. cursor: "pointer",
  433. transition: "background-color 0.2s ease",
  434. "&:hover": {
  435. backgroundColor: theme.colors.gray[100]
  436. }
  437. },
  438. hint: {
  439. backgroundColor: "transparent",
  440. color: theme.colors.info,
  441. border: `1px solid ${theme.colors.info}`,
  442. borderRadius: theme.borderRadius.md,
  443. padding: `${theme.spacing.sm} ${theme.spacing.lg}`,
  444. cursor: "pointer",
  445. transition: "background-color 0.2s ease",
  446. "&:hover": {
  447. backgroundColor: theme.colors.gray[100]
  448. }
  449. }
  450. },
  451. results: {
  452. message: {
  453. fontSize: theme.typography.fontSize.lg,
  454. fontWeight: theme.typography.fontWeight.bold,
  455. marginBottom: theme.spacing.md,
  456. color: theme.colors.info,
  457. textAlign: "center",
  458. "&.correct": {
  459. color: theme.colors.success
  460. },
  461. "&.incorrect": {
  462. color: theme.colors.error
  463. }
  464. }
  465. },
  466. explanation: {
  467. lineHeight: "1.6",
  468. color: theme.colors.gray[600],
  469. fontSize: theme.typography.fontSize.md,
  470. meaningLabel: {
  471. display: "inline-block",
  472. fontWeight: theme.typography.fontWeight.normal,
  473. fontSize: theme.typography.fontSize.md,
  474. color: theme.colors.gray[800],
  475. marginRight: theme.spacing.xs
  476. },
  477. meaningText: {
  478. display: "inline-block",
  479. fontWeight: theme.typography.fontWeight.bold,
  480. fontSize: theme.typography.fontSize.md,
  481. color: theme.colors.radical[800],
  482. textDecoration: "none"
  483. },
  484. mnemonicContainer: {
  485. marginTop: theme.spacing.md,
  486. textAlign: "left",
  487. lineHeight: "1.6"
  488. },
  489. mnemonicLabel: {
  490. display: "block",
  491. fontWeight: theme.typography.fontWeight.bold,
  492. fontSize: theme.typography.fontSize.md,
  493. color: theme.colors.gray[800],
  494. marginBottom: theme.spacing.xs
  495. },
  496. mnemonic: {
  497. color: theme.colors.gray[600],
  498. fontSize: theme.typography.fontSize.md
  499. },
  500. mnemonicHighlight: {
  501. backgroundColor: theme.colors.gray[200],
  502. padding: `0 ${theme.spacing.xs}`,
  503. borderRadius: theme.borderRadius.sm,
  504. color: theme.colors.gray[800]
  505. }
  506. },
  507. kanjiOption: {
  508. base: {
  509. padding: theme.spacing.lg,
  510. borderRadius: theme.borderRadius.md,
  511. border: `2px solid ${theme.colors.gray[300]}`,
  512. backgroundColor: theme.colors.white,
  513. display: "flex",
  514. alignItems: "center",
  515. justifyContent: "center",
  516. transition: "all 0.2s ease",
  517. '&:hover': {
  518. borderColor: theme.colors.kanji,
  519. backgroundColor: "rgba(235, 1, 156, 0.1)"
  520. }
  521. },
  522. selected: {
  523. borderColor: theme.colors.kanji,
  524. backgroundColor: "rgba(235, 1, 156, 0.2)"
  525. }
  526. }
  527. }
  528. };
  529.  
  530. const PRACTICE_TYPES = {
  531. RADICAL: "radical",
  532. KANJI: "kanji"
  533. };
  534.  
  535. const MODAL_STATES$1 = {
  536. READY: "ready"
  537. };
  538.  
  539. const EVENTS$1 = {
  540. CLOSE: "close",
  541. START_REVIEW: "startReview"
  542. };
  543.  
  544. class BaseReviewSession {
  545. constructor(selectedItems) {
  546. if (new.target === BaseReviewSession) {
  547. throw new Error("BaseReviewSession is an abstract class and cannot be instantiated directly.");
  548. }
  549. this.originalItems = selectedItems;
  550. this.currentItem = null;
  551. }
  552.  
  553. shuffleArray(array) {
  554. for (let i = array.length - 1; i > 0; i--) {
  555. const j = Math.floor(Math.random() * (i + 1));
  556. [array[i], array[j]] = [array[j], array[i]];
  557. }
  558. return array;
  559. }
  560.  
  561. nextItem() {
  562. throw new Error("nextItem() must be implemented by derived classes");
  563. }
  564.  
  565. checkAnswer(userAnswer) {
  566. throw new Error("checkAnswer() must be implemented by derived classes");
  567. }
  568.  
  569. isComplete() {
  570. throw new Error("isComplete() must be implemented by derived classes");
  571. }
  572.  
  573. getProgress() {
  574. throw new Error("getProgress() must be implemented by derived classes");
  575. }
  576. }
  577.  
  578. class KanjiReviewSession extends BaseReviewSession {
  579. constructor(config) {
  580. super(config.items);
  581. this.mode = config.mode || PRACTICE_MODES.STANDARD;
  582. this.allUnlockedKanji = config.allUnlockedKanji || [];
  583. this.allCards = [];
  584. this.remainingItems = [];
  585. // Progress tracking
  586. this.correctMeanings = new Set();
  587. this.correctReadings = new Set();
  588. this.correctRecognition = new Set();
  589. // Initialize cards based on mode
  590. this.initializeCards();
  591. }
  592.  
  593. initializeCards() {
  594. switch (this.mode) {
  595. case PRACTICE_MODES.STANDARD:
  596. this.initializeStandardCards();
  597. break;
  598. case PRACTICE_MODES.ENGLISH_TO_KANJI:
  599. this.initializeRecognitionCards();
  600. break;
  601. case PRACTICE_MODES.COMBINED:
  602. this.initializeStandardCards();
  603. this.initializeRecognitionCards();
  604. break;
  605. }
  606. // Shuffle all cards together
  607. this.remainingItems = this.shuffleArray([...this.allCards]);
  608. }
  609.  
  610. initializeStandardCards() {
  611. this.originalItems.forEach(kanji => {
  612. // Add meaning card
  613. this.allCards.push({
  614. ...kanji,
  615. type: "meaning",
  616. questionType: "What is the meaning of this kanji?"
  617. });
  618. // Add reading card
  619. this.allCards.push({
  620. ...kanji,
  621. type: "reading",
  622. questionType: "What is the reading of this kanji?"
  623. });
  624. });
  625. }
  626.  
  627. initializeRecognitionCards() {
  628. this.originalItems.forEach(kanji => {
  629. const primaryMeaning = kanji.meanings.find(m => m.primary)?.meaning;
  630. // Create recognition card
  631. this.allCards.push({
  632. ...kanji,
  633. type: "recognition",
  634. questionType: "Select the kanji that means",
  635. meaningToMatch: primaryMeaning,
  636. options: this.generateKanjiOptions(kanji)
  637. });
  638. });
  639. }
  640.  
  641. generateKanjiOptions(correctKanji) {
  642. const numberOfOptions = 4;
  643. const options = [correctKanji];
  644. // Create a pool of incorrect options from the selected kanji
  645. const availableOptions = this.originalItems.filter(k => k.id !== correctKanji.id);
  646.  
  647. // Randomly select additional options from the available pool
  648. while (options.length < numberOfOptions && availableOptions.length > 0) {
  649. const randomIndex = Math.floor(Math.random() * availableOptions.length);
  650. const selectedOption = availableOptions[randomIndex];
  651. options.push(selectedOption);
  652. availableOptions.splice(randomIndex, 1);
  653. }
  654.  
  655. // If we still need more options (rare case when very few kanji are selected)
  656. // fill remaining slots with kanji from allUnlockedKanji
  657. if (options.length < numberOfOptions) {
  658. const additionalOptions = this.allUnlockedKanji.filter(k =>
  659. !options.some(selected => selected.id === k.id) &&
  660. !this.originalItems.some(selected => selected.id === k.id)
  661. );
  662.  
  663. while (options.length < numberOfOptions && additionalOptions.length > 0) {
  664. const randomIndex = Math.floor(Math.random() * additionalOptions.length);
  665. const selectedOption = additionalOptions[randomIndex];
  666. options.push(selectedOption);
  667. additionalOptions.splice(randomIndex, 1);
  668. }
  669. }
  670. return this.shuffleArray(options);
  671. }
  672.  
  673. nextItem() {
  674. if (this.remainingItems.length === 0) {
  675. // Get items that haven't been answered correctly
  676. const remainingUnlearned = [];
  677. this.originalItems.forEach(kanji => {
  678. switch (this.mode) {
  679. case PRACTICE_MODES.STANDARD:
  680. if (!this.correctMeanings.has(kanji.id)) {
  681. remainingUnlearned.push({
  682. ...kanji,
  683. type: "meaning",
  684. questionType: "What is the meaning of this kanji?"
  685. });
  686. }
  687. if (!this.correctReadings.has(kanji.id)) {
  688. remainingUnlearned.push({
  689. ...kanji,
  690. type: "reading",
  691. questionType: "What is the reading of this kanji?"
  692. });
  693. }
  694. break;
  695. case PRACTICE_MODES.ENGLISH_TO_KANJI:
  696. if (!this.correctRecognition.has(kanji.id)) {
  697. const primaryMeaning = kanji.meanings.find(m => m.primary)?.meaning;
  698. remainingUnlearned.push({
  699. ...kanji,
  700. type: "recognition",
  701. questionType: "Select the kanji that means",
  702. meaningToMatch: primaryMeaning,
  703. options: this.generateKanjiOptions(kanji)
  704. });
  705. }
  706. break;
  707. case PRACTICE_MODES.COMBINED:
  708. if (!this.correctMeanings.has(kanji.id)) {
  709. remainingUnlearned.push({
  710. ...kanji,
  711. type: "meaning",
  712. questionType: "What is the meaning of this kanji?"
  713. });
  714. }
  715. if (!this.correctReadings.has(kanji.id)) {
  716. remainingUnlearned.push({
  717. ...kanji,
  718. type: "reading",
  719. questionType: "What is the reading of this kanji?"
  720. });
  721. }
  722. if (!this.correctRecognition.has(kanji.id)) {
  723. const primaryMeaning = kanji.meanings.find(m => m.primary)?.meaning;
  724. remainingUnlearned.push({
  725. ...kanji,
  726. type: "recognition",
  727. questionType: "Select the kanji that means",
  728. meaningToMatch: primaryMeaning,
  729. options: this.generateKanjiOptions(kanji)
  730. });
  731. }
  732. break;
  733. }
  734. });
  735.  
  736. // Shuffle the remaining items
  737. if (remainingUnlearned.length > 0) {
  738. this.remainingItems = this.shuffleArray(remainingUnlearned);
  739. }
  740. }
  741.  
  742. this.currentItem = this.remainingItems.shift();
  743. return this.currentItem;
  744. }
  745.  
  746. checkAnswer(userAnswer) {
  747. if (!this.currentItem) return false;
  748.  
  749. let isCorrect = false;
  750.  
  751. switch (this.currentItem.type) {
  752. case "meaning":
  753. isCorrect = this.checkMeaningAnswer(userAnswer);
  754. if (isCorrect) this.correctMeanings.add(this.currentItem.id);
  755. break;
  756. case "reading":
  757. isCorrect = this.checkReadingAnswer(userAnswer);
  758. if (isCorrect) this.correctReadings.add(this.currentItem.id);
  759. break;
  760. case "recognition":
  761. isCorrect = parseInt(userAnswer) === this.currentItem.id;
  762. if (isCorrect) this.correctRecognition.add(this.currentItem.id);
  763. break;
  764. }
  765.  
  766. return isCorrect;
  767. }
  768.  
  769. checkMeaningAnswer(userAnswer) {
  770. const normalizedUserAnswer = userAnswer.toLowerCase().trim();
  771. // Check primary meanings
  772. const isPrimaryCorrect = this.currentItem.meanings.some(m =>
  773. m.meaning.toLowerCase() === normalizedUserAnswer
  774. );
  775. if (isPrimaryCorrect) return true;
  776. // Check auxiliary meanings
  777. return this.currentItem.auxiliaryMeanings.some(m =>
  778. m.meaning.toLowerCase() === normalizedUserAnswer
  779. );
  780. }
  781.  
  782. checkReadingAnswer(userAnswer) {
  783. const userReading = userAnswer.trim();
  784. return this.currentItem.readings.some(r => r.reading === userReading);
  785. }
  786.  
  787. isComplete() {
  788. const progress = this.getProgress();
  789. return progress.current === progress.total;
  790. }
  791.  
  792. getProgress() {
  793. const totalKanji = this.originalItems.length;
  794. let total, current;
  795.  
  796. switch (this.mode) {
  797. case PRACTICE_MODES.STANDARD:
  798. total = totalKanji * 2; // One point each for meaning and reading
  799. current = this.correctMeanings.size + this.correctReadings.size;
  800. return {
  801. total,
  802. current,
  803. meaningProgress: this.correctMeanings.size,
  804. readingProgress: this.correctReadings.size
  805. };
  806.  
  807. case PRACTICE_MODES.ENGLISH_TO_KANJI:
  808. total = totalKanji; // One point for each recognition test
  809. current = this.correctRecognition.size;
  810. return {
  811. total,
  812. current,
  813. recognitionProgress: this.correctRecognition.size
  814. };
  815.  
  816. case PRACTICE_MODES.COMBINED:
  817. total = totalKanji * 3; // One point each for meaning, reading, and recognition
  818. current = this.correctMeanings.size +
  819. this.correctReadings.size +
  820. this.correctRecognition.size;
  821. return {
  822. total,
  823. current,
  824. meaningProgress: this.correctMeanings.size,
  825. readingProgress: this.correctReadings.size,
  826. recognitionProgress: this.correctRecognition.size
  827. };
  828.  
  829. default:
  830. return {
  831. total: 0,
  832. current: 0
  833. };
  834. }
  835. }
  836. }
  837.  
  838. class RadicalReviewSession extends BaseReviewSession {
  839. constructor(config) {
  840. super(config.items);
  841. this.remainingItems = this.shuffleArray([...config.items]);
  842. this.correctAnswers = new Set();
  843. }
  844.  
  845. nextItem() {
  846. if (this.remainingItems.length === 0) {
  847. const remainingUnlearned = this.originalItems.filter(item => !this.correctAnswers.has(item.id));
  848.  
  849. if (remainingUnlearned.length === 1) {
  850. this.remainingItems = remainingUnlearned;
  851. } else {
  852. this.remainingItems = this.shuffleArray(
  853. remainingUnlearned.filter(item => !this.currentItem || item.id !== this.currentItem.id)
  854. );
  855. }
  856. }
  857. this.currentItem = this.remainingItems.shift();
  858. return this.currentItem;
  859. }
  860.  
  861. checkAnswer(userAnswer) {
  862. const isCorrect = this.currentItem.meaning.toLowerCase() === userAnswer.toLowerCase();
  863. if (isCorrect) {
  864. this.correctAnswers.add(this.currentItem.id);
  865. }
  866. return isCorrect;
  867. }
  868.  
  869. isComplete() {
  870. return this.correctAnswers.size === this.originalItems.length;
  871. }
  872.  
  873. getProgress() {
  874. const totalRadicals = this.originalItems.length;
  875. let current = this.correctAnswers.size;
  876.  
  877. return {
  878. current,
  879. total: totalRadicals,
  880. remaining: totalRadicals - current,
  881. percentComplete: Math.round((current / totalRadicals) * 100)
  882. };
  883. }
  884. }
  885.  
  886. function disableScroll() {
  887. const scrollPosition = window.scrollY || document.documentElement.scrollTop;
  888.  
  889. $("html, body").css({
  890. overflow: "hidden",
  891. height: "100%",
  892. position: "fixed",
  893. top: `-${scrollPosition}px`,
  894. width: "100%",
  895. });
  896. }
  897.  
  898. function enableScroll() {
  899. const scrollPosition = parseInt($("html").css("top")) * -1;
  900.  
  901. $("html, body").css({
  902. overflow: "auto",
  903. height: "auto",
  904. position: "static",
  905. top: "auto",
  906. width: "auto",
  907. });
  908.  
  909. window.scrollTo(0, scrollPosition);
  910. }
  911.  
  912. // Cache for SVG content to avoid repeated fetches
  913. const svgCache = new Map();
  914.  
  915. async function loadSvgContent(url) {
  916. if (svgCache.has(url)) {
  917. return svgCache.get(url);
  918. }
  919. const response = await fetch(url);
  920. const svgContent = await response.text();
  921. svgCache.set(url, svgContent);
  922. return svgContent;
  923. }
  924.  
  925. class RadicalGrid {
  926. constructor(radicals, onSelectionChange) {
  927. this.radicals = radicals;
  928. this.selectedRadicals = new Set();
  929. this.onSelectionChange = onSelectionChange;
  930. this.$container = null;
  931. }
  932.  
  933. updateRadicalSelection($element, radical, isSelected) {
  934. $element.css(
  935. isSelected
  936. ? { ...styles.practiceModal.radical.base, ...styles.practiceModal.radical.selected }
  937. : styles.practiceModal.radical.base
  938. );
  939.  
  940. if (isSelected) {
  941. this.selectedRadicals.add(radical.id);
  942. } else {
  943. this.selectedRadicals.delete(radical.id);
  944. }
  945.  
  946. this.onSelectionChange(this.selectedRadicals);
  947. }
  948.  
  949. toggleAllRadicals(shouldSelect) {
  950. if (shouldSelect) {
  951. this.radicals.forEach(radical => this.selectedRadicals.add(radical.id));
  952. } else {
  953. this.selectedRadicals.clear();
  954. }
  955.  
  956. this.$container.find(".radical-selection-item").each((_, element) => {
  957. const $element = $(element);
  958. const radicalId = parseInt($element.data("radical-id"));
  959. this.updateRadicalSelection(
  960. $element,
  961. this.radicals.find(r => r.id === radicalId),
  962. shouldSelect
  963. );
  964. });
  965.  
  966. this.onSelectionChange(this.selectedRadicals);
  967. }
  968.  
  969. getSelectedRadicals() {
  970. return Array.from(this.selectedRadicals).map(id =>
  971. this.radicals.find(radical => radical.id === id)
  972. );
  973. }
  974.  
  975. async createRadicalElement(radical) {
  976. const $element = $("<div>")
  977. .addClass("radical-selection-item")
  978. .css(styles.practiceModal.radical.base)
  979. .data("radical-id", radical.id)
  980. .append(
  981. $("<div>")
  982. .addClass("radical-character")
  983. .css(styles.practiceModal.radical.character)
  984. .text(radical.character || "")
  985. )
  986. .on("click", () => {
  987. const isCurrentlySelected = this.selectedRadicals.has(radical.id);
  988. this.updateRadicalSelection($element, radical, !isCurrentlySelected);
  989. });
  990.  
  991. if (!radical.character && radical.svg) {
  992. try {
  993. const svgContent = await loadSvgContent(radical.svg);
  994. $element.find(".radical-character").html(svgContent);
  995. const svg = $element.find("svg")[0];
  996. if (svg) {
  997. svg.setAttribute("width", "100%");
  998. svg.setAttribute("height", "100%");
  999. }
  1000. } catch (error) {
  1001. console.error("Error loading SVG:", error);
  1002. $element.find(".radical-character").text(radical.meaning);
  1003. }
  1004. }
  1005.  
  1006. return $element;
  1007. }
  1008.  
  1009. async render() {
  1010. this.$container = $("<div>")
  1011. .css(styles.practiceModal.grid);
  1012.  
  1013. // Create and append all radical elements
  1014. const radicalElements = await Promise.all(
  1015. this.radicals.map(radical => this.createRadicalElement(radical))
  1016. );
  1017. radicalElements.forEach($element => this.$container.append($element));
  1018. return this.$container;
  1019. }
  1020. }
  1021.  
  1022. class RadicalSelectionModal {
  1023. constructor(radicals) {
  1024. this.radicals = radicals;
  1025. this.state = MODAL_STATES$1.READY;
  1026. this.totalRadicals = radicals.length;
  1027. this.$modal = null;
  1028. this.radicalGrid = null;
  1029. this.callbacks = new Map();
  1030. }
  1031.  
  1032. on(event, callback) {
  1033. this.callbacks.set(event, callback);
  1034. return this;
  1035. }
  1036.  
  1037. emit(event, data) {
  1038. const callback = this.callbacks.get(event);
  1039. if (callback) callback(data);
  1040. }
  1041.  
  1042. updateSelectAllButton(selectedCount) {
  1043. const selectAllButton = $("#ep-practice-modal-select-all");
  1044. const isAllSelected = selectedCount === this.totalRadicals;
  1045. selectAllButton
  1046. .text(isAllSelected ? "Deselect All" : "Select All")
  1047. .css({
  1048. color: isAllSelected ? theme.colors.error : theme.colors.white,
  1049. borderColor: isAllSelected ? theme.colors.error : theme.colors.white
  1050. });
  1051. }
  1052.  
  1053. updateStartButton(selectedCount) {
  1054. const startButton = $("#ep-practice-modal-start");
  1055. if (selectedCount > 0) {
  1056. startButton
  1057. .prop("disabled", false)
  1058. .text(`Start Review (${selectedCount} Selected)`)
  1059. .css({
  1060. ...styles.practiceModal.buttons.start.base,
  1061. ...styles.practiceModal.buttons.start.radical
  1062. });
  1063. } else {
  1064. startButton
  1065. .prop("disabled", true)
  1066. .text("Start Review (0 Selected)")
  1067. .css({
  1068. ...styles.practiceModal.buttons.start.base,
  1069. ...styles.practiceModal.buttons.start.radical,
  1070. ...styles.practiceModal.buttons.start.disabled
  1071. });
  1072. }
  1073. }
  1074.  
  1075. handleSelectionChange(selectedRadicals) {
  1076. const selectedCount = selectedRadicals.size;
  1077. this.updateSelectAllButton(selectedCount);
  1078. this.updateStartButton(selectedCount);
  1079. }
  1080.  
  1081. async render() {
  1082. this.$modal = $(modalTemplate).appendTo("body");
  1083. $("#username").text($("p.user-summary__username:first").text());
  1084. this.$modal.css(styles.practiceModal.backdrop);
  1085. $("#ep-practice-modal-welcome").css(styles.practiceModal.welcomeText.container);
  1086. $("#ep-practice-modal-welcome h1").css(styles.practiceModal.welcomeText.username);
  1087. $("#ep-practice-modal-footer").css(styles.practiceModal.footer);
  1088. $("#ep-practice-modal-start").css({
  1089. ...styles.practiceModal.buttons.start.base,
  1090. ...styles.practiceModal.buttons.start.radical,
  1091. ...styles.practiceModal.buttons.start.disabled
  1092. });
  1093. $("#ep-practice-modal-select-all").css(styles.practiceModal.buttons.selectAll);
  1094. $("#ep-practice-modal-content").css(styles.practiceModal.contentWrapper);
  1095. $("#ep-practice-modal-close").css(styles.practiceModal.buttons.exit);
  1096.  
  1097. this.radicalGrid = new RadicalGrid(
  1098. this.radicals,
  1099. this.handleSelectionChange.bind(this)
  1100. );
  1101.  
  1102. const $grid = await this.radicalGrid.render();
  1103. $("#ep-practice-modal-grid").replaceWith($grid);
  1104.  
  1105. this.updateStartButton(0);
  1106.  
  1107. $("#ep-practice-modal-select-all").on("click", () => {
  1108. const isSelectingAll = $("#ep-practice-modal-select-all").text() === "Select All";
  1109. this.radicalGrid.toggleAllRadicals(isSelectingAll);
  1110. });
  1111.  
  1112. $("#ep-practice-modal-close").on("click", () => {
  1113. this.emit(EVENTS$1.CLOSE);
  1114. });
  1115.  
  1116. $("#ep-practice-modal-start").on("click", () => {
  1117. const selectedRadicals = this.radicalGrid.getSelectedRadicals();
  1118. if (selectedRadicals.length > 0) {
  1119. this.emit(EVENTS$1.START_REVIEW, selectedRadicals);
  1120. }
  1121. });
  1122.  
  1123. return this.$modal;
  1124. }
  1125.  
  1126. remove() {
  1127. if (this.$modal) {
  1128. this.$modal.remove();
  1129. this.$modal = null;
  1130. }
  1131. }
  1132. }
  1133.  
  1134. const REVIEW_STATES = {
  1135. ANSWERING: "answering",
  1136. REVIEWING: "reviewing"};
  1137.  
  1138. const REVIEW_EVENTS = {
  1139. CLOSE: "close",
  1140. NEXT_ITEM: "nextItem",
  1141. COMPLETE: "complete",
  1142. STUDY_AGAIN: "studyAgain"
  1143. };
  1144.  
  1145. class ReviewCard {
  1146. constructor(item, state = REVIEW_STATES.ANSWERING) {
  1147. this.item = item;
  1148. this.state = state;
  1149. this.$container = null;
  1150. this.isKanji = !!this.item.readings;
  1151. this.selectedOption = null;
  1152. this.handleKanjiSelection = this.handleKanjiSelection.bind(this);
  1153. }
  1154.  
  1155. handleKanjiSelection(event, option) {
  1156. const $selectedElement = $(event.currentTarget);
  1157. this.$container.find('.kanji-option').css(styles.reviewModal.kanjiOption.base);
  1158. $selectedElement.css({
  1159. ...styles.reviewModal.kanjiOption.base,
  1160. ...styles.reviewModal.kanjiOption.selected
  1161. });
  1162. this.selectedOption = option.id;
  1163. const $submitButton = this.$container.find('#ep-review-submit');
  1164. $submitButton
  1165. .prop('disabled', false)
  1166. .css({
  1167. ...styles.reviewModal.buttons.submit,
  1168. opacity: 1,
  1169. cursor: "pointer"
  1170. });
  1171. }
  1172.  
  1173.  
  1174. getQuestionText() {
  1175. if (this.item.type === "recognition") {
  1176. return ["Select the kanji that means ", this.createEmphasisSpan(this.item.meaningToMatch)];
  1177. }
  1178. if (!this.isKanji) {
  1179. return ["What is the meaning of this ", this.createEmphasisSpan("radical"), "?"];
  1180. }
  1181. if (this.item.type === "reading") {
  1182. const readingType = this.item.readings.find(r => r.primary)?.type;
  1183. const readingText = readingType === "onyomi" ? "on'yomi" : "kun'yomi";
  1184. return ["What is the ", this.createEmphasisSpan(readingText), " reading for this kanji?"];
  1185. }
  1186. return ["What is the ", this.createEmphasisSpan("meaning"), " of this kanji?"];
  1187. }
  1188. createEmphasisSpan(text) {
  1189. return $("<span>")
  1190. .text(text)
  1191. .css({
  1192. fontWeight: theme.typography.fontWeight.bold,
  1193. color: this.isKanji ? theme.colors.kanji : theme.colors.radical,
  1194. padding: `${theme.spacing.xs}`,
  1195. borderRadius: theme.borderRadius.sm,
  1196. backgroundColor: this.isKanji ?
  1197. "rgba(235, 1, 156, 0.1)" :
  1198. "rgba(5, 152, 228, 0.1)"
  1199. });
  1200. }
  1201.  
  1202. createKanjiOption(option) {
  1203. const $option = $("<div>")
  1204. .addClass("kanji-option")
  1205. .css(styles.reviewModal.kanjiOption.base)
  1206. .data("kanji-id", option.id)
  1207. .append(
  1208. $("<div>")
  1209. .addClass("kanji-character")
  1210. .css({
  1211. fontSize: theme.typography.fontSize["2xl"],
  1212. color: theme.colors.gray[800],
  1213. textAlign: "center"
  1214. })
  1215. .text(option.character)
  1216. );
  1217.  
  1218. $option.on("click", (event) => this.handleKanjiSelection(event, option));
  1219. return $option;
  1220. }
  1221.  
  1222. async renderCharacter() {
  1223. const $character = $("<div>")
  1224. .addClass("ep-review-character")
  1225. .css(styles.reviewModal.character);
  1226.  
  1227. if (this.item.character) {
  1228. $character.text(this.item.character);
  1229. } else if (this.item.svg) {
  1230. try {
  1231. const svgContent = await loadSvgContent(this.item.svg);
  1232. $character.html(svgContent);
  1233. const svg = $character.find("svg")[0];
  1234. if (svg) {
  1235. svg.setAttribute("width", "100%");
  1236. svg.setAttribute("height", "100%");
  1237. }
  1238. } catch (error) {
  1239. console.error("Error loading SVG:", error);
  1240. $character.text(this.item.meaning);
  1241. }
  1242. }
  1243.  
  1244. return $character;
  1245. }
  1246.  
  1247. async renderAnsweringState() {
  1248. const $content = $("<div>").addClass("ep-review-content");
  1249. if (this.item.type === "recognition") {
  1250. return this.renderRecognitionCard($content);
  1251. } else {
  1252. const $character = await this.renderCharacter();
  1253. const $question = $("<div>")
  1254. .addClass("ep-review-question")
  1255. .css({
  1256. fontSize: theme.typography.fontSize.lg,
  1257. marginBottom: theme.spacing.lg,
  1258. color: theme.colors.gray[700]
  1259. });
  1260. const questionContent = this.getQuestionText();
  1261. questionContent.forEach(content => {
  1262. if (content instanceof jQuery) {
  1263. $question.append(content);
  1264. } else {
  1265. $question.append(document.createTextNode(content));
  1266. }
  1267. });
  1268. const $inputSection = $("<div>")
  1269. .addClass("ep-review-input-section")
  1270. .css(styles.reviewModal.inputSection)
  1271. .append(
  1272. $("<input>")
  1273. .attr({
  1274. type: "text",
  1275. id: "ep-review-answer",
  1276. placeholder: this.item.type === "reading" ? "Enter reading..." : "Enter meaning...",
  1277. tabindex: "1",
  1278. autofocus: true
  1279. })
  1280. .css(styles.reviewModal.input),
  1281. $("<button>")
  1282. .attr("id", "ep-review-submit")
  1283. .text("Submit")
  1284. .attr("tabindex", "2")
  1285. .css(styles.reviewModal.buttons.submit)
  1286. );
  1287. $content.append($character);
  1288. $content.append($question);
  1289. $content.append($inputSection);
  1290. return $content;
  1291. }
  1292. }
  1293.  
  1294. async renderStandardAnsweringCard($content) {
  1295. const $character = await this.renderCharacter();
  1296. const $question = $("<div>")
  1297. .addClass("ep-review-question")
  1298. .css({
  1299. fontSize: theme.typography.fontSize.lg,
  1300. marginBottom: theme.spacing.lg,
  1301. color: theme.colors.gray[700]
  1302. });
  1303.  
  1304. const questionContent = this.getQuestionText();
  1305. questionContent.forEach(content => {
  1306. if (content instanceof jQuery) {
  1307. $question.append(content);
  1308. } else {
  1309. $question.append(document.createTextNode(content));
  1310. }
  1311. });
  1312.  
  1313. const $inputSection = $("<div>")
  1314. .addClass("ep-review-input-section")
  1315. .css(styles.reviewModal.inputSection)
  1316. .append(
  1317. $("<input>")
  1318. .attr({
  1319. type: "text",
  1320. id: "ep-review-answer",
  1321. placeholder: this.item.type === "reading" ? "Enter reading..." : "Enter meaning...",
  1322. tabindex: "1",
  1323. autofocus: true
  1324. })
  1325. .css(styles.reviewModal.input),
  1326. $("<button>")
  1327. .attr("id", "ep-review-submit")
  1328. .text("Submit")
  1329. .attr("tabindex", "2")
  1330. .css(styles.reviewModal.buttons.submit)
  1331. );
  1332.  
  1333. return $content.append($character, $question, $inputSection);
  1334. }
  1335.  
  1336. async renderRecognitionCard($content) {
  1337. const $questionContainer = $("<div>")
  1338. .css({
  1339. textAlign: "center",
  1340. marginBottom: theme.spacing.xl
  1341. });
  1342.  
  1343. const $question = $("<div>")
  1344. .addClass("ep-review-question")
  1345. .css({
  1346. fontSize: theme.typography.fontSize.lg,
  1347. color: theme.colors.gray[700],
  1348. marginBottom: theme.spacing.md
  1349. });
  1350.  
  1351. const questionContent = this.getQuestionText();
  1352. questionContent.forEach(content => {
  1353. if (content instanceof jQuery) {
  1354. $question.append(content);
  1355. } else {
  1356. $question.append(document.createTextNode(content));
  1357. }
  1358. });
  1359.  
  1360. $questionContainer.append($question);
  1361.  
  1362. const $optionsGrid = $("<div>")
  1363. .css({
  1364. display: "grid",
  1365. gridTemplateColumns: "repeat(2, 1fr)",
  1366. gap: theme.spacing.lg,
  1367. padding: theme.spacing.xl,
  1368. maxWidth: "500px",
  1369. margin: "0 auto"
  1370. });
  1371.  
  1372. this.item.options.forEach(option => {
  1373. $optionsGrid.append(this.createKanjiOption(option));
  1374. });
  1375.  
  1376. const $submitButton = $("<button>")
  1377. .attr({
  1378. id: "ep-review-submit",
  1379. disabled: true
  1380. })
  1381. .text("Submit")
  1382. .css({
  1383. ...styles.reviewModal.buttons.submit,
  1384. opacity: 0.5,
  1385. cursor: "not-allowed"
  1386. });
  1387.  
  1388. const $submitButtonContainer = $("<div>")
  1389. .css({
  1390. textAlign: "center",
  1391. marginTop: theme.spacing.xl
  1392. })
  1393. .append($submitButton);
  1394.  
  1395. return $content.append($questionContainer, $optionsGrid, $submitButtonContainer);
  1396. }
  1397.  
  1398. processMnemonic(mnemonic) {
  1399. if (!mnemonic) return "";
  1400.  
  1401. if (!this.isKanji) {
  1402. return mnemonic.replace(/<radical>(.*?)<\/radical>/g, (_, content) =>
  1403. `<span style="background-color: ${theme.colors.radical}; padding: 0 ${theme.spacing.xs}; border-radius: ${theme.borderRadius.sm}; color: ${theme.colors.white}">${content}</span>`
  1404. );
  1405. }
  1406.  
  1407. return mnemonic
  1408. .replace(/<radical>(.*?)<\/radical>/g, (_, content) =>
  1409. `<span style="background-color: ${theme.colors.radical}; padding: 0 ${theme.spacing.xs}; border-radius: ${theme.borderRadius.sm}; color: ${theme.colors.white}">${content}</span>`
  1410. )
  1411. .replace(/<kanji>(.*?)<\/kanji>/g, (_, content) =>
  1412. `<span style="background-color: ${theme.colors.kanji}; padding: 0 ${theme.spacing.xs}; border-radius: ${theme.borderRadius.sm}; color: ${theme.colors.white}">${content}</span>`
  1413. )
  1414. .replace(/<reading>(.*?)<\/reading>/g, (_, content) =>
  1415. `<span style="background-color: ${theme.colors.gray[200]}; padding: 0 ${theme.spacing.xs}; border-radius: ${theme.borderRadius.sm}; color: ${theme.colors.gray[800]}">${content}</span>`
  1416. );
  1417. }
  1418.  
  1419. async renderReviewingState() {
  1420. const $content = $("<div>").addClass("ep-review-content");
  1421. const $character = await this.renderCharacter();
  1422. const $explanation = $("<div>")
  1423. .addClass("ep-review-explanation")
  1424. .css(styles.reviewModal.explanation);
  1425.  
  1426. const primaryReading = this.item.readings?.find(r => r.primary);
  1427. const primaryMeaning = this.item.meanings?.find(m => m.primary);
  1428.  
  1429. const $continueButton = $("<button>")
  1430. .attr("id", "ep-review-continue")
  1431. .text("Continue Review")
  1432. .css({
  1433. ...styles.reviewModal.buttons.submit,
  1434. minWidth: "120px",
  1435. display: "block",
  1436. margin: "30px auto 0"
  1437. });
  1438. const $buttonContainer = $("<div>")
  1439. .addClass("ep-review-buttons")
  1440. .css({
  1441. display: "flex",
  1442. gap: theme.spacing.md,
  1443. justifyContent: "center",
  1444. marginTop: theme.spacing.xl
  1445. })
  1446. .append($continueButton);
  1447.  
  1448. // Handle non-kanji (radical) review state
  1449. if (!this.isKanji) {
  1450. $content.append(
  1451. $character,
  1452. $explanation.append(
  1453. $("<h3>").append(
  1454. $("<span>")
  1455. .text("Meaning: ")
  1456. .css(styles.reviewModal.explanation.meaningLabel),
  1457. $("<a>")
  1458. .attr({
  1459. href: this.item.documentationUrl,
  1460. target: "_blank",
  1461. title: `Click to learn more about: ${this.item.meaning}`
  1462. })
  1463. .text(this.item.meaning)
  1464. .css(styles.reviewModal.explanation.meaningText)
  1465. ),
  1466. $("<div>")
  1467. .addClass("ep-mnemonic-container")
  1468. .css(styles.reviewModal.explanation.mnemonicContainer)
  1469. .append(
  1470. $("<span>")
  1471. .text("Mnemonic:")
  1472. .css(styles.reviewModal.explanation.mnemonicLabel),
  1473. $("<div>")
  1474. .addClass("ep-review-mnemonic")
  1475. .html(this.processMnemonic(this.item.meaningMnemonic))
  1476. .css(styles.reviewModal.explanation.mnemonic)
  1477. )
  1478. )
  1479. );
  1480.  
  1481. $content.append($buttonContainer);
  1482. return $content;
  1483. }
  1484.  
  1485. // Handle kanji review states based on question type
  1486. switch (this.item.type) {
  1487. case "recognition":
  1488. $explanation.append(
  1489. this.createExplanationSection(
  1490. "Meaning",
  1491. this.item.meaningToMatch,
  1492. this.item.meaningMnemonic,
  1493. true
  1494. )
  1495. );
  1496.  
  1497. if (primaryReading) {
  1498. const readingType = primaryReading.type === "onyomi" ? "On'yomi" : "Kun'yomi";
  1499. $explanation.append(
  1500. this.createExplanationSection(
  1501. "Reading",
  1502. `${readingType}: ${primaryReading.reading}`,
  1503. this.item.readingMnemonic,
  1504. false
  1505. )
  1506. );
  1507. }
  1508. break;
  1509.  
  1510. case "reading":
  1511. if (primaryReading) {
  1512. const readingType = primaryReading.type === "onyomi" ? "On'yomi" : "Kun'yomi";
  1513. $explanation.append(
  1514. this.createExplanationSection(
  1515. "Reading",
  1516. `${readingType}: ${primaryReading.reading}`,
  1517. this.item.readingMnemonic,
  1518. true
  1519. )
  1520. );
  1521. }
  1522.  
  1523. if (primaryMeaning) {
  1524. $explanation.append(
  1525. this.createExplanationSection(
  1526. "Meaning",
  1527. primaryMeaning.meaning,
  1528. this.item.meaningMnemonic,
  1529. false
  1530. )
  1531. );
  1532. }
  1533. break;
  1534.  
  1535. case "meaning":
  1536. if (primaryMeaning) {
  1537. $explanation.append(
  1538. this.createExplanationSection(
  1539. "Meaning",
  1540. primaryMeaning.meaning,
  1541. this.item.meaningMnemonic,
  1542. true
  1543. )
  1544. );
  1545. }
  1546.  
  1547. if (primaryReading) {
  1548. const readingType = primaryReading.type === "onyomi" ? "On'yomi" : "Kun'yomi";
  1549. $explanation.append(
  1550. this.createExplanationSection(
  1551. "Reading",
  1552. `${readingType}: ${primaryReading.reading}`,
  1553. this.item.readingMnemonic,
  1554. false
  1555. )
  1556. );
  1557. }
  1558. break;
  1559. }
  1560.  
  1561.  
  1562. $content.append($character, $explanation);
  1563. $content.append($buttonContainer);
  1564.  
  1565. return $content;
  1566. }
  1567.  
  1568. createExplanationSection(title, answer, mnemonic, isExpanded) {
  1569. const $section = $("<div>")
  1570. .addClass("explanation-section")
  1571. .css({
  1572. marginBottom: theme.spacing.md,
  1573. width: "100%",
  1574. display: "block"
  1575. });
  1576. const $header = $("<div>")
  1577. .css({
  1578. display: "block",
  1579. padding: `${theme.spacing.sm} 0`,
  1580. width: "100%",
  1581. borderBottom: `1px solid ${theme.colors.gray[200]}`,
  1582. });
  1583.  
  1584. const $headerContent = $("<div>")
  1585. .css({
  1586. display: "flex",
  1587. alignItems: "center",
  1588. cursor: "pointer",
  1589. width: "100%"
  1590. }).append(
  1591. $("<span>")
  1592. .text(isExpanded ? "▼" : "▶")
  1593. .css({
  1594. color: theme.colors.gray[600],
  1595. marginRight: theme.spacing.sm,
  1596. fontSize: theme.typography.fontSize.md,
  1597. flexShink: 0
  1598. }),
  1599. $("<h3>")
  1600. .text(title)
  1601. .css({
  1602. margin: 0,
  1603. color: theme.colors.gray[800],
  1604. fontWeight: theme.typography.fontWeight.medium,
  1605. fontSize: theme.typography.fontSize.md,
  1606. flex: 1
  1607. })
  1608. );
  1609. $header.append($headerContent);
  1610. const $content = $("<div>")
  1611. .css({
  1612. display: isExpanded ? "block" : "none",
  1613. paddingLeft: theme.spacing.xl,
  1614. paddingTop: theme.spacing.md,
  1615. paddingBottom: theme.spacing.md
  1616. });
  1617. if (title.toLowerCase() === "reading") {
  1618. // Extract reading type and format display
  1619. const readingType = this.item.readings.find(r => r.primary)?.type;
  1620. const formattedType = readingType === "onyomi" ? "On'yomi" : "Kun'yomi";
  1621. $content.append(
  1622. $("<div>")
  1623. .css({
  1624. fontSize: theme.typography.fontSize.lg,
  1625. color: theme.colors.gray[800],
  1626. marginBottom: theme.spacing.md
  1627. })
  1628. .append(
  1629. $("<span>")
  1630. .text(`${formattedType}: `)
  1631. .css({
  1632. color: theme.colors.gray[600],
  1633. fontSize: theme.typography.fontSize.md
  1634. }),
  1635. $("<span>")
  1636. .text(this.item.readings.find(r => r.primary)?.reading || "")
  1637. )
  1638. );
  1639. if (mnemonic) {
  1640. $content.append(
  1641. $("<div>")
  1642. .addClass("ep-mnemonic-container")
  1643. .css(styles.reviewModal.explanation.mnemonicContainer)
  1644. .append(
  1645. $("<span>")
  1646. .text("Mnemonic:")
  1647. .css(styles.reviewModal.explanation.mnemonicLabel),
  1648. $("<div>")
  1649. .addClass("ep-review-mnemonic")
  1650. .html(this.processMnemonic(mnemonic))
  1651. .css(styles.reviewModal.explanation.mnemonic)
  1652. )
  1653. );
  1654. }
  1655. } else {
  1656. const meaningText = this.item.type === "recognition"
  1657. ? this.item.meaningToMatch
  1658. : this.item.meanings.find(m => m.primary)?.meaning;
  1659. $content.append(
  1660. $("<div>")
  1661. .css({
  1662. fontSize: theme.typography.fontSize.lg,
  1663. color: theme.colors.gray[800],
  1664. marginBottom: theme.spacing.md
  1665. })
  1666. .text(meaningText)
  1667. );
  1668. if (mnemonic) {
  1669. $content.append(
  1670. $("<div>")
  1671. .addClass("ep-mnemonic-container")
  1672. .css(styles.reviewModal.explanation.mnemonicContainer)
  1673. .append(
  1674. $("<span>")
  1675. .text("Mnemonic:")
  1676. .css(styles.reviewModal.explanation.mnemonicLabel),
  1677. $("<div>")
  1678. .addClass("ep-review-mnemonic")
  1679. .html(this.processMnemonic(mnemonic))
  1680. .css(styles.reviewModal.explanation.mnemonic)
  1681. )
  1682. );
  1683. }
  1684. }
  1685. $header.on("click", function() {
  1686. const $content = $(this).siblings("div");
  1687. const isVisible = $content.is(":visible");
  1688. $content.slideToggle(200);
  1689. const $arrow = $(this).find("span").first();
  1690. $arrow.text(isVisible ? "▶" : "▼");
  1691. });
  1692. return $section.append($header, $content);
  1693. }
  1694.  
  1695. async render() {
  1696. this.$container = $("<div>")
  1697. .addClass("ep-review-card")
  1698. .css({
  1699. padding: theme.spacing.xl,
  1700. display: "flex",
  1701. flexDirection: "column",
  1702. width: "100%",
  1703. gap: theme.spacing.xl
  1704. });
  1705. const $characterContainer = $("<div>")
  1706. .css({
  1707. textAlign: "center",
  1708. width: "100%"
  1709. });
  1710. const $contentContainer = $("<div>")
  1711. .css({
  1712. width: "100%",
  1713. textAlign: "left"
  1714. });
  1715. const content = await (this.state === REVIEW_STATES.ANSWERING
  1716. ? this.renderAnsweringState()
  1717. : this.renderReviewingState());
  1718. if (this.state === REVIEW_STATES.ANSWERING) {
  1719. const $character = content.find(".ep-review-character").detach();
  1720. $characterContainer.append($character);
  1721. $contentContainer.append(content);
  1722. } else {
  1723. const $character = content.find(".ep-review-character").detach();
  1724. $characterContainer.append($character);
  1725. $contentContainer.append(content.find(".ep-review-explanation"));
  1726. }
  1727. this.$container.append($characterContainer, $contentContainer);
  1728. return this.$container;
  1729. }
  1730.  
  1731. async updateState(newState) {
  1732. if (this.state === newState) return;
  1733. this.state = newState;
  1734. const content = this.state === REVIEW_STATES.ANSWERING
  1735. ? await this.renderAnsweringState()
  1736. : await this.renderReviewingState();
  1737.  
  1738. this.$container.empty().append(content);
  1739. }
  1740.  
  1741. getAnswer() {
  1742. if (this.item.type === "recognition") {
  1743. return this.selectedOption?.toString() || "";
  1744. }
  1745. return $("#ep-review-answer").val()?.trim() || "";
  1746. }
  1747.  
  1748. remove() {
  1749. if (this.$container) {
  1750. this.$container.remove();
  1751. this.$container = null;
  1752. }
  1753. }
  1754. }
  1755.  
  1756. class ReviewSessionModal {
  1757. constructor(reviewSession) {
  1758. this.reviewSession = reviewSession;
  1759. this.state = REVIEW_STATES.ANSWERING;
  1760. this.$modal = null;
  1761. this.currentCard = null;
  1762. this.callbacks = new Map();
  1763. this.isKanjiSession = !!this.reviewSession.correctMeanings;
  1764.  
  1765. // Session configuration for Play Again
  1766. this.sessionConfig = {
  1767. mode: this.reviewSession.mode,
  1768. items: this.reviewSession.originalItems,
  1769. };
  1770.  
  1771. if (this.sessionConfig.mode !== "radical") {
  1772. this.sessionConfig.allUnlockedKanji = this.reviewSession.allUnlockedKanji;
  1773. }
  1774. this.handlePlayAgain = this.handlePlayAgain.bind(this);
  1775. this.handleAnswer = this.handleAnswer.bind(this);
  1776. this.handleNextItem = this.handleNextItem.bind(this);
  1777. this.showHint = this.showHint.bind(this);
  1778. this.setupInput = this.setupInput.bind(this);
  1779. this.showCurrentItem = this.showCurrentItem.bind(this);
  1780. this.updateProgress = this.updateProgress.bind(this);
  1781. this.showReviewInterface = this.showReviewInterface.bind(this);
  1782. this.hideReviewInterface = this.hideReviewInterface.bind(this);
  1783. this.showInputInterface = this.showInputInterface.bind(this);
  1784. this.hideInputInterface = this.hideInputInterface.bind(this);
  1785. this.showCompletionScreen = this.showCompletionScreen.bind(this);
  1786. }
  1787.  
  1788. // Setup Hiragana Keyboard
  1789. setupInput() {
  1790. const input = document.querySelector("#ep-review-answer");
  1791. if (!input) return;
  1792.  
  1793. const currentItem = this.reviewSession.currentItem;
  1794. if (!currentItem) return;
  1795.  
  1796. if (this.isKanjiSession && currentItem.type === "reading") {
  1797. wanakana.bind(input, {
  1798. IMEMode: "toHiragana",
  1799. useObsoleteKana: false,
  1800. passRomaji: false,
  1801. upcaseKatakana: false,
  1802. convertLongVowelMark: true
  1803. });
  1804. }
  1805. }
  1806.  
  1807. on(event, callback) {
  1808. this.callbacks.set(event, callback);
  1809. return this;
  1810. }
  1811.  
  1812. emit(event, data) {
  1813. const callback = this.callbacks.get(event);
  1814. if (callback) callback(data);
  1815. }
  1816.  
  1817. handlePlayAgain() {
  1818. const newSession = this.isKanjiSession ? new KanjiReviewSession({
  1819. items: this.sessionConfig.items,
  1820. mode: this.sessionConfig.mode,
  1821. allUnlockedKanji: this.sessionConfig.allUnlockedKanji
  1822. }) : new RadicalReviewSession({
  1823. items: this.sessionConfig.items,
  1824. mode: "radical",
  1825. });
  1826.  
  1827. // Initialize new session
  1828. newSession.nextItem();
  1829.  
  1830. // Clean up current modal
  1831. this.remove();
  1832.  
  1833. const newModal = new ReviewSessionModal(newSession);
  1834. newModal
  1835. .on(REVIEW_EVENTS.CLOSE, () => {
  1836. enableScroll();
  1837. newModal.remove();
  1838. })
  1839. .on(REVIEW_EVENTS.STUDY_AGAIN, () => {
  1840. newModal.remove();
  1841. enableScroll();
  1842. if (this.isKanjiSession) {
  1843. handleKanjiPractice();
  1844. } else {
  1845. handleRadicalPractice();
  1846. }
  1847. });
  1848. return newModal.render();
  1849. }
  1850.  
  1851. updateProgress() {
  1852. const progress = this.reviewSession.getProgress();
  1853. const mode = this.reviewSession.mode;
  1854. let progressText;
  1855.  
  1856. switch (mode) {
  1857. case PRACTICE_MODES.ENGLISH_TO_KANJI:
  1858. progressText = `${progress.recognitionProgress}/${progress.total} Correct`;
  1859. break;
  1860. case PRACTICE_MODES.COMBINED:
  1861. progressText = `Meanings: ${progress.meaningProgress}/${progress.total/3} | ` +
  1862. `Readings: ${progress.readingProgress}/${progress.total/3} | ` +
  1863. `Recognition: ${progress.recognitionProgress}/${progress.total/3}`;
  1864. break;
  1865. case PRACTICE_MODES.STANDARD:
  1866. progressText = `Meanings: ${progress.meaningProgress}/${progress.total/2} | ` +
  1867. `Readings: ${progress.readingProgress}/${progress.total/2}`;
  1868. break;
  1869. default: // RADICAL
  1870. progressText = `${progress.current}/${progress.total/1} Correct`;
  1871. }
  1872.  
  1873. $("#ep-review-progress-correct").html(progressText);
  1874.  
  1875. if (mode === PRACTICE_MODES.COMBINED) {
  1876. $("#ep-review-progress-correct").css({
  1877. fontSize: theme.typography.fontSize.xs
  1878. });
  1879. }
  1880. }
  1881.  
  1882. showReviewInterface() {
  1883. $("#ep-review-result").show();
  1884. $("#ep-review-result-message").show();
  1885. $("#ep-review-explanation").show();
  1886. $(".ep-review-buttons").hide();
  1887. }
  1888.  
  1889. hideReviewInterface() {
  1890. $("#ep-review-result").hide();
  1891. $("#ep-review-result-message").hide();
  1892. $("#ep-review-explanation").hide();
  1893. $("#ep-review-show-hint").hide();
  1894. $(".ep-review-buttons").show();
  1895. }
  1896.  
  1897. showInputInterface() {
  1898. $("#ep-review-input-section").show();
  1899. $("#ep-review-answer").val("").prop("disabled", false);
  1900. $("#ep-review-submit").show();
  1901. $("#ep-review-answer").focus();
  1902.  
  1903. this.setupInput();
  1904. }
  1905.  
  1906. hideInputInterface() {
  1907. $("#ep-review-input-section").hide();
  1908. $("#ep-review-submit").hide();
  1909. $("#ep-review-answer").prop("disabled", true);
  1910. }
  1911.  
  1912. async showCurrentItem() {
  1913. const currentItem = this.reviewSession.currentItem;
  1914. if (this.currentCard) {
  1915. this.currentCard.remove();
  1916. }
  1917. this.state = REVIEW_STATES.ANSWERING;
  1918. this.hideReviewInterface();
  1919. this.currentCard = new ReviewCard(currentItem, REVIEW_STATES.ANSWERING);
  1920. const $card = await this.currentCard.render();
  1921. // Clear and append the new card
  1922. $("#ep-review-content").empty().append($card);
  1923. // Ensure input is focused after rendering
  1924. if (currentItem.type !== "recognition") {
  1925. const $input = $("#ep-review-answer");
  1926. if ($input.length) {
  1927. $input.focus();
  1928. this.setupInput();
  1929. }
  1930. }
  1931. }
  1932.  
  1933. async handleAnswer() {
  1934. const currentCard = this.currentCard;
  1935. if (!currentCard) return;
  1936. const userAnswer = currentCard.getAnswer();
  1937. if (!userAnswer) return;
  1938. const isCorrect = this.reviewSession.checkAnswer(userAnswer);
  1939. $(".ep-review-input-section, .ep-review-question, .ep-review-content, .kanji-option, #ep-review-submit").hide();
  1940. $(".ep-review-character").css({
  1941. marginBottom: "0"
  1942. });
  1943. // Create result container if it doesn't exist
  1944. if ($("#ep-review-result-container").length === 0) {
  1945. $(".ep-review-card").append(
  1946. $("<div>")
  1947. .attr("id", "ep-review-result-container")
  1948. .css({
  1949. ...styles.reviewModal.content,
  1950. padding: 0
  1951. })
  1952. );
  1953. }
  1954. if (isCorrect) {
  1955. $("#ep-review-result-container")
  1956. .empty()
  1957. .append(
  1958. $("<div>")
  1959. .attr("id", "ep-review-result-message")
  1960. .text("Correct!")
  1961. .css({
  1962. ...styles.reviewModal.results.message,
  1963. color: theme.colors.success,
  1964. })
  1965. );
  1966. this.updateProgress();
  1967. setTimeout(() => this.handleNextItem(), 1000);
  1968. } else {
  1969. $("#ep-review-result-container")
  1970. .empty()
  1971. .append(
  1972. $("<div>")
  1973. .attr("id", "ep-review-result-message")
  1974. .text("Incorrect")
  1975. .css({
  1976. ...styles.reviewModal.results.message,
  1977. color: theme.colors.error,
  1978. }),
  1979. $("<div>")
  1980. .addClass("ep-review-buttons")
  1981. .css({
  1982. display: "flex",
  1983. gap: theme.spacing.md,
  1984. justifyContent: "center"
  1985. })
  1986. .append(
  1987. $("<button>")
  1988. .attr("id", "ep-review-show-hint")
  1989. .text("Show Answer")
  1990. .css({
  1991. ...styles.reviewModal.buttons.hint,
  1992. minWidth: "120px"
  1993. }),
  1994. $("<button>")
  1995. .attr("id", "ep-review-continue")
  1996. .text("Continue Review")
  1997. .css({
  1998. ...styles.reviewModal.buttons.submit,
  1999. minWidth: "120px"
  2000. })
  2001. )
  2002. );
  2003. }
  2004. }
  2005.  
  2006. async showHint() {
  2007. await this.currentCard.updateState(REVIEW_STATES.REVIEWING);
  2008. }
  2009.  
  2010. async handleNextItem() {
  2011. if (this.reviewSession.isComplete()) {
  2012. this.showCompletionScreen();
  2013. return;
  2014. }
  2015.  
  2016. this.reviewSession.nextItem();
  2017. await this.showCurrentItem();
  2018. this.emit(REVIEW_EVENTS.NEXT_ITEM);
  2019. }
  2020.  
  2021. showCompletionScreen() {
  2022. const progress = this.reviewSession.getProgress();
  2023. const mode = this.reviewSession.mode;
  2024. let languageLearningQuotes;
  2025.  
  2026. if (this.isKanjiSession) {
  2027. languageLearningQuotes = [
  2028. "Every kanji you learn unlocks new understanding",
  2029. "One character a day",
  2030. "Continuation is power",
  2031. "Each review strengthens your kanji recognition",
  2032. "Little by little, steadily",
  2033. "Each character you master opens new doors to understanding",
  2034. "Your journey through the world of kanji grows stronger each day"
  2035. ];
  2036. } else {
  2037. languageLearningQuotes = [
  2038. "Every radical mastered unlocks new understanding",
  2039. "Building your foundation, one radical at a time",
  2040. "Mastering radicals today, recognizing kanji tomorrow",
  2041. "Each radical review strengthens your foundation",
  2042. "Little by little, your radical knowledge grows",
  2043. "Each radical you master opens new paths of understanding",
  2044. "Your journey through radicals grows stronger each day",
  2045. "Steady progress in radicals paves the way forward",
  2046. "Your radical knowledge builds the bridge to comprehension"
  2047. ];
  2048. }
  2049. const randomQuote = languageLearningQuotes[
  2050. Math.floor(Math.random() * languageLearningQuotes.length)
  2051. ];
  2052.  
  2053. let completionMessage;
  2054. switch (mode) {
  2055. case PRACTICE_MODES.ENGLISH_TO_KANJI:
  2056. completionMessage = `Review completed!<br>${progress.recognitionProgress}/${progress.total} Correct`;
  2057. break;
  2058. case PRACTICE_MODES.COMBINED:
  2059. completionMessage = `Review completed!<br>` +
  2060. `Meanings: ${progress.meaningProgress}/${progress.total/3} | ` +
  2061. `Readings: ${progress.readingProgress}/${progress.total/3} | ` +
  2062. `Recognition: ${progress.recognitionProgress}/${progress.total/3}`;
  2063. break;
  2064. case PRACTICE_MODES.STANDARD:
  2065. completionMessage = `Review completed!<br>` +
  2066. `Meanings: ${progress.meaningProgress}/${progress.total/2} | ` +
  2067. `Readings: ${progress.readingProgress}/${progress.total/2}`;
  2068. break;
  2069. default:
  2070. completionMessage = `Review completed!`;
  2071. }
  2072.  
  2073. const $completionContent = $("<div>")
  2074. .css({
  2075. textAlign: "center",
  2076. padding: theme.spacing.xl
  2077. })
  2078. .append(
  2079. $("<h1>")
  2080. .html(completionMessage)
  2081. .css({
  2082. ...styles.reviewModal.progress,
  2083. marginBottom: theme.spacing.lg
  2084. }),
  2085. $("<p>")
  2086. .text(`"${randomQuote}"`)
  2087. .css({
  2088. color: theme.colors.gray[600],
  2089. marginBottom: theme.spacing.xl,
  2090. fontStyle: "italic"
  2091. }),
  2092. $("<div>")
  2093. .css({
  2094. display: "flex",
  2095. gap: theme.spacing.md,
  2096. justifyContent: "center"
  2097. })
  2098. .append(
  2099. $("<button>")
  2100. .text("Play Again")
  2101. .css({
  2102. ...styles.reviewModal.buttons.submit,
  2103. backgroundColor: theme.colors.success,
  2104. minWidth: "120px"
  2105. })
  2106. .on("click", this.handlePlayAgain),
  2107. $("<button>")
  2108. .text("Study Different Items")
  2109. .css({
  2110. ...styles.reviewModal.buttons.submit,
  2111. minWidth: "120px"
  2112. })
  2113. .on("click", () => {
  2114. this.emit(REVIEW_EVENTS.STUDY_AGAIN);
  2115. })
  2116. )
  2117. );
  2118.  
  2119. $("#ep-review-content").empty().append($completionContent);
  2120. this.emit(REVIEW_EVENTS.COMPLETE, { progress });
  2121. }
  2122.  
  2123. async render() {
  2124. this.$modal = $(reviewModalTemplate).appendTo("body");
  2125. this.$modal.css({
  2126. position: "fixed",
  2127. top: 0,
  2128. left: 0,
  2129. width: "100%",
  2130. height: "100%",
  2131. backgroundColor: "rgba(0, 0, 0, 0.9)",
  2132. zIndex: theme.zIndex.modal,
  2133. display: "flex",
  2134. alignItems: "center",
  2135. justifyContent: "center"
  2136. });
  2137.  
  2138. $("#ep-review-modal-wrapper").css(styles.reviewModal.container);
  2139. $("#ep-review-modal-header").css(styles.reviewModal.header);
  2140. $("#ep-review-progress").css(styles.reviewModal.progress);
  2141. $("#ep-review-exit").css(styles.reviewModal.buttons.exit);
  2142.  
  2143. // Set up event delegation
  2144. this.$modal
  2145. .on("click", "#ep-review-submit", this.handleAnswer)
  2146. .on("keypress", "#ep-review-answer", (e) => {
  2147. if (e.which === 13) {
  2148. this.handleAnswer();
  2149. }
  2150. })
  2151. .on("click", "#ep-review-show-hint", this.showHint)
  2152. .on("click", "#ep-review-continue", this.handleNextItem);
  2153.  
  2154. $("#ep-review-exit").on("click", () => {
  2155. this.emit(REVIEW_EVENTS.CLOSE);
  2156. });
  2157.  
  2158. this.updateProgress();
  2159. await this.showCurrentItem();
  2160.  
  2161. return this.$modal;
  2162. }
  2163.  
  2164. remove() {
  2165. if (this.currentCard) {
  2166. this.currentCard.remove();
  2167. }
  2168.  
  2169. const input = document.querySelector("#ep-review-answer");
  2170. if (input) {
  2171. wanakana.unbind(input);
  2172. }
  2173.  
  2174. if (this.$modal) {
  2175. this.$modal.remove();
  2176. this.$modal = null;
  2177. }
  2178. }
  2179. }
  2180.  
  2181. // Assumption: User has wkof.file_cache for the IndexedDB operations to work
  2182.  
  2183. async function getCurrentUserLevel() {
  2184. return new Promise((resolve, reject) => {
  2185. const request = indexedDB.open(DB_VALUES.DB_NAME, 1);
  2186. request.onsuccess = (event) => {
  2187. const db = event.target.result;
  2188. const transaction = db.transaction([DB_VALUES.FILE_STORE], "readonly");
  2189. const store = transaction.objectStore(DB_VALUES.FILE_STORE);
  2190. const getUser = store.get(DB_VALUES.USER_RECORD);
  2191. getUser.onsuccess = () => {
  2192. const userData = getUser.result;
  2193. resolve(userData.content.data.level);
  2194. };
  2195. getUser.onerror = () => {
  2196. reject(handleError("USER_LEVEL"));
  2197. };
  2198. };
  2199. request.onerror = () => {
  2200. reject(handleError("OPEN"));
  2201. };
  2202. });
  2203. }
  2204.  
  2205. async function getCurrentLevelRadicals() {
  2206. try {
  2207. const userLevel = await getCurrentUserLevel();
  2208. return new Promise((resolve, reject) => {
  2209. const request = indexedDB.open(DB_VALUES.DB_NAME, 1);
  2210. request.onsuccess = (event) => {
  2211. const db = event.target.result;
  2212. const transaction = db.transaction([DB_VALUES.FILE_STORE], "readonly");
  2213. const store = transaction.objectStore(DB_VALUES.FILE_STORE);
  2214. const getSubjects = store.get(DB_VALUES.SUBJECT_RECORD);
  2215. getSubjects.onsuccess = () => {
  2216. const subjectsData = getSubjects.result;
  2217. const currentLevelRadicals = Object.values(subjectsData.content.data)
  2218. .filter(subject =>
  2219. subject.object === "radical" &&
  2220. subject.data.level === userLevel
  2221. )
  2222. .map(radical => ({
  2223. id: radical.id,
  2224. character: radical.data.characters,
  2225. meaning: radical.data.meanings[0].meaning,
  2226. documentationUrl: radical.data.document_url,
  2227. meaningMnemonic: radical.data.meaning_mnemonic,
  2228. svg: radical.data.character_images.find(img =>
  2229. img.content_type === "image/svg+xml"
  2230. )?.url || null
  2231. }));
  2232. resolve(currentLevelRadicals);
  2233. };
  2234. getSubjects.onerror = () => {
  2235. reject(handleError("SUBJECT_DATA"));
  2236. };
  2237. };
  2238. request.onerror = () => {
  2239. reject(handleError("OPEN"));
  2240. };
  2241. });
  2242. } catch (error) {
  2243. console.error("Error in getCurrentLevelRadicals:", error);
  2244. throw error;
  2245. }
  2246. }
  2247.  
  2248. async function getCurrentLevelKanji() {
  2249. return new Promise(async (resolve, reject) => {
  2250. const userLevel = await getCurrentUserLevel();
  2251.  
  2252. const request = indexedDB.open('wkof.file_cache', 1);
  2253. request.onsuccess = (event) => {
  2254. const db = event.target.result;
  2255. const transaction = db.transaction(['files'], 'readonly');
  2256. const store = transaction.objectStore('files');
  2257. Promise.all([
  2258. new Promise(resolve => {
  2259. store.get('Apiv2.assignments').onsuccess = (e) =>
  2260. resolve(e.target.result.content.data);
  2261. }),
  2262. new Promise(resolve => {
  2263. store.get('Apiv2.subjects').onsuccess = (e) =>
  2264. resolve(e.target.result.content.data);
  2265. })
  2266. ]).then(([assignments, subjects]) => {
  2267. const unlockedKanjiIds = new Set(
  2268. Object.values(assignments)
  2269. .filter(a => a.data.subject_type === "kanji")
  2270. .map(a => a.data.subject_id)
  2271. );
  2272.  
  2273. // Helper function to get radical information
  2274. const getRadicalInfo = (radicalId) => {
  2275. const radical = subjects[radicalId];
  2276. if (!radical) return null;
  2277. return {
  2278. id: radical.id,
  2279. character: radical.data.characters,
  2280. meaning: radical.data.meanings[0].meaning,
  2281. svg: radical.data.character_images?.find(img =>
  2282. img.content_type === 'image/svg+xml'
  2283. )?.url || null
  2284. };
  2285. };
  2286. const currentLevelKanji = Object.values(subjects)
  2287. .filter(subject =>
  2288. subject.object === "kanji" &&
  2289. subject.data.level === userLevel &&
  2290. unlockedKanjiIds.has(subject.id)
  2291. )
  2292. .map(kanji => ({
  2293. id: kanji.id,
  2294. character: kanji.data.characters,
  2295. meanings: kanji.data.meanings.filter(m => m.accepted_answer),
  2296. readings: kanji.data.readings.filter(r => r.accepted_answer),
  2297. meaningMnemonic: kanji.data.meaning_mnemonic,
  2298. meaningHint: kanji.data.meaning_hint,
  2299. readingMnemonic: kanji.data.reading_mnemonic,
  2300. readingHint: kanji.data.reading_hint,
  2301. documentUrl: kanji.data.document_url,
  2302. radicals: kanji.data.component_subject_ids
  2303. .map(getRadicalInfo)
  2304. .filter(Boolean),
  2305. auxiliaryMeanings: kanji.data.auxiliary_meanings
  2306. ?.filter(m => m.type === "whitelist")
  2307. ?? []
  2308. }));
  2309. resolve(currentLevelKanji);
  2310. });
  2311. };
  2312.  
  2313. request.onerror = (error) => reject(error);
  2314. });
  2315. }
  2316.  
  2317. function handleError(type) {
  2318. if (type == "OPEN") {
  2319. return new Error(DB_ERRORS.OPEN);
  2320. }
  2321.  
  2322. if (type == "USER_LEVEL") {
  2323. return new Error(DB_ERRORS.USER_LEVEL);
  2324. }
  2325.  
  2326. if (type == "SUBJECT_DATA") {
  2327. return new Error(DB_ERRORS.SUBJECT_DATA);
  2328. }
  2329. }
  2330.  
  2331. async function handleRadicalPractice() {
  2332. try {
  2333. disableScroll();
  2334. const radicals = await getCurrentLevelRadicals();
  2335. const selectionModal = new RadicalSelectionModal(radicals)
  2336. .on(EVENTS$1.CLOSE, () => {
  2337. enableScroll();
  2338. selectionModal.remove();
  2339. })
  2340. .on(EVENTS$1.START_REVIEW, (selectedRadicals) => {
  2341. selectionModal.remove();
  2342. startRadicalReview(selectedRadicals);
  2343. });
  2344.  
  2345. await selectionModal.render();
  2346.  
  2347. } catch (error) {
  2348. console.error("Error in radical practice:", error);
  2349. enableScroll();
  2350. }
  2351. }
  2352.  
  2353. async function startRadicalReview(selectedRadicals) {
  2354. try {
  2355. const session = {
  2356. items: selectedRadicals,
  2357. mode: "radical",
  2358. };
  2359.  
  2360. const reviewSession = new RadicalReviewSession(session);
  2361. reviewSession.nextItem();
  2362.  
  2363. const reviewModal = new ReviewSessionModal(reviewSession);
  2364.  
  2365. reviewModal
  2366. .on(REVIEW_EVENTS.CLOSE, () => {
  2367. const progress = reviewSession.getProgress();
  2368. $("#ep-review-modal-header").remove();
  2369. $("#ep-review-content")
  2370. .empty()
  2371. .append(
  2372. $("<div>")
  2373. .css(styles.reviewModal.content)
  2374. .append([
  2375. $("<p>", {
  2376. css: {
  2377. ...styles.reviewModal.progress,
  2378. marginBottom: 0
  2379. },
  2380. text: `${progress.current}/${progress.total} Correct (${progress.percentComplete}%)`
  2381. }),
  2382. $("<p>", {
  2383. css: {
  2384. marginTop: 0,
  2385. textAlign: "center"
  2386. },
  2387. text: "Closing..."
  2388. })
  2389. ])
  2390. );
  2391.  
  2392. setTimeout(() => {
  2393. enableScroll();
  2394. reviewModal.remove();
  2395. }, 1000);
  2396. })
  2397. .on(REVIEW_EVENTS.STUDY_AGAIN, () => {
  2398. reviewModal.remove();
  2399. enableScroll();
  2400. handleRadicalPractice();
  2401. });
  2402.  
  2403. await reviewModal.render();
  2404. } catch (error) {
  2405. console.error("Error in startRadicalReview:", error);
  2406. enableScroll();
  2407. }
  2408. }
  2409.  
  2410. const MODAL_STATES = {
  2411. READY: "ready"
  2412. };
  2413.  
  2414. const EVENTS = {
  2415. CLOSE: "close",
  2416. START_REVIEW: "startReview"
  2417. };
  2418.  
  2419. class KanjiGrid {
  2420. constructor(kanji, onSelectionChange) {
  2421. this.kanji = kanji;
  2422. this.selectedKanji = new Set();
  2423. this.onSelectionChange = onSelectionChange;
  2424. this.$container = null;
  2425. }
  2426.  
  2427. updateKanjiSelection($element, kanji, isSelected) {
  2428. const baseStyles = {
  2429. ...styles.practiceModal.radical.base,
  2430. border: `2px solid ${isSelected ? theme.colors.kanji : 'rgba(255, 255, 255, 0.2)'}`,
  2431. background: isSelected ? 'rgba(235, 1, 156, 0.2)' : 'rgba(255, 255, 255, 0.1)',
  2432. transition: 'all 0.2s ease',
  2433. '&:hover': {
  2434. borderColor: theme.colors.kanji,
  2435. background: isSelected ? 'rgba(235, 1, 156, 0.3)' : 'rgba(255, 255, 255, 0.2)'
  2436. }
  2437. };
  2438.  
  2439. $element.css(baseStyles);
  2440.  
  2441. if (isSelected) {
  2442. this.selectedKanji.add(kanji.id);
  2443. } else {
  2444. this.selectedKanji.delete(kanji.id);
  2445. }
  2446.  
  2447. this.onSelectionChange(this.selectedKanji);
  2448. }
  2449.  
  2450. toggleAllKanji(shouldSelect) {
  2451. if (shouldSelect) {
  2452. this.kanji.forEach(kanji => this.selectedKanji.add(kanji.id));
  2453. } else {
  2454. this.selectedKanji.clear();
  2455. }
  2456.  
  2457. this.$container.find(".kanji-selection-item").each((_, element) => {
  2458. const $element = $(element);
  2459. const kanjiId = parseInt($element.data("kanji-id"));
  2460. this.updateKanjiSelection(
  2461. $element,
  2462. this.kanji.find(k => k.id === kanjiId),
  2463. shouldSelect
  2464. );
  2465. });
  2466.  
  2467. this.onSelectionChange(this.selectedKanji);
  2468. }
  2469.  
  2470. getSelectedKanji() {
  2471. return Array.from(this.selectedKanji).map(id =>
  2472. this.kanji.find(kanji => kanji.id === id)
  2473. );
  2474. }
  2475.  
  2476. createKanjiElement(kanji) {
  2477. const $element = $("<div>")
  2478. .addClass("kanji-selection-item")
  2479. .css({
  2480. ...styles.practiceModal.radical.base,
  2481. position: "relative"
  2482. })
  2483. .data("kanji-id", kanji.id)
  2484. .append(
  2485. $("<div>")
  2486. .addClass("kanji-character")
  2487. .css({
  2488. fontSize: theme.typography.fontSize.xl,
  2489. color: theme.colors.white
  2490. })
  2491. .text(kanji.character)
  2492. );
  2493.  
  2494. $element
  2495. .on("click", () => {
  2496. const isCurrentlySelected = this.selectedKanji.has(kanji.id);
  2497. this.updateKanjiSelection($element, kanji, !isCurrentlySelected);
  2498. });
  2499.  
  2500. return $element;
  2501. }
  2502.  
  2503. async render() {
  2504. this.$container = $("<div>")
  2505. .css({
  2506. ...styles.practiceModal.grid,
  2507. gridTemplateColumns: "repeat(auto-fill, minmax(80px, 1fr))"
  2508. });
  2509.  
  2510. this.kanji.forEach(kanji => {
  2511. const $element = this.createKanjiElement(kanji);
  2512. this.$container.append($element);
  2513. });
  2514. return this.$container;
  2515. }
  2516. }
  2517.  
  2518. class KanjiSelectionModal {
  2519. constructor(kanji, allUnlockedKanji) {
  2520. this.kanji = kanji;
  2521. this.allUnlockedKanji = allUnlockedKanji;
  2522. this.selectedMode = PRACTICE_MODES.STANDARD;
  2523. this.state = MODAL_STATES.READY;
  2524. this.totalKanji = kanji.length;
  2525. this.$modal = null;
  2526. this.kanjiGrid = null;
  2527. this.callbacks = new Map();
  2528. }
  2529.  
  2530. on(event, callback) {
  2531. this.callbacks.set(event, callback);
  2532. return this;
  2533. }
  2534.  
  2535. emit(event, data) {
  2536. const callback = this.callbacks.get(event);
  2537. if (callback) callback(data);
  2538. }
  2539.  
  2540. validateSelection(selectedCount) {
  2541. const minRequired = {
  2542. [PRACTICE_MODES.STANDARD]: 1,
  2543. [PRACTICE_MODES.ENGLISH_TO_KANJI]: 4,
  2544. [PRACTICE_MODES.COMBINED]: 4
  2545. };
  2546.  
  2547. const required = minRequired[this.selectedMode];
  2548. const isValid = selectedCount >= required;
  2549. const startButton = $("#ep-practice-modal-start");
  2550.  
  2551. if (isValid) {
  2552. startButton
  2553. .prop("disabled", false)
  2554. .text(`Start Review (${selectedCount} Selected)`)
  2555. .css({
  2556. ...styles.practiceModal.buttons.start.base,
  2557. ...styles.practiceModal.buttons.start.kanji,
  2558. opacity: 1,
  2559. cursor: "pointer"
  2560. });
  2561. } else {
  2562. startButton
  2563. .prop("disabled", true)
  2564. .text(`Select at least ${required} kanji`)
  2565. .css({
  2566. ...styles.practiceModal.buttons.start.base,
  2567. ...styles.practiceModal.buttons.start.kanji,
  2568. opacity: 0.5,
  2569. cursor: "not-allowed"
  2570. });
  2571. }
  2572. }
  2573.  
  2574. updateSelectAllButton(selectedCount) {
  2575. const selectAllButton = $("#ep-practice-modal-select-all");
  2576. const isAllSelected = selectedCount === this.totalKanji;
  2577. selectAllButton
  2578. .text(isAllSelected ? "Deselect All" : "Select All")
  2579. .css({
  2580. color: isAllSelected ? theme.colors.error : theme.colors.white,
  2581. borderColor: isAllSelected ? theme.colors.error : theme.colors.white,
  2582. '&:hover': {
  2583. borderColor: isAllSelected ? theme.colors.error : theme.colors.kanji
  2584. }
  2585. });
  2586. }
  2587.  
  2588. handleSelectionChange(selectedKanji) {
  2589. const selectedCount = selectedKanji.size;
  2590. this.updateSelectAllButton(selectedCount);
  2591. this.validateSelection(selectedCount);
  2592. }
  2593.  
  2594. createModeSelector() {
  2595. const $container = $("<div>")
  2596. .css(styles.practiceModal.modeSelector.container);
  2597.  
  2598. const $label = $("<div>")
  2599. .text("Select Practice Mode")
  2600. .css(styles.practiceModal.modeSelector.label);
  2601.  
  2602. const $options = $("<div>")
  2603. .css(styles.practiceModal.modeSelector.options);
  2604.  
  2605. const createOption = (mode, label) => {
  2606. const $option = $("<button>")
  2607. .text(label)
  2608. .css({
  2609. ...styles.practiceModal.modeSelector.option.base,
  2610. ...(this.selectedMode === mode ? styles.practiceModal.modeSelector.option.selected : {})
  2611. })
  2612. .on("click", () => {
  2613. $options.find("button").css(styles.practiceModal.modeSelector.option.base);
  2614. $option.css({
  2615. ...styles.practiceModal.modeSelector.option.base,
  2616. ...styles.practiceModal.modeSelector.option.selected
  2617. });
  2618. this.selectedMode = mode;
  2619. const currentSelection = this.kanjiGrid.getSelectedKanji();
  2620. this.validateSelection(currentSelection.length);
  2621. });
  2622. return $option;
  2623. };
  2624.  
  2625. $options.append(
  2626. createOption(PRACTICE_MODES.STANDARD, "Standard Practice"),
  2627. createOption(PRACTICE_MODES.ENGLISH_TO_KANJI, "English → Kanji"),
  2628. createOption(PRACTICE_MODES.COMBINED, "Combined Practice")
  2629. );
  2630.  
  2631. return $container.append($label, $options);
  2632. }
  2633.  
  2634. async render() {
  2635. this.$modal = $(modalTemplate).appendTo("body");
  2636. $("#username").text($("p.user-summary__username:first").text());
  2637. this.$modal.css(styles.practiceModal.backdrop);
  2638. $("#ep-practice-modal-welcome").css(styles.practiceModal.welcomeText.container);
  2639. $("#ep-practice-modal-welcome h1").css(styles.practiceModal.welcomeText.username);
  2640. $("#ep-practice-modal-welcome h2")
  2641. .text("Please select the Kanji characters you would like to practice")
  2642. .css({
  2643. color: theme.colors.white,
  2644. opacity: 0.9
  2645. });
  2646.  
  2647. const $modeSelector = this.createModeSelector();
  2648. $modeSelector.insertAfter("#ep-practice-modal-welcome");
  2649.  
  2650. $("#ep-practice-modal-footer").css(styles.practiceModal.footer);
  2651. $("#ep-practice-modal-content").css(styles.practiceModal.contentWrapper);
  2652. // Initial disabled state with kanji color scheme
  2653. $("#ep-practice-modal-start").css({
  2654. ...styles.practiceModal.buttons.start.base,
  2655. ...styles.practiceModal.buttons.start.kanji,
  2656. opacity: 0.5,
  2657. cursor: "not-allowed"
  2658. });
  2659.  
  2660. $("#ep-practice-modal-select-all").css({
  2661. ...styles.practiceModal.buttons.selectAll,
  2662. '&:hover': {
  2663. borderColor: theme.colors.kanji
  2664. }
  2665. });
  2666.  
  2667. $("#ep-practice-modal-close").css({
  2668. ...styles.practiceModal.buttons.exit,
  2669. '&:hover': {
  2670. borderColor: theme.colors.kanji,
  2671. color: theme.colors.kanji
  2672. }
  2673. });
  2674.  
  2675. this.kanjiGrid = new KanjiGrid(
  2676. this.kanji,
  2677. this.handleSelectionChange.bind(this)
  2678. );
  2679.  
  2680. const $grid = await this.kanjiGrid.render();
  2681. $("#ep-practice-modal-grid").replaceWith($grid);
  2682.  
  2683. $("#ep-practice-modal-select-all").on("click", () => {
  2684. const isSelectingAll = $("#ep-practice-modal-select-all").text() === "Select All";
  2685. this.kanjiGrid.toggleAllKanji(isSelectingAll);
  2686. });
  2687.  
  2688. $("#ep-practice-modal-close").on("click", () => {
  2689. this.emit(EVENTS.CLOSE);
  2690. });
  2691.  
  2692. $("#ep-practice-modal-start").on("click", () => {
  2693. const selectedKanji = this.kanjiGrid.getSelectedKanji();
  2694. const minRequired = {
  2695. [PRACTICE_MODES.STANDARD]: 1,
  2696. [PRACTICE_MODES.ENGLISH_TO_KANJI]: 4,
  2697. [PRACTICE_MODES.COMBINED]: 4
  2698. };
  2699.  
  2700. if (selectedKanji.length >= minRequired[this.selectedMode]) {
  2701. this.emit(EVENTS.START_REVIEW, {
  2702. kanji: selectedKanji,
  2703. mode: this.selectedMode,
  2704. allUnlockedKanji: this.allUnlockedKanji
  2705. });
  2706. }
  2707. });
  2708.  
  2709. return this.$modal;
  2710. }
  2711.  
  2712. remove() {
  2713. if (this.$modal) {
  2714. this.$modal.remove();
  2715. this.$modal = null;
  2716. }
  2717. }
  2718. }
  2719.  
  2720. async function handleKanjiPractice() {
  2721. try {
  2722. disableScroll();
  2723. const kanji = await getCurrentLevelKanji();
  2724. const selectionModal = new KanjiSelectionModal(kanji, kanji) // Using current level kanji as unlocked list for now
  2725. .on(EVENTS.CLOSE, () => {
  2726. enableScroll();
  2727. selectionModal.remove();
  2728. })
  2729. .on(EVENTS.START_REVIEW, (data) => {
  2730. selectionModal.remove();
  2731. startKanjiReview(data.kanji, data.mode, data.allUnlockedKanji);
  2732. });
  2733.  
  2734. await selectionModal.render();
  2735.  
  2736. } catch (error) {
  2737. console.error("Error in kanji practice:", error);
  2738. enableScroll();
  2739. }
  2740. }
  2741.  
  2742. async function startKanjiReview(selectedKanji, mode, allUnlockedKanji) {
  2743. try {
  2744. const reviewSession = new KanjiReviewSession({
  2745. items: selectedKanji,
  2746. mode: mode,
  2747. allUnlockedKanji: allUnlockedKanji
  2748. });
  2749. reviewSession.nextItem();
  2750.  
  2751. const reviewModal = new ReviewSessionModal(reviewSession);
  2752.  
  2753. reviewModal
  2754. .on(REVIEW_EVENTS.CLOSE, () => {
  2755. const progress = reviewSession.getProgress();
  2756. $("#ep-review-modal-header").remove();
  2757.  
  2758. const closingContent = [$("<p>", {
  2759. css: {
  2760. marginTop: 0,
  2761. textAlign: "center"
  2762. },
  2763. text: "Closing..."
  2764. })];
  2765.  
  2766. $("#ep-review-content")
  2767. .empty()
  2768. .append(
  2769. $("<div>")
  2770. .css(styles.reviewModal.content)
  2771. .append((() => {
  2772. if (reviewSession.mode === PRACTICE_MODES.STANDARD) {
  2773. closingContent.unshift($("<p>", {
  2774. css: {
  2775. ...styles.reviewModal.progress,
  2776. marginBottom: 0
  2777. },
  2778. text: `Meanings: ${progress.meaningProgress}/${progress.total/2} - Readings: ${progress.readingProgress}/${progress.total/2}`
  2779. }));
  2780. return closingContent;
  2781. } else if (reviewSession.mode === PRACTICE_MODES.ENGLISH_TO_KANJI) {
  2782. closingContent.unshift($("<p>", {
  2783. css: {
  2784. ...styles.reviewModal.progress,
  2785. marginBottom: 0
  2786. },
  2787. text: `${progress.recognitionProgress}/${progress.total} Correct`
  2788. }));
  2789. return closingContent;
  2790. } else { // COMBINATION PRACTICE_MODE
  2791. closingContent.unshift($("<p>", {
  2792. css: {
  2793. ...styles.reviewModal.progress,
  2794. marginBottom: 0
  2795. },
  2796. text: `Meanings: ${progress.meaningProgress}/${progress.total/3} | ` +
  2797. `Readings: ${progress.readingProgress}/${progress.total/3} | ` +
  2798. `Recognition: ${progress.recognitionProgress}/${progress.total/3}`
  2799. }));
  2800. return closingContent;
  2801. }
  2802. })())
  2803. );
  2804.  
  2805. setTimeout(() => {
  2806. enableScroll();
  2807. reviewModal.remove();
  2808. }, 1000);
  2809. })
  2810. .on(REVIEW_EVENTS.STUDY_AGAIN, () => {
  2811. reviewModal.remove();
  2812. enableScroll();
  2813. handleKanjiPractice();
  2814. });
  2815.  
  2816. await reviewModal.render();
  2817. } catch (error) {
  2818. console.error("Error in startKanjiReview:", error);
  2819. enableScroll();
  2820. }
  2821. }
  2822.  
  2823. class PracticeButton {
  2824. constructor(type) {
  2825. this.type = type;
  2826. this.buttonStyle = this.getButtonStyle();
  2827. this.handleClick = this.handleClick.bind(this);
  2828. }
  2829.  
  2830. getButtonStyle() {
  2831. return this.type === PRACTICE_TYPES.RADICAL
  2832. ? styles.buttons.practice.radical
  2833. : styles.buttons.practice.kanji;
  2834. }
  2835.  
  2836. async handleClick() {
  2837. try {
  2838. if (this.type === PRACTICE_TYPES.RADICAL) {
  2839. await handleRadicalPractice();
  2840. } else {
  2841. await handleKanjiPractice();
  2842. }
  2843. } catch (error) {
  2844. console.error(`Error handling ${this.type} practice:`, error);
  2845. }
  2846. }
  2847.  
  2848. render() {
  2849. const $button = $("<button>")
  2850. .attr("id", `ep-${this.type}-btn`)
  2851. .text("Practice")
  2852. .css(this.buttonStyle)
  2853. .on("click", this.handleClick);
  2854.  
  2855. const selector = `${SELECTORS.DIV_LEVEL_PROGRESS_CONTENT} ${SELECTORS.DIV_CONTENT_WRAPPER} ${SELECTORS.DIV_CONTENT_TITLE}`;
  2856. // Doing a conditional check to add the practice button to the correct DIV.
  2857. const targetSelector = this.type === PRACTICE_TYPES.RADICAL
  2858. ? `${selector}:first`
  2859. : `${selector}:last`;
  2860.  
  2861. $button.appendTo(targetSelector);
  2862.  
  2863. return $button;
  2864. }
  2865. }
  2866.  
  2867. function initializePracticeButtons() {
  2868. // First style the containers where the "PRACTICE" buttons be
  2869. $(`${SELECTORS.DIV_LEVEL_PROGRESS_CONTENT} ${SELECTORS.DIV_CONTENT_WRAPPER} ${SELECTORS.DIV_CONTENT_TITLE}`)
  2870. .css(styles.layout.contentTitle);
  2871.  
  2872. const radicalButton = new PracticeButton(PRACTICE_TYPES.RADICAL);
  2873. const kanjiButton = new PracticeButton(PRACTICE_TYPES.KANJI);
  2874.  
  2875. radicalButton.render();
  2876. kanjiButton.render();
  2877. }
  2878.  
  2879. $(document).ready(() => {
  2880. initializePracticeButtons();
  2881. });
  2882.  
  2883. })();
  2884. //# sourceMappingURL=extra-practice.user.js.map