style.css 末尾補上兩段(可選)讓「清空全部」與空狀態更清楚(若 Day 11 已有類似就可略過):
/* Day 12:清空全部按鈕(危險動作) */
.btn-warn{
border: 1px solid #ef4444; color: #fff; background: #ef4444;
border-radius: 10px; padding: 8px 12px; cursor: pointer;
}
.btn-warn:hover{ background: #dc2626; border-color:#dc2626; }
/* 空狀態(若未定義) */
.empty{
text-align: center; color: var(--muted);
padding: 24px 8px; border: 1px dashed var(--border);
border-radius: var(--radius); background: var(--surface);
}
你也可以在 review.html 的「最新心得」區塊上方,額外加一個「清空全部」按鈕(若想要):
<div class="review-actions" style="margin-bottom:10px;">
<button id="btnClearAll" class="btn-warn" type="button">清空全部心得</button>
</div>
js/reviews.js直接覆蓋 你現有的
js/reviews.js。此版包含:載入、保存、刪除、清空、字數、驗證、Ctrl/⌘+Enter 快速送出。
// js/reviews.js — Day 12:心得牆永久保存(LocalStorage)
(function(){
// 來自 app.js
const SHOWS = window.DRAMA_SHOWS || [];
const LABELS = window.DRAMA_LABELS || {};
// 儲存鍵
const RV_KEY = 'dramaweb:reviews:v1';
// DOM
const $form = $('#reviewForm');
const $title = $('#titleInput');
const $rating = $('#ratingInput');
const $name = $('#nameInput');
const $content = $('#contentInput');
const $count = $('#contentCount');
const $msg = $('#formMsg');
const $list = $('#reviewList');
const $reset = $('#btnResetReview');
const $clearAll= $('#btnClearAll'); // 可能不存在,容錯處理
// 工具
function esc(str){
return String(str)
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
.replace(/"/g,'"').replace(/'/g,''');
}
function nowISO(){ return new Date().toISOString(); }
// LocalStorage:load/save
function loadReviews(){
try{
const raw = localStorage.getItem(RV_KEY);
const arr = raw ? JSON.parse(raw) : [];
// 兼容:確保必要欄位存在
return Array.isArray(arr) ? arr.filter(Boolean) : [];
}catch(e){ return []; }
}
function saveReviews(list){
localStorage.setItem(RV_KEY, JSON.stringify(list));
}
// 狀態:所有心得(持久化)
let REVIEWS = loadReviews(); // [{id,title,rating,name,content,genres,createdAt}...]
// datalist
function buildDatalist(){
const $dl = $('#showList').empty();
SHOWS.forEach(s => $dl.append(`<option value="${s.title}"></option>`));
}
// render
function render(){
if (!REVIEWS.length){
$list.html(`<div class="empty">目前還沒有心得,快成為第一個分享的人吧!</div>`);
return;
}
// 以建立時間排序(新到舊)
const sorted = REVIEWS.slice().sort((a,b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
const html = sorted.map(item => {
const tags = (item.genres || []).map(g => LABELS[g] || g).join(' / ');
const metaRight = tags ? ` · ${tags}` : '';
// 顯示日期(YYYY-MM-DD)
const dateStr = (item.createdAt || '').slice(0,10);
return `
<article class="review-item" data-id="${item.id}">
<div class="review-head">
<h3 class="review-title">${esc(item.title)}</h3>
<span class="review-meta">
⭐ ${item.rating} · ${esc(item.name || '匿名')}${metaRight}${dateStr ? ` · ${dateStr}` : ''}
</span>
</div>
<p class="review-content">${esc(item.content)}</p>
<div class="review-actions">
<button class="btn-del" type="button">刪除</button>
</div>
</article>
`;
}).join('');
$list.html(html);
}
// 即時字數
$content.on('input', function(){ $count.text($(this).val().length); });
// 驗證
function setError($el, $errEl, cond, msg){
if (cond){
$el.attr('aria-invalid','true'); if (msg) $errEl.text(msg); $errEl.prop('hidden', false);
} else {
$el.attr('aria-invalid','false'); $errEl.prop('hidden', true);
}
return !cond;
}
function validate(){
const titleVal = $title.val().trim();
const ratingVal = parseFloat($rating.val());
const contentVal = $content.val().trim();
const okTitle = setError($title, $('#errTitle'), !(titleVal.length >= 2 && titleVal.length <= 40));
const okRating = setError($rating, $('#errRating'), !(Number.isFinite(ratingVal) && ratingVal >= 1 && ratingVal <= 10));
const okContent = setError($content, $('#errContent'), !(contentVal.length >= 30 && contentVal.length <= 1000));
if (!okTitle) { $title.focus(); return false; }
if (!okRating) { $rating.focus(); return false; }
if (!okContent) { $content.focus(); return false; }
return true;
}
function findShowByTitle(title){
return SHOWS.find(s => s.title === title);
}
// 送出
$form.on('submit', function(e){
e.preventDefault();
if (!validate()) return;
const titleVal = $title.val().trim();
const ratingVal = parseFloat($rating.val());
const nameVal = $name.val().trim() || '匿名';
const contentVal = $content.val().trim();
const match = findShowByTitle(titleVal);
const review = {
id: 'rv_' + Date.now(),
title: titleVal,
rating: ratingVal.toFixed(1),
name: nameVal,
content: contentVal,
genres: match ? match.genres : [],
createdAt: nowISO()
};
REVIEWS.push(review);
saveReviews(REVIEWS);
// reset & 提示
$form[0].reset();
$count.text('0');
$msg.text('已送出並保存!').prop('hidden', false);
setTimeout(() => $msg.prop('hidden', true), 1500);
render();
});
// Ctrl/⌘ + Enter 快速送出
$(document).on('keydown', function(e){
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter'){
$('#btnSubmitReview').click();
}
});
// 清空表單
$reset.on('click', function(){
$form[0].reset();
$count.text('0');
['#titleInput','#ratingInput','#contentInput'].forEach(sel => $(sel).attr('aria-invalid','false'));
$('.error').prop('hidden', true);
});
// 刪除單筆(帶確認)
$(document).on('click', '.review-item .btn-del', function(){
const id = $(this).closest('.review-item').data('id');
const idx = REVIEWS.findIndex(r => r.id === id);
if (idx < 0) return;
if (!confirm('確定要刪除此則心得嗎?')) return;
REVIEWS.splice(idx, 1);
saveReviews(REVIEWS);
render();
});
// 清空全部(若頁面有此按鈕)
if ($clearAll && $clearAll.length){
$clearAll.on('click', function(){
if (!REVIEWS.length) return;
if (!confirm('確定要清空全部心得嗎?此動作無法復原。')) return;
REVIEWS = [];
saveReviews(REVIEWS);
render();
});
}
// 初始化
$(function(){
buildDatalist();
render(); // 從 LocalStorage 載入並顯示
});
})();
review.html → 先前送出的心得還在。