Lashtw commited on
Commit
7a399ee
·
verified ·
1 Parent(s): e64f9e8

Update index.html

Browse files
Files changed (1) hide show
  1. 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">重置皇冠</button>
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
- localStorage.setItem('flashcards', JSON.stringify(words));
 
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 = words[index].english;
651
- editChineseInput.value = words[index].chinese;
 
 
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.english);
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.english);
803
  answerInput.placeholder = "請輸入英文答案...";
804
  break;
 
 
 
 
 
 
 
 
 
805
  }
806
  }
807
- frontDisplay.textContent = frontText;
 
 
 
808
  backDisplay.textContent = backText;
809
  setTimeout(() => answerInput.focus(), 100);
810
  };
@@ -841,7 +1001,13 @@
841
  wordPool = [...words];
842
  }
843
 
844
- if (mode === 'review') {
 
 
 
 
 
 
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
- correctAnswer = isFlipped ? card.english.split('(')[0].trim() : card.chinese.split('(')[0].trim();
 
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
- correctAnswer = card.english.split('(')[0].trim();
913
  answerLang = 'en';
914
  break;
915
  case 'en-zh':
916
- correctAnswer = card.chinese.split('(')[0].trim();
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) && ans !== '');
934
  }
935
 
936
  if (isCorrect) {
@@ -976,7 +1145,7 @@
976
 
977
  quizStartTime = 0; // Reset timer
978
 
979
- if (currentMode !== 'hard') {
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 = (word, rate = 1) => {
1102
  if ('speechSynthesis' in window) {
1103
  window.speechSynthesis.cancel();
1104
- const wordToSpeak = word.split('(')[0].trim();
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
- const wordToSpeak = word.split('(')[0].trim();
 
 
 
 
 
 
 
 
 
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.english;
1178
- if (currentMode === 'review' && wordsForCurrentMode[currentCardIndex]) return wordsForCurrentMode[currentCardIndex].english;
1179
- if (quizQueue.length > 0) return quizQueue[0].english;
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
- // Visual feedback
 
 
 
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.split('(')[0].trim(); break;
1322
- case 'en-zh': correctAnswer = card.chinese.split('(')[0].trim(); break;
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
- try {
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
- url.searchParams.set('data', base64Words);
1401
- if (start) url.searchParams.set('start', start);
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("產生分享連結時發生錯誤:", 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 sharedData = urlParams.get('data');
1514
  let loadedFromUrl = false;
1515
 
1516
- if (sharedData) {
1517
- try {
1518
- const binaryString = atob(sharedData);
1519
- const compressedData = new Uint8Array(binaryString.length).map((_, i) => binaryString.charCodeAt(i));
1520
- const decodedWordsString = pako.inflate(compressedData, { to: 'string' });
1521
- const sharedWords = JSON.parse(decodedWordsString);
1522
-
1523
- if (Array.isArray(sharedWords) && sharedWords.length > 0) {
1524
- words = sharedWords.map(word => ({ ...word, proficiency: 0, incorrectCount: 0 }));
1525
- saveWordsToStorage();
1526
- loadedFromUrl = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 => ({ ...word, proficiency: word.proficiency || 0, incorrectCount: word.incorrectCount || 0 }));
 
 
 
 
1537
  } else {
1538
  const defaultWords = [
1539
- { english: 'yesterday (adv.)', chinese: '昨天' }, { english: 'study (v.)', 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('data')) {
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();