Spaces:
Running
Running
Update index.html
Browse files- index.html +575 -103
index.html
CHANGED
|
@@ -12,6 +12,33 @@
|
|
| 12 |
<script src="https://cdn.jsdelivr.net/npm/qrcode-generator/qrcode.js"></script>
|
| 13 |
<!-- 載入 Pako 壓縮函式庫 -->
|
| 14 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js"></script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
<style>
|
| 16 |
/* 定義閃卡的翻轉效果 */
|
| 17 |
.flip-container {
|
|
@@ -89,6 +116,10 @@
|
|
| 89 |
border-radius: 10px;
|
| 90 |
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
| 91 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
</style>
|
| 93 |
</head>
|
| 94 |
<body class="bg-gray-50 min-h-screen flex flex-col items-center p-4 sm:p-8 font-sans">
|
|
@@ -126,7 +157,7 @@
|
|
| 126 |
<div class="mb-4">
|
| 127 |
<div class="relative mb-3 border-b pb-2">
|
| 128 |
<h3 class="text-lg font-semibold text-gray-600 text-center">主要功能</h3>
|
| 129 |
-
<button id="reset-crowns-btn" class="absolute right-0 top-1/2 -translate-y-1/2 text-xs bg-gray-200 hover:bg-gray-300 text-gray-700 font-semibold py-1 px-3 rounded-full transition-colors disabled:bg-gray-300">重置
|
| 130 |
</div>
|
| 131 |
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
| 132 |
<button data-mode="review" class="mode-btn bg-sky-500 hover:bg-sky-600">複習模式</button>
|
|
@@ -151,9 +182,18 @@
|
|
| 151 |
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
| 152 |
<button data-mode="hard" class="mode-btn bg-red-700 hover:bg-red-800">🧠 困難單字複習</button>
|
| 153 |
<button data-mode="speed" class="mode-btn bg-slate-700 hover:bg-slate-800">⚡ 極速挑戰 ⚡</button>
|
|
|
|
| 154 |
</div>
|
| 155 |
</div>
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
<!-- 教師工具 -->
|
| 158 |
<div id="teacher-tools" class="mt-8 pt-4 border-t flex justify-end items-center gap-3">
|
| 159 |
<button id="grade-report-btn" class="text-white font-semibold py-2 px-5 rounded-full transition-transform hover:scale-105 bg-blue-500 hover:bg-blue-600 text-sm">📊 成績報告</button>
|
|
@@ -181,6 +221,29 @@
|
|
| 181 |
<button id="save-title-btn" class="bg-indigo-600 text-white font-bold py-2 px-6 rounded-full hover:bg-indigo-700 shrink-0 transition-colors">儲存標題</button>
|
| 182 |
</div>
|
| 183 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
<!-- 新增單字區塊 -->
|
| 186 |
<div id="add-words-section" class="mb-6 p-4 bg-gray-50 rounded-2xl hidden">
|
|
@@ -241,6 +304,13 @@
|
|
| 241 |
<div id="flashcard-container" class="flip-container w-full h-full transform transition-transform duration-300">
|
| 242 |
<div class="flipper w-full h-full">
|
| 243 |
<div class="front flex flex-col justify-center items-center">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
<span id="front-display" class="text-5xl font-bold text-center"></span>
|
| 245 |
<div class="absolute bottom-6 right-6 flex items-center gap-2">
|
| 246 |
<button id="speak-slow-btn" class="speak-btn p-4 rounded-full bg-white/30 hover:bg-white/50 transition-colors hidden">
|
|
@@ -343,6 +413,14 @@
|
|
| 343 |
<label for="edit-chinese-input" class="block text-sm font-semibold text-gray-700 mb-1">中文</label>
|
| 344 |
<input id="edit-chinese-input" type="text" class="w-full p-3 border border-gray-300 rounded-lg text-lg">
|
| 345 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
</div>
|
| 347 |
<div class="flex justify-end gap-4 mt-8">
|
| 348 |
<button type="button" id="cancel-edit-btn" class="px-6 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors">取消</button>
|
|
@@ -351,6 +429,28 @@
|
|
| 351 |
</div>
|
| 352 |
</div>
|
| 353 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
<!-- 分享連結 Modal -->
|
| 355 |
<div id="share-modal" class="hidden fixed inset-0 bg-gray-900 bg-opacity-75 flex items-center justify-center z-50 p-4">
|
| 356 |
<div class="bg-white p-8 rounded-2xl shadow-xl w-full max-w-lg">
|
|
@@ -451,6 +551,10 @@
|
|
| 451 |
<p>FB教育社群:<a href="https://www.facebook.com/groups/1554372228718393" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:underline">萬物皆數</a></p>
|
| 452 |
</footer>
|
| 453 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
<script>
|
| 455 |
document.addEventListener('DOMContentLoaded', () => {
|
| 456 |
// DOM 元素
|
|
@@ -485,17 +589,31 @@
|
|
| 485 |
const addWordsBtn = document.getElementById('add-words-btn');
|
| 486 |
const cancelAddWordsBtn = document.getElementById('cancel-add-words-btn');
|
| 487 |
const wordListContainer = document.getElementById('word-list-container');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
|
| 489 |
// 學習介面
|
| 490 |
const flashcardContainer = document.getElementById('flashcard-container');
|
| 491 |
const frontDisplay = document.getElementById('front-display');
|
| 492 |
const backDisplay = document.getElementById('back-display');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
const speakBtn = document.getElementById('speak-btn');
|
| 494 |
const speakSlowBtn = document.getElementById('speak-slow-btn');
|
| 495 |
const modeTitle = document.getElementById('mode-title');
|
| 496 |
const backToMenuBtn = document.getElementById('back-to-menu-btn');
|
| 497 |
const progressBarContainer = document.getElementById('progress-bar-container');
|
| 498 |
const progressBar = document.getElementById('progress-bar');
|
|
|
|
|
|
|
|
|
|
| 499 |
|
| 500 |
// 測驗相關
|
| 501 |
const quizContainer = document.getElementById('quiz-container');
|
|
@@ -536,6 +654,8 @@
|
|
| 536 |
const editWordIndexInput = document.getElementById('edit-word-index');
|
| 537 |
const editEnglishInput = document.getElementById('edit-english-input');
|
| 538 |
const editChineseInput = document.getElementById('edit-chinese-input');
|
|
|
|
|
|
|
| 539 |
const saveEditBtn = document.getElementById('save-edit-btn');
|
| 540 |
const cancelEditBtn = document.getElementById('cancel-edit-btn');
|
| 541 |
const shareModal = document.getElementById('share-modal');
|
|
@@ -559,6 +679,12 @@
|
|
| 559 |
const wordToDeleteSpan = document.getElementById('word-to-delete');
|
| 560 |
const cancelDeleteBtn = document.getElementById('cancel-delete-btn');
|
| 561 |
const confirmDeleteBtn = document.getElementById('confirm-delete-btn');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 562 |
|
| 563 |
// 音效元素
|
| 564 |
const correctSound = document.getElementById('correct-sound');
|
|
@@ -586,11 +712,14 @@
|
|
| 586 |
let currentSpeedCard = null;
|
| 587 |
let currentSpeedQuestionType = '';
|
| 588 |
let quizStartTime = 0;
|
|
|
|
|
|
|
| 589 |
|
| 590 |
const modeDetails = {
|
| 591 |
'review': { title: '複習模式' }, 'zh-en': { title: '中翻英測驗' },
|
| 592 |
'en-zh': { title: '英翻中測驗' }, 'listen': { title: '聽力測驗' },
|
| 593 |
'speed': { title: '極速挑戰' }, 'hard': { title: '困難單字複習' },
|
|
|
|
| 594 |
};
|
| 595 |
|
| 596 |
// --- 視圖管理 ---
|
|
@@ -610,7 +739,8 @@
|
|
| 610 |
|
| 611 |
// --- 資料處理 ---
|
| 612 |
const saveWordsToStorage = () => {
|
| 613 |
-
|
|
|
|
| 614 |
};
|
| 615 |
const shuffleArray = (array) => {
|
| 616 |
for (let i = array.length - 1; i > 0; i--) {
|
|
@@ -626,10 +756,14 @@
|
|
| 626 |
words.forEach((word, index) => {
|
| 627 |
const item = document.createElement('div');
|
| 628 |
item.className = 'list-item p-3 bg-gray-100 rounded-lg flex items-center justify-between';
|
|
|
|
|
|
|
|
|
|
| 629 |
item.innerHTML = `
|
| 630 |
<div class="flex items-center">
|
| 631 |
<span class="w-8 text-sm text-gray-500">${index + 1}.</span>
|
| 632 |
<p class="font-semibold text-gray-800">${word.english}</p>
|
|
|
|
| 633 |
<p class="text-gray-600 ml-4">${word.chinese}</p>
|
| 634 |
</div>
|
| 635 |
<div class="flex gap-2">
|
|
@@ -646,9 +780,12 @@
|
|
| 646 |
if (!button) return;
|
| 647 |
const index = parseInt(button.dataset.index, 10);
|
| 648 |
if (button.classList.contains('edit-btn')) {
|
|
|
|
| 649 |
editWordIndexInput.value = index;
|
| 650 |
-
editEnglishInput.value =
|
| 651 |
-
editChineseInput.value =
|
|
|
|
|
|
|
| 652 |
editWordModal.classList.remove('hidden');
|
| 653 |
} else if (button.classList.contains('delete-btn')) {
|
| 654 |
indexToDelete = index;
|
|
@@ -661,9 +798,17 @@
|
|
| 661 |
const index = parseInt(editWordIndexInput.value, 10);
|
| 662 |
const newEnglish = editEnglishInput.value.trim();
|
| 663 |
const newChinese = editChineseInput.value.trim();
|
|
|
|
|
|
|
|
|
|
| 664 |
if (index >= 0 && newEnglish && newChinese) {
|
| 665 |
words[index].english = newEnglish;
|
| 666 |
words[index].chinese = newChinese;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
saveWordsToStorage();
|
| 668 |
renderWordList();
|
| 669 |
editWordModal.classList.add('hidden');
|
|
@@ -752,10 +897,13 @@
|
|
| 752 |
speedResultView.classList.add('hidden');
|
| 753 |
hintDisplay.textContent = '';
|
| 754 |
hintBtn.disabled = false;
|
|
|
|
|
|
|
| 755 |
|
| 756 |
let frontText, backText;
|
| 757 |
|
| 758 |
if (isSpeed) {
|
|
|
|
| 759 |
switch (currentSpeedQuestionType) {
|
| 760 |
case 'zh-en':
|
| 761 |
frontText = card.chinese;
|
|
@@ -772,7 +920,7 @@
|
|
| 772 |
frontText = '請聽發音';
|
| 773 |
backText = card.english;
|
| 774 |
[speakBtn, speakSlowBtn].forEach(btn => btn.classList.remove('hidden'));
|
| 775 |
-
speakWord(card
|
| 776 |
answerInput.placeholder = "請輸入英文答案...";
|
| 777 |
break;
|
| 778 |
}
|
|
@@ -799,12 +947,24 @@
|
|
| 799 |
case 'listen':
|
| 800 |
frontText = '請聽發音'; backText = card.english;
|
| 801 |
[speakBtn, speakSlowBtn].forEach(btn => btn.classList.remove('hidden'));
|
| 802 |
-
speakWord(card
|
| 803 |
answerInput.placeholder = "請輸入英文答案...";
|
| 804 |
break;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 805 |
}
|
| 806 |
}
|
| 807 |
-
|
|
|
|
|
|
|
|
|
|
| 808 |
backDisplay.textContent = backText;
|
| 809 |
setTimeout(() => answerInput.focus(), 100);
|
| 810 |
};
|
|
@@ -841,7 +1001,13 @@
|
|
| 841 |
wordPool = [...words];
|
| 842 |
}
|
| 843 |
|
| 844 |
-
if (mode === '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 845 |
wordsForCurrentMode = wordPool;
|
| 846 |
} else if (randomQuestionsCheckbox.checked) {
|
| 847 |
const randomCount = parseInt(randomQuestionsCountInput.value, 10);
|
|
@@ -903,20 +1069,23 @@
|
|
| 903 |
|
| 904 |
if (isReview) {
|
| 905 |
const isFlipped = flashcardContainer.classList.contains('flipped');
|
| 906 |
-
|
|
|
|
| 907 |
answerLang = isFlipped ? 'en' : 'zh';
|
| 908 |
} else {
|
| 909 |
let effectiveMode = isSpeed ? currentSpeedQuestionType : (currentMode === 'hard' ? 'zh-en' : currentMode);
|
|
|
|
| 910 |
switch (effectiveMode) {
|
| 911 |
-
case 'zh-en': case 'listen':
|
| 912 |
-
|
| 913 |
answerLang = 'en';
|
| 914 |
break;
|
| 915 |
case 'en-zh':
|
| 916 |
-
|
| 917 |
answerLang = 'zh';
|
| 918 |
break;
|
| 919 |
}
|
|
|
|
| 920 |
}
|
| 921 |
|
| 922 |
let isCorrect;
|
|
@@ -929,8 +1098,8 @@
|
|
| 929 |
};
|
| 930 |
isCorrect = normalize(userAnswer) === normalize(correctAnswer);
|
| 931 |
} else if (answerLang === 'zh') {
|
| 932 |
-
const possibleAnswers = correctAnswer.split(/[;;]/).map(a => a.trim());
|
| 933 |
-
isCorrect = possibleAnswers.some(ans => userAnswer.trim().includes(ans)
|
| 934 |
}
|
| 935 |
|
| 936 |
if (isCorrect) {
|
|
@@ -976,7 +1145,7 @@
|
|
| 976 |
|
| 977 |
quizStartTime = 0; // Reset timer
|
| 978 |
|
| 979 |
-
if (
|
| 980 |
completionStatus[currentMode] = true;
|
| 981 |
localStorage.setItem('completionStatus', JSON.stringify(completionStatus));
|
| 982 |
updateCompletionUI();
|
|
@@ -1098,10 +1267,10 @@
|
|
| 1098 |
confetti({ particleCount: 150, spread: 70, origin: { y: 0.6 } });
|
| 1099 |
};
|
| 1100 |
|
| 1101 |
-
const speakWithBrowserTTS = (
|
| 1102 |
if ('speechSynthesis' in window) {
|
| 1103 |
window.speechSynthesis.cancel();
|
| 1104 |
-
const wordToSpeak =
|
| 1105 |
const utterance = new SpeechSynthesisUtterance(wordToSpeak);
|
| 1106 |
utterance.lang = 'en-US';
|
| 1107 |
utterance.rate = rate;
|
|
@@ -1113,7 +1282,16 @@
|
|
| 1113 |
speakBtn.disabled = true;
|
| 1114 |
speakSlowBtn.disabled = true;
|
| 1115 |
|
| 1116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1117 |
if (!wordToSpeak) {
|
| 1118 |
speakBtn.disabled = false;
|
| 1119 |
speakSlowBtn.disabled = false;
|
|
@@ -1122,7 +1300,6 @@
|
|
| 1122 |
|
| 1123 |
const cleanedWord = wordToSpeak.replace(/[^a-zA-Z\s-]/g, '');
|
| 1124 |
|
| 1125 |
-
// 【已修改】智慧判斷:如果是片語,直接用瀏覽器語音;如果是單字,才用 API
|
| 1126 |
if (cleanedWord.includes(' ')) {
|
| 1127 |
speakWithBrowserTTS(cleanedWord, 0.75);
|
| 1128 |
speakBtn.disabled = false;
|
|
@@ -1152,32 +1329,29 @@
|
|
| 1152 |
}
|
| 1153 |
|
| 1154 |
if (audioUrl) {
|
|
|
|
| 1155 |
apiAudioPlayer.src = audioUrl;
|
| 1156 |
apiAudioPlayer.play().catch(error => {
|
| 1157 |
console.error("Audio playback error:", error);
|
| 1158 |
speakWithBrowserTTS(cleanedWord, 0.75);
|
| 1159 |
-
}).finally(() => {
|
| 1160 |
-
speakBtn.disabled = false;
|
| 1161 |
-
speakSlowBtn.disabled = false;
|
| 1162 |
});
|
| 1163 |
} else {
|
| 1164 |
speakWithBrowserTTS(cleanedWord, 0.75);
|
| 1165 |
-
speakBtn.disabled = false;
|
| 1166 |
-
speakSlowBtn.disabled = false;
|
| 1167 |
}
|
| 1168 |
} catch (error) {
|
| 1169 |
console.error("Dictionary API error:", error);
|
| 1170 |
speakWithBrowserTTS(cleanedWord, 0.75);
|
|
|
|
| 1171 |
speakBtn.disabled = false;
|
| 1172 |
speakSlowBtn.disabled = false;
|
| 1173 |
}
|
| 1174 |
};
|
| 1175 |
|
| 1176 |
const getCurrentWordToSpeak = () => {
|
| 1177 |
-
if (currentMode === 'speed' && currentSpeedCard) return currentSpeedCard
|
| 1178 |
-
if (currentMode === 'review' && wordsForCurrentMode[currentCardIndex]) return wordsForCurrentMode[currentCardIndex]
|
| 1179 |
-
if (quizQueue.length > 0) return quizQueue[0]
|
| 1180 |
-
return
|
| 1181 |
};
|
| 1182 |
|
| 1183 |
const updateCompletionUI = () => {
|
|
@@ -1238,6 +1412,217 @@
|
|
| 1238 |
qrcodeContainer.textContent = 'QR碼產生失敗,網址可能過長。';
|
| 1239 |
}
|
| 1240 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1241 |
|
| 1242 |
// --- 事件監聽 ---
|
| 1243 |
document.querySelectorAll('.mode-btn[data-mode]').forEach(btn => {
|
|
@@ -1249,7 +1634,10 @@
|
|
| 1249 |
localStorage.removeItem('completionStatus');
|
| 1250 |
updateCompletionUI();
|
| 1251 |
|
| 1252 |
-
|
|
|
|
|
|
|
|
|
|
| 1253 |
const originalText = resetCrownsBtn.textContent;
|
| 1254 |
resetCrownsBtn.textContent = '已重置!';
|
| 1255 |
resetCrownsBtn.disabled = true;
|
|
@@ -1264,7 +1652,6 @@
|
|
| 1264 |
const isQuiz = !['review', 'speed', ''].includes(currentMode);
|
| 1265 |
if (isQuiz && quizStartTime > 0) {
|
| 1266 |
const attempted = wordsForCurrentMode.length - quizQueue.length;
|
| 1267 |
-
// Only save if at least one question was attempted
|
| 1268 |
if (attempted > 0) {
|
| 1269 |
const elapsedTime = Math.round((Date.now() - quizStartTime) / 1000);
|
| 1270 |
const reportData = {
|
|
@@ -1297,6 +1684,11 @@
|
|
| 1297 |
}
|
| 1298 |
});
|
| 1299 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1300 |
prevBtn.addEventListener('click', () => { if (currentCardIndex > 0) { currentCardIndex--; displayCard(); }});
|
| 1301 |
nextBtn.addEventListener('click', () => { if (currentCardIndex < wordsForCurrentMode.length - 1) { currentCardIndex++; displayCard(); }});
|
| 1302 |
quizForm.addEventListener('submit', (e) => { e.preventDefault(); checkAnswer(); });
|
|
@@ -1309,7 +1701,7 @@
|
|
| 1309 |
speakSlowBtn.addEventListener('click', (e) => {
|
| 1310 |
e.stopPropagation();
|
| 1311 |
const word = getCurrentWordToSpeak();
|
| 1312 |
-
if(word) speakWithBrowserTTS(word, 0.2);
|
| 1313 |
});
|
| 1314 |
|
| 1315 |
hintBtn.addEventListener('click', () => {
|
|
@@ -1318,8 +1710,8 @@
|
|
| 1318 |
const questionType = currentMode === 'speed' ? currentSpeedQuestionType : (currentMode === 'hard' ? 'zh-en' : currentMode);
|
| 1319 |
let correctAnswer;
|
| 1320 |
switch (questionType) {
|
| 1321 |
-
case 'zh-en': case 'listen': correctAnswer = card.english.
|
| 1322 |
-
case 'en-zh': correctAnswer = card.chinese.
|
| 1323 |
default: return;
|
| 1324 |
}
|
| 1325 |
if (correctAnswer) {
|
|
@@ -1350,7 +1742,6 @@
|
|
| 1350 |
localStorage.setItem('flashcardsBookTitle', bookTitle);
|
| 1351 |
localStorage.setItem('flashcardsLessonTitle', lessonTitle);
|
| 1352 |
updateMainTitle();
|
| 1353 |
-
// Simple feedback
|
| 1354 |
const originalText = saveTitleBtn.textContent;
|
| 1355 |
saveTitleBtn.textContent = '已儲存!';
|
| 1356 |
saveTitleBtn.classList.remove('bg-indigo-600', 'hover:bg-indigo-700');
|
|
@@ -1382,48 +1773,45 @@
|
|
| 1382 |
closeReportModalBtn.addEventListener('click', () => gradeReportModal.classList.add('hidden'));
|
| 1383 |
|
| 1384 |
generateLinkBtn.addEventListener('click', async () => {
|
| 1385 |
-
generateLinkBtn.textContent = '
|
| 1386 |
generateLinkBtn.disabled = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1387 |
|
| 1388 |
-
|
| 1389 |
-
const wordsString = JSON.stringify(words);
|
| 1390 |
-
const compressedData = pako.deflate(wordsString);
|
| 1391 |
-
const base64Words = btoa(String.fromCharCode.apply(null, compressedData));
|
| 1392 |
-
|
| 1393 |
const start = startRangeInput.value || '';
|
| 1394 |
const end = endRangeInput.value || '';
|
| 1395 |
const isLocked = lockSettingsCheckbox.checked;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1396 |
|
| 1397 |
const baseUrl = window.location.href.split('?')[0];
|
| 1398 |
const url = new URL(baseUrl);
|
|
|
|
|
|
|
| 1399 |
|
| 1400 |
-
|
| 1401 |
-
|
| 1402 |
-
if (end) url.searchParams.set('end', end);
|
| 1403 |
-
if (isLocked) {
|
| 1404 |
-
url.searchParams.set('lock', 'true');
|
| 1405 |
-
if(randomQuestionsCheckbox.checked) {
|
| 1406 |
-
const randomCount = randomQuestionsCountInput.value;
|
| 1407 |
-
if(randomCount) url.searchParams.set('random', randomCount);
|
| 1408 |
-
}
|
| 1409 |
-
}
|
| 1410 |
-
|
| 1411 |
-
const longUrl = url.toString();
|
| 1412 |
-
|
| 1413 |
-
const response = await fetch(`https://tinyurl.com/api-create.php?url=${encodeURIComponent(longUrl)}`);
|
| 1414 |
-
if (response.ok) {
|
| 1415 |
-
const shortUrl = await response.text();
|
| 1416 |
-
shareLinkInput.value = shortUrl;
|
| 1417 |
-
generateQRCode(shortUrl);
|
| 1418 |
-
} else {
|
| 1419 |
-
shareLinkInput.value = longUrl;
|
| 1420 |
-
generateQRCode(longUrl);
|
| 1421 |
-
copyFeedback.textContent = '縮短網址失敗,已產生原始連結。';
|
| 1422 |
-
}
|
| 1423 |
shareResultContainer.classList.remove('hidden');
|
|
|
|
| 1424 |
} catch (error) {
|
| 1425 |
-
console.error("
|
| 1426 |
-
alert("
|
| 1427 |
} finally {
|
| 1428 |
generateLinkBtn.textContent = '重新產生';
|
| 1429 |
generateLinkBtn.disabled = false;
|
|
@@ -1506,37 +1894,138 @@
|
|
| 1506 |
startRangeInput.addEventListener('input', updateRandomCountMax);
|
| 1507 |
endRangeInput.addEventListener('input', updateRandomCountMax);
|
| 1508 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1509 |
|
| 1510 |
// --- 應用程式初始化 ---
|
| 1511 |
-
const initializeApp = () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1512 |
const urlParams = new URLSearchParams(window.location.search);
|
| 1513 |
-
const
|
| 1514 |
let loadedFromUrl = false;
|
| 1515 |
|
| 1516 |
-
if (
|
| 1517 |
-
|
| 1518 |
-
|
| 1519 |
-
|
| 1520 |
-
|
| 1521 |
-
|
| 1522 |
-
|
| 1523 |
-
|
| 1524 |
-
|
| 1525 |
-
|
| 1526 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1527 |
}
|
| 1528 |
-
} catch (error) {
|
| 1529 |
-
console.error("無法從 URL 解析分享的資料:", error);
|
| 1530 |
}
|
| 1531 |
}
|
| 1532 |
|
| 1533 |
if (!loadedFromUrl) {
|
| 1534 |
const storedWords = localStorage.getItem('flashcards');
|
| 1535 |
if (storedWords && JSON.parse(storedWords).length > 0) {
|
| 1536 |
-
words = JSON.parse(storedWords).map(word => ({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1537 |
} else {
|
| 1538 |
const defaultWords = [
|
| 1539 |
-
{ english: 'yesterday (adv.)', chinese: '昨天'
|
|
|
|
| 1540 |
{ english: 'jog (v.)', chinese: '慢跑' }, { english: 'watch (v.)', chinese: '觀看' },
|
| 1541 |
{ english: 'last (adj.)', chinese: '前一個的' }, { english: 'death (n.)', chinese: '死亡' },
|
| 1542 |
{ english: 'a few (adj.)', chinese: '一些' }, { english: 'ago (adv.)', chinese: '以前' },
|
|
@@ -1557,28 +2046,8 @@
|
|
| 1557 |
updateCompletionUI();
|
| 1558 |
highScore = parseInt(localStorage.getItem('flashcardsHighScore') || '0', 10);
|
| 1559 |
highscoreDisplay.textContent = highScore;
|
| 1560 |
-
|
| 1561 |
-
const sharedStart = urlParams.get('start');
|
| 1562 |
-
const sharedEnd = urlParams.get('end');
|
| 1563 |
-
const sharedRandom = urlParams.get('random');
|
| 1564 |
-
const isLocked = urlParams.get('lock') === 'true';
|
| 1565 |
-
|
| 1566 |
-
if (sharedStart) startRangeInput.value = sharedStart;
|
| 1567 |
-
if (sharedEnd) endRangeInput.value = sharedEnd;
|
| 1568 |
-
if (sharedRandom) {
|
| 1569 |
-
randomQuestionsCheckbox.checked = true;
|
| 1570 |
-
randomQuestionsCountInput.value = sharedRandom;
|
| 1571 |
-
randomQuestionsCountInput.disabled = false;
|
| 1572 |
-
}
|
| 1573 |
-
|
| 1574 |
-
if (isLocked) {
|
| 1575 |
-
settingsContainer.classList.add('opacity-50', 'pointer-events-none');
|
| 1576 |
-
// 只隱藏分享與管理按鈕,保留成績報告按鈕
|
| 1577 |
-
if (shareGameBtn) shareGameBtn.classList.add('hidden');
|
| 1578 |
-
if (manageWordsBtn) manageWordsBtn.classList.add('hidden');
|
| 1579 |
-
}
|
| 1580 |
|
| 1581 |
-
if (urlParams.has('
|
| 1582 |
history.replaceState(null, '', window.location.pathname);
|
| 1583 |
}
|
| 1584 |
|
|
@@ -1586,6 +2055,9 @@
|
|
| 1586 |
endRangeInput.max = words.length;
|
| 1587 |
endRangeInput.placeholder = `到 ${words.length}`;
|
| 1588 |
updateRandomCountMax();
|
|
|
|
|
|
|
|
|
|
| 1589 |
showView('menu');
|
| 1590 |
};
|
| 1591 |
initializeApp();
|
|
|
|
| 12 |
<script src="https://cdn.jsdelivr.net/npm/qrcode-generator/qrcode.js"></script>
|
| 13 |
<!-- 載入 Pako 壓縮函式庫 -->
|
| 14 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js"></script>
|
| 15 |
+
<!-- 載入 PDF.js 函式庫 -->
|
| 16 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.11.338/pdf.min.js"></script>
|
| 17 |
+
|
| 18 |
+
<!-- 【新】Firebase SDK -->
|
| 19 |
+
<script type="module">
|
| 20 |
+
import { initializeApp } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-app.js";
|
| 21 |
+
import { getFirestore, collection, addDoc, getDoc, doc } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-firestore.js";
|
| 22 |
+
|
| 23 |
+
// 您的 Firebase 設定金鑰
|
| 24 |
+
const firebaseConfig = {
|
| 25 |
+
apiKey: "AIzaSyAIRUONOLmA1pe42cmH_MNPgGC3oHKuceo",
|
| 26 |
+
authDomain: "englishflashcardbackstage.firebaseapp.com",
|
| 27 |
+
projectId: "englishflashcardbackstage",
|
| 28 |
+
storageBucket: "englishflashcardbackstage.firebasestorage.app",
|
| 29 |
+
messagingSenderId: "911755356145",
|
| 30 |
+
appId: "1:911755356145:web:a4a5b0245b3bb9f15d4650"
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
// 初始化 Firebase
|
| 34 |
+
const app = initializeApp(firebaseConfig);
|
| 35 |
+
const db = getFirestore(app);
|
| 36 |
+
|
| 37 |
+
// 將 db 實例暴露到全域,以便主腳本可以存取
|
| 38 |
+
window.firebaseDB = db;
|
| 39 |
+
window.firebaseFirestore = { collection, addDoc, getDoc, doc };
|
| 40 |
+
</script>
|
| 41 |
+
|
| 42 |
<style>
|
| 43 |
/* 定義閃卡的翻轉效果 */
|
| 44 |
.flip-container {
|
|
|
|
| 116 |
border-radius: 10px;
|
| 117 |
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
| 118 |
}
|
| 119 |
+
/* 進度條動畫 */
|
| 120 |
+
#ai-progress-bar-inner, #audio-preload-bar {
|
| 121 |
+
transition: width 0.3s ease-in-out;
|
| 122 |
+
}
|
| 123 |
</style>
|
| 124 |
</head>
|
| 125 |
<body class="bg-gray-50 min-h-screen flex flex-col items-center p-4 sm:p-8 font-sans">
|
|
|
|
| 157 |
<div class="mb-4">
|
| 158 |
<div class="relative mb-3 border-b pb-2">
|
| 159 |
<h3 class="text-lg font-semibold text-gray-600 text-center">主要功能</h3>
|
| 160 |
+
<button id="reset-crowns-btn" class="absolute right-0 top-1/2 -translate-y-1/2 text-xs bg-gray-200 hover:bg-gray-300 text-gray-700 font-semibold py-1 px-3 rounded-full transition-colors disabled:bg-gray-300">重置進度</button>
|
| 161 |
</div>
|
| 162 |
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
| 163 |
<button data-mode="review" class="mode-btn bg-sky-500 hover:bg-sky-600">複習模式</button>
|
|
|
|
| 182 |
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
| 183 |
<button data-mode="hard" class="mode-btn bg-red-700 hover:bg-red-800">🧠 困難單字複習</button>
|
| 184 |
<button data-mode="speed" class="mode-btn bg-slate-700 hover:bg-slate-800">⚡ 極速挑戰 ⚡</button>
|
| 185 |
+
<button data-mode="sentence-cloze" class="mode-btn bg-cyan-600 hover:bg-cyan-700">📝 情境克漏字</button>
|
| 186 |
</div>
|
| 187 |
</div>
|
| 188 |
|
| 189 |
+
<!-- Audio Preload Progress -->
|
| 190 |
+
<div id="audio-preload-container" class="hidden mt-4">
|
| 191 |
+
<p id="audio-preload-text" class="text-sm text-center text-gray-500 mb-1">正在預載語音檔案...</p>
|
| 192 |
+
<div class="w-full bg-gray-200 rounded-full h-2.5">
|
| 193 |
+
<div id="audio-preload-bar" class="bg-sky-600 h-2.5 rounded-full" style="width: 0%"></div>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
<!-- 教師工具 -->
|
| 198 |
<div id="teacher-tools" class="mt-8 pt-4 border-t flex justify-end items-center gap-3">
|
| 199 |
<button id="grade-report-btn" class="text-white font-semibold py-2 px-5 rounded-full transition-transform hover:scale-105 bg-blue-500 hover:bg-blue-600 text-sm">📊 成績報告</button>
|
|
|
|
| 221 |
<button id="save-title-btn" class="bg-indigo-600 text-white font-bold py-2 px-6 rounded-full hover:bg-indigo-700 shrink-0 transition-colors">儲存標題</button>
|
| 222 |
</div>
|
| 223 |
</div>
|
| 224 |
+
|
| 225 |
+
<!-- AI 解析區塊 -->
|
| 226 |
+
<div class="mb-6 p-4 bg-violet-50 rounded-2xl border-2 border-dashed border-violet-200">
|
| 227 |
+
<h3 class="text-xl font-semibold mb-2 text-violet-800">🚀 透過 AI 從 PDF 建立單字庫</h3>
|
| 228 |
+
<div class="mb-4">
|
| 229 |
+
<label for="api-key-input" class="block text-sm font-semibold text-violet-700 mb-1">您的 Gemini API 金鑰</label>
|
| 230 |
+
<div class="flex items-center gap-2">
|
| 231 |
+
<input type="password" id="api-key-input" class="w-full p-2 border border-violet-300 rounded-lg" placeholder="請在此貼上您的 API 金鑰">
|
| 232 |
+
<button id="save-api-key-btn" class="bg-violet-500 text-white font-semibold py-2 px-4 rounded-lg hover:bg-violet-600 shrink-0">儲存</button>
|
| 233 |
+
</div>
|
| 234 |
+
<p class="text-xs text-gray-500 mt-1">金鑰將會儲存在您的瀏覽器中,方便下次使用。可從 <a href="https://aistudio.google.com/app/apikey" target="_blank" class="text-blue-500 hover:underline">Google AI Studio</a> 取得。</p>
|
| 235 |
+
</div>
|
| 236 |
+
<div class="flex items-center gap-4">
|
| 237 |
+
<input type="file" id="pdf-upload-input" accept=".pdf" class="block w-full text-sm text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-violet-100 file:text-violet-700 hover:file:bg-violet-200"/>
|
| 238 |
+
<button id="parse-pdf-btn" class="bg-violet-600 text-white font-bold py-2 px-6 rounded-full hover:bg-violet-700 shrink-0 transition-colors disabled:bg-violet-300">AI智慧解析</button>
|
| 239 |
+
</div>
|
| 240 |
+
<div id="ai-progress-container" class="mt-3 hidden">
|
| 241 |
+
<div class="w-full bg-gray-200 rounded-full h-2.5">
|
| 242 |
+
<div id="ai-progress-bar-inner" class="bg-violet-600 h-2.5 rounded-full" style="width: 0%"></div>
|
| 243 |
+
</div>
|
| 244 |
+
</div>
|
| 245 |
+
<p id="parse-status" class="text-center text-sm text-violet-600 mt-2 h-5"></p>
|
| 246 |
+
</div>
|
| 247 |
|
| 248 |
<!-- 新增單字區塊 -->
|
| 249 |
<div id="add-words-section" class="mb-6 p-4 bg-gray-50 rounded-2xl hidden">
|
|
|
|
| 304 |
<div id="flashcard-container" class="flip-container w-full h-full transform transition-transform duration-300">
|
| 305 |
<div class="flipper w-full h-full">
|
| 306 |
<div class="front flex flex-col justify-center items-center">
|
| 307 |
+
<div id="cloze-question-container" class="hidden w-full text-center">
|
| 308 |
+
<p id="sentence-en-display" class="text-3xl mb-2"></p>
|
| 309 |
+
<div class="text-center">
|
| 310 |
+
<button id="toggle-translation-btn" class="text-sm bg-white/20 hover:bg-white/30 text-white py-1 px-3 rounded-full mb-2">顯示翻譯</button>
|
| 311 |
+
<p id="sentence-zh-display" class="text-xl font-light hidden"></p>
|
| 312 |
+
</div>
|
| 313 |
+
</div>
|
| 314 |
<span id="front-display" class="text-5xl font-bold text-center"></span>
|
| 315 |
<div class="absolute bottom-6 right-6 flex items-center gap-2">
|
| 316 |
<button id="speak-slow-btn" class="speak-btn p-4 rounded-full bg-white/30 hover:bg-white/50 transition-colors hidden">
|
|
|
|
| 413 |
<label for="edit-chinese-input" class="block text-sm font-semibold text-gray-700 mb-1">中文</label>
|
| 414 |
<input id="edit-chinese-input" type="text" class="w-full p-3 border border-gray-300 rounded-lg text-lg">
|
| 415 |
</div>
|
| 416 |
+
<div>
|
| 417 |
+
<label for="edit-sentence-en-input" class="block text-sm font-semibold text-gray-700 mb-1">英文例句 (用 ___ 代表單字)</label>
|
| 418 |
+
<input id="edit-sentence-en-input" type="text" class="w-full p-3 border border-gray-300 rounded-lg text-lg">
|
| 419 |
+
</div>
|
| 420 |
+
<div>
|
| 421 |
+
<label for="edit-sentence-zh-input" class="block text-sm font-semibold text-gray-700 mb-1">中文例句</label>
|
| 422 |
+
<input id="edit-sentence-zh-input" type="text" class="w-full p-3 border border-gray-300 rounded-lg text-lg">
|
| 423 |
+
</div>
|
| 424 |
</div>
|
| 425 |
<div class="flex justify-end gap-4 mt-8">
|
| 426 |
<button type="button" id="cancel-edit-btn" class="px-6 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors">取消</button>
|
|
|
|
| 429 |
</div>
|
| 430 |
</div>
|
| 431 |
|
| 432 |
+
<!-- AI 解析確認 Modal -->
|
| 433 |
+
<div id="parse-confirm-modal" class="hidden fixed inset-0 bg-gray-900 bg-opacity-75 flex items-center justify-center z-50 p-4">
|
| 434 |
+
<div class="bg-white p-8 rounded-2xl shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
|
| 435 |
+
<div class="flex justify-between items-center mb-4">
|
| 436 |
+
<h3 class="text-2xl font-bold text-gray-800">AI 解析結果預覽</h3>
|
| 437 |
+
<div class="flex items-center">
|
| 438 |
+
<input type="checkbox" id="select-all-checkbox" class="h-4 w-4 rounded text-indigo-600 focus:ring-indigo-500 border-gray-300 mr-2">
|
| 439 |
+
<label for="select-all-checkbox" class="text-sm font-medium text-gray-700">全選/取消全選</label>
|
| 440 |
+
</div>
|
| 441 |
+
</div>
|
| 442 |
+
|
| 443 |
+
<p class="text-gray-600 mb-6">請勾選您要新增至單字庫的項目。</p>
|
| 444 |
+
<div id="parse-results-container" class="flex-grow overflow-y-auto space-y-2 pr-2 border-t pt-4">
|
| 445 |
+
<!-- AI 解析結果將會顯示於此 -->
|
| 446 |
+
</div>
|
| 447 |
+
<div class="flex justify-end gap-4 mt-8 pt-4 border-t">
|
| 448 |
+
<button type="button" id="cancel-parse-btn" class="px-6 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors">取消</button>
|
| 449 |
+
<button type="button" id="confirm-parse-btn" class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">確認新增</button>
|
| 450 |
+
</div>
|
| 451 |
+
</div>
|
| 452 |
+
</div>
|
| 453 |
+
|
| 454 |
<!-- 分享連結 Modal -->
|
| 455 |
<div id="share-modal" class="hidden fixed inset-0 bg-gray-900 bg-opacity-75 flex items-center justify-center z-50 p-4">
|
| 456 |
<div class="bg-white p-8 rounded-2xl shadow-xl w-full max-w-lg">
|
|
|
|
| 551 |
<p>FB教育社群:<a href="https://www.facebook.com/groups/1554372228718393" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:underline">萬物皆數</a></p>
|
| 552 |
</footer>
|
| 553 |
|
| 554 |
+
<script type="module">
|
| 555 |
+
// This is a placeholder for the Firebase SDK script in the head
|
| 556 |
+
// The main logic is in the script tag below
|
| 557 |
+
</script>
|
| 558 |
<script>
|
| 559 |
document.addEventListener('DOMContentLoaded', () => {
|
| 560 |
// DOM 元素
|
|
|
|
| 589 |
const addWordsBtn = document.getElementById('add-words-btn');
|
| 590 |
const cancelAddWordsBtn = document.getElementById('cancel-add-words-btn');
|
| 591 |
const wordListContainer = document.getElementById('word-list-container');
|
| 592 |
+
const apiKeyInput = document.getElementById('api-key-input');
|
| 593 |
+
const saveApiKeyBtn = document.getElementById('save-api-key-btn');
|
| 594 |
+
const pdfUploadInput = document.getElementById('pdf-upload-input');
|
| 595 |
+
const parsePdfBtn = document.getElementById('parse-pdf-btn');
|
| 596 |
+
const parseStatus = document.getElementById('parse-status');
|
| 597 |
+
const aiProgressContainer = document.getElementById('ai-progress-container');
|
| 598 |
+
const aiProgressBarInner = document.getElementById('ai-progress-bar-inner');
|
| 599 |
|
| 600 |
// 學習介面
|
| 601 |
const flashcardContainer = document.getElementById('flashcard-container');
|
| 602 |
const frontDisplay = document.getElementById('front-display');
|
| 603 |
const backDisplay = document.getElementById('back-display');
|
| 604 |
+
const clozeQuestionContainer = document.getElementById('cloze-question-container');
|
| 605 |
+
const sentenceEnDisplay = document.getElementById('sentence-en-display');
|
| 606 |
+
const sentenceZhDisplay = document.getElementById('sentence-zh-display');
|
| 607 |
+
const toggleTranslationBtn = document.getElementById('toggle-translation-btn');
|
| 608 |
const speakBtn = document.getElementById('speak-btn');
|
| 609 |
const speakSlowBtn = document.getElementById('speak-slow-btn');
|
| 610 |
const modeTitle = document.getElementById('mode-title');
|
| 611 |
const backToMenuBtn = document.getElementById('back-to-menu-btn');
|
| 612 |
const progressBarContainer = document.getElementById('progress-bar-container');
|
| 613 |
const progressBar = document.getElementById('progress-bar');
|
| 614 |
+
const audioPreloadContainer = document.getElementById('audio-preload-container');
|
| 615 |
+
const audioPreloadBar = document.getElementById('audio-preload-bar');
|
| 616 |
+
const audioPreloadText = document.getElementById('audio-preload-text');
|
| 617 |
|
| 618 |
// 測驗相關
|
| 619 |
const quizContainer = document.getElementById('quiz-container');
|
|
|
|
| 654 |
const editWordIndexInput = document.getElementById('edit-word-index');
|
| 655 |
const editEnglishInput = document.getElementById('edit-english-input');
|
| 656 |
const editChineseInput = document.getElementById('edit-chinese-input');
|
| 657 |
+
const editSentenceEnInput = document.getElementById('edit-sentence-en-input');
|
| 658 |
+
const editSentenceZhInput = document.getElementById('edit-sentence-zh-input');
|
| 659 |
const saveEditBtn = document.getElementById('save-edit-btn');
|
| 660 |
const cancelEditBtn = document.getElementById('cancel-edit-btn');
|
| 661 |
const shareModal = document.getElementById('share-modal');
|
|
|
|
| 679 |
const wordToDeleteSpan = document.getElementById('word-to-delete');
|
| 680 |
const cancelDeleteBtn = document.getElementById('cancel-delete-btn');
|
| 681 |
const confirmDeleteBtn = document.getElementById('confirm-delete-btn');
|
| 682 |
+
const parseConfirmModal = document.getElementById('parse-confirm-modal');
|
| 683 |
+
const parseResultsContainer = document.getElementById('parse-results-container');
|
| 684 |
+
const cancelParseBtn = document.getElementById('cancel-parse-btn');
|
| 685 |
+
const confirmParseBtn = document.getElementById('confirm-parse-btn');
|
| 686 |
+
const selectAllCheckbox = document.getElementById('select-all-checkbox');
|
| 687 |
+
|
| 688 |
|
| 689 |
// 音效元素
|
| 690 |
const correctSound = document.getElementById('correct-sound');
|
|
|
|
| 712 |
let currentSpeedCard = null;
|
| 713 |
let currentSpeedQuestionType = '';
|
| 714 |
let quizStartTime = 0;
|
| 715 |
+
let parsedWordsFromAI = [];
|
| 716 |
+
|
| 717 |
|
| 718 |
const modeDetails = {
|
| 719 |
'review': { title: '複習模式' }, 'zh-en': { title: '中翻英測驗' },
|
| 720 |
'en-zh': { title: '英翻中測驗' }, 'listen': { title: '聽力測驗' },
|
| 721 |
'speed': { title: '極速挑戰' }, 'hard': { title: '困難單字複習' },
|
| 722 |
+
'sentence-cloze': { title: '情境克漏字' },
|
| 723 |
};
|
| 724 |
|
| 725 |
// --- 視圖管理 ---
|
|
|
|
| 739 |
|
| 740 |
// --- 資料處理 ---
|
| 741 |
const saveWordsToStorage = () => {
|
| 742 |
+
const wordsToSave = words.map(({audioUrl, ...rest}) => rest);
|
| 743 |
+
localStorage.setItem('flashcards', JSON.stringify(wordsToSave));
|
| 744 |
};
|
| 745 |
const shuffleArray = (array) => {
|
| 746 |
for (let i = array.length - 1; i > 0; i--) {
|
|
|
|
| 756 |
words.forEach((word, index) => {
|
| 757 |
const item = document.createElement('div');
|
| 758 |
item.className = 'list-item p-3 bg-gray-100 rounded-lg flex items-center justify-between';
|
| 759 |
+
const hasSentenceIcon = word.sentence && word.sentence.en ?
|
| 760 |
+
`<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-cyan-500 ml-2" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd" /></svg>` : '';
|
| 761 |
+
|
| 762 |
item.innerHTML = `
|
| 763 |
<div class="flex items-center">
|
| 764 |
<span class="w-8 text-sm text-gray-500">${index + 1}.</span>
|
| 765 |
<p class="font-semibold text-gray-800">${word.english}</p>
|
| 766 |
+
${hasSentenceIcon}
|
| 767 |
<p class="text-gray-600 ml-4">${word.chinese}</p>
|
| 768 |
</div>
|
| 769 |
<div class="flex gap-2">
|
|
|
|
| 780 |
if (!button) return;
|
| 781 |
const index = parseInt(button.dataset.index, 10);
|
| 782 |
if (button.classList.contains('edit-btn')) {
|
| 783 |
+
const word = words[index];
|
| 784 |
editWordIndexInput.value = index;
|
| 785 |
+
editEnglishInput.value = word.english;
|
| 786 |
+
editChineseInput.value = word.chinese;
|
| 787 |
+
editSentenceEnInput.value = word.sentence?.en || '';
|
| 788 |
+
editSentenceZhInput.value = word.sentence?.zh || '';
|
| 789 |
editWordModal.classList.remove('hidden');
|
| 790 |
} else if (button.classList.contains('delete-btn')) {
|
| 791 |
indexToDelete = index;
|
|
|
|
| 798 |
const index = parseInt(editWordIndexInput.value, 10);
|
| 799 |
const newEnglish = editEnglishInput.value.trim();
|
| 800 |
const newChinese = editChineseInput.value.trim();
|
| 801 |
+
const newSentenceEn = editSentenceEnInput.value.trim();
|
| 802 |
+
const newSentenceZh = editSentenceZhInput.value.trim();
|
| 803 |
+
|
| 804 |
if (index >= 0 && newEnglish && newChinese) {
|
| 805 |
words[index].english = newEnglish;
|
| 806 |
words[index].chinese = newChinese;
|
| 807 |
+
if (newSentenceEn && newSentenceZh) {
|
| 808 |
+
words[index].sentence = { en: newSentenceEn, zh: newSentenceZh };
|
| 809 |
+
} else {
|
| 810 |
+
delete words[index].sentence;
|
| 811 |
+
}
|
| 812 |
saveWordsToStorage();
|
| 813 |
renderWordList();
|
| 814 |
editWordModal.classList.add('hidden');
|
|
|
|
| 897 |
speedResultView.classList.add('hidden');
|
| 898 |
hintDisplay.textContent = '';
|
| 899 |
hintBtn.disabled = false;
|
| 900 |
+
frontDisplay.classList.add('hidden');
|
| 901 |
+
clozeQuestionContainer.classList.add('hidden');
|
| 902 |
|
| 903 |
let frontText, backText;
|
| 904 |
|
| 905 |
if (isSpeed) {
|
| 906 |
+
// 極速挑戰暫不支援克漏字
|
| 907 |
switch (currentSpeedQuestionType) {
|
| 908 |
case 'zh-en':
|
| 909 |
frontText = card.chinese;
|
|
|
|
| 920 |
frontText = '請聽發音';
|
| 921 |
backText = card.english;
|
| 922 |
[speakBtn, speakSlowBtn].forEach(btn => btn.classList.remove('hidden'));
|
| 923 |
+
speakWord(card);
|
| 924 |
answerInput.placeholder = "請輸入英文答案...";
|
| 925 |
break;
|
| 926 |
}
|
|
|
|
| 947 |
case 'listen':
|
| 948 |
frontText = '請聽發音'; backText = card.english;
|
| 949 |
[speakBtn, speakSlowBtn].forEach(btn => btn.classList.remove('hidden'));
|
| 950 |
+
speakWord(card);
|
| 951 |
answerInput.placeholder = "請輸入英文答案...";
|
| 952 |
break;
|
| 953 |
+
case 'sentence-cloze':
|
| 954 |
+
clozeQuestionContainer.classList.remove('hidden');
|
| 955 |
+
sentenceEnDisplay.textContent = card.sentence.en;
|
| 956 |
+
sentenceZhDisplay.textContent = `(${card.sentence.zh})`;
|
| 957 |
+
sentenceZhDisplay.classList.add('hidden');
|
| 958 |
+
toggleTranslationBtn.textContent = '顯示翻譯';
|
| 959 |
+
backText = card.english;
|
| 960 |
+
answerInput.placeholder = "請填入空格中的單字...";
|
| 961 |
+
break;
|
| 962 |
}
|
| 963 |
}
|
| 964 |
+
if (frontText) {
|
| 965 |
+
frontDisplay.classList.remove('hidden');
|
| 966 |
+
frontDisplay.textContent = frontText;
|
| 967 |
+
}
|
| 968 |
backDisplay.textContent = backText;
|
| 969 |
setTimeout(() => answerInput.focus(), 100);
|
| 970 |
};
|
|
|
|
| 1001 |
wordPool = [...words];
|
| 1002 |
}
|
| 1003 |
|
| 1004 |
+
if (mode === 'sentence-cloze') {
|
| 1005 |
+
wordsForCurrentMode = wordPool.filter(w => w.sentence && w.sentence.en && w.sentence.zh);
|
| 1006 |
+
if (wordsForCurrentMode.length === 0) {
|
| 1007 |
+
alert("題庫中沒有附帶例句的單字可供此模式使用。");
|
| 1008 |
+
return;
|
| 1009 |
+
}
|
| 1010 |
+
} else if (mode === 'review') {
|
| 1011 |
wordsForCurrentMode = wordPool;
|
| 1012 |
} else if (randomQuestionsCheckbox.checked) {
|
| 1013 |
const randomCount = parseInt(randomQuestionsCountInput.value, 10);
|
|
|
|
| 1069 |
|
| 1070 |
if (isReview) {
|
| 1071 |
const isFlipped = flashcardContainer.classList.contains('flipped');
|
| 1072 |
+
let rawAnswer = isFlipped ? card.english : card.chinese;
|
| 1073 |
+
correctAnswer = rawAnswer.replace(/[\((\[【].*?[\))\]】]/g, "").trim();
|
| 1074 |
answerLang = isFlipped ? 'en' : 'zh';
|
| 1075 |
} else {
|
| 1076 |
let effectiveMode = isSpeed ? currentSpeedQuestionType : (currentMode === 'hard' ? 'zh-en' : currentMode);
|
| 1077 |
+
let rawAnswer;
|
| 1078 |
switch (effectiveMode) {
|
| 1079 |
+
case 'zh-en': case 'listen': case 'sentence-cloze':
|
| 1080 |
+
rawAnswer = card.english;
|
| 1081 |
answerLang = 'en';
|
| 1082 |
break;
|
| 1083 |
case 'en-zh':
|
| 1084 |
+
rawAnswer = card.chinese;
|
| 1085 |
answerLang = 'zh';
|
| 1086 |
break;
|
| 1087 |
}
|
| 1088 |
+
correctAnswer = rawAnswer.replace(/[\((\[【].*?[\))\]】]/g, "").trim();
|
| 1089 |
}
|
| 1090 |
|
| 1091 |
let isCorrect;
|
|
|
|
| 1098 |
};
|
| 1099 |
isCorrect = normalize(userAnswer) === normalize(correctAnswer);
|
| 1100 |
} else if (answerLang === 'zh') {
|
| 1101 |
+
const possibleAnswers = correctAnswer.split(/[;;]/).map(a => a.trim()).filter(a => a);
|
| 1102 |
+
isCorrect = possibleAnswers.some(ans => userAnswer.trim().includes(ans));
|
| 1103 |
}
|
| 1104 |
|
| 1105 |
if (isCorrect) {
|
|
|
|
| 1145 |
|
| 1146 |
quizStartTime = 0; // Reset timer
|
| 1147 |
|
| 1148 |
+
if (!['hard', 'sentence-cloze'].includes(currentMode)) {
|
| 1149 |
completionStatus[currentMode] = true;
|
| 1150 |
localStorage.setItem('completionStatus', JSON.stringify(completionStatus));
|
| 1151 |
updateCompletionUI();
|
|
|
|
| 1267 |
confetti({ particleCount: 150, spread: 70, origin: { y: 0.6 } });
|
| 1268 |
};
|
| 1269 |
|
| 1270 |
+
const speakWithBrowserTTS = (wordText, rate = 1) => {
|
| 1271 |
if ('speechSynthesis' in window) {
|
| 1272 |
window.speechSynthesis.cancel();
|
| 1273 |
+
const wordToSpeak = wordText.replace(/[\((\[【].*?[\))\]】]/g, "").trim();
|
| 1274 |
const utterance = new SpeechSynthesisUtterance(wordToSpeak);
|
| 1275 |
utterance.lang = 'en-US';
|
| 1276 |
utterance.rate = rate;
|
|
|
|
| 1282 |
speakBtn.disabled = true;
|
| 1283 |
speakSlowBtn.disabled = true;
|
| 1284 |
|
| 1285 |
+
if (word.audioUrl) {
|
| 1286 |
+
apiAudioPlayer.src = word.audioUrl;
|
| 1287 |
+
apiAudioPlayer.play().finally(() => {
|
| 1288 |
+
speakBtn.disabled = false;
|
| 1289 |
+
speakSlowBtn.disabled = false;
|
| 1290 |
+
});
|
| 1291 |
+
return;
|
| 1292 |
+
}
|
| 1293 |
+
|
| 1294 |
+
const wordToSpeak = word.english.replace(/[\((\[【].*?[\))\]】]/g, "").trim();
|
| 1295 |
if (!wordToSpeak) {
|
| 1296 |
speakBtn.disabled = false;
|
| 1297 |
speakSlowBtn.disabled = false;
|
|
|
|
| 1300 |
|
| 1301 |
const cleanedWord = wordToSpeak.replace(/[^a-zA-Z\s-]/g, '');
|
| 1302 |
|
|
|
|
| 1303 |
if (cleanedWord.includes(' ')) {
|
| 1304 |
speakWithBrowserTTS(cleanedWord, 0.75);
|
| 1305 |
speakBtn.disabled = false;
|
|
|
|
| 1329 |
}
|
| 1330 |
|
| 1331 |
if (audioUrl) {
|
| 1332 |
+
word.audioUrl = audioUrl; // Cache the URL
|
| 1333 |
apiAudioPlayer.src = audioUrl;
|
| 1334 |
apiAudioPlayer.play().catch(error => {
|
| 1335 |
console.error("Audio playback error:", error);
|
| 1336 |
speakWithBrowserTTS(cleanedWord, 0.75);
|
|
|
|
|
|
|
|
|
|
| 1337 |
});
|
| 1338 |
} else {
|
| 1339 |
speakWithBrowserTTS(cleanedWord, 0.75);
|
|
|
|
|
|
|
| 1340 |
}
|
| 1341 |
} catch (error) {
|
| 1342 |
console.error("Dictionary API error:", error);
|
| 1343 |
speakWithBrowserTTS(cleanedWord, 0.75);
|
| 1344 |
+
} finally {
|
| 1345 |
speakBtn.disabled = false;
|
| 1346 |
speakSlowBtn.disabled = false;
|
| 1347 |
}
|
| 1348 |
};
|
| 1349 |
|
| 1350 |
const getCurrentWordToSpeak = () => {
|
| 1351 |
+
if (currentMode === 'speed' && currentSpeedCard) return currentSpeedCard;
|
| 1352 |
+
if (currentMode === 'review' && wordsForCurrentMode[currentCardIndex]) return wordsForCurrentMode[currentCardIndex];
|
| 1353 |
+
if (quizQueue.length > 0) return quizQueue[0];
|
| 1354 |
+
return null;
|
| 1355 |
};
|
| 1356 |
|
| 1357 |
const updateCompletionUI = () => {
|
|
|
|
| 1412 |
qrcodeContainer.textContent = 'QR碼產生失敗,網址可能過長。';
|
| 1413 |
}
|
| 1414 |
};
|
| 1415 |
+
|
| 1416 |
+
|
| 1417 |
+
// --- AI PDF 解析功能 ---
|
| 1418 |
+
const updateAIProgressBar = (percentage) => {
|
| 1419 |
+
aiProgressBarInner.style.width = `${percentage}%`;
|
| 1420 |
+
};
|
| 1421 |
+
|
| 1422 |
+
pdfUploadInput.addEventListener('change', () => {
|
| 1423 |
+
parseStatus.textContent = '';
|
| 1424 |
+
aiProgressContainer.classList.add('hidden');
|
| 1425 |
+
});
|
| 1426 |
+
|
| 1427 |
+
parsePdfBtn.addEventListener('click', async () => {
|
| 1428 |
+
const file = pdfUploadInput.files[0];
|
| 1429 |
+
if (!file) {
|
| 1430 |
+
parseStatus.textContent = '請先選擇一個 PDF 檔案。';
|
| 1431 |
+
return;
|
| 1432 |
+
}
|
| 1433 |
+
|
| 1434 |
+
parseStatus.textContent = '準備開始解析...';
|
| 1435 |
+
parsePdfBtn.disabled = true;
|
| 1436 |
+
aiProgressContainer.classList.remove('hidden');
|
| 1437 |
+
updateAIProgressBar(0);
|
| 1438 |
+
|
| 1439 |
+
try {
|
| 1440 |
+
const fileReader = new FileReader();
|
| 1441 |
+
fileReader.onload = async (event) => {
|
| 1442 |
+
const typedarray = new Uint8Array(event.target.result);
|
| 1443 |
+
|
| 1444 |
+
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.11.338/pdf.worker.min.js`;
|
| 1445 |
+
|
| 1446 |
+
const pdf = await pdfjsLib.getDocument(typedarray).promise;
|
| 1447 |
+
let fullText = '';
|
| 1448 |
+
|
| 1449 |
+
for (let i = 1; i <= pdf.numPages; i++) {
|
| 1450 |
+
parseStatus.textContent = `讀取 PDF 頁面 (${i}/${pdf.numPages})...`;
|
| 1451 |
+
updateAIProgressBar((i / pdf.numPages) * 70); // PDF 讀取佔 70% 進度
|
| 1452 |
+
const page = await pdf.getPage(i);
|
| 1453 |
+
const textContent = await page.getTextContent();
|
| 1454 |
+
const pageText = textContent.items.map(item => item.str).join(' ');
|
| 1455 |
+
fullText += pageText + '\n\n';
|
| 1456 |
+
}
|
| 1457 |
+
|
| 1458 |
+
parseStatus.textContent = 'AI 分析中,請稍候...';
|
| 1459 |
+
updateAIProgressBar(85); // 準備呼叫 AI
|
| 1460 |
+
await callGeminiToParseText(fullText);
|
| 1461 |
+
};
|
| 1462 |
+
fileReader.readAsArrayBuffer(file);
|
| 1463 |
+
} catch (error) {
|
| 1464 |
+
console.error('PDF 解析失敗:', error);
|
| 1465 |
+
parseStatus.textContent = 'PDF 解析失敗,請檢查檔案或控制台錯誤訊息。';
|
| 1466 |
+
parsePdfBtn.disabled = false;
|
| 1467 |
+
aiProgressContainer.classList.add('hidden');
|
| 1468 |
+
}
|
| 1469 |
+
});
|
| 1470 |
+
|
| 1471 |
+
async function callGeminiToParseText(text) {
|
| 1472 |
+
const apiKey = apiKeyInput.value.trim();
|
| 1473 |
+
if (!apiKey) {
|
| 1474 |
+
parseStatus.textContent = '錯誤:請先輸入您的 Gemini API 金鑰並儲存。';
|
| 1475 |
+
parsePdfBtn.disabled = false;
|
| 1476 |
+
aiProgressContainer.classList.add('hidden');
|
| 1477 |
+
return;
|
| 1478 |
+
}
|
| 1479 |
+
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent?key=${apiKey}`;
|
| 1480 |
+
|
| 1481 |
+
const prompt = `
|
| 1482 |
+
請分析以下從英文單字學習講義擷取的文字。你的任務是辨識出每一個單字條目,並為每個條目提取以下資訊:
|
| 1483 |
+
1. "english": 完整的英文單字,包含詞性標註,例如 "yesterday (adv.)" 或 "study (v.)"。
|
| 1484 |
+
2. "chinese": 對應的中文翻譯。
|
| 1485 |
+
3. "sentence": 一個包含例句的物件。這個物件應該有兩個屬性:
|
| 1486 |
+
- "en": 英文例句。請將該條目的主要英文單字(不含詞性)替換成 "___"。
|
| 1487 |
+
- "zh": 完整的中文例句翻譯。
|
| 1488 |
+
|
| 1489 |
+
規則:
|
| 1490 |
+
- 如果一個單字條目有多個例句,請只選擇第一個或最能代表該單字用法的例句。
|
| 1491 |
+
- 如果同一個單字因為詞性不同而有多個條目(例如 "study (v.)" 和 "study (n.)"),請將它們視為獨立的條目,並各自尋找一個代表性的例句。
|
| 1492 |
+
- 如果一個單字條目沒有提供例句,則省略 "sentence" 物件。
|
| 1493 |
+
|
| 1494 |
+
請將所有解析出的單字條目,以一個 JSON 陣列的格式回傳。每一個陣列中的物件都應該符合上述的結構。
|
| 1495 |
+
|
| 1496 |
+
這是你要分析的文字內容:
|
| 1497 |
+
---
|
| 1498 |
+
${text}
|
| 1499 |
+
---
|
| 1500 |
+
`;
|
| 1501 |
+
|
| 1502 |
+
const payload = {
|
| 1503 |
+
contents: [{ parts: [{ text: prompt }] }],
|
| 1504 |
+
generationConfig: {
|
| 1505 |
+
responseMimeType: "application/json",
|
| 1506 |
+
responseSchema: {
|
| 1507 |
+
type: "ARRAY",
|
| 1508 |
+
items: {
|
| 1509 |
+
type: "OBJECT",
|
| 1510 |
+
properties: {
|
| 1511 |
+
english: { type: "STRING" },
|
| 1512 |
+
chinese: { type: "STRING" },
|
| 1513 |
+
sentence: {
|
| 1514 |
+
type: "OBJECT",
|
| 1515 |
+
properties: {
|
| 1516 |
+
en: { type: "STRING" },
|
| 1517 |
+
zh: { type: "STRING" }
|
| 1518 |
+
}
|
| 1519 |
+
}
|
| 1520 |
+
}
|
| 1521 |
+
}
|
| 1522 |
+
}
|
| 1523 |
+
}
|
| 1524 |
+
};
|
| 1525 |
+
|
| 1526 |
+
try {
|
| 1527 |
+
const response = await fetch(apiUrl, {
|
| 1528 |
+
method: 'POST',
|
| 1529 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1530 |
+
body: JSON.stringify(payload)
|
| 1531 |
+
});
|
| 1532 |
+
|
| 1533 |
+
if (!response.ok) {
|
| 1534 |
+
const errorBody = await response.json();
|
| 1535 |
+
console.error('API Error:', errorBody);
|
| 1536 |
+
throw new Error(`API 請求失敗,狀態碼:${response.status}. 錯誤訊息: ${errorBody.error.message}`);
|
| 1537 |
+
}
|
| 1538 |
+
const result = await response.json();
|
| 1539 |
+
|
| 1540 |
+
if (result.candidates && result.candidates.length > 0 && result.candidates[0].content?.parts?.[0]?.text) {
|
| 1541 |
+
const jsonText = result.candidates[0].content.parts[0].text;
|
| 1542 |
+
parsedWordsFromAI = JSON.parse(jsonText);
|
| 1543 |
+
|
| 1544 |
+
updateAIProgressBar(100);
|
| 1545 |
+
parseStatus.textContent = 'AI 分析完成!請確認結果。';
|
| 1546 |
+
showParseConfirmation(parsedWordsFromAI);
|
| 1547 |
+
setTimeout(() => {
|
| 1548 |
+
aiProgressContainer.classList.add('hidden');
|
| 1549 |
+
parseStatus.textContent = '';
|
| 1550 |
+
}, 2000);
|
| 1551 |
+
} else {
|
| 1552 |
+
throw new Error("從 AI 收到無效的回應格式。");
|
| 1553 |
+
}
|
| 1554 |
+
} catch (error) {
|
| 1555 |
+
console.error('AI 解析失敗:', error);
|
| 1556 |
+
parseStatus.textContent = `AI 解析失敗: ${error.message}`;
|
| 1557 |
+
aiProgressContainer.classList.add('hidden');
|
| 1558 |
+
} finally {
|
| 1559 |
+
parsePdfBtn.disabled = false;
|
| 1560 |
+
}
|
| 1561 |
+
}
|
| 1562 |
+
|
| 1563 |
+
function showParseConfirmation(parsedWords) {
|
| 1564 |
+
parseResultsContainer.innerHTML = '';
|
| 1565 |
+
selectAllCheckbox.checked = true;
|
| 1566 |
+
|
| 1567 |
+
parsedWords.forEach((word, index) => {
|
| 1568 |
+
const item = document.createElement('div');
|
| 1569 |
+
item.className = 'p-3 bg-gray-50 rounded-md border flex items-start';
|
| 1570 |
+
item.innerHTML = `
|
| 1571 |
+
<input type="checkbox" checked id="word-checkbox-${index}" data-index="${index}" class="word-checkbox h-5 w-5 rounded text-indigo-600 focus:ring-indigo-500 border-gray-300 mr-4 mt-1">
|
| 1572 |
+
<div class="flex-1">
|
| 1573 |
+
<p><strong>英文:</strong> ${word.english}</p>
|
| 1574 |
+
<p><strong>中文:</strong> ${word.chinese}</p>
|
| 1575 |
+
${word.sentence ? `
|
| 1576 |
+
<div class="mt-2 pt-2 border-t border-gray-200">
|
| 1577 |
+
<p class="text-sm text-gray-600"><strong>例句 (英):</strong> ${word.sentence.en}</p>
|
| 1578 |
+
<p class="text-sm text-gray-600"><strong>例句 (中):</strong> ${word.sentence.zh}</p>
|
| 1579 |
+
</div>
|
| 1580 |
+
` : ''}
|
| 1581 |
+
</div>
|
| 1582 |
+
`;
|
| 1583 |
+
parseResultsContainer.appendChild(item);
|
| 1584 |
+
});
|
| 1585 |
+
parseConfirmModal.classList.remove('hidden');
|
| 1586 |
+
}
|
| 1587 |
+
|
| 1588 |
+
selectAllCheckbox.addEventListener('change', (e) => {
|
| 1589 |
+
const isChecked = e.target.checked;
|
| 1590 |
+
parseResultsContainer.querySelectorAll('.word-checkbox').forEach(checkbox => {
|
| 1591 |
+
checkbox.checked = isChecked;
|
| 1592 |
+
});
|
| 1593 |
+
});
|
| 1594 |
+
|
| 1595 |
+
confirmParseBtn.addEventListener('click', () => {
|
| 1596 |
+
const selectedWords = [];
|
| 1597 |
+
const checkboxes = parseResultsContainer.querySelectorAll('.word-checkbox:checked');
|
| 1598 |
+
|
| 1599 |
+
checkboxes.forEach(checkbox => {
|
| 1600 |
+
const index = parseInt(checkbox.dataset.index, 10);
|
| 1601 |
+
selectedWords.push(parsedWordsFromAI[index]);
|
| 1602 |
+
});
|
| 1603 |
+
|
| 1604 |
+
const newWords = selectedWords.map(word => ({
|
| 1605 |
+
...word,
|
| 1606 |
+
proficiency: 0,
|
| 1607 |
+
incorrectCount: 0
|
| 1608 |
+
}));
|
| 1609 |
+
words.push(...newWords);
|
| 1610 |
+
saveWordsToStorage();
|
| 1611 |
+
renderWordList();
|
| 1612 |
+
preloadAudioFiles(); // 解析完後也預載新的音檔
|
| 1613 |
+
parseConfirmModal.classList.add('hidden');
|
| 1614 |
+
parseStatus.textContent = `成功新增 ${selectedWords.length} 個單字!`;
|
| 1615 |
+
pdfUploadInput.value = '';
|
| 1616 |
+
parsedWordsFromAI = [];
|
| 1617 |
+
});
|
| 1618 |
+
|
| 1619 |
+
cancelParseBtn.addEventListener('click', () => {
|
| 1620 |
+
parseConfirmModal.classList.add('hidden');
|
| 1621 |
+
parseStatus.textContent = '操作已取消。';
|
| 1622 |
+
pdfUploadInput.value = '';
|
| 1623 |
+
parsedWordsFromAI = [];
|
| 1624 |
+
});
|
| 1625 |
+
|
| 1626 |
|
| 1627 |
// --- 事件監聽 ---
|
| 1628 |
document.querySelectorAll('.mode-btn[data-mode]').forEach(btn => {
|
|
|
|
| 1634 |
localStorage.removeItem('completionStatus');
|
| 1635 |
updateCompletionUI();
|
| 1636 |
|
| 1637 |
+
highScore = 0;
|
| 1638 |
+
localStorage.removeItem('flashcardsHighScore');
|
| 1639 |
+
highscoreDisplay.textContent = highScore;
|
| 1640 |
+
|
| 1641 |
const originalText = resetCrownsBtn.textContent;
|
| 1642 |
resetCrownsBtn.textContent = '已重置!';
|
| 1643 |
resetCrownsBtn.disabled = true;
|
|
|
|
| 1652 |
const isQuiz = !['review', 'speed', ''].includes(currentMode);
|
| 1653 |
if (isQuiz && quizStartTime > 0) {
|
| 1654 |
const attempted = wordsForCurrentMode.length - quizQueue.length;
|
|
|
|
| 1655 |
if (attempted > 0) {
|
| 1656 |
const elapsedTime = Math.round((Date.now() - quizStartTime) / 1000);
|
| 1657 |
const reportData = {
|
|
|
|
| 1684 |
}
|
| 1685 |
});
|
| 1686 |
|
| 1687 |
+
toggleTranslationBtn.addEventListener('click', () => {
|
| 1688 |
+
const isHidden = sentenceZhDisplay.classList.toggle('hidden');
|
| 1689 |
+
toggleTranslationBtn.textContent = isHidden ? '顯示翻譯' : '隱藏翻譯';
|
| 1690 |
+
});
|
| 1691 |
+
|
| 1692 |
prevBtn.addEventListener('click', () => { if (currentCardIndex > 0) { currentCardIndex--; displayCard(); }});
|
| 1693 |
nextBtn.addEventListener('click', () => { if (currentCardIndex < wordsForCurrentMode.length - 1) { currentCardIndex++; displayCard(); }});
|
| 1694 |
quizForm.addEventListener('submit', (e) => { e.preventDefault(); checkAnswer(); });
|
|
|
|
| 1701 |
speakSlowBtn.addEventListener('click', (e) => {
|
| 1702 |
e.stopPropagation();
|
| 1703 |
const word = getCurrentWordToSpeak();
|
| 1704 |
+
if(word) speakWithBrowserTTS(word.english, 0.2);
|
| 1705 |
});
|
| 1706 |
|
| 1707 |
hintBtn.addEventListener('click', () => {
|
|
|
|
| 1710 |
const questionType = currentMode === 'speed' ? currentSpeedQuestionType : (currentMode === 'hard' ? 'zh-en' : currentMode);
|
| 1711 |
let correctAnswer;
|
| 1712 |
switch (questionType) {
|
| 1713 |
+
case 'zh-en': case 'listen': case 'sentence-cloze': correctAnswer = card.english.replace(/[\((\[【].*?[\))\]】]/g, "").trim(); break;
|
| 1714 |
+
case 'en-zh': correctAnswer = card.chinese.replace(/[\((\[【].*?[\))\]】]/g, "").trim(); break;
|
| 1715 |
default: return;
|
| 1716 |
}
|
| 1717 |
if (correctAnswer) {
|
|
|
|
| 1742 |
localStorage.setItem('flashcardsBookTitle', bookTitle);
|
| 1743 |
localStorage.setItem('flashcardsLessonTitle', lessonTitle);
|
| 1744 |
updateMainTitle();
|
|
|
|
| 1745 |
const originalText = saveTitleBtn.textContent;
|
| 1746 |
saveTitleBtn.textContent = '已儲存!';
|
| 1747 |
saveTitleBtn.classList.remove('bg-indigo-600', 'hover:bg-indigo-700');
|
|
|
|
| 1773 |
closeReportModalBtn.addEventListener('click', () => gradeReportModal.classList.add('hidden'));
|
| 1774 |
|
| 1775 |
generateLinkBtn.addEventListener('click', async () => {
|
| 1776 |
+
generateLinkBtn.textContent = '上傳中...';
|
| 1777 |
generateLinkBtn.disabled = true;
|
| 1778 |
+
|
| 1779 |
+
const db = window.firebaseDB;
|
| 1780 |
+
const { collection, addDoc } = window.firebaseFirestore;
|
| 1781 |
+
|
| 1782 |
+
if (!db) {
|
| 1783 |
+
alert("Firebase 初始化失敗,無法分享。");
|
| 1784 |
+
generateLinkBtn.textContent = '產生分享連結';
|
| 1785 |
+
generateLinkBtn.disabled = false;
|
| 1786 |
+
return;
|
| 1787 |
+
}
|
| 1788 |
|
| 1789 |
+
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1790 |
const start = startRangeInput.value || '';
|
| 1791 |
const end = endRangeInput.value || '';
|
| 1792 |
const isLocked = lockSettingsCheckbox.checked;
|
| 1793 |
+
const randomCount = randomQuestionsCheckbox.checked ? randomQuestionsCountInput.value : '';
|
| 1794 |
+
|
| 1795 |
+
const deckData = {
|
| 1796 |
+
words: words.map(({audioUrl, ...rest}) => rest), // Don't save audioUrl to Firestore
|
| 1797 |
+
settings: { start, end, isLocked, randomCount },
|
| 1798 |
+
createdAt: new Date()
|
| 1799 |
+
};
|
| 1800 |
+
|
| 1801 |
+
const docRef = await addDoc(collection(db, "shared_decks"), deckData);
|
| 1802 |
|
| 1803 |
const baseUrl = window.location.href.split('?')[0];
|
| 1804 |
const url = new URL(baseUrl);
|
| 1805 |
+
url.searchParams.set('deck', docRef.id);
|
| 1806 |
+
const finalUrl = url.toString();
|
| 1807 |
|
| 1808 |
+
shareLinkInput.value = finalUrl;
|
| 1809 |
+
generateQRCode(finalUrl);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1810 |
shareResultContainer.classList.remove('hidden');
|
| 1811 |
+
|
| 1812 |
} catch (error) {
|
| 1813 |
+
console.error("分享至 Firebase 失敗:", error);
|
| 1814 |
+
alert("分享失敗,請檢查您的網路連線或 Firebase 設定。");
|
| 1815 |
} finally {
|
| 1816 |
generateLinkBtn.textContent = '重新產生';
|
| 1817 |
generateLinkBtn.disabled = false;
|
|
|
|
| 1894 |
startRangeInput.addEventListener('input', updateRandomCountMax);
|
| 1895 |
endRangeInput.addEventListener('input', updateRandomCountMax);
|
| 1896 |
|
| 1897 |
+
saveApiKeyBtn.addEventListener('click', () => {
|
| 1898 |
+
const apiKey = apiKeyInput.value.trim();
|
| 1899 |
+
if (apiKey) {
|
| 1900 |
+
localStorage.setItem('geminiApiKey', apiKey);
|
| 1901 |
+
const originalText = saveApiKeyBtn.textContent;
|
| 1902 |
+
saveApiKeyBtn.textContent = '已儲存!';
|
| 1903 |
+
setTimeout(() => {
|
| 1904 |
+
saveApiKeyBtn.textContent = originalText;
|
| 1905 |
+
}, 2000);
|
| 1906 |
+
} else {
|
| 1907 |
+
localStorage.removeItem('geminiApiKey');
|
| 1908 |
+
alert('API 金鑰已清除。');
|
| 1909 |
+
}
|
| 1910 |
+
});
|
| 1911 |
+
|
| 1912 |
+
async function preloadAudioFiles() {
|
| 1913 |
+
const wordsToPreload = words.filter(word => {
|
| 1914 |
+
const wordToSpeak = word.english.replace(/[\((\[【].*?[\))\]】]/g, "").trim();
|
| 1915 |
+
const cleanedWord = wordToSpeak.replace(/[^a-zA-Z\s-]/g, '');
|
| 1916 |
+
// 只預載沒有快取、且不是片語的單字
|
| 1917 |
+
return !word.audioUrl && !cleanedWord.includes(' ');
|
| 1918 |
+
});
|
| 1919 |
+
|
| 1920 |
+
if (wordsToPreload.length === 0) return;
|
| 1921 |
+
|
| 1922 |
+
audioPreloadContainer.classList.remove('hidden');
|
| 1923 |
+
let loadedCount = 0;
|
| 1924 |
+
|
| 1925 |
+
for (const word of wordsToPreload) {
|
| 1926 |
+
try {
|
| 1927 |
+
const wordToSpeak = word.english.replace(/[\((\[【].*?[\))\]】]/g, "").trim();
|
| 1928 |
+
const cleanedWord = wordToSpeak.replace(/[^a-zA-Z\s-]/g, '');
|
| 1929 |
+
|
| 1930 |
+
const response = await fetch(`https://api.dictionaryapi.dev/api/v2/entries/en/${cleanedWord}`);
|
| 1931 |
+
if (!response.ok) continue;
|
| 1932 |
+
|
| 1933 |
+
const data = await response.json();
|
| 1934 |
+
let audioUrl = '';
|
| 1935 |
+
if (data && data.length > 0) {
|
| 1936 |
+
for (const entry of data) {
|
| 1937 |
+
for (const phonetic of entry.phonetics || []) {
|
| 1938 |
+
if (phonetic.audio) {
|
| 1939 |
+
if (phonetic.audio.includes('-us.mp3')) {
|
| 1940 |
+
audioUrl = phonetic.audio;
|
| 1941 |
+
break;
|
| 1942 |
+
}
|
| 1943 |
+
if (!audioUrl) audioUrl = phonetic.audio;
|
| 1944 |
+
}
|
| 1945 |
+
}
|
| 1946 |
+
if (audioUrl) break;
|
| 1947 |
+
}
|
| 1948 |
+
}
|
| 1949 |
+
if (audioUrl) {
|
| 1950 |
+
word.audioUrl = audioUrl; // Cache the URL
|
| 1951 |
+
}
|
| 1952 |
+
} catch (error) {
|
| 1953 |
+
console.warn(`Could not preload audio for "${word.english}":`, error);
|
| 1954 |
+
}
|
| 1955 |
+
loadedCount++;
|
| 1956 |
+
const percentage = Math.round((loadedCount / wordsToPreload.length) * 100);
|
| 1957 |
+
audioPreloadBar.style.width = `${percentage}%`;
|
| 1958 |
+
}
|
| 1959 |
+
|
| 1960 |
+
audioPreloadText.textContent = '語音檔案預載完成!';
|
| 1961 |
+
setTimeout(() => {
|
| 1962 |
+
audioPreloadContainer.classList.add('hidden');
|
| 1963 |
+
}, 2000);
|
| 1964 |
+
}
|
| 1965 |
+
|
| 1966 |
|
| 1967 |
// --- 應用程式初始化 ---
|
| 1968 |
+
const initializeApp = async () => {
|
| 1969 |
+
const savedApiKey = localStorage.getItem('geminiApiKey');
|
| 1970 |
+
if (savedApiKey) {
|
| 1971 |
+
apiKeyInput.value = savedApiKey;
|
| 1972 |
+
}
|
| 1973 |
+
|
| 1974 |
const urlParams = new URLSearchParams(window.location.search);
|
| 1975 |
+
const deckId = urlParams.get('deck');
|
| 1976 |
let loadedFromUrl = false;
|
| 1977 |
|
| 1978 |
+
if (deckId) {
|
| 1979 |
+
const db = window.firebaseDB;
|
| 1980 |
+
const { getDoc, doc } = window.firebaseFirestore;
|
| 1981 |
+
if (db) {
|
| 1982 |
+
try {
|
| 1983 |
+
const docRef = doc(db, "shared_decks", deckId);
|
| 1984 |
+
const docSnap = await getDoc(docRef);
|
| 1985 |
+
|
| 1986 |
+
if (docSnap.exists()) {
|
| 1987 |
+
const data = docSnap.data();
|
| 1988 |
+
if (data.words && Array.isArray(data.words)) {
|
| 1989 |
+
words = data.words.map(word => ({ ...word, proficiency: 0, incorrectCount: 0 }));
|
| 1990 |
+
saveWordsToStorage();
|
| 1991 |
+
|
| 1992 |
+
const { start, end, isLocked, randomCount } = data.settings || {};
|
| 1993 |
+
if (start) startRangeInput.value = start;
|
| 1994 |
+
if (end) endRangeInput.value = end;
|
| 1995 |
+
if (randomCount) {
|
| 1996 |
+
randomQuestionsCheckbox.checked = true;
|
| 1997 |
+
randomQuestionsCountInput.value = randomCount;
|
| 1998 |
+
randomQuestionsCountInput.disabled = false;
|
| 1999 |
+
}
|
| 2000 |
+
if (isLocked) {
|
| 2001 |
+
settingsContainer.classList.add('opacity-50', 'pointer-events-none');
|
| 2002 |
+
if (shareGameBtn) shareGameBtn.classList.add('hidden');
|
| 2003 |
+
if (manageWordsBtn) manageWordsBtn.classList.add('hidden');
|
| 2004 |
+
}
|
| 2005 |
+
loadedFromUrl = true;
|
| 2006 |
+
}
|
| 2007 |
+
} else {
|
| 2008 |
+
alert("找不到分享的單字庫,請確認連結是否正確。");
|
| 2009 |
+
}
|
| 2010 |
+
} catch (error) {
|
| 2011 |
+
console.error("從 Firebase 讀取資料失敗:", error);
|
| 2012 |
+
alert("讀取分享資料時發生錯誤。");
|
| 2013 |
}
|
|
|
|
|
|
|
| 2014 |
}
|
| 2015 |
}
|
| 2016 |
|
| 2017 |
if (!loadedFromUrl) {
|
| 2018 |
const storedWords = localStorage.getItem('flashcards');
|
| 2019 |
if (storedWords && JSON.parse(storedWords).length > 0) {
|
| 2020 |
+
words = JSON.parse(storedWords).map(word => ({
|
| 2021 |
+
...word,
|
| 2022 |
+
proficiency: word.proficiency || 0,
|
| 2023 |
+
incorrectCount: word.incorrectCount || 0
|
| 2024 |
+
}));
|
| 2025 |
} else {
|
| 2026 |
const defaultWords = [
|
| 2027 |
+
{ english: 'yesterday (adv.)', chinese: '昨天', sentence: { en: 'I walked to school ___.', zh: '我昨天走路上學。' } },
|
| 2028 |
+
{ english: 'study (v.)', chinese: '研讀' },
|
| 2029 |
{ english: 'jog (v.)', chinese: '慢跑' }, { english: 'watch (v.)', chinese: '觀看' },
|
| 2030 |
{ english: 'last (adj.)', chinese: '前一個的' }, { english: 'death (n.)', chinese: '死亡' },
|
| 2031 |
{ english: 'a few (adj.)', chinese: '一些' }, { english: 'ago (adv.)', chinese: '以前' },
|
|
|
|
| 2046 |
updateCompletionUI();
|
| 2047 |
highScore = parseInt(localStorage.getItem('flashcardsHighScore') || '0', 10);
|
| 2048 |
highscoreDisplay.textContent = highScore;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2049 |
|
| 2050 |
+
if (urlParams.has('deck')) {
|
| 2051 |
history.replaceState(null, '', window.location.pathname);
|
| 2052 |
}
|
| 2053 |
|
|
|
|
| 2055 |
endRangeInput.max = words.length;
|
| 2056 |
endRangeInput.placeholder = `到 ${words.length}`;
|
| 2057 |
updateRandomCountMax();
|
| 2058 |
+
|
| 2059 |
+
preloadAudioFiles();
|
| 2060 |
+
|
| 2061 |
showView('menu');
|
| 2062 |
};
|
| 2063 |
initializeApp();
|