mirror of
https://github.com/afoim/fuwari.git
synced 2026-01-31 00:53:19 +08:00
feat(widget): 新增文章更新通知系统
- 将通知组件重构为可折叠的铃铛图标和面板,支持最小化状态 - 使用 IndexedDB 替代 localStorage 存储文章数据,支持多站点范围隔离 - 添加内容差异对比功能,可查看文章更新的具体变化 - 实现通知状态持久化,未读更新显示红点提示 - 更新站点公告以说明新功能
This commit is contained in:
@@ -1,26 +1,86 @@
|
||||
<div id="new-post-notification" class="fixed bottom-4 right-4 z-50 transform translate-y-20 opacity-0 transition-all duration-300 pointer-events-none">
|
||||
<div class="bg-[var(--card-bg)] border border-[var(--primary)] rounded-xl shadow-lg p-4 max-w-sm relative pointer-events-auto">
|
||||
<button id="close-notification" class="absolute top-2 right-2 text-black/50 dark:text-white/50 hover:text-[var(--primary)] transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="bg-[var(--primary)]/10 p-2 rounded-full shrink-0 text-[var(--primary)]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-black dark:text-white mb-1 transition-colors">发现新文章</h3>
|
||||
<div id="new-post-list" class="text-sm text-black/80 dark:text-white/80 transition-colors space-y-1"></div>
|
||||
<div id="new-post-notification" class="fixed bottom-4 right-4 z-50 flex flex-col items-end pointer-events-none">
|
||||
<!-- Minimized State (Bell Icon) -->
|
||||
<button id="notification-minimized" class="pointer-events-auto bg-[var(--card-bg)] border border-[var(--primary)] text-[var(--primary)] p-3 rounded-full shadow-lg transform transition-all duration-300 hover:scale-110 active:scale-95 flex items-center justify-center relative group">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"></path><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"></path></svg>
|
||||
<span id="notification-dot" class="absolute top-0 right-0 w-3 h-3 bg-red-500 rounded-full border-2 border-[var(--card-bg)] hidden"></span>
|
||||
</button>
|
||||
|
||||
<!-- Expanded State (Panel) -->
|
||||
<div id="notification-panel" class="pointer-events-auto bg-[var(--card-bg)] border border-[var(--primary)] rounded-xl shadow-lg p-4 max-w-sm w-80 transform translate-y-4 opacity-0 scale-95 origin-bottom-right transition-all duration-300 hidden absolute bottom-14 right-0">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2 text-[var(--primary)]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg>
|
||||
<h3 class="font-bold text-black dark:text-white">发现新文章</h3>
|
||||
</div>
|
||||
<button id="minimize-notification" class="text-black/50 dark:text-white/50 hover:text-[var(--primary)] transition-colors p-1 rounded-md hover:bg-[var(--primary)]/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="new-post-list" class="text-sm text-black/80 dark:text-white/80 transition-colors space-y-1 max-h-[60vh] overflow-y-auto custom-scrollbar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(async function() {
|
||||
const STORAGE_KEY = 'blog-posts-cache';
|
||||
<script>
|
||||
import * as Diff from 'diff';
|
||||
|
||||
(async function() {
|
||||
const DB_NAME = 'fuwari-rss-store';
|
||||
const DB_VERSION = 2;
|
||||
const STORE_NAME = 'posts';
|
||||
const LOCAL_STORAGE_KEY = 'blog-posts-cache';
|
||||
const NOTIFICATION_ID = 'new-post-notification';
|
||||
const LIST_ID = 'new-post-list';
|
||||
|
||||
// Compute a context-aware ID for the current site/path
|
||||
const SCOPE_ID = window.location.pathname.split('/')[1] || 'root';
|
||||
|
||||
// IndexedDB Helpers
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
// We use 'id' as the keyPath which will be a composite key
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function generateId(guid) {
|
||||
// Create a unique ID combining the scope (pathname root) and the article guid
|
||||
return `${SCOPE_ID}:${guid}`;
|
||||
}
|
||||
|
||||
function getStoredPosts(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function savePosts(db, posts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
|
||||
posts.forEach(post => {
|
||||
// Ensure we save with the scoped ID
|
||||
const itemToSave = { ...post, id: generateId(post.guid) };
|
||||
store.put(itemToSave);
|
||||
});
|
||||
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to get current RSS items
|
||||
async function fetchRSS() {
|
||||
try {
|
||||
@@ -30,43 +90,260 @@
|
||||
const xml = parser.parseFromString(text, 'text/xml');
|
||||
const items = Array.from(xml.querySelectorAll('item'));
|
||||
|
||||
return items.map(item => ({
|
||||
title: item.querySelector('title')?.textContent || '',
|
||||
link: item.querySelector('link')?.textContent || '',
|
||||
guid: item.querySelector('guid')?.textContent || item.querySelector('link')?.textContent || '',
|
||||
pubDate: new Date(item.querySelector('pubDate')?.textContent || '').getTime()
|
||||
}));
|
||||
return items.map(item => {
|
||||
const title = item.querySelector('title')?.textContent || '';
|
||||
const link = item.querySelector('link')?.textContent || '';
|
||||
const guid = item.querySelector('guid')?.textContent || link;
|
||||
const pubDate = new Date(item.querySelector('pubDate')?.textContent || '').getTime();
|
||||
const description = item.querySelector('description')?.textContent || '';
|
||||
|
||||
// Try to get content from multiple possible sources to ensure we capture it
|
||||
const contentEncoded = item.getElementsByTagNameNS('http://purl.org/rss/1.0/modules/content/', 'encoded')[0]?.textContent;
|
||||
const content = contentEncoded ||
|
||||
item.getElementsByTagName('content:encoded')[0]?.textContent ||
|
||||
item.querySelector('content')?.textContent || '';
|
||||
|
||||
return {
|
||||
title,
|
||||
link,
|
||||
guid,
|
||||
pubDate,
|
||||
description,
|
||||
content // Full content is stored here
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch RSS:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to show notification
|
||||
function showNotification(newPosts) {
|
||||
const notification = document.getElementById(NOTIFICATION_ID);
|
||||
const list = document.getElementById(LIST_ID);
|
||||
if (!notification || !list) return;
|
||||
// Helper to compute text diff
|
||||
function computeDiff(oldText, newText) {
|
||||
if (!oldText || !newText) return null;
|
||||
|
||||
// Strip HTML tags for cleaner comparison (optional, but usually better for text posts)
|
||||
const stripHtml = (html) => {
|
||||
const tmp = document.createElement("DIV");
|
||||
tmp.innerHTML = html;
|
||||
return tmp.textContent || tmp.innerText || "";
|
||||
};
|
||||
|
||||
const cleanOld = stripHtml(oldText);
|
||||
const cleanNew = stripHtml(newText);
|
||||
|
||||
// Use 'diff' library to compute line-by-line diff
|
||||
const diffs = Diff.diffLines(cleanOld, cleanNew);
|
||||
|
||||
// Filter to only show relevant changes (context)
|
||||
// We don't want to show the whole file if only one line changed.
|
||||
// Simple logic: return chunks that are added or removed, plus a bit of context.
|
||||
|
||||
// But for simplicity in UI rendering, let's just return the diff structure
|
||||
// and let the UI render it with colors.
|
||||
// We only return if there are actual changes.
|
||||
const hasChanges = diffs.some(part => part.added || part.removed);
|
||||
|
||||
if (!hasChanges) return null;
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
// Helper to show notification
|
||||
function showNotification(newPosts, timestamp, isFresh, initTime) {
|
||||
const minimizedBtn = document.getElementById('notification-minimized');
|
||||
const panel = document.getElementById('notification-panel');
|
||||
const list = document.getElementById(LIST_ID);
|
||||
const dot = document.getElementById('notification-dot');
|
||||
const minimizeBtn = document.getElementById('minimize-notification');
|
||||
|
||||
if (!minimizedBtn || !panel || !list) return;
|
||||
|
||||
// Always show the minimized bell
|
||||
minimizedBtn.classList.remove('translate-y-20', 'opacity-0');
|
||||
|
||||
const initTimeStr = new Date(initTime).toLocaleString();
|
||||
|
||||
// Logic for "No updates"
|
||||
if (newPosts.length === 0) {
|
||||
list.innerHTML = `<div class="text-center text-gray-500 dark:text-gray-400 py-4">
|
||||
<p class="text-sm">自上一次访问,暂无文章更新</p>
|
||||
<p class="text-xs mt-1 opacity-70">检查于: ${new Date(timestamp).toLocaleString()}</p>
|
||||
<p class="text-xs mt-1 opacity-50">初始化于: ${initTimeStr}</p>
|
||||
</div>`;
|
||||
dot?.classList.add('hidden');
|
||||
|
||||
// Setup event listeners even if no posts (so it can be opened)
|
||||
setupEventListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show timestamp header
|
||||
const timeStr = new Date(timestamp).toLocaleString();
|
||||
let html = `
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2 px-1 flex flex-col gap-0.5">
|
||||
<div>发现更新于: ${timeStr}</div>
|
||||
<div class="opacity-70">初始化于: ${initTimeStr}</div>
|
||||
</div>`;
|
||||
|
||||
// Show all posts, no limit
|
||||
let html = '';
|
||||
newPosts.forEach(post => {
|
||||
// Added break-words to handle long titles wrapping
|
||||
html += `<a href="${post.link}" class="block hover:text-[var(--primary)] hover:underline break-words transition-colors text-black dark:text-white mb-2 last:mb-0" target="_blank">• ${post.title}</a>`;
|
||||
const isUpdated = post.isUpdated;
|
||||
const badge = isUpdated
|
||||
? '<span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded ml-2">Updated</span>'
|
||||
: '<span class="text-xs bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-300 px-1.5 py-0.5 rounded ml-2">New</span>';
|
||||
|
||||
// Generate Diff View button if updated
|
||||
let diffButton = '';
|
||||
const safeId = 'diff-' + post.guid.replace(/[^a-zA-Z0-9-_]/g, '_');
|
||||
|
||||
if (isUpdated && post.diff) {
|
||||
diffButton = `
|
||||
<button data-diff-toggle="${safeId}" class="ml-auto text-xs text-[var(--primary)] hover:underline focus:outline-none">
|
||||
View Changes
|
||||
</button>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="mb-2 last:mb-0">
|
||||
<div class="flex items-center justify-between p-2 rounded-lg hover:bg-[var(--primary)]/5 transition-colors">
|
||||
<a href="${post.link}" class="font-medium truncate pr-2 hover:text-[var(--primary)] transition-colors text-black dark:text-white block flex-1" target="_blank">
|
||||
${post.title}
|
||||
</a>
|
||||
<div class="flex items-center shrink-0">
|
||||
${diffButton}
|
||||
${badge}
|
||||
</div>
|
||||
</div>
|
||||
${isUpdated && post.diff ? `
|
||||
<div id="${safeId}" class="hidden mt-2 p-2 bg-gray-50 dark:bg-gray-800 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 max-h-60 overflow-y-auto">
|
||||
${post.diff.map(part => {
|
||||
const colorClass = part.added ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 block my-1 p-1 rounded' :
|
||||
part.removed ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 block my-1 p-1 rounded' :
|
||||
'text-gray-500 dark:text-gray-400 block my-1 p-1';
|
||||
return `<div class="${colorClass}">${part.value}</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
list.innerHTML = html;
|
||||
|
||||
// Show animation
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('translate-y-20', 'opacity-0', 'pointer-events-none');
|
||||
}, 1000); // Slight delay after page load
|
||||
// Show red dot ONLY if it is a fresh update
|
||||
if (isFresh) {
|
||||
dot?.classList.remove('hidden');
|
||||
// Auto-open panel on first load if there are updates
|
||||
setTimeout(() => {
|
||||
openPanel();
|
||||
}, 1500);
|
||||
} else {
|
||||
dot?.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Close handler
|
||||
document.getElementById('close-notification')?.addEventListener('click', () => {
|
||||
notification.classList.add('translate-y-20', 'opacity-0', 'pointer-events-none');
|
||||
setupEventListeners();
|
||||
|
||||
function setupEventListeners() {
|
||||
// State management functions
|
||||
const openPanel = () => {
|
||||
panel.classList.remove('hidden');
|
||||
// Small delay to allow display:block to apply before transition
|
||||
requestAnimationFrame(() => {
|
||||
panel.classList.remove('translate-y-4', 'opacity-0', 'scale-95');
|
||||
});
|
||||
dot?.classList.add('hidden'); // Hide dot when opened
|
||||
};
|
||||
|
||||
const closePanel = () => {
|
||||
panel.classList.add('translate-y-4', 'opacity-0', 'scale-95');
|
||||
setTimeout(() => {
|
||||
panel.classList.add('hidden');
|
||||
}, 300); // Match transition duration
|
||||
};
|
||||
|
||||
// Event Listeners
|
||||
// Remove old listeners to prevent duplicates if called multiple times?
|
||||
// Actually, in this script structure, showNotification is called once per page load.
|
||||
minimizedBtn.onclick = () => {
|
||||
if (panel.classList.contains('hidden')) {
|
||||
openPanel();
|
||||
} else {
|
||||
closePanel();
|
||||
}
|
||||
};
|
||||
|
||||
minimizeBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
closePanel();
|
||||
};
|
||||
|
||||
// Add event delegation for toggles
|
||||
// Ensure we don't add duplicate listeners to list
|
||||
// A simple way is to re-create the listener or check via a flag.
|
||||
// But since this function runs once, it's fine.
|
||||
if (!list.hasAttribute('data-listening')) {
|
||||
list.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-diff-toggle]');
|
||||
if (btn) {
|
||||
const targetId = btn.getAttribute('data-diff-toggle');
|
||||
const target = document.getElementById(targetId);
|
||||
if (target) {
|
||||
target.classList.toggle('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
list.setAttribute('data-listening', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to trigger open from outside (used by auto-open logic above)
|
||||
// We need to expose openPanel or move the auto-open logic inside setupEventListeners or keep it here
|
||||
// The previous code called openPanel() which was defined below.
|
||||
// We need to make sure openPanel is available.
|
||||
function openPanel() {
|
||||
panel.classList.remove('hidden');
|
||||
requestAnimationFrame(() => {
|
||||
panel.classList.remove('translate-y-4', 'opacity-0', 'scale-95');
|
||||
});
|
||||
dot?.classList.add('hidden');
|
||||
}
|
||||
|
||||
const closePanel = () => {
|
||||
panel.classList.add('translate-y-4', 'opacity-0', 'scale-95');
|
||||
setTimeout(() => {
|
||||
panel.classList.add('hidden');
|
||||
}, 300); // Match transition duration
|
||||
};
|
||||
|
||||
// Event Listeners
|
||||
minimizedBtn.onclick = () => {
|
||||
if (panel.classList.contains('hidden')) {
|
||||
openPanel();
|
||||
} else {
|
||||
closePanel();
|
||||
}
|
||||
};
|
||||
|
||||
minimizeBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
closePanel();
|
||||
};
|
||||
|
||||
// Add event delegation for toggles
|
||||
list.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-diff-toggle]');
|
||||
if (btn) {
|
||||
const targetId = btn.getAttribute('data-diff-toggle');
|
||||
const target = document.getElementById(targetId);
|
||||
if (target) {
|
||||
target.classList.toggle('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-open panel on first load if it's high priority?
|
||||
// Or keep it minimized to be less intrusive?
|
||||
// Let's auto-open it briefly to catch attention, then user can minimize
|
||||
}
|
||||
|
||||
// Main logic
|
||||
try {
|
||||
@@ -76,36 +353,124 @@
|
||||
|
||||
if (isDevMode) {
|
||||
console.log('[Notification] Developer mode active');
|
||||
const mockPosts = [
|
||||
{ title: '测试新文章 1', link: '#', guid: 'test-1' },
|
||||
{ title: '测试新文章 2 - 标题很长很长很长很长很长很长很长很长很长很长很长', link: '#', guid: 'test-2' },
|
||||
{ title: '测试新文章 3', link: '#', guid: 'test-3' },
|
||||
{ title: '测试新文章 4 (应该被隐藏)', link: '#', guid: 'test-4' }
|
||||
const mockDiff = [
|
||||
{ value: "This is some unchanged text context.\n", added: undefined, removed: undefined },
|
||||
{ value: "This line was removed.\n", added: undefined, removed: true },
|
||||
{ value: "This line is new.\n", added: true, removed: undefined },
|
||||
{ value: "More unchanged text.\n", added: undefined, removed: undefined }
|
||||
];
|
||||
showNotification(mockPosts);
|
||||
return; // Skip normal logic
|
||||
|
||||
const mockPosts = [
|
||||
{ title: 'Test New Post 1', link: '#', guid: 'test-1', isUpdated: false },
|
||||
{
|
||||
title: 'Test Updated Post',
|
||||
link: '#',
|
||||
guid: 'test-2',
|
||||
isUpdated: true,
|
||||
diff: mockDiff
|
||||
},
|
||||
];
|
||||
showNotification(mockPosts, Date.now(), true);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPosts = await fetchRSS();
|
||||
if (currentPosts.length === 0) return;
|
||||
|
||||
const storedData = localStorage.getItem(STORAGE_KEY);
|
||||
const db = await openDB();
|
||||
const storedPosts = await getStoredPosts(db);
|
||||
const NOTIFICATION_STATE_KEY = 'fuwari-notification-state';
|
||||
const INIT_TIME_KEY = 'fuwari-notification-init-time';
|
||||
|
||||
if (!storedData) {
|
||||
// First visit: just cache current state
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(currentPosts.map(p => p.guid)));
|
||||
// Helper to get or set init time
|
||||
let initTimestamp = localStorage.getItem(INIT_TIME_KEY);
|
||||
if (!initTimestamp) {
|
||||
initTimestamp = Date.now().toString();
|
||||
localStorage.setItem(INIT_TIME_KEY, initTimestamp);
|
||||
}
|
||||
const initTime = parseInt(initTimestamp);
|
||||
|
||||
// Migration from localStorage (Old cache)
|
||||
const localCache = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (storedPosts.length === 0 && localCache) {
|
||||
console.log('[Notification] Migrating from localStorage...');
|
||||
// Just init DB and clear old cache
|
||||
await savePosts(db, currentPosts);
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEY);
|
||||
// Treat as first visit (no updates)
|
||||
showNotification([], Date.now(), false, initTime);
|
||||
return;
|
||||
}
|
||||
|
||||
if (storedPosts.length === 0) {
|
||||
// First visit
|
||||
await savePosts(db, currentPosts);
|
||||
// Clear any stale notification state
|
||||
localStorage.removeItem(NOTIFICATION_STATE_KEY);
|
||||
showNotification([], Date.now(), false, initTime);
|
||||
} else {
|
||||
// Subsequent visit: compare
|
||||
const cachedGuids = JSON.parse(storedData); // Array of strings
|
||||
const newPosts = currentPosts.filter(post => !cachedGuids.includes(post.guid));
|
||||
// Compare
|
||||
const storedMap = new Map(
|
||||
storedPosts
|
||||
.filter(p => p.id && p.id.startsWith(`${SCOPE_ID}:`))
|
||||
.map(p => [p.guid, p])
|
||||
);
|
||||
|
||||
// Only notify if we found new posts that are genuinely newer than what we have
|
||||
if (newPosts.length > 0) {
|
||||
showNotification(newPosts);
|
||||
const detectedChanges = [];
|
||||
|
||||
currentPosts.forEach(post => {
|
||||
const stored = storedMap.get(post.guid);
|
||||
if (!stored) {
|
||||
// New post
|
||||
detectedChanges.push({ ...post, isUpdated: false });
|
||||
} else {
|
||||
const dateUpdated = post.pubDate > stored.pubDate;
|
||||
const contentChanged = post.content && stored.content && post.content !== stored.content;
|
||||
|
||||
if (contentChanged) {
|
||||
const diff = computeDiff(stored.content, post.content);
|
||||
if (diff) {
|
||||
detectedChanges.push({ ...post, isUpdated: true, diff });
|
||||
}
|
||||
} else if (dateUpdated) {
|
||||
detectedChanges.push({ ...post, isUpdated: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (detectedChanges.length > 0) {
|
||||
// CASE 1: New updates detected
|
||||
const timestamp = Date.now();
|
||||
// Persist notification state
|
||||
const state = {
|
||||
timestamp: timestamp,
|
||||
items: detectedChanges
|
||||
};
|
||||
localStorage.setItem(NOTIFICATION_STATE_KEY, JSON.stringify(state));
|
||||
|
||||
// Update DB so we don't detect them again as "fresh" next time
|
||||
await savePosts(db, currentPosts);
|
||||
|
||||
// Show with Red Dot + Auto Open
|
||||
showNotification(detectedChanges, timestamp, true, initTime);
|
||||
} else {
|
||||
// CASE 2: No new updates
|
||||
// Check if we have a persisted notification state
|
||||
const persistedStateStr = localStorage.getItem(NOTIFICATION_STATE_KEY);
|
||||
if (persistedStateStr) {
|
||||
try {
|
||||
const state = JSON.parse(persistedStateStr);
|
||||
// Show persisted items, NO Red Dot, NO Auto Open
|
||||
showNotification(state.items, state.timestamp, false, initTime);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse notification state', e);
|
||||
showNotification([], Date.now(), false, initTime);
|
||||
}
|
||||
} else {
|
||||
// No updates and no history -> "No updates"
|
||||
showNotification([], Date.now(), false, initTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache with current state
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(currentPosts.map(p => p.guid)));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('New post check failed:', e);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { LinkPreset } from "./types/config";
|
||||
export const noticeConfig: NoticeConfig = {
|
||||
enable: true,
|
||||
level: "warning",
|
||||
content: "请注意,该博客对于每篇文章都是有评论区的,在确保你的网络可以访问 giscus.app 的情况下,如果你看不到或者必须手动刷新后才能看见,请反馈。<br>另外,如果你遇到了背景图被缩放的Bug,也请反馈。",
|
||||
content: "我们刚刚添加了文章更新系统,自此之后的每次文章更新都会通过右下角的小铃铛提醒您。",
|
||||
};
|
||||
|
||||
export const siteConfig: SiteConfig = {
|
||||
|
||||
Reference in New Issue
Block a user