目標:提升心得牆表單的使用體驗:
要改的檔案:review.html、js/reviews.js、style.css
把你的 js/reviews.js 內容整個替換為下列版本(含:即時驗證、草稿自動保存、Ctrl/⌘+Enter 送出、載入更多、清空等):
// js/reviews.js — Day26 強化版
(function(){
const SHOWS=window.DRAMA_SHOWS||[]; const LABELS=window.DRAMA_LABELS||{};
const RV_KEY='dramaweb:reviews:v2';
const DRAFT_KEY='dramaweb:review-draft';
const $form=$('#reviewForm'), $title=$('#titleInput'), $rating=$('#ratingInput'),
$name=$('#nameInput'), $content=$('#contentInput'), $count=$('#contentCount'),
$msg=$('#formMsg'), $list=$('#reviewList'), $reset=$('#btnResetReview'),
$clearAll=$('#btnClearAll');
let PAGE_SIZE=5, visibleCount=PAGE_SIZE, reviewSort='new';
const esc=s=>String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
const linkify=s=>esc(s).replace(/(https?:\/\/[^\s]+)/g,'<a href="$1" target="_blank" rel="noopener">$1</a>')
.replace(/@([\p{L}\d_]{2,20})/gu,'<span class="mention">@$1</span>');
const loadReviews=()=>{ try{ const arr=JSON.parse(localStorage.getItem(RV_KEY)||'[]'); return Array.isArray(arr)?arr:[]; }catch(e){ return []; } };
const saveReviews=l=>localStorage.setItem(RV_KEY, JSON.stringify(l));
let REVIEWS=loadReviews();
// —— 草稿保存 / 還原 ——
function saveDraft(){
const draft={
title:$title.val(), rating:$rating.val(), name:$name.val(), content:$content.val(),
ts:Date.now()
};
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
}
function loadDraft(){
try{
const d=JSON.parse(localStorage.getItem(DRAFT_KEY)||'null');
if(!d) return;
if(d.title) $title.val(d.title);
if(d.rating) $rating.val(d.rating);
if(d.name) $name.val(d.name);
if(d.content) $content.val(d.content);
$count.text(($content.val()||'').length);
}catch(e){}
}
// —— datalist 建置 ——
(function buildDatalist(){
const $dl=$('#showList').empty();
SHOWS.forEach(s=>$dl.append(`<option value="${s.title}"></option>`));
})();
// —— 渲染列表 / 載入更多 ——
function mountLoadMore(total){
const $wrap=$('.loadmore-wrap');
if($wrap.length===0) $('#reviewList').after('<div class="loadmore-wrap"><button id="btnLoadMore" type="button">載入更多</button></div>');
if(visibleCount>=total){ $('#btnLoadMore').prop('disabled',true).text('沒有更多了'); }
else { $('#btnLoadMore').prop('disabled',false).text('載入更多'); }
}
function render(){
if(!REVIEWS.length){
$list.html(`<div class="empty">目前還沒有心得,快成為第一個分享的人吧!</div>`);
$('.loadmore-wrap').remove();
return;
}
const sorted=REVIEWS.slice().sort((a,b)=>{
const cmp=(a.createdAt||'').localeCompare(b.createdAt||'');
return reviewSort==='new'?-cmp:cmp;
});
const cut=sorted.slice(0,visibleCount);
const html=cut.map(i=>{
const tags=(i.genres||[]).map(g=>LABELS[g]||g).join(' / ');
const dateStr=(i.createdAt||'').slice(0,10);
return `<article class="review-item" data-id="${i.id}">
<div class="review-head">
<h3 class="review-title">${esc(i.title)}</h3>
<span class="review-meta">⭐ ${i.rating} · ${esc(i.name||'匿名')}${tags?` · ${tags}`:''}${dateStr?` · ${dateStr}`:''}</span>
</div>
<p class="review-content">${linkify(i.content)}</p>
<div class="review-actions"><button class="btn-del" type="button">刪除</button></div>
</article>`;
}).join('');
$list.toggleClass('separated',true).html(html);
mountLoadMore(REVIEWS.length);
}
// —— 驗證 / 錯誤顯示 ——
function setError($el,$err,cond,msg){
if(cond){ $el.attr('aria-invalid','true'); if(msg) $err.text(msg); $err.prop('hidden',false); }
else { $el.attr('aria-invalid','false'); $err.prop('hidden',true); }
return !cond;
}
function validate(){
const t=$title.val().trim();
const r=parseFloat($rating.val());
const c=$content.val().trim();
const okT=setError($title,$('#errTitle'),!(t.length>=2&&t.length<=40));
const okR=setError($rating,$('#errRating'),!(Number.isFinite(r)&&r>=1&&r<=10));
const okC=setError($content,$('#errContent'),!(c.length>=30&&c.length<=1000));
if(!okT){$title.focus();return false;}
if(!okR){$rating.focus();return false;}
if(!okC){$content.focus();return false;}
return true;
}
// —— 表單互動 ——
$content.on('input', function(){ $count.text($(this).val().length); saveDraft(); });
$title.on('input', saveDraft); $rating.on('input', saveDraft); $name.on('input', saveDraft);
$form.on('submit', function(e){
e.preventDefault();
if(!validate()) return;
const t=$title.val().trim();
const r=parseFloat($rating.val());
const n=$name.val().trim()||'匿名';
const c=$content.val().trim();
const m=SHOWS.find(s=>s.title===t);
const review={
id:'rv_'+Date.now(), title:t, rating:r.toFixed(1), name:n, content:c,
genres:m?m.genres:[], createdAt:new Date().toISOString()
};
REVIEWS.push(review); saveReviews(REVIEWS);
// 清表單 + 清草稿
$form[0].reset(); localStorage.removeItem(DRAFT_KEY); $count.text('0');
$msg.text('已送出並保存!').prop('hidden',false);
setTimeout(()=> $msg.prop('hidden',true), 1500);
visibleCount=PAGE_SIZE; reviewSort='new'; 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); saveDraft();
});
// 刪除一則心得
$(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); visibleCount=PAGE_SIZE; render();
});
}
// 載入更多
$(document).on('click','#btnLoadMore', function(){ visibleCount+=PAGE_SIZE; render(); });
// 行動版選單
$(document).on('click','.menu', function(){
const $nav=$('#siteNav'); const open=!$nav.hasClass('open');
$nav.toggleClass('open', open); $(this).attr('aria-expanded', open?'true':'false');
});
// 初始化
$(function(){ loadDraft(); visibleCount=PAGE_SIZE; render(); });
})();
在你的 style.css 末尾加入(或覆蓋同名選擇器),強化錯誤可視化與焦點樣式:
/* Day26: form UX polish */
.field input:focus-visible, .field textarea:focus-visible{
outline: none;
box-shadow: 0 0 0 3px rgba(0,229,255,.25), inset 0 0 10px rgba(255,255,255,.05);
}
.field .error{ color:#ffb3e6; font-size:.92rem; margin:2px 0 0; }
.form-msg{ margin-top:8px; color:#b8ff5c; }