Compare commits

..

7 Commits

Author SHA1 Message Date
Richard Wong 41d6eed467 feat: added hint feature 2026-01-10 16:28:29 +08:00
Richard Wong ae0160b5d2 feat: added basic show-answer statistics 2026-01-10 16:12:42 +08:00
Richard Wong 0e42ad3a83 fix: removed localStorage for site not updating 2026-01-10 15:36:33 +08:00
Richard Wong e8f0950dc0 feat: added clear-all button
feat: disable ui elements over eliminating elements
2026-01-10 13:50:09 +08:00
Richard Wong 998cf03865 fix: shuffle being triggered from toggling non-shuffle settings 2026-01-10 13:13:11 +08:00
Richard Wong d0fc6664f2 feat: added live validation toggle 2026-01-10 13:00:27 +08:00
Richard Wong 7abc197742 feat: split DEP6 bridge illustration (english only) 2026-01-04 09:06:22 +08:00
8 changed files with 332 additions and 169 deletions

View File

@ -10,10 +10,13 @@
"set_shuffle": "Set Shuffle:",
"note_set_shuffle": "(Otherwise cards will appear in sequential order)",
"hide_reference": "Set Hide Reference:",
"note_hide_reference": "(If you also want to test the verse reference)",
"pick_pack": "Pick Your Packs:",
"shuffle_card": "Shuffle Cards:",
"verses": "Verses:"
"note_hide_reference": "Check this box to hide the verse reference and test your memory on the reference as well.",
"live_validation": "Enable live validation",
"note_live_validation": "Check this box to enable live validation feedback as you type. Uncheck to only show correctness upon completion.",
"pick_pack": "Pick your pack(s)",
"tools": "Tools:",
"verses": "Verses:",
"problem_verses_session": "Statistics (This Session):"
},
"verse_validator": {
"input_reference": "Input Verse Reference:",
@ -90,7 +93,11 @@
{ "value": "dep-6-part-b", "label": "Why should we witness?" },
{ "value": "dep-6-part-c", "label": "How do we witness?" },
{ "value": "dep-6-part-d", "label": "Examples of witness" },
{ "value": "dep-6-part-e", "label": "Bridge Illustration" }
{ "value": "dep-6-part-e", "label": "Bridge Illustration (Creation story)" },
{ "value": "dep-6-part-f", "label": "Bridge Illustration (Man's condition)" },
{ "value": "dep-6-part-g", "label": "Bridge Illustration (Salvation not by ourselves)" },
{ "value": "dep-6-part-h", "label": "Bridge Illustration (God's solution)" },
{ "value": "dep-6-part-i", "label": "Bridge Illustration (Man must)" }
]
},
{

View File

@ -10,10 +10,13 @@
"set_shuffle": "무작위 설정:",
"note_set_shuffle": "(Otherwise cards will appear in sequential order)",
"hide_reference": "Set Hide Reference:",
"note_hide_reference": "(If you also want to test the verse reference)",
"pick_pack": "Pick Your Packs:",
"shuffle_card": "Shuffle Cards:",
"verses": "Verses:"
"note_hide_reference": "Check this box to hide the verse reference and test your memory on the reference as well.",
"live_validation": "Enable live validation",
"note_live_validation": "Check this box to enable live validation feedback as you type. Uncheck to only show correctness upon completion.",
"pick_pack": "Pick your pack(s)",
"tools": "Tools:",
"verses": "Verses:",
"problem_verses_session": "Statistics (This Session):"
},
"verse_validator": {
"input_reference": "Input Verse Reference: ",

View File

@ -28,3 +28,20 @@
color: black;
}
}
.setting-disabled {
opacity: 0.5;
}
.tool-bar {
display: flex;
gap: 10px; /* Adjust as needed */
align-items: center;
}
.lang-bar {
display: flex;
gap: 10px; /* Adjust as needed */
align-items: center;
}

View File

@ -4,7 +4,7 @@ Implemented features:
- create checklist from keys
*/
import fullVerseData from "./assets/verse.json" // the actual verse json data file
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo, useCallback } from "react";
import CheckboxTree from 'react-checkbox-tree';
import 'react-checkbox-tree/lib/react-checkbox-tree.css';
import _ from 'underscore';
@ -15,50 +15,24 @@ import { useTranslation } from 'react-i18next';
import logo from './assets/droplet.svg';
import { Suspense } from "react";
const GenerateTestList = ({ VerseData, packs, testCount, toShuffle, toHideReference, translate}) => {
let testList = packs.reduce(
// grab all elements included checked in "packs"
(accumulator, currentValue) => accumulator.concat(VerseData[currentValue]),
new Array()
);
testList = toShuffle ? _.sample(testList, testCount) : _.first(testList, testCount);
return (
<ArrayTester
array={testList}
toHideReference={toHideReference}
translate={translate}
/>
)
}
const ArrayTester = ({ array, toHideReference, translate}) => {
const ArrayTester = ({ array, toHideReference, liveValidation, clearKey, translate, onShowAnswer}) => {
const list = array.map((element, index) => (
// key needs to be unique; chose 3 elements that will separate all elements
<VerseValidator
key={element.pack + element.title + element.reference}
element={element}
toHideReference={toHideReference}
liveValidation={liveValidation}
clearKey={clearKey} // Pass clearKey down
t={translate} // this passes the t i18 object to the function
index={index + 1}
onShowAnswer={onShowAnswer}
/>
))
return list
}
const GenerateReviewList = ({ VerseData, packs, testCount, toShuffle, translate}) => {
let testList = packs.reduce(
// grab all elements included checked in "packs"
(accumulator, currentValue) => accumulator.concat(VerseData[currentValue]),
new Array()
);
testList = toShuffle ? _.sample(testList, testCount) : _.first(testList, testCount);
return (
<ArrayPrinter
array={testList}
translate={translate}
/>
)
}
const ArrayPrinter = ({ array, translate}) => {
const list = array.map((element, index) => (
@ -74,7 +48,6 @@ const ArrayPrinter = ({ array, translate}) => {
}
const CheckboxWidget = ({nodes, checked, expanded, setChecked, setExpanded}) => {
return (
<div className="CheckboxTree">
@ -111,16 +84,34 @@ const loadCustomData = (language) => {
function Page() {
// refresh button for refresh
const RefreshButton = ({ onClick }) => {
return <button onClick={onClick}>Shuffle</button>;
const RefreshButton = ({ onClick, disabled }) => {
return <button onClick={onClick} disabled={disabled}>Shuffle</button>;
};
// refresh variables where incrementing state forces refresh
const [refreshKey, setRefreshKey] = useState(0);
const handleRefresh = () => {
const [shuffleKey, setShuffleKey] = useState(0);
const handleShuffle = () => {
// Increment the key to force a re-render
setRefreshKey(refreshKey => refreshKey + 1);
setShuffleKey(shuffleKey => shuffleKey + 1);
};
// New state for clearing all inputs
const [clearKey, setClearKey] = useState(0);
const handleClearAll = () => {
setClearKey(clearKey => clearKey + 1);
};
// New state for tracking problem verses within the session
const [sessionProblemVerses, setSessionProblemVerses] = useState({});
// Callback for when 'Show Answer' is clicked in VerseValidator
const handleShowAnswer = useCallback((verseIdentifier) => {
const key = `${verseIdentifier.pack}|${verseIdentifier.reference}`;
setSessionProblemVerses(prev => ({
...prev,
[key]: (prev[key] || 0) + 1
}));
}, []);
// setup i18 for function
const { t, i18n } = useTranslation();
@ -201,6 +192,31 @@ function Page() {
setHideReference(!toHideReference);
};
// state for liveValidation
const [liveValidation, setLiveValidation] = useState(true);
// Function to handle checkbox change
const handleLiveValidationCheckboxChange = () => {
// Toggle the state when the checkbox is changed
setLiveValidation(!liveValidation);
};
// generate testList using cached state that depends only on shuffle-dependent variables
// this fixes the bug where changing other state causes a re-shuffle
const testList = useMemo(() => {
if (!VerseData || checked.length === 0) {
return [];
}
let list = checked.reduce(
(accumulator, currentValue) => accumulator.concat(VerseData[currentValue]),
[]
);
return toShuffle ? _.sample(list, testCount) : _.first(list, testCount);
}, [VerseData, checked, testCount, toShuffle, shuffleKey]);
// Reset session problem verses when the testList changes (new session)
useEffect(() => {
setSessionProblemVerses({});
}, [testList]);
@ -208,8 +224,12 @@ function Page() {
<div className="App">
<h1>{t('main.title')}</h1>
<h2>{t('main.pick_lang')}</h2>
<button type="button" onClick={() => changeLanguage('en')}>English</button>
<button type="button" onClick={() => changeLanguage('kn')}>Korean</button>
<div className="lang-bar">
<button type="button" onClick={() => changeLanguage('en')}>English</button>
<button type="button" onClick={() => changeLanguage('kn')}>Korean</button>
</div>
<h2>{t('main.pick_num_verses')}</h2>
<label className="test-count-box-label" htmlFor="testCountBox">
{t('main.num_verses_tested')}
@ -246,20 +266,30 @@ function Page() {
</h2>
<p>{t('main.note_set_shuffle')}</p>
<div>
{!(toShuffle || toReview) ?
<>
<h2>
{t('main.hide_reference')}
<input
type="checkbox"
checked={toHideReference}
onChange={handleHideReferenceCheckboxChange}
/>
</h2>
<p>{t('main.note_hide_reference')}</p>
</>:
<p></p>}
<div className={(toShuffle || toReview) ? 'setting-disabled' : ''}>
<h2>
{t('main.hide_reference')}
<input
type="checkbox"
checked={toHideReference}
onChange={handleHideReferenceCheckboxChange}
disabled={toShuffle || toReview}
/>
</h2>
<p>{t('main.note_hide_reference')}</p>
</div>
<div className={toReview ? 'setting-disabled' : ''}>
<h2>
{t('main.live_validation')}
<input
type="checkbox"
checked={liveValidation}
onChange={handleLiveValidationCheckboxChange}
disabled={toReview}
/>
</h2>
<p>{t('main.note_live_validation')}</p>
</div>
@ -272,34 +302,43 @@ function Page() {
setExpanded={setExpanded}
/>
<div key={refreshKey}>
{toShuffle ?
<>
<h2>{t('main.shuffle_card')}</h2>
<RefreshButton onClick={handleRefresh} />
</>:
<p></p>}
<h2>{t('main.tools')}</h2>
<div className="tool-bar">
<RefreshButton onClick={handleShuffle} disabled={!toShuffle} />
<button onClick={handleClearAll}>Clear All</button>
</div>
<h1>{t('main.verses')}</h1>
{toReview ?
<GenerateReviewList
VerseData={VerseData}
packs={checked}
testCount={testCount}
toShuffle={toShuffle}
<ArrayPrinter
array={testList}
translate={t}
/> :
<GenerateTestList
VerseData={VerseData}
packs={checked}
testCount={testCount}
toShuffle={toShuffle}
<ArrayTester
array={testList}
toHideReference={toHideReference}
liveValidation={liveValidation}
clearKey={clearKey} // Pass clearKey down
translate={t}
onShowAnswer={handleShowAnswer}
/>
}
{Object.keys(sessionProblemVerses).length > 0 && (
<div className="session-problem-verses">
<h2>{t('main.problem_verses_session')}</h2>
<ul>
{Object.entries(sessionProblemVerses)
.sort(([, countA], [, countB]) => countB - countA) // Sort by count in descending order
.map(([key, count]) => {
// Assuming key format is "pack:reference"
const [pack, reference] = key.split('|');
return <li key={key}>{reference} (Shown Answer: {count} time{count > 1 ? 's' : ''})</li>;
})}
</ul>
</div>
)}
<hr />
<p><x-small> Built on: {VITE_BUILD_DATE} </x-small></p>

View File

@ -58,7 +58,7 @@
font-size: 15px;
}
.answer-button-box {
.verse-validator-button-box {
display: flex;
gap: 10px;
padding-top: 10px;

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import "./VerseValidator.css";
import { StringDiff } from "react-string-diff";
import { containsKorean, jamoSubstringMatch } from './utils';
@ -10,10 +10,17 @@ const STATE = {
};
// function to render and handle logic of each of the cells
const VerseValidator = ({ element: { pack, title, chapterTitle, reference, verse } , toHideReference, t, index}) => { // useful use of destructuring here
const VerseValidator = (
{ element:
{ pack, title, chapterTitle, reference, verse },
toHideReference,
liveValidation,
clearKey,
t,
index,
onShowAnswer
}) => { // useful use of destructuring here
const [inputReference, setReference] = useState('')
const [referenceBool, setReferenceBool] = useState(STATE.INCORRECT)
const [inputChapterTitle, setChapterTitle] = useState('')
@ -22,9 +29,33 @@ const VerseValidator = ({ element: { pack, title, chapterTitle, reference, verse
const [titleBool, setTitleBool] = useState(STATE.INCORRECT)
const [inputVerse, setVerse] = useState('')
const [verseBool, setVerseBool] = useState(STATE.INCORRECT)
const[hintBool, setHintBool] = useState(false)
const[diffBool, setDiffBool] = useState(false)
const [hintBool, setHintBool] = useState(false)
const [diffBool, setDiffBool] = useState(false)
const [isComposing, setIsComposing] = useState(false);
const isInitialMount = useRef(true);
// State for hint word counts
const [referenceHintCount, setReferenceHintCount] = useState(0);
const [titleHintCount, setTitleHintCount] = useState(0);
const [chapterTitleHintCount, setChapterTitleHintCount] = useState(0);
const [verseHintCount, setVerseHintCount] = useState(0);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
} else {
handleReset();
}
}, [clearKey]);
useEffect(() => {
// Re-run validation for all fields when liveValidation changes
// Using current input values to re-evaluate their state
validateReference(inputReference);
validateChapterTitle(inputChapterTitle);
validateTitle(inputTitle);
validateVerse(inputVerse);
}, [liveValidation]); // Dependency array: re-run effect when liveValidation changes
// handle reset
const handleReset = () => {
@ -37,6 +68,12 @@ const VerseValidator = ({ element: { pack, title, chapterTitle, reference, verse
setVerse('');
setVerseBool(STATE.INCORRECT);
setDiffBool(false); // optionally hide answer again
setHintBool(false);
// Reset hint counts
setReferenceHintCount(0);
setTitleHintCount(0);
setChapterTitleHintCount(0);
setVerseHintCount(0);
};
@ -46,13 +83,13 @@ const VerseValidator = ({ element: { pack, title, chapterTitle, reference, verse
setIsComposing(true);
};
function resultChecker(string1, string2) {
function resultChecker(string1, string2, liveValidation) {
var result = STATE.INCORRECT; // init
// contains korean
if (containsKorean(string1)) {
if (string1 === string2) {
result = STATE.CORRECT;
} else if (jamoSubstringMatch(string2, string1) & string1 !== "") {
} else if (liveValidation && jamoSubstringMatch(string2, string1) & string1 !== "") {
result = STATE.PARTIAL;
} else {
result = STATE.INCORRECT;
@ -60,7 +97,7 @@ const VerseValidator = ({ element: { pack, title, chapterTitle, reference, verse
} else { // does not contain korean
if (string1 === string2) {
result = STATE.CORRECT;
} else if (string2.startsWith(string1) & string1 !== "") {
} else if (liveValidation && string2.startsWith(string1) & string1 !== "") {
result = STATE.PARTIAL;
} else {
result = STATE.INCORRECT;
@ -81,7 +118,7 @@ const VerseValidator = ({ element: { pack, title, chapterTitle, reference, verse
.toLowerCase()
.normalize("NFC");
const result = resultChecker(string1, string2);
const result = resultChecker(string1, string2, liveValidation);
setReferenceBool(result);
};
@ -109,7 +146,7 @@ const VerseValidator = ({ element: { pack, title, chapterTitle, reference, verse
.toLowerCase()
.normalize("NFC");
const result = resultChecker(string1, string2);
const result = resultChecker(string1, string2, liveValidation);
setTitleBool(result);
};
@ -140,7 +177,7 @@ const VerseValidator = ({ element: { pack, title, chapterTitle, reference, verse
.toLowerCase()
.normalize("NFC");
const result = resultChecker(string1, string2);
const result = resultChecker(string1, string2, liveValidation);
setChapterTitleBool(result);
};
@ -167,7 +204,7 @@ const VerseValidator = ({ element: { pack, title, chapterTitle, reference, verse
.toLowerCase()
.normalize("NFC");
const result = resultChecker(string1, string2);
const result = resultChecker(string1, string2, liveValidation);
setVerseBool(result);
};
@ -179,21 +216,6 @@ const VerseValidator = ({ element: { pack, title, chapterTitle, reference, verse
}`;
// const DiffViewer = ({oldValue, newValue}) => {
// const string1 = String(oldValue)
// .replace(/[\p{P}\p{S}]/gu, "")
// .toLowerCase()
// .normalize("NFC");
// const string2 = String(newValue)
// .replace(/[\p{P}\p{S}]/gu, "")
// .toLowerCase()
// .normalize("NFC");
// return (<StringDiff oldValue={string1} newValue={string2} diffMethod="diffWords" />)
// }
const DiffViewerStrict = ({oldValue, newValue}) => {
const string1 = String(oldValue)
.toLowerCase()
@ -257,6 +279,19 @@ const VerseValidator = ({ element: { pack, title, chapterTitle, reference, verse
validateReference(value);
}}
/>
{hintBool && (
<div className="hint-area">
<p className="hint-text">
Hint: {reference.split(' ').slice(0, referenceHintCount).join(' ')}
</p>
<button
onClick={() => setReferenceHintCount(prev => prev + 1)}
disabled={referenceHintCount >= reference.split(' ').length}
>
Next Word
</button>
</div>
)}
</div>
) : (
<h2>
@ -290,70 +325,122 @@ const VerseValidator = ({ element: { pack, title, chapterTitle, reference, verse
setChapterTitle(value);
validateChapterTitle(value);
}}
/>
{hintBool && (
<div className="hint-area">
<p className="hint-text">
Hint: {chapterTitle.split(' ').slice(0, chapterTitleHintCount).join(' ')}
</p>
<button
onClick={() => setChapterTitleHintCount(prev => prev + 1)}
disabled={chapterTitleHintCount >= chapterTitle.split(' ').length}
>
Next Word
</button>
</div>
)}
</div>
)}
{/* input box for title */}
<label className="title-box-label">
{t('verse_validator.input_title')}
</label>
<textarea
className={titleClassName}
type="text"
id="titleBox"
name="titleBox"
value={inputTitle}
onInput={(event) => {
const value = event.target.value;
setTitle(value);
if (!isComposing) {
<div>
<label className="title-box-label">
{t('verse_validator.input_title')}
</label>
<textarea
className={titleClassName}
type="text"
id="titleBox"
name="titleBox"
value={inputTitle}
onInput={(event) => {
const value = event.target.value;
setTitle(value);
if (!isComposing) {
validateTitle(value);
}
}}
onCompositionStart={handleCompositionStart}
onCompositionEnd={(event) => {
const value = event.target.value;
setIsComposing(false);
setTitle(value);
validateTitle(value);
}
}}
onCompositionStart={handleCompositionStart}
onCompositionEnd={(event) => {
const value = event.target.value;
setIsComposing(false);
setTitle(value);
validateTitle(value);
}}
/>
}}
/>
{hintBool && (
<div className="hint-area">
<p className="hint-text">
Hint: {title.split(' ').slice(0, titleHintCount).join(' ')}
</p>
<button
onClick={() => setTitleHintCount(prev => prev + 1)}
disabled={titleHintCount >= title.split(' ').length}
>
Next Word
</button>
</div>
)}
</div>
{/* input box for verse */}
<label className="verse-box-label">
{t('verse_validator.input_verse')}
</label>
<textarea
className={verseClassName}
type="text"
id="verseBox"
name="verseBox"
value={inputVerse}
onInput={(event) => {
const value = event.target.value;
setVerse(value);
if (!isComposing) {
<div>
<label className="verse-box-label">
{t('verse_validator.input_verse')}
</label>
<textarea
className={verseClassName}
type="text"
id="verseBox"
name="verseBox"
value={inputVerse}
onInput={(event) => {
const value = event.target.value;
setVerse(value);
if (!isComposing) {
validateVerse(value);
}
}}
onCompositionStart={handleCompositionStart}
onCompositionEnd={(event) => {
const value = event.target.value;
setIsComposing(false);
setVerse(value);
validateVerse(value);
}
}}
onCompositionStart={handleCompositionStart}
onCompositionEnd={(event) => {
const value = event.target.value;
setIsComposing(false);
setVerse(value);
validateVerse(value);
}}
}}
/>
{hintBool && (
<div className="hint-area">
<p className="hint-text">
Hint: {verse.split(' ').slice(0, verseHintCount).join(' ')}
</p>
<button
onClick={() => setVerseHintCount(prev => prev + 1)}
disabled={verseHintCount >= verse.split(' ').length}
>
Next Word
</button>
</div>
)}
</div>
/>
{/* button to toggle show answer*/}
<div className="answer-button-box">
{/* <button onClick={() => setHintBool(!hintBool)}>Show Answer:</button> */}
<button onClick={() => setDiffBool(!diffBool)}>Show Answer:</button>
{/* buttons to toggle per-block functionality*/}
<div className="verse-validator-button-box">
{/* hint button*/}
<button onClick={() => setHintBool(!hintBool)}>
{hintBool ? 'Hide Hints' : 'Show Hints'}
</button>
{/* show answer button*/}
<button onClick={() => {
// Toggle the diff display
setDiffBool(prev => !prev);
// If it's being turned ON, and onShowAnswer is provided, call it.
// We only want to count when the user explicitly reveals the answer.
if (!diffBool && onShowAnswer) {
onShowAnswer({ pack, title, reference });
}
}}>Show Answer</button>
{/* reset button*/}
<button onClick={handleReset}>Reset</button>
</div>

View File

@ -1722,7 +1722,9 @@
"title": "Separated from God by our sin",
"reference": "Isaiah 59:1-2",
"verse": "Surely the arm of the LORD is not too short to save, nor his ear too dull to hear. But your iniquities have separated you from your God; your sins have hidden his face from you, so that he will not hear."
},
}
],
"dep-6-part-f": [
{
"pack": "DEP6",
"chapterTitle": "Bridge Illustration",
@ -1764,7 +1766,9 @@
"title": "Man's condition - Eternal death",
"reference": "Revelation 21:8",
"verse": "But the cowardly, the unbelieving, the vile, the murderers, the sexually immoral, those who practice magic arts, the idolaters and all liars - their place will be in the fiery lake of burning sulfur. This is the second death.\""
},
}
],
"dep-6-part-g": [
{
"pack": "DEP6",
"chapterTitle": "Bridge Illustration",
@ -1834,7 +1838,9 @@
"title": "Salvation not by ourselves - The flesh counts for nothing",
"reference": "John 6:63",
"verse": "The Spirit gives life, the flesh counts for nothing. The words I have spoken to you are spirit and they are life."
},
}
],
"dep-6-part-h": [
{
"pack": "DEP6",
"chapterTitle": "Bridge Illustration",
@ -1869,7 +1875,9 @@
"title": "God's solution - Forgiving us all our sins",
"reference": "Colossians 2:13",
"verse": "When you were dead in your sins and in the uncircumcision of your sinful nature, God made you alive with Christ. He forgave us all our sins."
},
}
],
"dep-6-part-i": [
{
"pack": "DEP6",
"chapterTitle": "Bridge Illustration",

View File

@ -17,11 +17,13 @@ i18n
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
fallbackLng: 'en',
supportedLngs: ['en', 'kn'],
detection: { // adds some caching
order: ['querystring', 'cookie', 'localStorage', 'navigator', 'htmlTag', 'path', 'subdomain'],
caches: ['localStorage', 'cookie'],
order: ['querystring', 'cookie', 'navigator', 'htmlTag', 'path', 'subdomain'],
caches: ['cookie'], // Removed 'localStorage' to address Vivaldi mobile issue, kept 'cookie'
load: 'languageOnly'
},
debug: true,
debug: false,
});
export default i18n;