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
- 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,'&').replace(/</g,'<').replace(/>/g,'>')
.replace(/"/g,'"').replace(/'/g,''');
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>