iT邦幫忙

2025 iThome 鐵人賽

0

目標:提升心得牆表單的使用體驗:

  • 輸入即時驗證(聚焦到錯誤欄位)
  • 送出後有柔和提示
  • 自動草稿保存(LocalStorage),誤關頁面不怕內容消失

要改的檔案review.htmljs/reviews.jsstyle.css

1.(覆蓋)reviews.js

把你的 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
  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(); });
})();

2.(追加)style.css 微調

在你的 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; }

3. 驗收

  • 內容未滿 30 字會被擋,會自動聚焦到錯誤欄位
  • 打字中斷也不怕,草稿會自動保存,下次回來會載入
  • 送出會在表單下出現「已送出並保存!」訊息後自動淡出

上一篇
Day 25:入場動畫 & 捲動揭示
下一篇
Day 27:全站 Toast 提示 & 空狀態一致
系列文
給愛追劇的你:30天互動網站挑戰30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言