摘要
承接 Day 24 的即時生成,今天把 Gemini 回覆存進本機,做出「語錄清單」:支援收藏、排序、搜尋、刪除、複製。
透過 localStorage 陣列與清單渲染,讓暖心語錄不只即時出現,更能持續回顧與整理。
QuoteItem = { id, text, task?, reason?, source: 'gemini'|'manual', createdAt }
假設已具備:
註:若你的命名略有不同,對應到相同職責即可。
<!-- [Day25-NEW] 語錄清單 -->
<section id="quoteWallSection" aria-labelledby="quote-wall-title" style="margin-top:1rem;">
<h3 id="quote-wall-title">AI 語錄清單</h3>
<div style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
<label for="quoteSearchInput">搜尋:</label>
<input id="quoteSearchInput" type="search" placeholder="輸入關鍵字(語錄 / 任務 / 原因)" style="min-width:240px;" />
<label for="quoteSortSelect">排序:</label>
<select id="quoteSortSelect">
<option value="time_desc">依新增時間(新→舊)</option>
<option value="time_asc">依新增時間(舊→新)</option>
<option value="alpha_asc">依語錄字母/筆畫(A→Z)</option>
<option value="alpha_desc">依語錄字母/筆畫(Z→A)</option>
</select>
<!-- 可選:匯出語錄清單 -->
<button id="btnExportQuoteJSON" type="button">匯出語錄(JSON)</button>
</div>
<ul id="quoteList" aria-live="polite" style="margin-top:.5rem;"></ul>
<p id="quoteWallFeedback" class="muted" aria-live="polite"></p>
</section>
// ===== [Day25-NEW] 語錄清單:常數、節點 =====
const QUOTE_WALL_KEY = 'quote_wall'; // Array<QuoteItem>
const quoteList = document.getElementById('quoteList');
const quoteSearchInput = document.getElementById('quoteSearchInput');
const quoteSortSelect = document.getElementById('quoteSortSelect');
const quoteWallFeedback = document.getElementById('quoteWallFeedback');
const btnExportQuoteJSON = document.getElementById('btnExportQuoteJSON');
// ===== [Day25-NEW] 語錄清單:存取工具 =====
function readQuoteWall() {
try { return JSON.parse(localStorage.getItem(QUOTE_WALL_KEY)) || []; }
catch { return []; }
}
function writeQuoteWall(list) {
localStorage.setItem(QUOTE_WALL_KEY, JSON.stringify(Array.isArray(list) ? list : []));
}
function addQuoteItem({ text, task = '', reason = '', source = 'gemini' }) {
const list = readQuoteWall();
list.push({
id: crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()),
text: String(text || '').trim(),
task: String(task || ''),
reason: String(reason || ''),
source,
createdAt: Date.now()
});
writeQuoteWall(list);
return list[list.length - 1];
}
// ===== [Day25-NEW] 語錄清單:篩選 + 排序 =====
function applyQuoteFilterSort(list) {
let arr = Array.isArray(list) ? list.slice() : [];
const q = (quoteSearchInput?.value || '').trim().toLowerCase();
if (q) {
arr = arr.filter(item => {
const hay = [item.text, item.task, item.reason].join(' ').toLowerCase();
return hay.includes(q);
});
}
const sort = quoteSortSelect?.value || 'time_desc';
switch (sort) {
case 'time_asc':
arr.sort((a,b)=> a.createdAt - b.createdAt);
break;
case 'alpha_asc':
arr.sort((a,b)=> (a.text||'').localeCompare(b.text||''));
break;
case 'alpha_desc':
arr.sort((a,b)=> (b.text||'').localeCompare(a.text||''));
break;
case 'time_desc':
default:
arr.sort((a,b)=> b.createdAt - a.createdAt);
break;
}
return arr;
}
// ===== [Day25-NEW] 語錄清單:渲染 =====
function renderQuoteWall() {
const raw = readQuoteWall();
const list = applyQuoteFilterSort(raw);
if (!quoteList) return;
if (!list.length) {
quoteList.innerHTML = '<li class="muted">尚無收藏語錄。先在表單按「下次吧…」或條件分支按「我有點累…」,AI 會回一句話並自動收藏。</li>';
return;
}
quoteList.innerHTML = list.map(item => `
<li data-id="${item.id}">
<div>
「${escapeHTML(item.text)}」
<small class="muted">(${new Date(item.createdAt).toLocaleString()}|來源:${item.source})</small>
</div>
<div class="muted">
${item.task ? `任務:${escapeHTML(item.task)} ` : ''}
${item.reason ? `原因:${escapeHTML(item.reason)} ` : ''}
</div>
<div style="margin-top:.25rem;">
<button class="btn-copy" type="button">複製</button>
<button class="btn-remove" type="button">刪除</button>
</div>
</li>
`).join('');
}
// 小工具:安全轉義(避免語錄裡出現引號/符號時破版)
function escapeHTML(s='') {
return String(s)
.replaceAll('&','&')
.replaceAll('<','<')
.replaceAll('>','>')
.replaceAll('"','"')
.replaceAll("'",''');
}
// ===== [Day25-NEW] 語錄清單:事件委派(複製 / 刪除) =====
quoteList?.addEventListener('click', async (e) => {
const li = e.target.closest('li[data-id]');
if (!li) return;
const id = li.getAttribute('data-id');
const list = readQuoteWall();
const item = list.find(x => x.id === id);
if (!item) return;
if (e.target.closest('.btn-copy')) {
try {
await navigator.clipboard.writeText(item.text);
if (quoteWallFeedback) quoteWallFeedback.textContent = '已複製到剪貼簿 ✅';
setTimeout(()=> quoteWallFeedback && (quoteWallFeedback.textContent = ''), 1500);
} catch {
prompt('複製失敗,手動複製以下內容:', item.text);
}
return;
}
if (e.target.closest('.btn-remove')) {
if (!confirm('確定要刪除這句語錄嗎?')) return;
const kept = list.filter(x => x.id !== id);
writeQuoteWall(kept);
renderQuoteWall();
}
});
// ===== [Day25-NEW] 語錄清單:搜尋 / 排序器 =====
quoteSearchInput?.addEventListener('input', () => renderQuoteWall());
quoteSortSelect?.addEventListener('change', () => renderQuoteWall());
// ===== [Day25-NEW] 語錄匯出(可選) =====
btnExportQuoteJSON?.addEventListener('click', () => {
const list = readQuoteWall();
if (!list.length) {
quoteWallFeedback.textContent = '目前沒有可匯出的語錄。';
return;
}
const pretty = JSON.stringify(list, null, 2);
triggerDownload('quote-wall.json', 'application/json;charset=utf-8', pretty);
quoteWallFeedback.textContent = `已匯出 ${list.length} 則語錄(JSON)。`;
setTimeout(()=> quoteWallFeedback && (quoteWallFeedback.textContent = ''), 1500);
});
// [Day13-CHANGED][Day25-NEW] 「下次吧…」:生成後自動收藏
btnLater?.addEventListener('click', async ()=>{
const { task, reason } = getCurrentTaskAndReason();
feedback.textContent = '想一想…(AI 正在回覆)';
try {
const line = await genQuote({ task, reason, tone: 'gentle' });
feedback.textContent = line || '先緩一下也很好,等精神回來再開始。';
// [Day25-NEW] 自動收藏到語錄清單
if (line) addQuoteItem({ text: line, task, reason, source: 'gemini' });
} catch (err) {
feedback.textContent = '沒關係,你隨時都能回來開始。我會一直在這等你 😊';
}
});
// [Day15-CHANGED][Day25-NEW] 「我有點累…」:生成後自動收藏
btnLater_2?.addEventListener('click', async () => {
const { task, reason } = getCurrentTaskAndReason();
decisionFeedback.textContent = '想一想…(AI 正在回覆)';
try {
const line = await genQuote({ task, reason, tone: 'reassuring' });
decisionFeedback.textContent = line || '你已經很努力了,補個眠就是前進。';
// [Day25-NEW] 自動收藏
if (line) addQuoteItem({ text: line, task, reason, source: 'gemini' });
} catch {
decisionFeedback.textContent = '別擔心,你隨時都能回來,我一直都在 ✨';
}
});
// [Day25-CHANGED] showPage('history') 增補:語錄清單渲染
if (page === 'history') {
if (historySection) {
historySection.hidden = false;
renderMoodChartsAll().finally(() => {
if (typeof renderHistory === 'function') renderHistory();
// [Day25-NEW] 語錄清單也要一起渲染
if (typeof renderQuoteWall === 'function') renderQuoteWall();
});
focusOrFallback(historySection, 'history-title');
}
}