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

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>
  

About the author

Đỗ Thu Hoài
Ko biết để gì cho hợp lý! https://www.threads.com/@dwo_th

41 comments

  1. Lê Hùng ✅
    e nghĩ a chia sẽ giao diện đó nữa chứ :D :D
  2. A Đê Min
    Template này có gì đặc biệt đâu em 🤔
  3. Anh Trai Nắng
    Được quá nhở 😍
  4. A Đê Min
    Tại đang làm cái blog cá nhân theo dạng Facebook nên nghĩ ra 😁
  5. Lê Hùng ✅
    hi vì e thích đơn giản a :D a share mẫu giao diện đó đi :P
  6. A Đê Min
    Nó chưa hoàn chỉnh, khi nào hoàn chỉnh a share
  7. Anonymous
    giờ ông TruongDevs nghỉ hoạt động cái blog TruongDevs và thành lập cái khác đi là hết giả mạo ngay ấy mà.
  8. Anonymous
    ơ cái comment bị lỗi rôig
  9. Tomorrow
    Quá hay
  10. A Đê Min
    Thank bác!
  11. Tomorrow
    e chạy thử ko thấy load ra đc j

    đã chỉnh FLEX_DAYS thành 10, max-results=1500
  12. A Đê Min
    Bác cứ để đấy đừng xóa đi nhé. Tối về e check lại xem sao
  13. ANewPlus
    Share theme đi anh
  14. A Đê Min
    Template này có gì đâu mà share, nhìn đơn giản không hợp với xu hướng bây giờ
  15. Tomorrow
    kb có ae nào chạy đc ko, e chạy thử lại r vẫn ko lên
  16. Lê Hùng ✅
    lâu rồi chưa ra bài viết mới a nhỉ :P
  17. A Đê Min
    Không có ý tưởng gì e 😁
  18. Lê Hùng ✅
    viết bài chia sẽ mã nguồn giao diện blogger, tự động chuyển hướng liên kết khi trong bài viết là đường link và ngược lại nếu là nội dung text thì không chuyển hướng đó a :D, như hồi xưa bên linkthuthuat á a :D
  19. A Đê Min
    Vẫn không hiểu lắm chức năng như em nói
  20. Lê Hùng ✅
    kiểu như title vẫn bình thường a nhé, còn ở phần body, a tùy biến sao, mà khi mình viết bài chỉ cần bỏ liên kết vào bài viết thì tự động chuyển hướng liên kết theo link, và ngược lại, nếu người dùng viết bài như bình thường thì không chuyển hướng đó a :D
  21. A Đê Min
    Thử đoạn code này xem đúng ý e không?
    Chỉ áp dụng trong phạm vi nội dung bài viết (.post-body)
    [pre] <script>
    document.addEventListener("DOMContentLoaded", function() {
    const postBody = document.querySelector('.post-body');
    if (!postBody) return;

    // Nhận dạng các URL
    const urlRegex = /((https?:\/\/|www\.)[^\s<]+)/g;

    const walker = document.createTreeWalker(postBody, NodeFilter.SHOW_TEXT, null, false);
    const textNodes = [];

    while (walker.nextNode()) {
    const node = walker.currentNode;
    // Bỏ qua các text đã nằm trong thẻ <a>
    if (!node.parentNode.closest('a')) textNodes.push(node);
    }

    textNodes.forEach(node => {
    const text = node.nodeValue;
    if (urlRegex.test(text)) {
    const replacedHTML = text.replace(urlRegex, function(url) {
    let href = url.startsWith('http') ? url : 'https://' + url;
    return `<a href="${href}" target="_blank" rel="nofollow noopener">${url}</a>`;
    });

    const span = document.createElement('span');
    span.innerHTML = replacedHTML;
    node.parentNode.replaceChild(span, node);
    }
    });
    });
    </script>[/pre]
  22. A Đê Min
    Sao domain với nội dung website chẳng liên quan gì đến nhau vậy? 🤔
  23. Anonymous
    em gắn tạm chờ tenten duyệt doamin chủ thể đăng ký dưới 18
  24. A Đê Min
    Vậy để khi có domain ổn định đi đỡ phải thay đổi lại nhé. Và gửi yêu cầu liên kết tại đây nhé 😁
  25. .Thinhem
    Nhận xét này đã bị tác giả xóa.
  26. A Đê Min
    Bác có vẻ yêu âm nhạc nhỉ? 😁
  27. Lê Hùng ✅
    gửi e mã nguồn youtube + hình ảnh ở phần comment đc k a :D
  28. A Đê Min
    Done nha bạn! 👌
  29. ANewPlus
    cho thuê subdomain ko bạn
  30. A Đê Min
    Không bạn
  31. A Đê Min
    Chưa thấy đặt link của mình, hơn nữa không cần thiết comment phải gắn link vào toàn bộ nội dung đâu
  32. A Đê Min
    Lượng truy cập có tí ti nên cần gì nhỉ 😂
  33. A Đê Min
    Done nha!
  34. Lê Hùng ✅
    kéo tóp comment :v
  35. .Thinhem
    trà đá hà nội nhiều tên phết nhể
  36. NAD
    Blogger bây giờ hình như chỉ lấy đc max-results=150 thì phải, bài cũ hơn ko lấy đc
  37. A Đê Min
    Vẫn bình thường mà bác, như cái trang nhận xét nó lấy full đến bài cuối cùng luôn
  38. Lê Hùng ✅
    Chúc a zai cuối tuần vui vẻ và hạnh phúc, hóng a ra bài mới :P, để e cóp dán nà kk
  39. A Đê Min
    Cảm ơn em! Nhưng cạn ý tưởng rồi 😂
  40. Admin
    Chuyển sang làm thơ đi anh
  41. Đỗ Thu Hoài
    Xin phép ad để ảnh tạm đây: https://i.imgur.com/HHJndpN.jpeg