(function () {
'use strict';
// ==UserScript==
// @name Extra Practice
// @namespace https://github.com/mrpassiontea/Extra-Practice
// @version 2.0.0
// @description Practice your current level's Radicals and Kanji with standard, english -> Kanji, and combination mode!
// @author @mrpassiontea
// @match https://www.wanikani.com/
// @match *://*.wanikani.com/dashboard
// @match *://*.wanikani.com/dashboard?*
// @copyright 2025, mrpassiontea
// @grant none
// @grant window.onurlchange
// @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @require https://unpkg.com/[email protected]/wanakana.min.js
// @license MIT; http://opensource.org/licenses/MIT
// @run-at document-end
// ==/UserScript==
const SELECTORS = {
DIV_LEVEL_PROGRESS_CONTENT: "div.wk-panel__content div.level-progress-dashboard",
DIV_CONTENT_WRAPPER: "div.level-progress-dashboard__content",
DIV_CONTENT_TITLE: "div.level-progress-dashboard__content-title"
};
const DB_VALUES = {
DB_NAME: "wkof.file_cache",
USER_RECORD: "Apiv2.user",
SUBJECT_RECORD: "Apiv2.subjects",
FILE_STORE: "files"
};
const DB_ERRORS = {
OPEN: "Failed to open database",
USER_LEVEL: "Failed to retrieve user level",
SUBJECT_DATA: "Failed to retrieve subjects data"
};
const PRACTICE_MODES = {
STANDARD: 'standard',
ENGLISH_TO_KANJI: 'englishToKanji',
COMBINED: 'combined'
};
const modalTemplate = `
<div id="ep-practice-modal">
<div id="ep-practice-modal-content">
<div id="ep-practice-modal-welcome">
<h1>Hello, <span id="username"></span></h1>
<h2>Please select all the Radicals that you would like to include in your practice session</h2>
</div>
<button id="ep-practice-modal-select-all">Select All</button>
<div id="ep-practice-modal-grid"></div>
<div id="ep-practice-modal-footer">
<button id="ep-practice-modal-start" disabled>Start Review (0 Selected)</button>
<button id="ep-practice-modal-close">Exit</button>
</div>
</div>
</div>
`;
const reviewModalTemplate = `
<div id="ep-review-modal">
<div id="ep-review-modal-wrapper">
<div id="ep-review-modal-header">
<div id="ep-review-progress">
<span id="ep-review-progress-correct">0</span>
</div>
<button id="ep-review-exit">End Review</button>
</div>
<div id="ep-review-content">
<div id="ep-review-character"></div>
<div id="ep-review-input-section">
<input type="text" id="ep-review-answer" placeholder="Enter meaning..." tabindex="1" autofocus />
<button id="ep-review-submit" tabindex="2">Submit</button>
</div>
<div id="ep-review-result" style="display: none;">
<div id="ep-review-result-message"></div>
<button id="ep-review-show-hint" style="display: none;">Show Answer</button>
</div>
<div id="ep-review-explanation" style="display: none;">
<h3>
<span id="ep-review-meaning-label">Meaning:</span>
<span id="ep-review-meaning"></span>
</h3>
<div class="mnemonic-container">
<span id="ep-review-mnemonic-label">Mnemonic:</span>
<div id="ep-review-mnemonic"></div>
</div>
</div>
</div>
</div>
</div>
`;
// Theme constants for consistent values across the application
const theme = {
colors: {
radical: "#0598e4",
kanji: "#eb019c",
white: "#FFFFFF",
black: "#000000",
gray: {
100: "#F3F4F6",
200: "#E5E7EB",
300: "#D1D5DB",
400: "#9CA3AF",
600: "#4B5563",
700: "#374151",
800: "#1F2937"},
overlay: {
dark: "rgba(0, 0, 0, 0.9)"},
success: "#10B981",
error: "#EF4444",
info: "#3B82F6"
},
spacing: {
xs: "0.5rem", // 8px
sm: "0.75rem", // 12px
md: "1rem", // 16px
lg: "1.5rem", // 24px
xl: "2rem"},
typography: {
fontSize: {
xs: "0.875rem", // 14px
sm: "1rem", // 16px
md: "1.25rem", // 20px
lg: "1.5rem", // 24px
xl: "2rem", // 32px
"2xl": "6rem" // 96px (for the big character display)
},
fontWeight: {
normal: "400",
medium: "500",
bold: "700"
}
},
borderRadius: {
sm: "3px",
md: "4px",
lg: "8px"
},
zIndex: {
modal: 99999
}
};
// Common style mixins for reusable patterns
const mixins = {
modalBackdrop: {
position: "fixed",
top: "0",
left: "0",
width: "100%",
height: "100%",
zIndex: theme.zIndex.modal
}};
// Component-specific styles
const styles = {
layout: {
contentTitle: {
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}
},
buttons: {
practice: {
radical: {
marginBottom: theme.spacing.md,
backgroundColor: theme.colors.radical,
padding: theme.spacing.sm,
borderRadius: theme.borderRadius.sm,
color: theme.colors.white,
fontWeight: theme.typography.fontWeight.medium,
cursor: "pointer"
},
kanji: {
marginBottom: theme.spacing.md,
backgroundColor: theme.colors.kanji,
padding: theme.spacing.sm,
borderRadius: theme.borderRadius.sm,
color: theme.colors.white,
fontWeight: theme.typography.fontWeight.medium,
cursor: "pointer"
}
}
},
practiceModal: {
backdrop: {
...mixins.modalBackdrop,
backgroundColor: theme.colors.overlay.dark,
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center"
},
contentWrapper: {
width: "100%",
maxWidth: "800px",
padding: `0 ${theme.spacing.xl}`,
display: "flex",
flexDirection: "column",
alignItems: "center"
},
welcomeText: {
container: {
color: theme.colors.white,
textAlign: "center",
fontSize: theme.typography.fontSize.sm,
marginBottom: theme.spacing.md,
display: "flex",
flexDirection: "column",
alignItems: "center",
maxWidth: "750px"
},
username: {
fontSize: theme.typography.fontSize.xl,
marginBottom: theme.spacing.md
}
},
grid: {
display: "grid",
gridTemplateColumns: "repeat(5, minmax(100px, 1fr))",
gap: theme.spacing.md,
padding: `${theme.spacing.md} ${theme.spacing.xl}`,
maxHeight: "50vh",
maxWidth: "600px",
margin: "0 auto",
justifyContent: "center"
},
radical: {
base: {
background: "rgba(255, 255, 255, 0.1)",
border: "2px solid rgba(255, 255, 255, 0.2)",
borderRadius: theme.borderRadius.lg,
padding: theme.spacing.md,
cursor: "pointer",
display: "flex",
flexDirection: "column",
alignItems: "center",
transition: "all 0.2s ease"
},
selected: {
background: "rgba(5, 152, 228, 0.3)",
border: `2px solid ${theme.colors.radical}`
},
character: {
fontSize: theme.typography.fontSize.xl,
color: theme.colors.white
}},
buttons: {
start: {
base: {
padding: `${theme.spacing.sm} ${theme.spacing.lg}`,
borderRadius: theme.borderRadius.md,
border: "none",
fontWeight: theme.typography.fontWeight.medium,
transition: "all 0.2s ease",
cursor: "pointer",
color: theme.colors.white
},
radical: {
backgroundColor: theme.colors.radical,
'&:hover': {
backgroundColor: theme.colors.radical,
opacity: 0.9
}
},
kanji: {
backgroundColor: theme.colors.kanji,
'&:hover': {
backgroundColor: theme.colors.kanji,
opacity: 0.9
}
}
},
selectAll: {
color: theme.colors.white,
background: "transparent",
border: `1px solid ${theme.colors.white}`,
cursor: "pointer",
fontSize: theme.typography.fontSize.xs,
marginBottom: theme.spacing.md,
padding: theme.spacing.sm,
borderRadius: theme.borderRadius.sm,
fontWeight: theme.typography.fontWeight.bold,
transition: "all 0.2s ease"
},
exit: {
border: `1px solid ${theme.colors.white}`,
backgroundColor: "rgba(255, 255, 255, 0.9)",
padding: `${theme.spacing.sm} ${theme.spacing.md}`,
color: theme.colors.black,
fontWeight: theme.typography.fontWeight.medium,
borderRadius: theme.borderRadius.sm,
cursor: "pointer",
transition: "all 0.2s ease"
}
},
footer: {
padding: `${theme.spacing.md} ${theme.spacing.xl}`,
display: "flex",
justifyContent: "center",
width: "100%",
maxWidth: "600px",
gap: theme.spacing.md
},
modeSelector: {
container: {
display: "flex",
flexDirection: "column",
alignItems: "center",
marginBottom: theme.spacing.xl,
width: "100%",
maxWidth: "600px"
},
label: {
color: theme.colors.white,
fontSize: theme.typography.fontSize.md,
marginBottom: theme.spacing.md
},
options: {
display: "flex",
gap: theme.spacing.md,
justifyContent: "center",
width: "100%"
},
option: {
base: {
padding: `${theme.spacing.sm} ${theme.spacing.md}`,
borderRadius: theme.borderRadius.md,
border: `2px solid ${theme.colors.gray[400]}`,
backgroundColor: "transparent",
color: theme.colors.white,
cursor: "pointer",
transition: "all 0.2s ease",
fontSize: theme.typography.fontSize.sm,
fontWeight: theme.typography.fontWeight.medium,
'&:hover': {
borderColor: theme.colors.kanji,
backgroundColor: "rgba(235, 1, 156, 0.1)"
}
},
selected: {
borderColor: theme.colors.kanji,
backgroundColor: "rgba(235, 1, 156, 0.2)"
}
}
}},
reviewModal: {
container: {
backgroundColor: theme.colors.white,
borderRadius: theme.borderRadius.lg,
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
maxWidth: "600px",
width: "100%",
display: "flex",
flexDirection: "column"
},
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: theme.spacing.lg,
borderBottom: `1px solid ${theme.colors.gray[200]}`,
gap: theme.spacing.md
},
progress: {
fontWeight: theme.typography.fontWeight.bold,
fontSize: theme.typography.fontSize.md,
color: theme.colors.gray[800]
},
content: {
padding: theme.spacing.xl,
display: "flex",
flexDirection: "column",
width: "100%",
gap: theme.spacing.xl,
},
character: {
fontSize: theme.typography.fontSize["2xl"],
color: theme.colors.gray[800],
marginBottom: theme.spacing.xl,
textAlign: "center"
},
inputSection: {
width: "100%",
display: "flex",
gap: theme.spacing.md,
marginBottom: theme.spacing.xl
},
input: {
flex: "1",
padding: theme.spacing.sm,
fontSize: theme.typography.fontSize.sm,
borderRadius: theme.borderRadius.md,
border: `1px solid ${theme.colors.gray[300]}`
},
buttons: {
submit: {
backgroundColor: theme.colors.info,
color: theme.colors.white,
padding: `${theme.spacing.sm} ${theme.spacing.lg}`,
borderRadius: theme.borderRadius.md,
border: "none",
fontWeight: theme.typography.fontWeight.medium,
cursor: "pointer",
transition: "background-color 0.2s ease",
"&:hover": {
backgroundColor: "#2563EB"
}
},
exit: {
backgroundColor: "transparent",
color: theme.colors.kanji,
border: `1px solid ${theme.colors.kanji}`,
borderRadius: theme.borderRadius.md,
padding: `${theme.spacing.sm} ${theme.spacing.lg}`,
fontWeight: theme.typography.fontWeight.medium,
cursor: "pointer",
transition: "background-color 0.2s ease",
"&:hover": {
backgroundColor: theme.colors.gray[100]
}
},
hint: {
backgroundColor: "transparent",
color: theme.colors.info,
border: `1px solid ${theme.colors.info}`,
borderRadius: theme.borderRadius.md,
padding: `${theme.spacing.sm} ${theme.spacing.lg}`,
cursor: "pointer",
transition: "background-color 0.2s ease",
"&:hover": {
backgroundColor: theme.colors.gray[100]
}
}
},
results: {
message: {
fontSize: theme.typography.fontSize.lg,
fontWeight: theme.typography.fontWeight.bold,
marginBottom: theme.spacing.md,
color: theme.colors.info,
textAlign: "center",
"&.correct": {
color: theme.colors.success
},
"&.incorrect": {
color: theme.colors.error
}
}
},
explanation: {
lineHeight: "1.6",
color: theme.colors.gray[600],
fontSize: theme.typography.fontSize.md,
meaningLabel: {
display: "inline-block",
fontWeight: theme.typography.fontWeight.normal,
fontSize: theme.typography.fontSize.md,
color: theme.colors.gray[800],
marginRight: theme.spacing.xs
},
meaningText: {
display: "inline-block",
fontWeight: theme.typography.fontWeight.bold,
fontSize: theme.typography.fontSize.md,
color: theme.colors.radical[800],
textDecoration: "none"
},
mnemonicContainer: {
marginTop: theme.spacing.md,
textAlign: "left",
lineHeight: "1.6"
},
mnemonicLabel: {
display: "block",
fontWeight: theme.typography.fontWeight.bold,
fontSize: theme.typography.fontSize.md,
color: theme.colors.gray[800],
marginBottom: theme.spacing.xs
},
mnemonic: {
color: theme.colors.gray[600],
fontSize: theme.typography.fontSize.md
},
mnemonicHighlight: {
backgroundColor: theme.colors.gray[200],
padding: `0 ${theme.spacing.xs}`,
borderRadius: theme.borderRadius.sm,
color: theme.colors.gray[800]
}
},
kanjiOption: {
base: {
padding: theme.spacing.lg,
borderRadius: theme.borderRadius.md,
border: `2px solid ${theme.colors.gray[300]}`,
backgroundColor: theme.colors.white,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s ease",
'&:hover': {
borderColor: theme.colors.kanji,
backgroundColor: "rgba(235, 1, 156, 0.1)"
}
},
selected: {
borderColor: theme.colors.kanji,
backgroundColor: "rgba(235, 1, 156, 0.2)"
}
}
}
};
const PRACTICE_TYPES = {
RADICAL: "radical",
KANJI: "kanji"
};
const MODAL_STATES$1 = {
READY: "ready"
};
const EVENTS$1 = {
CLOSE: "close",
START_REVIEW: "startReview"
};
class BaseReviewSession {
constructor(selectedItems) {
if (new.target === BaseReviewSession) {
throw new Error("BaseReviewSession is an abstract class and cannot be instantiated directly.");
}
this.originalItems = selectedItems;
this.currentItem = null;
}
shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
nextItem() {
throw new Error("nextItem() must be implemented by derived classes");
}
checkAnswer(userAnswer) {
throw new Error("checkAnswer() must be implemented by derived classes");
}
isComplete() {
throw new Error("isComplete() must be implemented by derived classes");
}
getProgress() {
throw new Error("getProgress() must be implemented by derived classes");
}
}
class KanjiReviewSession extends BaseReviewSession {
constructor(config) {
super(config.items);
this.mode = config.mode || PRACTICE_MODES.STANDARD;
this.allUnlockedKanji = config.allUnlockedKanji || [];
this.allCards = [];
this.remainingItems = [];
// Progress tracking
this.correctMeanings = new Set();
this.correctReadings = new Set();
this.correctRecognition = new Set();
// Initialize cards based on mode
this.initializeCards();
}
initializeCards() {
switch (this.mode) {
case PRACTICE_MODES.STANDARD:
this.initializeStandardCards();
break;
case PRACTICE_MODES.ENGLISH_TO_KANJI:
this.initializeRecognitionCards();
break;
case PRACTICE_MODES.COMBINED:
this.initializeStandardCards();
this.initializeRecognitionCards();
break;
}
// Shuffle all cards together
this.remainingItems = this.shuffleArray([...this.allCards]);
}
initializeStandardCards() {
this.originalItems.forEach(kanji => {
// Add meaning card
this.allCards.push({
...kanji,
type: "meaning",
questionType: "What is the meaning of this kanji?"
});
// Add reading card
this.allCards.push({
...kanji,
type: "reading",
questionType: "What is the reading of this kanji?"
});
});
}
initializeRecognitionCards() {
this.originalItems.forEach(kanji => {
const primaryMeaning = kanji.meanings.find(m => m.primary)?.meaning;
// Create recognition card
this.allCards.push({
...kanji,
type: "recognition",
questionType: "Select the kanji that means",
meaningToMatch: primaryMeaning,
options: this.generateKanjiOptions(kanji)
});
});
}
generateKanjiOptions(correctKanji) {
const numberOfOptions = 4;
const options = [correctKanji];
// Create a pool of incorrect options from the selected kanji
const availableOptions = this.originalItems.filter(k => k.id !== correctKanji.id);
// Randomly select additional options from the available pool
while (options.length < numberOfOptions && availableOptions.length > 0) {
const randomIndex = Math.floor(Math.random() * availableOptions.length);
const selectedOption = availableOptions[randomIndex];
options.push(selectedOption);
availableOptions.splice(randomIndex, 1);
}
// If we still need more options (rare case when very few kanji are selected)
// fill remaining slots with kanji from allUnlockedKanji
if (options.length < numberOfOptions) {
const additionalOptions = this.allUnlockedKanji.filter(k =>
!options.some(selected => selected.id === k.id) &&
!this.originalItems.some(selected => selected.id === k.id)
);
while (options.length < numberOfOptions && additionalOptions.length > 0) {
const randomIndex = Math.floor(Math.random() * additionalOptions.length);
const selectedOption = additionalOptions[randomIndex];
options.push(selectedOption);
additionalOptions.splice(randomIndex, 1);
}
}
return this.shuffleArray(options);
}
nextItem() {
if (this.remainingItems.length === 0) {
// Get items that haven't been answered correctly
const remainingUnlearned = [];
this.originalItems.forEach(kanji => {
switch (this.mode) {
case PRACTICE_MODES.STANDARD:
if (!this.correctMeanings.has(kanji.id)) {
remainingUnlearned.push({
...kanji,
type: "meaning",
questionType: "What is the meaning of this kanji?"
});
}
if (!this.correctReadings.has(kanji.id)) {
remainingUnlearned.push({
...kanji,
type: "reading",
questionType: "What is the reading of this kanji?"
});
}
break;
case PRACTICE_MODES.ENGLISH_TO_KANJI:
if (!this.correctRecognition.has(kanji.id)) {
const primaryMeaning = kanji.meanings.find(m => m.primary)?.meaning;
remainingUnlearned.push({
...kanji,
type: "recognition",
questionType: "Select the kanji that means",
meaningToMatch: primaryMeaning,
options: this.generateKanjiOptions(kanji)
});
}
break;
case PRACTICE_MODES.COMBINED:
if (!this.correctMeanings.has(kanji.id)) {
remainingUnlearned.push({
...kanji,
type: "meaning",
questionType: "What is the meaning of this kanji?"
});
}
if (!this.correctReadings.has(kanji.id)) {
remainingUnlearned.push({
...kanji,
type: "reading",
questionType: "What is the reading of this kanji?"
});
}
if (!this.correctRecognition.has(kanji.id)) {
const primaryMeaning = kanji.meanings.find(m => m.primary)?.meaning;
remainingUnlearned.push({
...kanji,
type: "recognition",
questionType: "Select the kanji that means",
meaningToMatch: primaryMeaning,
options: this.generateKanjiOptions(kanji)
});
}
break;
}
});
// Shuffle the remaining items
if (remainingUnlearned.length > 0) {
this.remainingItems = this.shuffleArray(remainingUnlearned);
}
}
this.currentItem = this.remainingItems.shift();
return this.currentItem;
}
checkAnswer(userAnswer) {
if (!this.currentItem) return false;
let isCorrect = false;
switch (this.currentItem.type) {
case "meaning":
isCorrect = this.checkMeaningAnswer(userAnswer);
if (isCorrect) this.correctMeanings.add(this.currentItem.id);
break;
case "reading":
isCorrect = this.checkReadingAnswer(userAnswer);
if (isCorrect) this.correctReadings.add(this.currentItem.id);
break;
case "recognition":
isCorrect = parseInt(userAnswer) === this.currentItem.id;
if (isCorrect) this.correctRecognition.add(this.currentItem.id);
break;
}
return isCorrect;
}
checkMeaningAnswer(userAnswer) {
const normalizedUserAnswer = userAnswer.toLowerCase().trim();
// Check primary meanings
const isPrimaryCorrect = this.currentItem.meanings.some(m =>
m.meaning.toLowerCase() === normalizedUserAnswer
);
if (isPrimaryCorrect) return true;
// Check auxiliary meanings
return this.currentItem.auxiliaryMeanings.some(m =>
m.meaning.toLowerCase() === normalizedUserAnswer
);
}
checkReadingAnswer(userAnswer) {
const userReading = userAnswer.trim();
return this.currentItem.readings.some(r => r.reading === userReading);
}
isComplete() {
const progress = this.getProgress();
return progress.current === progress.total;
}
getProgress() {
const totalKanji = this.originalItems.length;
let total, current;
switch (this.mode) {
case PRACTICE_MODES.STANDARD:
total = totalKanji * 2; // One point each for meaning and reading
current = this.correctMeanings.size + this.correctReadings.size;
return {
total,
current,
meaningProgress: this.correctMeanings.size,
readingProgress: this.correctReadings.size
};
case PRACTICE_MODES.ENGLISH_TO_KANJI:
total = totalKanji; // One point for each recognition test
current = this.correctRecognition.size;
return {
total,
current,
recognitionProgress: this.correctRecognition.size
};
case PRACTICE_MODES.COMBINED:
total = totalKanji * 3; // One point each for meaning, reading, and recognition
current = this.correctMeanings.size +
this.correctReadings.size +
this.correctRecognition.size;
return {
total,
current,
meaningProgress: this.correctMeanings.size,
readingProgress: this.correctReadings.size,
recognitionProgress: this.correctRecognition.size
};
default:
return {
total: 0,
current: 0
};
}
}
}
class RadicalReviewSession extends BaseReviewSession {
constructor(config) {
super(config.items);
this.remainingItems = this.shuffleArray([...config.items]);
this.correctAnswers = new Set();
}
nextItem() {
if (this.remainingItems.length === 0) {
const remainingUnlearned = this.originalItems.filter(item => !this.correctAnswers.has(item.id));
if (remainingUnlearned.length === 1) {
this.remainingItems = remainingUnlearned;
} else {
this.remainingItems = this.shuffleArray(
remainingUnlearned.filter(item => !this.currentItem || item.id !== this.currentItem.id)
);
}
}
this.currentItem = this.remainingItems.shift();
return this.currentItem;
}
checkAnswer(userAnswer) {
const isCorrect = this.currentItem.meaning.toLowerCase() === userAnswer.toLowerCase();
if (isCorrect) {
this.correctAnswers.add(this.currentItem.id);
}
return isCorrect;
}
isComplete() {
return this.correctAnswers.size === this.originalItems.length;
}
getProgress() {
const totalRadicals = this.originalItems.length;
let current = this.correctAnswers.size;
return {
current,
total: totalRadicals,
remaining: totalRadicals - current,
percentComplete: Math.round((current / totalRadicals) * 100)
};
}
}
function disableScroll() {
const scrollPosition = window.scrollY || document.documentElement.scrollTop;
$("html, body").css({
overflow: "hidden",
height: "100%",
position: "fixed",
top: `-${scrollPosition}px`,
width: "100%",
});
}
function enableScroll() {
const scrollPosition = parseInt($("html").css("top")) * -1;
$("html, body").css({
overflow: "auto",
height: "auto",
position: "static",
top: "auto",
width: "auto",
});
window.scrollTo(0, scrollPosition);
}
// Cache for SVG content to avoid repeated fetches
const svgCache = new Map();
async function loadSvgContent(url) {
if (svgCache.has(url)) {
return svgCache.get(url);
}
const response = await fetch(url);
const svgContent = await response.text();
svgCache.set(url, svgContent);
return svgContent;
}
class RadicalGrid {
constructor(radicals, onSelectionChange) {
this.radicals = radicals;
this.selectedRadicals = new Set();
this.onSelectionChange = onSelectionChange;
this.$container = null;
}
updateRadicalSelection($element, radical, isSelected) {
$element.css(
isSelected
? { ...styles.practiceModal.radical.base, ...styles.practiceModal.radical.selected }
: styles.practiceModal.radical.base
);
if (isSelected) {
this.selectedRadicals.add(radical.id);
} else {
this.selectedRadicals.delete(radical.id);
}
this.onSelectionChange(this.selectedRadicals);
}
toggleAllRadicals(shouldSelect) {
if (shouldSelect) {
this.radicals.forEach(radical => this.selectedRadicals.add(radical.id));
} else {
this.selectedRadicals.clear();
}
this.$container.find(".radical-selection-item").each((_, element) => {
const $element = $(element);
const radicalId = parseInt($element.data("radical-id"));
this.updateRadicalSelection(
$element,
this.radicals.find(r => r.id === radicalId),
shouldSelect
);
});
this.onSelectionChange(this.selectedRadicals);
}
getSelectedRadicals() {
return Array.from(this.selectedRadicals).map(id =>
this.radicals.find(radical => radical.id === id)
);
}
async createRadicalElement(radical) {
const $element = $("<div>")
.addClass("radical-selection-item")
.css(styles.practiceModal.radical.base)
.data("radical-id", radical.id)
.append(
$("<div>")
.addClass("radical-character")
.css(styles.practiceModal.radical.character)
.text(radical.character || "")
)
.on("click", () => {
const isCurrentlySelected = this.selectedRadicals.has(radical.id);
this.updateRadicalSelection($element, radical, !isCurrentlySelected);
});
if (!radical.character && radical.svg) {
try {
const svgContent = await loadSvgContent(radical.svg);
$element.find(".radical-character").html(svgContent);
const svg = $element.find("svg")[0];
if (svg) {
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
}
} catch (error) {
console.error("Error loading SVG:", error);
$element.find(".radical-character").text(radical.meaning);
}
}
return $element;
}
async render() {
this.$container = $("<div>")
.css(styles.practiceModal.grid);
// Create and append all radical elements
const radicalElements = await Promise.all(
this.radicals.map(radical => this.createRadicalElement(radical))
);
radicalElements.forEach($element => this.$container.append($element));
return this.$container;
}
}
class RadicalSelectionModal {
constructor(radicals) {
this.radicals = radicals;
this.state = MODAL_STATES$1.READY;
this.totalRadicals = radicals.length;
this.$modal = null;
this.radicalGrid = null;
this.callbacks = new Map();
}
on(event, callback) {
this.callbacks.set(event, callback);
return this;
}
emit(event, data) {
const callback = this.callbacks.get(event);
if (callback) callback(data);
}
updateSelectAllButton(selectedCount) {
const selectAllButton = $("#ep-practice-modal-select-all");
const isAllSelected = selectedCount === this.totalRadicals;
selectAllButton
.text(isAllSelected ? "Deselect All" : "Select All")
.css({
color: isAllSelected ? theme.colors.error : theme.colors.white,
borderColor: isAllSelected ? theme.colors.error : theme.colors.white
});
}
updateStartButton(selectedCount) {
const startButton = $("#ep-practice-modal-start");
if (selectedCount > 0) {
startButton
.prop("disabled", false)
.text(`Start Review (${selectedCount} Selected)`)
.css({
...styles.practiceModal.buttons.start.base,
...styles.practiceModal.buttons.start.radical
});
} else {
startButton
.prop("disabled", true)
.text("Start Review (0 Selected)")
.css({
...styles.practiceModal.buttons.start.base,
...styles.practiceModal.buttons.start.radical,
...styles.practiceModal.buttons.start.disabled
});
}
}
handleSelectionChange(selectedRadicals) {
const selectedCount = selectedRadicals.size;
this.updateSelectAllButton(selectedCount);
this.updateStartButton(selectedCount);
}
async render() {
this.$modal = $(modalTemplate).appendTo("body");
$("#username").text($("p.user-summary__username:first").text());
this.$modal.css(styles.practiceModal.backdrop);
$("#ep-practice-modal-welcome").css(styles.practiceModal.welcomeText.container);
$("#ep-practice-modal-welcome h1").css(styles.practiceModal.welcomeText.username);
$("#ep-practice-modal-footer").css(styles.practiceModal.footer);
$("#ep-practice-modal-start").css({
...styles.practiceModal.buttons.start.base,
...styles.practiceModal.buttons.start.radical,
...styles.practiceModal.buttons.start.disabled
});
$("#ep-practice-modal-select-all").css(styles.practiceModal.buttons.selectAll);
$("#ep-practice-modal-content").css(styles.practiceModal.contentWrapper);
$("#ep-practice-modal-close").css(styles.practiceModal.buttons.exit);
this.radicalGrid = new RadicalGrid(
this.radicals,
this.handleSelectionChange.bind(this)
);
const $grid = await this.radicalGrid.render();
$("#ep-practice-modal-grid").replaceWith($grid);
this.updateStartButton(0);
$("#ep-practice-modal-select-all").on("click", () => {
const isSelectingAll = $("#ep-practice-modal-select-all").text() === "Select All";
this.radicalGrid.toggleAllRadicals(isSelectingAll);
});
$("#ep-practice-modal-close").on("click", () => {
this.emit(EVENTS$1.CLOSE);
});
$("#ep-practice-modal-start").on("click", () => {
const selectedRadicals = this.radicalGrid.getSelectedRadicals();
if (selectedRadicals.length > 0) {
this.emit(EVENTS$1.START_REVIEW, selectedRadicals);
}
});
return this.$modal;
}
remove() {
if (this.$modal) {
this.$modal.remove();
this.$modal = null;
}
}
}
const REVIEW_STATES = {
ANSWERING: "answering",
REVIEWING: "reviewing"};
const REVIEW_EVENTS = {
CLOSE: "close",
NEXT_ITEM: "nextItem",
COMPLETE: "complete",
STUDY_AGAIN: "studyAgain"
};
class ReviewCard {
constructor(item, state = REVIEW_STATES.ANSWERING) {
this.item = item;
this.state = state;
this.$container = null;
this.isKanji = !!this.item.readings;
this.selectedOption = null;
this.handleKanjiSelection = this.handleKanjiSelection.bind(this);
}
handleKanjiSelection(event, option) {
const $selectedElement = $(event.currentTarget);
this.$container.find('.kanji-option').css(styles.reviewModal.kanjiOption.base);
$selectedElement.css({
...styles.reviewModal.kanjiOption.base,
...styles.reviewModal.kanjiOption.selected
});
this.selectedOption = option.id;
const $submitButton = this.$container.find('#ep-review-submit');
$submitButton
.prop('disabled', false)
.css({
...styles.reviewModal.buttons.submit,
opacity: 1,
cursor: "pointer"
});
}
getQuestionText() {
if (this.item.type === "recognition") {
return ["Select the kanji that means ", this.createEmphasisSpan(this.item.meaningToMatch)];
}
if (!this.isKanji) {
return ["What is the meaning of this ", this.createEmphasisSpan("radical"), "?"];
}
if (this.item.type === "reading") {
const readingType = this.item.readings.find(r => r.primary)?.type;
const readingText = readingType === "onyomi" ? "on'yomi" : "kun'yomi";
return ["What is the ", this.createEmphasisSpan(readingText), " reading for this kanji?"];
}
return ["What is the ", this.createEmphasisSpan("meaning"), " of this kanji?"];
}
createEmphasisSpan(text) {
return $("<span>")
.text(text)
.css({
fontWeight: theme.typography.fontWeight.bold,
color: this.isKanji ? theme.colors.kanji : theme.colors.radical,
padding: `${theme.spacing.xs}`,
borderRadius: theme.borderRadius.sm,
backgroundColor: this.isKanji ?
"rgba(235, 1, 156, 0.1)" :
"rgba(5, 152, 228, 0.1)"
});
}
createKanjiOption(option) {
const $option = $("<div>")
.addClass("kanji-option")
.css(styles.reviewModal.kanjiOption.base)
.data("kanji-id", option.id)
.append(
$("<div>")
.addClass("kanji-character")
.css({
fontSize: theme.typography.fontSize["2xl"],
color: theme.colors.gray[800],
textAlign: "center"
})
.text(option.character)
);
$option.on("click", (event) => this.handleKanjiSelection(event, option));
return $option;
}
async renderCharacter() {
const $character = $("<div>")
.addClass("ep-review-character")
.css(styles.reviewModal.character);
if (this.item.character) {
$character.text(this.item.character);
} else if (this.item.svg) {
try {
const svgContent = await loadSvgContent(this.item.svg);
$character.html(svgContent);
const svg = $character.find("svg")[0];
if (svg) {
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
}
} catch (error) {
console.error("Error loading SVG:", error);
$character.text(this.item.meaning);
}
}
return $character;
}
async renderAnsweringState() {
const $content = $("<div>").addClass("ep-review-content");
if (this.item.type === "recognition") {
return this.renderRecognitionCard($content);
} else {
const $character = await this.renderCharacter();
const $question = $("<div>")
.addClass("ep-review-question")
.css({
fontSize: theme.typography.fontSize.lg,
marginBottom: theme.spacing.lg,
color: theme.colors.gray[700]
});
const questionContent = this.getQuestionText();
questionContent.forEach(content => {
if (content instanceof jQuery) {
$question.append(content);
} else {
$question.append(document.createTextNode(content));
}
});
const $inputSection = $("<div>")
.addClass("ep-review-input-section")
.css(styles.reviewModal.inputSection)
.append(
$("<input>")
.attr({
type: "text",
id: "ep-review-answer",
placeholder: this.item.type === "reading" ? "Enter reading..." : "Enter meaning...",
tabindex: "1",
autofocus: true
})
.css(styles.reviewModal.input),
$("<button>")
.attr("id", "ep-review-submit")
.text("Submit")
.attr("tabindex", "2")
.css(styles.reviewModal.buttons.submit)
);
$content.append($character);
$content.append($question);
$content.append($inputSection);
return $content;
}
}
async renderStandardAnsweringCard($content) {
const $character = await this.renderCharacter();
const $question = $("<div>")
.addClass("ep-review-question")
.css({
fontSize: theme.typography.fontSize.lg,
marginBottom: theme.spacing.lg,
color: theme.colors.gray[700]
});
const questionContent = this.getQuestionText();
questionContent.forEach(content => {
if (content instanceof jQuery) {
$question.append(content);
} else {
$question.append(document.createTextNode(content));
}
});
const $inputSection = $("<div>")
.addClass("ep-review-input-section")
.css(styles.reviewModal.inputSection)
.append(
$("<input>")
.attr({
type: "text",
id: "ep-review-answer",
placeholder: this.item.type === "reading" ? "Enter reading..." : "Enter meaning...",
tabindex: "1",
autofocus: true
})
.css(styles.reviewModal.input),
$("<button>")
.attr("id", "ep-review-submit")
.text("Submit")
.attr("tabindex", "2")
.css(styles.reviewModal.buttons.submit)
);
return $content.append($character, $question, $inputSection);
}
async renderRecognitionCard($content) {
const $questionContainer = $("<div>")
.css({
textAlign: "center",
marginBottom: theme.spacing.xl
});
const $question = $("<div>")
.addClass("ep-review-question")
.css({
fontSize: theme.typography.fontSize.lg,
color: theme.colors.gray[700],
marginBottom: theme.spacing.md
});
const questionContent = this.getQuestionText();
questionContent.forEach(content => {
if (content instanceof jQuery) {
$question.append(content);
} else {
$question.append(document.createTextNode(content));
}
});
$questionContainer.append($question);
const $optionsGrid = $("<div>")
.css({
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: theme.spacing.lg,
padding: theme.spacing.xl,
maxWidth: "500px",
margin: "0 auto"
});
this.item.options.forEach(option => {
$optionsGrid.append(this.createKanjiOption(option));
});
const $submitButton = $("<button>")
.attr({
id: "ep-review-submit",
disabled: true
})
.text("Submit")
.css({
...styles.reviewModal.buttons.submit,
opacity: 0.5,
cursor: "not-allowed"
});
const $submitButtonContainer = $("<div>")
.css({
textAlign: "center",
marginTop: theme.spacing.xl
})
.append($submitButton);
return $content.append($questionContainer, $optionsGrid, $submitButtonContainer);
}
processMnemonic(mnemonic) {
if (!mnemonic) return "";
if (!this.isKanji) {
return mnemonic.replace(/<radical>(.*?)<\/radical>/g, (_, content) =>
`<span style="background-color: ${theme.colors.radical}; padding: 0 ${theme.spacing.xs}; border-radius: ${theme.borderRadius.sm}; color: ${theme.colors.white}">${content}</span>`
);
}
return mnemonic
.replace(/<radical>(.*?)<\/radical>/g, (_, content) =>
`<span style="background-color: ${theme.colors.radical}; padding: 0 ${theme.spacing.xs}; border-radius: ${theme.borderRadius.sm}; color: ${theme.colors.white}">${content}</span>`
)
.replace(/<kanji>(.*?)<\/kanji>/g, (_, content) =>
`<span style="background-color: ${theme.colors.kanji}; padding: 0 ${theme.spacing.xs}; border-radius: ${theme.borderRadius.sm}; color: ${theme.colors.white}">${content}</span>`
)
.replace(/<reading>(.*?)<\/reading>/g, (_, content) =>
`<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>`
);
}
async renderReviewingState() {
const $content = $("<div>").addClass("ep-review-content");
const $character = await this.renderCharacter();
const $explanation = $("<div>")
.addClass("ep-review-explanation")
.css(styles.reviewModal.explanation);
const primaryReading = this.item.readings?.find(r => r.primary);
const primaryMeaning = this.item.meanings?.find(m => m.primary);
const $continueButton = $("<button>")
.attr("id", "ep-review-continue")
.text("Continue Review")
.css({
...styles.reviewModal.buttons.submit,
minWidth: "120px",
display: "block",
margin: "30px auto 0"
});
const $buttonContainer = $("<div>")
.addClass("ep-review-buttons")
.css({
display: "flex",
gap: theme.spacing.md,
justifyContent: "center",
marginTop: theme.spacing.xl
})
.append($continueButton);
// Handle non-kanji (radical) review state
if (!this.isKanji) {
$content.append(
$character,
$explanation.append(
$("<h3>").append(
$("<span>")
.text("Meaning: ")
.css(styles.reviewModal.explanation.meaningLabel),
$("<a>")
.attr({
href: this.item.documentationUrl,
target: "_blank",
title: `Click to learn more about: ${this.item.meaning}`
})
.text(this.item.meaning)
.css(styles.reviewModal.explanation.meaningText)
),
$("<div>")
.addClass("ep-mnemonic-container")
.css(styles.reviewModal.explanation.mnemonicContainer)
.append(
$("<span>")
.text("Mnemonic:")
.css(styles.reviewModal.explanation.mnemonicLabel),
$("<div>")
.addClass("ep-review-mnemonic")
.html(this.processMnemonic(this.item.meaningMnemonic))
.css(styles.reviewModal.explanation.mnemonic)
)
)
);
$content.append($buttonContainer);
return $content;
}
// Handle kanji review states based on question type
switch (this.item.type) {
case "recognition":
$explanation.append(
this.createExplanationSection(
"Meaning",
this.item.meaningToMatch,
this.item.meaningMnemonic,
true
)
);
if (primaryReading) {
const readingType = primaryReading.type === "onyomi" ? "On'yomi" : "Kun'yomi";
$explanation.append(
this.createExplanationSection(
"Reading",
`${readingType}: ${primaryReading.reading}`,
this.item.readingMnemonic,
false
)
);
}
break;
case "reading":
if (primaryReading) {
const readingType = primaryReading.type === "onyomi" ? "On'yomi" : "Kun'yomi";
$explanation.append(
this.createExplanationSection(
"Reading",
`${readingType}: ${primaryReading.reading}`,
this.item.readingMnemonic,
true
)
);
}
if (primaryMeaning) {
$explanation.append(
this.createExplanationSection(
"Meaning",
primaryMeaning.meaning,
this.item.meaningMnemonic,
false
)
);
}
break;
case "meaning":
if (primaryMeaning) {
$explanation.append(
this.createExplanationSection(
"Meaning",
primaryMeaning.meaning,
this.item.meaningMnemonic,
true
)
);
}
if (primaryReading) {
const readingType = primaryReading.type === "onyomi" ? "On'yomi" : "Kun'yomi";
$explanation.append(
this.createExplanationSection(
"Reading",
`${readingType}: ${primaryReading.reading}`,
this.item.readingMnemonic,
false
)
);
}
break;
}
$content.append($character, $explanation);
$content.append($buttonContainer);
return $content;
}
createExplanationSection(title, answer, mnemonic, isExpanded) {
const $section = $("<div>")
.addClass("explanation-section")
.css({
marginBottom: theme.spacing.md,
width: "100%",
display: "block"
});
const $header = $("<div>")
.css({
display: "block",
padding: `${theme.spacing.sm} 0`,
width: "100%",
borderBottom: `1px solid ${theme.colors.gray[200]}`,
});
const $headerContent = $("<div>")
.css({
display: "flex",
alignItems: "center",
cursor: "pointer",
width: "100%"
}).append(
$("<span>")
.text(isExpanded ? "▼" : "▶")
.css({
color: theme.colors.gray[600],
marginRight: theme.spacing.sm,
fontSize: theme.typography.fontSize.md,
flexShink: 0
}),
$("<h3>")
.text(title)
.css({
margin: 0,
color: theme.colors.gray[800],
fontWeight: theme.typography.fontWeight.medium,
fontSize: theme.typography.fontSize.md,
flex: 1
})
);
$header.append($headerContent);
const $content = $("<div>")
.css({
display: isExpanded ? "block" : "none",
paddingLeft: theme.spacing.xl,
paddingTop: theme.spacing.md,
paddingBottom: theme.spacing.md
});
if (title.toLowerCase() === "reading") {
// Extract reading type and format display
const readingType = this.item.readings.find(r => r.primary)?.type;
const formattedType = readingType === "onyomi" ? "On'yomi" : "Kun'yomi";
$content.append(
$("<div>")
.css({
fontSize: theme.typography.fontSize.lg,
color: theme.colors.gray[800],
marginBottom: theme.spacing.md
})
.append(
$("<span>")
.text(`${formattedType}: `)
.css({
color: theme.colors.gray[600],
fontSize: theme.typography.fontSize.md
}),
$("<span>")
.text(this.item.readings.find(r => r.primary)?.reading || "")
)
);
if (mnemonic) {
$content.append(
$("<div>")
.addClass("ep-mnemonic-container")
.css(styles.reviewModal.explanation.mnemonicContainer)
.append(
$("<span>")
.text("Mnemonic:")
.css(styles.reviewModal.explanation.mnemonicLabel),
$("<div>")
.addClass("ep-review-mnemonic")
.html(this.processMnemonic(mnemonic))
.css(styles.reviewModal.explanation.mnemonic)
)
);
}
} else {
const meaningText = this.item.type === "recognition"
? this.item.meaningToMatch
: this.item.meanings.find(m => m.primary)?.meaning;
$content.append(
$("<div>")
.css({
fontSize: theme.typography.fontSize.lg,
color: theme.colors.gray[800],
marginBottom: theme.spacing.md
})
.text(meaningText)
);
if (mnemonic) {
$content.append(
$("<div>")
.addClass("ep-mnemonic-container")
.css(styles.reviewModal.explanation.mnemonicContainer)
.append(
$("<span>")
.text("Mnemonic:")
.css(styles.reviewModal.explanation.mnemonicLabel),
$("<div>")
.addClass("ep-review-mnemonic")
.html(this.processMnemonic(mnemonic))
.css(styles.reviewModal.explanation.mnemonic)
)
);
}
}
$header.on("click", function() {
const $content = $(this).siblings("div");
const isVisible = $content.is(":visible");
$content.slideToggle(200);
const $arrow = $(this).find("span").first();
$arrow.text(isVisible ? "▶" : "▼");
});
return $section.append($header, $content);
}
async render() {
this.$container = $("<div>")
.addClass("ep-review-card")
.css({
padding: theme.spacing.xl,
display: "flex",
flexDirection: "column",
width: "100%",
gap: theme.spacing.xl
});
const $characterContainer = $("<div>")
.css({
textAlign: "center",
width: "100%"
});
const $contentContainer = $("<div>")
.css({
width: "100%",
textAlign: "left"
});
const content = await (this.state === REVIEW_STATES.ANSWERING
? this.renderAnsweringState()
: this.renderReviewingState());
if (this.state === REVIEW_STATES.ANSWERING) {
const $character = content.find(".ep-review-character").detach();
$characterContainer.append($character);
$contentContainer.append(content);
} else {
const $character = content.find(".ep-review-character").detach();
$characterContainer.append($character);
$contentContainer.append(content.find(".ep-review-explanation"));
}
this.$container.append($characterContainer, $contentContainer);
return this.$container;
}
async updateState(newState) {
if (this.state === newState) return;
this.state = newState;
const content = this.state === REVIEW_STATES.ANSWERING
? await this.renderAnsweringState()
: await this.renderReviewingState();
this.$container.empty().append(content);
}
getAnswer() {
if (this.item.type === "recognition") {
return this.selectedOption?.toString() || "";
}
return $("#ep-review-answer").val()?.trim() || "";
}
remove() {
if (this.$container) {
this.$container.remove();
this.$container = null;
}
}
}
class ReviewSessionModal {
constructor(reviewSession) {
this.reviewSession = reviewSession;
this.state = REVIEW_STATES.ANSWERING;
this.$modal = null;
this.currentCard = null;
this.callbacks = new Map();
this.isKanjiSession = !!this.reviewSession.correctMeanings;
// Session configuration for Play Again
this.sessionConfig = {
mode: this.reviewSession.mode,
items: this.reviewSession.originalItems,
};
if (this.sessionConfig.mode !== "radical") {
this.sessionConfig.allUnlockedKanji = this.reviewSession.allUnlockedKanji;
}
this.handlePlayAgain = this.handlePlayAgain.bind(this);
this.handleAnswer = this.handleAnswer.bind(this);
this.handleNextItem = this.handleNextItem.bind(this);
this.showHint = this.showHint.bind(this);
this.setupInput = this.setupInput.bind(this);
this.showCurrentItem = this.showCurrentItem.bind(this);
this.updateProgress = this.updateProgress.bind(this);
this.showReviewInterface = this.showReviewInterface.bind(this);
this.hideReviewInterface = this.hideReviewInterface.bind(this);
this.showInputInterface = this.showInputInterface.bind(this);
this.hideInputInterface = this.hideInputInterface.bind(this);
this.showCompletionScreen = this.showCompletionScreen.bind(this);
}
// Setup Hiragana Keyboard
setupInput() {
const input = document.querySelector("#ep-review-answer");
if (!input) return;
const currentItem = this.reviewSession.currentItem;
if (!currentItem) return;
if (this.isKanjiSession && currentItem.type === "reading") {
wanakana.bind(input, {
IMEMode: "toHiragana",
useObsoleteKana: false,
passRomaji: false,
upcaseKatakana: false,
convertLongVowelMark: true
});
}
}
on(event, callback) {
this.callbacks.set(event, callback);
return this;
}
emit(event, data) {
const callback = this.callbacks.get(event);
if (callback) callback(data);
}
handlePlayAgain() {
const newSession = this.isKanjiSession ? new KanjiReviewSession({
items: this.sessionConfig.items,
mode: this.sessionConfig.mode,
allUnlockedKanji: this.sessionConfig.allUnlockedKanji
}) : new RadicalReviewSession({
items: this.sessionConfig.items,
mode: "radical",
});
// Initialize new session
newSession.nextItem();
// Clean up current modal
this.remove();
const newModal = new ReviewSessionModal(newSession);
newModal
.on(REVIEW_EVENTS.CLOSE, () => {
enableScroll();
newModal.remove();
})
.on(REVIEW_EVENTS.STUDY_AGAIN, () => {
newModal.remove();
enableScroll();
if (this.isKanjiSession) {
handleKanjiPractice();
} else {
handleRadicalPractice();
}
});
return newModal.render();
}
updateProgress() {
const progress = this.reviewSession.getProgress();
const mode = this.reviewSession.mode;
let progressText;
switch (mode) {
case PRACTICE_MODES.ENGLISH_TO_KANJI:
progressText = `${progress.recognitionProgress}/${progress.total} Correct`;
break;
case PRACTICE_MODES.COMBINED:
progressText = `Meanings: ${progress.meaningProgress}/${progress.total/3} | ` +
`Readings: ${progress.readingProgress}/${progress.total/3} | ` +
`Recognition: ${progress.recognitionProgress}/${progress.total/3}`;
break;
case PRACTICE_MODES.STANDARD:
progressText = `Meanings: ${progress.meaningProgress}/${progress.total/2} | ` +
`Readings: ${progress.readingProgress}/${progress.total/2}`;
break;
default: // RADICAL
progressText = `${progress.current}/${progress.total/1} Correct`;
}
$("#ep-review-progress-correct").html(progressText);
if (mode === PRACTICE_MODES.COMBINED) {
$("#ep-review-progress-correct").css({
fontSize: theme.typography.fontSize.xs
});
}
}
showReviewInterface() {
$("#ep-review-result").show();
$("#ep-review-result-message").show();
$("#ep-review-explanation").show();
$(".ep-review-buttons").hide();
}
hideReviewInterface() {
$("#ep-review-result").hide();
$("#ep-review-result-message").hide();
$("#ep-review-explanation").hide();
$("#ep-review-show-hint").hide();
$(".ep-review-buttons").show();
}
showInputInterface() {
$("#ep-review-input-section").show();
$("#ep-review-answer").val("").prop("disabled", false);
$("#ep-review-submit").show();
$("#ep-review-answer").focus();
this.setupInput();
}
hideInputInterface() {
$("#ep-review-input-section").hide();
$("#ep-review-submit").hide();
$("#ep-review-answer").prop("disabled", true);
}
async showCurrentItem() {
const currentItem = this.reviewSession.currentItem;
if (this.currentCard) {
this.currentCard.remove();
}
this.state = REVIEW_STATES.ANSWERING;
this.hideReviewInterface();
this.currentCard = new ReviewCard(currentItem, REVIEW_STATES.ANSWERING);
const $card = await this.currentCard.render();
// Clear and append the new card
$("#ep-review-content").empty().append($card);
// Ensure input is focused after rendering
if (currentItem.type !== "recognition") {
const $input = $("#ep-review-answer");
if ($input.length) {
$input.focus();
this.setupInput();
}
}
}
async handleAnswer() {
const currentCard = this.currentCard;
if (!currentCard) return;
const userAnswer = currentCard.getAnswer();
if (!userAnswer) return;
const isCorrect = this.reviewSession.checkAnswer(userAnswer);
$(".ep-review-input-section, .ep-review-question, .ep-review-content, .kanji-option, #ep-review-submit").hide();
$(".ep-review-character").css({
marginBottom: "0"
});
// Create result container if it doesn't exist
if ($("#ep-review-result-container").length === 0) {
$(".ep-review-card").append(
$("<div>")
.attr("id", "ep-review-result-container")
.css({
...styles.reviewModal.content,
padding: 0
})
);
}
if (isCorrect) {
$("#ep-review-result-container")
.empty()
.append(
$("<div>")
.attr("id", "ep-review-result-message")
.text("Correct!")
.css({
...styles.reviewModal.results.message,
color: theme.colors.success,
})
);
this.updateProgress();
setTimeout(() => this.handleNextItem(), 1000);
} else {
$("#ep-review-result-container")
.empty()
.append(
$("<div>")
.attr("id", "ep-review-result-message")
.text("Incorrect")
.css({
...styles.reviewModal.results.message,
color: theme.colors.error,
}),
$("<div>")
.addClass("ep-review-buttons")
.css({
display: "flex",
gap: theme.spacing.md,
justifyContent: "center"
})
.append(
$("<button>")
.attr("id", "ep-review-show-hint")
.text("Show Answer")
.css({
...styles.reviewModal.buttons.hint,
minWidth: "120px"
}),
$("<button>")
.attr("id", "ep-review-continue")
.text("Continue Review")
.css({
...styles.reviewModal.buttons.submit,
minWidth: "120px"
})
)
);
}
}
async showHint() {
await this.currentCard.updateState(REVIEW_STATES.REVIEWING);
}
async handleNextItem() {
if (this.reviewSession.isComplete()) {
this.showCompletionScreen();
return;
}
this.reviewSession.nextItem();
await this.showCurrentItem();
this.emit(REVIEW_EVENTS.NEXT_ITEM);
}
showCompletionScreen() {
const progress = this.reviewSession.getProgress();
const mode = this.reviewSession.mode;
let languageLearningQuotes;
if (this.isKanjiSession) {
languageLearningQuotes = [
"Every kanji you learn unlocks new understanding",
"One character a day",
"Continuation is power",
"Each review strengthens your kanji recognition",
"Little by little, steadily",
"Each character you master opens new doors to understanding",
"Your journey through the world of kanji grows stronger each day"
];
} else {
languageLearningQuotes = [
"Every radical mastered unlocks new understanding",
"Building your foundation, one radical at a time",
"Mastering radicals today, recognizing kanji tomorrow",
"Each radical review strengthens your foundation",
"Little by little, your radical knowledge grows",
"Each radical you master opens new paths of understanding",
"Your journey through radicals grows stronger each day",
"Steady progress in radicals paves the way forward",
"Your radical knowledge builds the bridge to comprehension"
];
}
const randomQuote = languageLearningQuotes[
Math.floor(Math.random() * languageLearningQuotes.length)
];
let completionMessage;
switch (mode) {
case PRACTICE_MODES.ENGLISH_TO_KANJI:
completionMessage = `Review completed!<br>${progress.recognitionProgress}/${progress.total} Correct`;
break;
case PRACTICE_MODES.COMBINED:
completionMessage = `Review completed!<br>` +
`Meanings: ${progress.meaningProgress}/${progress.total/3} | ` +
`Readings: ${progress.readingProgress}/${progress.total/3} | ` +
`Recognition: ${progress.recognitionProgress}/${progress.total/3}`;
break;
case PRACTICE_MODES.STANDARD:
completionMessage = `Review completed!<br>` +
`Meanings: ${progress.meaningProgress}/${progress.total/2} | ` +
`Readings: ${progress.readingProgress}/${progress.total/2}`;
break;
default:
completionMessage = `Review completed!`;
}
const $completionContent = $("<div>")
.css({
textAlign: "center",
padding: theme.spacing.xl
})
.append(
$("<h1>")
.html(completionMessage)
.css({
...styles.reviewModal.progress,
marginBottom: theme.spacing.lg
}),
$("<p>")
.text(`"${randomQuote}"`)
.css({
color: theme.colors.gray[600],
marginBottom: theme.spacing.xl,
fontStyle: "italic"
}),
$("<div>")
.css({
display: "flex",
gap: theme.spacing.md,
justifyContent: "center"
})
.append(
$("<button>")
.text("Play Again")
.css({
...styles.reviewModal.buttons.submit,
backgroundColor: theme.colors.success,
minWidth: "120px"
})
.on("click", this.handlePlayAgain),
$("<button>")
.text("Study Different Items")
.css({
...styles.reviewModal.buttons.submit,
minWidth: "120px"
})
.on("click", () => {
this.emit(REVIEW_EVENTS.STUDY_AGAIN);
})
)
);
$("#ep-review-content").empty().append($completionContent);
this.emit(REVIEW_EVENTS.COMPLETE, { progress });
}
async render() {
this.$modal = $(reviewModalTemplate).appendTo("body");
this.$modal.css({
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.9)",
zIndex: theme.zIndex.modal,
display: "flex",
alignItems: "center",
justifyContent: "center"
});
$("#ep-review-modal-wrapper").css(styles.reviewModal.container);
$("#ep-review-modal-header").css(styles.reviewModal.header);
$("#ep-review-progress").css(styles.reviewModal.progress);
$("#ep-review-exit").css(styles.reviewModal.buttons.exit);
// Set up event delegation
this.$modal
.on("click", "#ep-review-submit", this.handleAnswer)
.on("keypress", "#ep-review-answer", (e) => {
if (e.which === 13) {
this.handleAnswer();
}
})
.on("click", "#ep-review-show-hint", this.showHint)
.on("click", "#ep-review-continue", this.handleNextItem);
$("#ep-review-exit").on("click", () => {
this.emit(REVIEW_EVENTS.CLOSE);
});
this.updateProgress();
await this.showCurrentItem();
return this.$modal;
}
remove() {
if (this.currentCard) {
this.currentCard.remove();
}
const input = document.querySelector("#ep-review-answer");
if (input) {
wanakana.unbind(input);
}
if (this.$modal) {
this.$modal.remove();
this.$modal = null;
}
}
}
// Assumption: User has wkof.file_cache for the IndexedDB operations to work
async function getCurrentUserLevel() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_VALUES.DB_NAME, 1);
request.onsuccess = (event) => {
const db = event.target.result;
const transaction = db.transaction([DB_VALUES.FILE_STORE], "readonly");
const store = transaction.objectStore(DB_VALUES.FILE_STORE);
const getUser = store.get(DB_VALUES.USER_RECORD);
getUser.onsuccess = () => {
const userData = getUser.result;
resolve(userData.content.data.level);
};
getUser.onerror = () => {
reject(handleError("USER_LEVEL"));
};
};
request.onerror = () => {
reject(handleError("OPEN"));
};
});
}
async function getCurrentLevelRadicals() {
try {
const userLevel = await getCurrentUserLevel();
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_VALUES.DB_NAME, 1);
request.onsuccess = (event) => {
const db = event.target.result;
const transaction = db.transaction([DB_VALUES.FILE_STORE], "readonly");
const store = transaction.objectStore(DB_VALUES.FILE_STORE);
const getSubjects = store.get(DB_VALUES.SUBJECT_RECORD);
getSubjects.onsuccess = () => {
const subjectsData = getSubjects.result;
const currentLevelRadicals = Object.values(subjectsData.content.data)
.filter(subject =>
subject.object === "radical" &&
subject.data.level === userLevel
)
.map(radical => ({
id: radical.id,
character: radical.data.characters,
meaning: radical.data.meanings[0].meaning,
documentationUrl: radical.data.document_url,
meaningMnemonic: radical.data.meaning_mnemonic,
svg: radical.data.character_images.find(img =>
img.content_type === "image/svg+xml"
)?.url || null
}));
resolve(currentLevelRadicals);
};
getSubjects.onerror = () => {
reject(handleError("SUBJECT_DATA"));
};
};
request.onerror = () => {
reject(handleError("OPEN"));
};
});
} catch (error) {
console.error("Error in getCurrentLevelRadicals:", error);
throw error;
}
}
async function getCurrentLevelKanji() {
return new Promise(async (resolve, reject) => {
const userLevel = await getCurrentUserLevel();
const request = indexedDB.open('wkof.file_cache', 1);
request.onsuccess = (event) => {
const db = event.target.result;
const transaction = db.transaction(['files'], 'readonly');
const store = transaction.objectStore('files');
Promise.all([
new Promise(resolve => {
store.get('Apiv2.assignments').onsuccess = (e) =>
resolve(e.target.result.content.data);
}),
new Promise(resolve => {
store.get('Apiv2.subjects').onsuccess = (e) =>
resolve(e.target.result.content.data);
})
]).then(([assignments, subjects]) => {
const unlockedKanjiIds = new Set(
Object.values(assignments)
.filter(a => a.data.subject_type === "kanji")
.map(a => a.data.subject_id)
);
// Helper function to get radical information
const getRadicalInfo = (radicalId) => {
const radical = subjects[radicalId];
if (!radical) return null;
return {
id: radical.id,
character: radical.data.characters,
meaning: radical.data.meanings[0].meaning,
svg: radical.data.character_images?.find(img =>
img.content_type === 'image/svg+xml'
)?.url || null
};
};
const currentLevelKanji = Object.values(subjects)
.filter(subject =>
subject.object === "kanji" &&
subject.data.level === userLevel &&
unlockedKanjiIds.has(subject.id)
)
.map(kanji => ({
id: kanji.id,
character: kanji.data.characters,
meanings: kanji.data.meanings.filter(m => m.accepted_answer),
readings: kanji.data.readings.filter(r => r.accepted_answer),
meaningMnemonic: kanji.data.meaning_mnemonic,
meaningHint: kanji.data.meaning_hint,
readingMnemonic: kanji.data.reading_mnemonic,
readingHint: kanji.data.reading_hint,
documentUrl: kanji.data.document_url,
radicals: kanji.data.component_subject_ids
.map(getRadicalInfo)
.filter(Boolean),
auxiliaryMeanings: kanji.data.auxiliary_meanings
?.filter(m => m.type === "whitelist")
?? []
}));
resolve(currentLevelKanji);
});
};
request.onerror = (error) => reject(error);
});
}
function handleError(type) {
if (type == "OPEN") {
return new Error(DB_ERRORS.OPEN);
}
if (type == "USER_LEVEL") {
return new Error(DB_ERRORS.USER_LEVEL);
}
if (type == "SUBJECT_DATA") {
return new Error(DB_ERRORS.SUBJECT_DATA);
}
}
async function handleRadicalPractice() {
try {
disableScroll();
const radicals = await getCurrentLevelRadicals();
const selectionModal = new RadicalSelectionModal(radicals)
.on(EVENTS$1.CLOSE, () => {
enableScroll();
selectionModal.remove();
})
.on(EVENTS$1.START_REVIEW, (selectedRadicals) => {
selectionModal.remove();
startRadicalReview(selectedRadicals);
});
await selectionModal.render();
} catch (error) {
console.error("Error in radical practice:", error);
enableScroll();
}
}
async function startRadicalReview(selectedRadicals) {
try {
const session = {
items: selectedRadicals,
mode: "radical",
};
const reviewSession = new RadicalReviewSession(session);
reviewSession.nextItem();
const reviewModal = new ReviewSessionModal(reviewSession);
reviewModal
.on(REVIEW_EVENTS.CLOSE, () => {
const progress = reviewSession.getProgress();
$("#ep-review-modal-header").remove();
$("#ep-review-content")
.empty()
.append(
$("<div>")
.css(styles.reviewModal.content)
.append([
$("<p>", {
css: {
...styles.reviewModal.progress,
marginBottom: 0
},
text: `${progress.current}/${progress.total} Correct (${progress.percentComplete}%)`
}),
$("<p>", {
css: {
marginTop: 0,
textAlign: "center"
},
text: "Closing..."
})
])
);
setTimeout(() => {
enableScroll();
reviewModal.remove();
}, 1000);
})
.on(REVIEW_EVENTS.STUDY_AGAIN, () => {
reviewModal.remove();
enableScroll();
handleRadicalPractice();
});
await reviewModal.render();
} catch (error) {
console.error("Error in startRadicalReview:", error);
enableScroll();
}
}
const MODAL_STATES = {
READY: "ready"
};
const EVENTS = {
CLOSE: "close",
START_REVIEW: "startReview"
};
class KanjiGrid {
constructor(kanji, onSelectionChange) {
this.kanji = kanji;
this.selectedKanji = new Set();
this.onSelectionChange = onSelectionChange;
this.$container = null;
}
updateKanjiSelection($element, kanji, isSelected) {
const baseStyles = {
...styles.practiceModal.radical.base,
border: `2px solid ${isSelected ? theme.colors.kanji : 'rgba(255, 255, 255, 0.2)'}`,
background: isSelected ? 'rgba(235, 1, 156, 0.2)' : 'rgba(255, 255, 255, 0.1)',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: theme.colors.kanji,
background: isSelected ? 'rgba(235, 1, 156, 0.3)' : 'rgba(255, 255, 255, 0.2)'
}
};
$element.css(baseStyles);
if (isSelected) {
this.selectedKanji.add(kanji.id);
} else {
this.selectedKanji.delete(kanji.id);
}
this.onSelectionChange(this.selectedKanji);
}
toggleAllKanji(shouldSelect) {
if (shouldSelect) {
this.kanji.forEach(kanji => this.selectedKanji.add(kanji.id));
} else {
this.selectedKanji.clear();
}
this.$container.find(".kanji-selection-item").each((_, element) => {
const $element = $(element);
const kanjiId = parseInt($element.data("kanji-id"));
this.updateKanjiSelection(
$element,
this.kanji.find(k => k.id === kanjiId),
shouldSelect
);
});
this.onSelectionChange(this.selectedKanji);
}
getSelectedKanji() {
return Array.from(this.selectedKanji).map(id =>
this.kanji.find(kanji => kanji.id === id)
);
}
createKanjiElement(kanji) {
const $element = $("<div>")
.addClass("kanji-selection-item")
.css({
...styles.practiceModal.radical.base,
position: "relative"
})
.data("kanji-id", kanji.id)
.append(
$("<div>")
.addClass("kanji-character")
.css({
fontSize: theme.typography.fontSize.xl,
color: theme.colors.white
})
.text(kanji.character)
);
$element
.on("click", () => {
const isCurrentlySelected = this.selectedKanji.has(kanji.id);
this.updateKanjiSelection($element, kanji, !isCurrentlySelected);
});
return $element;
}
async render() {
this.$container = $("<div>")
.css({
...styles.practiceModal.grid,
gridTemplateColumns: "repeat(auto-fill, minmax(80px, 1fr))"
});
this.kanji.forEach(kanji => {
const $element = this.createKanjiElement(kanji);
this.$container.append($element);
});
return this.$container;
}
}
class KanjiSelectionModal {
constructor(kanji, allUnlockedKanji) {
this.kanji = kanji;
this.allUnlockedKanji = allUnlockedKanji;
this.selectedMode = PRACTICE_MODES.STANDARD;
this.state = MODAL_STATES.READY;
this.totalKanji = kanji.length;
this.$modal = null;
this.kanjiGrid = null;
this.callbacks = new Map();
}
on(event, callback) {
this.callbacks.set(event, callback);
return this;
}
emit(event, data) {
const callback = this.callbacks.get(event);
if (callback) callback(data);
}
validateSelection(selectedCount) {
const minRequired = {
[PRACTICE_MODES.STANDARD]: 1,
[PRACTICE_MODES.ENGLISH_TO_KANJI]: 4,
[PRACTICE_MODES.COMBINED]: 4
};
const required = minRequired[this.selectedMode];
const isValid = selectedCount >= required;
const startButton = $("#ep-practice-modal-start");
if (isValid) {
startButton
.prop("disabled", false)
.text(`Start Review (${selectedCount} Selected)`)
.css({
...styles.practiceModal.buttons.start.base,
...styles.practiceModal.buttons.start.kanji,
opacity: 1,
cursor: "pointer"
});
} else {
startButton
.prop("disabled", true)
.text(`Select at least ${required} kanji`)
.css({
...styles.practiceModal.buttons.start.base,
...styles.practiceModal.buttons.start.kanji,
opacity: 0.5,
cursor: "not-allowed"
});
}
}
updateSelectAllButton(selectedCount) {
const selectAllButton = $("#ep-practice-modal-select-all");
const isAllSelected = selectedCount === this.totalKanji;
selectAllButton
.text(isAllSelected ? "Deselect All" : "Select All")
.css({
color: isAllSelected ? theme.colors.error : theme.colors.white,
borderColor: isAllSelected ? theme.colors.error : theme.colors.white,
'&:hover': {
borderColor: isAllSelected ? theme.colors.error : theme.colors.kanji
}
});
}
handleSelectionChange(selectedKanji) {
const selectedCount = selectedKanji.size;
this.updateSelectAllButton(selectedCount);
this.validateSelection(selectedCount);
}
createModeSelector() {
const $container = $("<div>")
.css(styles.practiceModal.modeSelector.container);
const $label = $("<div>")
.text("Select Practice Mode")
.css(styles.practiceModal.modeSelector.label);
const $options = $("<div>")
.css(styles.practiceModal.modeSelector.options);
const createOption = (mode, label) => {
const $option = $("<button>")
.text(label)
.css({
...styles.practiceModal.modeSelector.option.base,
...(this.selectedMode === mode ? styles.practiceModal.modeSelector.option.selected : {})
})
.on("click", () => {
$options.find("button").css(styles.practiceModal.modeSelector.option.base);
$option.css({
...styles.practiceModal.modeSelector.option.base,
...styles.practiceModal.modeSelector.option.selected
});
this.selectedMode = mode;
const currentSelection = this.kanjiGrid.getSelectedKanji();
this.validateSelection(currentSelection.length);
});
return $option;
};
$options.append(
createOption(PRACTICE_MODES.STANDARD, "Standard Practice"),
createOption(PRACTICE_MODES.ENGLISH_TO_KANJI, "English → Kanji"),
createOption(PRACTICE_MODES.COMBINED, "Combined Practice")
);
return $container.append($label, $options);
}
async render() {
this.$modal = $(modalTemplate).appendTo("body");
$("#username").text($("p.user-summary__username:first").text());
this.$modal.css(styles.practiceModal.backdrop);
$("#ep-practice-modal-welcome").css(styles.practiceModal.welcomeText.container);
$("#ep-practice-modal-welcome h1").css(styles.practiceModal.welcomeText.username);
$("#ep-practice-modal-welcome h2")
.text("Please select the Kanji characters you would like to practice")
.css({
color: theme.colors.white,
opacity: 0.9
});
const $modeSelector = this.createModeSelector();
$modeSelector.insertAfter("#ep-practice-modal-welcome");
$("#ep-practice-modal-footer").css(styles.practiceModal.footer);
$("#ep-practice-modal-content").css(styles.practiceModal.contentWrapper);
// Initial disabled state with kanji color scheme
$("#ep-practice-modal-start").css({
...styles.practiceModal.buttons.start.base,
...styles.practiceModal.buttons.start.kanji,
opacity: 0.5,
cursor: "not-allowed"
});
$("#ep-practice-modal-select-all").css({
...styles.practiceModal.buttons.selectAll,
'&:hover': {
borderColor: theme.colors.kanji
}
});
$("#ep-practice-modal-close").css({
...styles.practiceModal.buttons.exit,
'&:hover': {
borderColor: theme.colors.kanji,
color: theme.colors.kanji
}
});
this.kanjiGrid = new KanjiGrid(
this.kanji,
this.handleSelectionChange.bind(this)
);
const $grid = await this.kanjiGrid.render();
$("#ep-practice-modal-grid").replaceWith($grid);
$("#ep-practice-modal-select-all").on("click", () => {
const isSelectingAll = $("#ep-practice-modal-select-all").text() === "Select All";
this.kanjiGrid.toggleAllKanji(isSelectingAll);
});
$("#ep-practice-modal-close").on("click", () => {
this.emit(EVENTS.CLOSE);
});
$("#ep-practice-modal-start").on("click", () => {
const selectedKanji = this.kanjiGrid.getSelectedKanji();
const minRequired = {
[PRACTICE_MODES.STANDARD]: 1,
[PRACTICE_MODES.ENGLISH_TO_KANJI]: 4,
[PRACTICE_MODES.COMBINED]: 4
};
if (selectedKanji.length >= minRequired[this.selectedMode]) {
this.emit(EVENTS.START_REVIEW, {
kanji: selectedKanji,
mode: this.selectedMode,
allUnlockedKanji: this.allUnlockedKanji
});
}
});
return this.$modal;
}
remove() {
if (this.$modal) {
this.$modal.remove();
this.$modal = null;
}
}
}
async function handleKanjiPractice() {
try {
disableScroll();
const kanji = await getCurrentLevelKanji();
const selectionModal = new KanjiSelectionModal(kanji, kanji) // Using current level kanji as unlocked list for now
.on(EVENTS.CLOSE, () => {
enableScroll();
selectionModal.remove();
})
.on(EVENTS.START_REVIEW, (data) => {
selectionModal.remove();
startKanjiReview(data.kanji, data.mode, data.allUnlockedKanji);
});
await selectionModal.render();
} catch (error) {
console.error("Error in kanji practice:", error);
enableScroll();
}
}
async function startKanjiReview(selectedKanji, mode, allUnlockedKanji) {
try {
const reviewSession = new KanjiReviewSession({
items: selectedKanji,
mode: mode,
allUnlockedKanji: allUnlockedKanji
});
reviewSession.nextItem();
const reviewModal = new ReviewSessionModal(reviewSession);
reviewModal
.on(REVIEW_EVENTS.CLOSE, () => {
const progress = reviewSession.getProgress();
$("#ep-review-modal-header").remove();
const closingContent = [$("<p>", {
css: {
marginTop: 0,
textAlign: "center"
},
text: "Closing..."
})];
$("#ep-review-content")
.empty()
.append(
$("<div>")
.css(styles.reviewModal.content)
.append((() => {
if (reviewSession.mode === PRACTICE_MODES.STANDARD) {
closingContent.unshift($("<p>", {
css: {
...styles.reviewModal.progress,
marginBottom: 0
},
text: `Meanings: ${progress.meaningProgress}/${progress.total/2} - Readings: ${progress.readingProgress}/${progress.total/2}`
}));
return closingContent;
} else if (reviewSession.mode === PRACTICE_MODES.ENGLISH_TO_KANJI) {
closingContent.unshift($("<p>", {
css: {
...styles.reviewModal.progress,
marginBottom: 0
},
text: `${progress.recognitionProgress}/${progress.total} Correct`
}));
return closingContent;
} else { // COMBINATION PRACTICE_MODE
closingContent.unshift($("<p>", {
css: {
...styles.reviewModal.progress,
marginBottom: 0
},
text: `Meanings: ${progress.meaningProgress}/${progress.total/3} | ` +
`Readings: ${progress.readingProgress}/${progress.total/3} | ` +
`Recognition: ${progress.recognitionProgress}/${progress.total/3}`
}));
return closingContent;
}
})())
);
setTimeout(() => {
enableScroll();
reviewModal.remove();
}, 1000);
})
.on(REVIEW_EVENTS.STUDY_AGAIN, () => {
reviewModal.remove();
enableScroll();
handleKanjiPractice();
});
await reviewModal.render();
} catch (error) {
console.error("Error in startKanjiReview:", error);
enableScroll();
}
}
class PracticeButton {
constructor(type) {
this.type = type;
this.buttonStyle = this.getButtonStyle();
this.handleClick = this.handleClick.bind(this);
}
getButtonStyle() {
return this.type === PRACTICE_TYPES.RADICAL
? styles.buttons.practice.radical
: styles.buttons.practice.kanji;
}
async handleClick() {
try {
if (this.type === PRACTICE_TYPES.RADICAL) {
await handleRadicalPractice();
} else {
await handleKanjiPractice();
}
} catch (error) {
console.error(`Error handling ${this.type} practice:`, error);
}
}
render() {
const $button = $("<button>")
.attr("id", `ep-${this.type}-btn`)
.text("Practice")
.css(this.buttonStyle)
.on("click", this.handleClick);
const selector = `${SELECTORS.DIV_LEVEL_PROGRESS_CONTENT} ${SELECTORS.DIV_CONTENT_WRAPPER} ${SELECTORS.DIV_CONTENT_TITLE}`;
// Doing a conditional check to add the practice button to the correct DIV.
const targetSelector = this.type === PRACTICE_TYPES.RADICAL
? `${selector}:first`
: `${selector}:last`;
$button.appendTo(targetSelector);
return $button;
}
}
function initializePracticeButtons() {
// First style the containers where the "PRACTICE" buttons be
$(`${SELECTORS.DIV_LEVEL_PROGRESS_CONTENT} ${SELECTORS.DIV_CONTENT_WRAPPER} ${SELECTORS.DIV_CONTENT_TITLE}`)
.css(styles.layout.contentTitle);
const radicalButton = new PracticeButton(PRACTICE_TYPES.RADICAL);
const kanjiButton = new PracticeButton(PRACTICE_TYPES.KANJI);
radicalButton.render();
kanjiButton.render();
}
$(document).ready(() => {
initializePracticeButtons();
});
})();
//# sourceMappingURL=extra-practice.user.js.map