17.10.25 |
50
nhận xét
|
lượt xem
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>
e nghĩ a chia sẽ giao diện đó nữa chứ :D :D
REPLY DELETETemplate này có gì đặc biệt đâu em 🤔
REPLY DELETEhi vì e thích đơn giản a :D a share mẫu giao diện đó đi :P
REPLY DELETENó chưa hoàn chỉnh, khi nào hoàn chỉnh a share
REPLY DELETEĐược quá nhở 😍
REPLY DELETETại đang làm cái blog cá nhân theo dạng Facebook nên nghĩ ra 😁
REPLY DELETEgiờ ô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à.
REPLY DELETEơ cái comment bị lỗi rôig
REPLY DELETEQuá hay
REPLY DELETEThank bác!
REPLY DELETEe chạy thử ko thấy load ra đc j
REPLY DELETEđã chỉnh FLEX_DAYS thành 10, max-results=1500
Bác cứ để đấy đừng xóa đi nhé. Tối về e check lại xem sao
REPLY DELETEkb có ae nào chạy đc ko, e chạy thử lại r vẫn ko lên
REPLY DELETEShare theme đi anh
REPLY DELETETemplate này có gì đâu mà share, nhìn đơn giản không hợp với xu hướng bây giờ
REPLY DELETElâu rồi chưa ra bài viết mới a nhỉ :P
REPLY DELETEKhông có ý tưởng gì e 😁
REPLY DELETEviế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
REPLY DELETEVẫn không hiểu lắm chức năng như em nói
REPLY DELETEkiể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
REPLY DELETEThử đoạn code này xem đúng ý e không?
REPLY DELETEChỉ á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]
Nhận xét này đã bị tác giả xóa.
REPLY DELETE🎈 ⓈⒾⓃⒼⓁⒺⓈ ⒹⒶⓎ 👬 👭
REPLY DELETE💐 ①① ❍ ①① ❍ ②⑤ 👫 💕
Kẻ Cô Đơn Chờ Đón Lễ Độc Thân 💃 💘 🕺 😘
Thân Toàn Độc Mong Gặp Người Tình Xưa 👫 💞
https://www.youtube.com/watch?v=WDL--ga8pDA
👨 Nếu biết rằng tôi vẫn phòng không
💖 Trời ơi người ấy có ngóng trông
👩 Có nghĩ đến ngày xưa mặn nồng
💋 Hay là đang vui vẻ trong lòng 🤔
https://www.youtube.com/watch?v=Hi9TsnHDuVM
Bác có vẻ yêu âm nhạc nhỉ? 😁
REPLY DELETEgửi e mã nguồn youtube + hình ảnh ở phần comment đc k a :D
REPLY DELETEEm dùng đoạn code sau thử, lưu ý chỉ áp dụng trong khu vực comment (#comment-holder / #comments) nên sửa id cho đúng với phần comment của em
REPLY DELETE[pre]<style>
.yt-embed{position:relative;width:100%;max-width:640px;aspect-ratio:16/9;margin:8px 0;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.08)}
.yt-embed iframe{position:absolute;inset:0;width:100%;height:100%;border:0}
a.cmt-image{display:inline-block;max-width:100%;margin:6px 0}
a.cmt-image img{max-width:100%;height:auto;border-radius:8px}
</style>
<script>
(()=>{const R=['#comment-holder','#comments','.comment-thread'],S='.comment-content,.comment-body,.cmt-content,.comment',I=/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i,U=/https?:\/\/[^\s<>"']+/g,
Y=u=>{try{let x=new URL(u),h=x.hostname.replace(/^www\./,'');if(h==='youtu.be')return x.pathname.slice(1).split('/')[0];
if(h==='youtube.com'||h==='m.youtube.com'){if(x.pathname==='/'||x.pathname==='/watch')return x.searchParams.get('v');
if(x.pathname.startsWith('/shorts/'))return x.pathname.split('/')[2]||x.pathname.split('/')[1];
if(x.pathname.startsWith('/embed/'))return x.pathname.split('/')[2]}return null}catch{return null}},
E=id=>{let d=document.createElement('div');d.className='yt-embed';let f=document.createElement('iframe');f.loading='lazy';f.referrerPolicy='origin-when-cross-origin';f.allow='accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share';f.allowFullscreen=!0;f.title='YouTube video';f.src='https://www.youtube.com/embed/'+encodeURIComponent(id);d.appendChild(f);return d},
A=u=>{let a=document.createElement('a');a.href=u;a.target='_blank';a.rel='nofollow noopener';a.className='cmt-image';let i=document.createElement('img');i.loading='lazy';i.decoding='async';i.src=u;i.alt='Image from comment';a.appendChild(i);return a},
T=n=>{if(n.nodeType!==3)return;let t=n.nodeValue,m,last=0,c=0,f=document.createDocumentFragment();while((m=U.exec(t))){let u=m[0];f.appendChild(document.createTextNode(t.slice(last,m.index)));let id=Y(u);if(id)f.appendChild(E(id)),c=1;else if(I.test(u))f.appendChild(A(u)),c=1;else{let a=document.createElement('a');a.href=u;a.textContent=u;a.rel='nofollow noopener';a.target='_blank';f.appendChild(a)}last=U.lastIndex}
if(!c)return;f.appendChild(document.createTextNode(t.slice(last)));n.parentNode.replaceChild(f,n)},
P=r=>{if(r.dataset.l2m)return;r.dataset.l2m=1;let w=document.createTreeWalker(r,NodeFilter.SHOW_TEXT,{acceptNode(n){if(!/\bhttps?:\/\//.test(n.nodeValue||''))return NodeFilter.FILTER_REJECT;for(let p=n.parentNode;p;p=p.parentNode){if(p.nodeType!==1)continue;let tg=p.tagName;if(tg==='A'||tg==='CODE'||tg==='PRE'||p.classList?.contains('yt-embed'))return NodeFilter.FILTER_REJECT}return NodeFilter.FILTER_ACCEPT}});
let a=[];while(w.nextNode())a.push(w.currentNode);a.forEach(T)},
init=()=>{let roots=R.map(s=>document.querySelector(s)).filter(Boolean);if(!roots.length)return;roots.forEach(r=>{r.querySelectorAll(S).forEach(P);new MutationObserver(ms=>{for(const m of ms)m.addedNodes.forEach(n=>{if(n.nodeType!==1)return;if(n.matches?.(S))P(n);n.querySelectorAll?.(S).forEach(P)})}).observe(r,{childList:1,subtree:1})})};
document.readyState!=='loading'?init():document.addEventListener('DOMContentLoaded',init);
})();
</script>
[/pre]
cho mình xin liên kết nha:
REPLY DELETETên: Kho Nhạc Tổng Hợp
URL: https://www.khonhactonghop.site/
Mô tả: Kho Nhạc Tổng Hợp là trang âm nhạc
Done nha bạn! 👌
REPLY DELETEcho thuê subdomain ko bạn
REPLY DELETEKhông bạn
REPLY DELETENhận xét này đã bị tác giả xóa.
REPLY DELETEChư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
REPLY DELETENhận xét này đã bị tác giả xóa.
REPLY DELETEDone nha!
REPLY DELETENhìn vào dòng bên trên cùng thấy hàng loạt trang web dùng proxy qua cloudflare đều bị lỗi:
REPLY DELETEhttps://i.imgur.com/ZAMqmml.png
Nhận xét này đã bị tác giả xóa.
REPLY DELETEtrà đá hà nội nhiều tên phết nhể
REPLY DELETEkéo tóp comment :v
REPLY DELETEBlogger 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
REPLY DELETEVẫ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
REPLY DELETEChú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
REPLY DELETECảm ơn em! Nhưng cạn ý tưởng rồi 😂
REPLY DELETEChuyển sang làm thơ đi anh
REPLY DELETEXin phép ad để ảnh tạm đây: https://i.imgur.com/HHJndpN.jpeg
REPLY DELETEHello anh, em sống lại rồi nè =))
REPLY DELETEVụt mõm nó chưa e?
REPLY DELETE