Widget bài này năm xưa giống Facebook

17.10.25 | 50 nhận xét | lượt xem
Nội Dung Chính

    Widget hiển thị các bài đăng có cùng ngày/tháng với hôm nay trong những năm trước, theo kiểu bảng tin Facebook. Nếu không có bài “kỷ niệm”, widget vẫn hiển thị một thẻ mặc định

    Tính năng chính

    • Giao diện stories trượt ngang như tin tức của Facebook
    • Lọc bài theo ngày/tháng khớp hôm nay từ bài viết trong quá khư (có thể ± ngày theo cài đặt để phù hợp với múi giờ).

    Cách cài đặt nhanh

    1. Tuỳ chỉnh trong code:
      • LIMIT: Số bài muốn hiển thị.
      • FLEX_DAYS: Tăng giảm số ngày khớp (ví dụ 1 = hôm qua/hôm nay/ngày mai).

    Code

    <section id="anniversary-posts" class="story-wrap">
      <div class="story-head">
        <h3 class="story-title">Kỷ niệm</h3>
        <div class="story-ctrl">
          <button class="story-btn prev" aria-label="Prev" type="button" disabled>
            <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
              <path d="M15.5 19l-7-7 7-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
            </svg>
          </button>
          <button class="story-btn next" aria-label="Next" type="button" disabled>
            <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
              <path d="M8.5 5l7 7-7 7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
            </svg>
          </button>
        </div>
      </div>
    
      <div class="story-rail">
        <ul class="story-track" aria-live="polite">
          <li class="story-skeleton"></li>
          <li class="story-skeleton"></li>
          <li class="story-skeleton"></li>
          <li class="story-skeleton"></li>
        </ul>
      </div>
    </section>
    
    <style>
    .story-wrap{margin:14px 0;font:inherit}
    .story-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
    .story-title{margin:0;font-weight:700}
    .story-ctrl{display:flex;gap:8px}
    .story-btn{
      width:34px;height:34px;border:1px solid #e5e7eb;border-radius:999px;background:#fff;
      display:inline-flex;align-items:center;justify-content:center;cursor:pointer;opacity:.95
    }
    .story-btn[disabled]{opacity:.4;cursor:not-allowed}
    .story-btn:active{transform:scale(.98)}
    .story-rail{position:relative}
    .story-track{
      display:flex;gap:10px;overflow-x:auto;overflow-y:hidden;padding:4px 2px 12px;scrollbar-width:none;
      -ms-overflow-style:none;scroll-snap-type:x mandatory;scroll-behavior:smooth;-webkit-overflow-scrolling:touch
    }
    .story-track::-webkit-scrollbar{display:none}
    .story-card{
      position:relative;flex:0 0 auto;width:140px;height:248px;border-radius:14px;overflow:hidden;
      background:#e5e7eb;scroll-snap-align:start;user-select:none
    }
    .story-bg{position:absolute;inset:0;background:#ddd center/cover no-repeat}
    .story-grad{
      position:absolute;inset:0;
      background:linear-gradient(180deg,rgba(0,0,0,.0) 10%, rgba(0,0,0,.35) 65%, rgba(0,0,0,.6) 100%);
    }
    .story-text{
      position:absolute;left:8px;right:8px;bottom:8px;color:#fff;line-height:1.2;
      font-weight:700;text-shadow:0 1px 2px rgba(0,0,0,.4)
    }
    .story-text .name{
      display:block;max-height:2.6em;overflow:hidden;-webkit-line-clamp:2;display:-webkit-box;-webkit-box-orient:vertical
    }
    .story-text .date{font-weight:600;font-size:12px;opacity:.9;margin-top:4px}
    .story-chevron{
      position:relative;flex:0 0 auto;width:56px;height:248px;border-radius:14px;background:#fff;
      display:grid;place-items:center;border:1px solid #e5e7eb;scroll-snap-align:start
    }
    .story-chevron .btn{
      width:36px;height:36px;border-radius:999px;background:#fff;border:1px solid #e5e7eb;display:grid;place-items:center
    }
    .story-skeleton{
      flex:0 0 auto;width:140px;height:248px;border-radius:14px;
      background:linear-gradient(90deg,#f3f4f6 25%,#e5e7eb 37%,#f3f4f6 63%);background-size:400% 100%;
      animation:shine 1.1s infinite
    }
    @keyframes shine{0%{background-position:100% 0}100%{background-position:0 0}}
    @media (max-width:480px){
      .story-card{width:34vw;max-width:160px;height:56vw;max-height:260px}
      .story-chevron{height:56vw;max-height:260px}
    }
    @media (prefers-reduced-motion: reduce){
      .story-track{scroll-behavior:auto}
      .story-skeleton{animation:none}
    }
    </style>
    
    <script>
    (function(){
      "use strict";
    
      const LIMIT = 20;
      const HARD_CAP = 2000;
      const FEED0 = '/feeds/posts/summary?alt=json&max-results=150';
      const FLEX_DAYS = 1;
    
      const now  = new Date();
      const TODAY= { d: now.getDate(), m1: now.getMonth()+1, y: now.getFullYear() };
    
      const root  = document.getElementById('anniversary-posts');
      if(!root) return;
      const track = root.querySelector('.story-track');
      const bPrev = root.querySelector('.story-btn.prev');
      const bNext = root.querySelector('.story-btn.next');
    
      const picked = [];
    
      const pad2 = n => String(n).padStart(2,'0');
      const escapeHTML = s => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
                                             .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
      const NO_STORY_BG = (() => {
        const svg =
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 560" width="320" height="560">
      <defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
        <stop offset="0%" stop-color="#dbeafe"/><stop offset="100%" stop-color="#93c5fd"/></linearGradient></defs>
      <rect x="0" y="0" width="320" height="560" rx="24" fill="url(#g)"/>
      <g fill="none" stroke="#1f2937" stroke-width="10" opacity="0.25">
        <rect x="70" y="120" width="180" height="140" rx="14"/>
        <circle cx="160" cy="330" r="42"/><path d="M60 420h200M60 460h160"/></g></svg>`;
        return 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg);
      })();
    
      function getYMD(entry){
        const s = (entry?.published?.$t) || (entry?.updated?.$t) || '';
        const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
        return m ? { y:+m[1], m1:+m[2], d:+m[3] } : null;
      }
      function buildFlexSet(){
        const set = new Set();
        const base = new Date(TODAY.y, TODAY.m1-1, TODAY.d, 12);
        set.add(\`\${TODAY.d}-\${TODAY.m1}\`);
        for(let i=1;i<=FLEX_DAYS;i++){
          const d1=new Date(base); d1.setDate(base.getDate()-i);
          const d2=new Date(base); d2.setDate(base.getDate()+i);
          set.add(\`\${d1.getDate()}-\${d1.getMonth()+1}\`);
          set.add(\`\${d2.getDate()}-\${d2.getMonth()+1}\`);
        }
        return set;
      }
      const FLEXSET = buildFlexSet();
    
      function sameDayPast(parts){
        if(!parts || parts.y >= TODAY.y) return false;
        return FLEXSET.has(\`\${parts.d}-\${parts.m1}\`);
      }
      function getLink(entry){
        const alt = (entry.link || []).find(l => l.rel === 'alternate');
        return alt?.href || '#';
      }
      function getThumb(entry){
        if (entry.media$thumbnail?.url)
          return entry.media$thumbnail.url.replace(/\\/s\\d{2,4}(-c)?\\//, '/s720-c/');
        const html = (entry.content?.$t) || (entry.summary?.$t) || '';
        const m = html.match(/<img[^>]+src="([^"]+)"/i);
        return m ? m[1] : NO_STORY_BG;
      }
      function shuffle(a){ for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]];} return a; }
    
      function render(list){
        let html = '';
        if (!list.length){
          html = \`
            <li class="story-card">
              <div class="story-bg" style="background-image:url('\${NO_STORY_BG}')"></div>
              <div class="story-grad"></div>
              <div class="story-text"><span class="name">No Story</span></div>
            </li>\`;
          track.innerHTML = html;
          bPrev.disabled = bNext.disabled = true;
          return;
        }
        html = list.map(it => \`
          <li class="story-card">
            <a href="\${it.link}" class="story-link" aria-label="\${escapeHTML(it.title)}">
              <div class="story-bg" style="background-image:url('\${it.thumb}')"></div>
              <div class="story-grad"></div>
              <div class="story-text">
                <span class="name">\${escapeHTML(it.title)}</span>
                <span class="date">\${pad2(it.d)}/\${pad2(it.m1)}/\${it.y}</span>
              </div>
            </a>
          </li>\`).join('');
        html += \`
          <li class="story-chevron">
            <button type="button" class="btn go-next" aria-label="Next">
              <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
                <path d="M8.5 5l7 7-7 7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
              </svg>
            </button>
          </li>\`;
        track.innerHTML = html;
        track.querySelector('.go-next')?.addEventListener('click', () => scrollByStep(1));
        updateButtons();
      }
    
      async function crawl(url, scanned=0){
        if (!url || scanned >= HARD_CAP) return;
        try{
          const res = await fetch(url, { credentials:'same-origin' });
          if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
          const data = await res.json();
          const entries = data?.feed?.entry || [];
          for (const e of entries){
            const parts = getYMD(e);
            if (sameDayPast(parts)){
              picked.push({
                title: e?.title?.$t || '(Không tiêu đề)',
                link:  getLink(e),
                thumb: getThumb(e),
                y: parts.y, m1: parts.m1, d: parts.d
              });
            }
          }
          const next = (data?.feed?.link || []).find(l => l.rel === 'next');
          if (next?.href && scanned + entries.length < HARD_CAP){
            const u = next.href.startsWith('http') ? new URL(next.href) : null;
            const href = u ? (u.pathname + u.search) : next.href;
            return crawl(href, scanned + entries.length);
          }
        }catch(err){ console.error('Anniversary feed error:', err); }
      }
    
      function scrollByStep(dir=1){
        const step = Math.max(track.clientWidth*0.95, 320);
        track.scrollBy({ left: step*dir, behavior:'smooth' });
      }
      function updateButtons(){
        const max = track.scrollWidth - track.clientWidth - 1;
        bPrev.disabled = track.scrollLeft <= 0;
        bNext.disabled = track.scrollLeft >= max;
      }
      track.addEventListener('wheel', e => {
        if (Math.abs(e.deltaY) > Math.abs(e.deltaX)){
          track.scrollLeft += e.deltaY; e.preventDefault(); updateButtons();
        }
      }, { passive:false });
      track.addEventListener('scroll', () => updateButtons(), { passive:true });
      bPrev.addEventListener('click', () => scrollByStep(-1));
      bNext.addEventListener('click', () => scrollByStep(1));
    
      (async function init(){
        track.innerHTML = '<li class="story-skeleton"></li><li class="story-skeleton"></li><li class="story-skeleton"></li><li class="story-skeleton"></li>';
        await crawl(FEED0);
        shuffle(picked);
        render(picked.slice(0, LIMIT));
      })();
    })();
    </script>
      
    Bạn đang xem bài viết "Widget bài này năm xưa giống Facebook" tại chuyên mục: Blogspot , Widget

    50

    nhận xét
    Mới nhất ⇅

    Bình luận

    Chèn hình ảnh: Chỉ cần dán link hình ảnh - Upload ảnh

    Chèn code: [pre]Code đã mã hóa [/pre]

    Chèn emoji: Nhấn tổ hợp phím “Windows + . (dấu chấm)”

    Chèn link: Click để chèn link