mirror of
https://github.com/afoim/fuwari.git
synced 2026-01-31 00:53:19 +08:00
feat(widget): 新增文章更新检测与通知组件
- 添加新文章通知组件,通过比对 RSS 源检测新发布的文章并在页面右下角显示通知 - 添加内容差异高亮组件,通过比对本地缓存检测文章内容更新并高亮显示变化部分 - 新增 diff 依赖包用于文本差异比较,同时添加对应的类型定义 - 在布局中引入新组件,支持开发者调试模式
This commit is contained in:
@@ -37,6 +37,7 @@
|
||||
"astro": "5.7.9",
|
||||
"astro-expressive-code": "^0.41.3",
|
||||
"astro-icon": "^1.1.5",
|
||||
"diff": "^8.0.3",
|
||||
"katex": "^0.16.22",
|
||||
"overlayscrollbars": "^2.11.1",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
@@ -57,6 +58,7 @@
|
||||
"@iconify-json/material-symbols-light": "^1.2.49",
|
||||
"@iconify-json/simple-icons": "^1.2.42",
|
||||
"@rollup/plugin-yaml": "^4.1.2",
|
||||
"@types/diff": "^8.0.0",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/sanitize-html": "^2.15.0",
|
||||
|
||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@@ -76,6 +76,9 @@ importers:
|
||||
astro-icon:
|
||||
specifier: ^1.1.5
|
||||
version: 1.1.5
|
||||
diff:
|
||||
specifier: ^8.0.3
|
||||
version: 8.0.3
|
||||
katex:
|
||||
specifier: ^0.16.22
|
||||
version: 0.16.22
|
||||
@@ -131,6 +134,9 @@ importers:
|
||||
'@rollup/plugin-yaml':
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2(rollup@2.79.2)
|
||||
'@types/diff':
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
'@types/markdown-it':
|
||||
specifier: ^14.1.2
|
||||
version: 14.1.2
|
||||
@@ -1728,6 +1734,10 @@ packages:
|
||||
'@types/debug@4.1.12':
|
||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||
|
||||
'@types/diff@8.0.0':
|
||||
resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==}
|
||||
deprecated: This is a stub types definition. diff provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/estree@0.0.39':
|
||||
resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==}
|
||||
|
||||
@@ -2354,6 +2364,10 @@ packages:
|
||||
resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
diff@8.0.3:
|
||||
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
direction@2.0.1:
|
||||
resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==}
|
||||
hasBin: true
|
||||
@@ -6877,6 +6891,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
|
||||
'@types/diff@8.0.0':
|
||||
dependencies:
|
||||
diff: 8.0.3
|
||||
|
||||
'@types/estree@0.0.39': {}
|
||||
|
||||
'@types/estree@1.0.7': {}
|
||||
@@ -7667,6 +7685,8 @@ snapshots:
|
||||
|
||||
diff@5.2.0: {}
|
||||
|
||||
diff@8.0.3: {}
|
||||
|
||||
direction@2.0.1: {}
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
||||
114
src/components/widget/NewPostNotification.astro
Normal file
114
src/components/widget/NewPostNotification.astro
Normal file
@@ -0,0 +1,114 @@
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(async function() {
|
||||
const STORAGE_KEY = 'blog-posts-cache';
|
||||
const NOTIFICATION_ID = 'new-post-notification';
|
||||
const LIST_ID = 'new-post-list';
|
||||
|
||||
// Helper to get current RSS items
|
||||
async function fetchRSS() {
|
||||
try {
|
||||
const response = await fetch('/rss.xml');
|
||||
const text = await response.text();
|
||||
const parser = new DOMParser();
|
||||
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()
|
||||
}));
|
||||
} 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;
|
||||
|
||||
// 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>`;
|
||||
});
|
||||
|
||||
list.innerHTML = html;
|
||||
|
||||
// Show animation
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('translate-y-20', 'opacity-0', 'pointer-events-none');
|
||||
}, 1000); // Slight delay after page load
|
||||
|
||||
// Close handler
|
||||
document.getElementById('close-notification')?.addEventListener('click', () => {
|
||||
notification.classList.add('translate-y-20', 'opacity-0', 'pointer-events-none');
|
||||
});
|
||||
}
|
||||
|
||||
// Main logic
|
||||
try {
|
||||
// Developer Mode Check
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isDevMode = urlParams.has('debug-notification');
|
||||
|
||||
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' }
|
||||
];
|
||||
showNotification(mockPosts);
|
||||
return; // Skip normal logic
|
||||
}
|
||||
|
||||
const currentPosts = await fetchRSS();
|
||||
if (currentPosts.length === 0) return;
|
||||
|
||||
const storedData = localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if (!storedData) {
|
||||
// First visit: just cache current state
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(currentPosts.map(p => p.guid)));
|
||||
} else {
|
||||
// Subsequent visit: compare
|
||||
const cachedGuids = JSON.parse(storedData); // Array of strings
|
||||
const newPosts = currentPosts.filter(post => !cachedGuids.includes(post.guid));
|
||||
|
||||
// Only notify if we found new posts that are genuinely newer than what we have
|
||||
if (newPosts.length > 0) {
|
||||
showNotification(newPosts);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
146
src/components/widget/PostContentHighlighter.astro
Normal file
146
src/components/widget/PostContentHighlighter.astro
Normal file
@@ -0,0 +1,146 @@
|
||||
<div id="post-update-notification" class="fixed top-20 right-4 z-50 transform translate-x-full opacity-0 transition-all duration-300 pointer-events-none">
|
||||
<div class="bg-[var(--card-bg)] border border-[var(--admonitions-color-tip)] rounded-xl shadow-lg p-4 max-w-sm relative pointer-events-auto flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 text-[var(--admonitions-color-tip)]">
|
||||
<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="12" y1="18" x2="12" y2="12"></line><line x1="9" y1="15" x2="15" y2="15"></line></svg>
|
||||
<span class="font-bold text-sm">内容已更新</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-main)] opacity-80">检测到文章内容有变化,已为您高亮差异部分。</p>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<button id="scroll-to-diff" class="px-3 py-1 bg-[var(--admonitions-color-tip)] text-white rounded text-xs hover:opacity-90 transition-opacity">
|
||||
跳转到更新处
|
||||
</button>
|
||||
<button id="close-diff-toast" class="px-3 py-1 bg-transparent border border-[var(--line-divider)] text-[var(--text-main)] rounded text-xs hover:bg-[var(--btn-regular-bg-hover)] transition-colors">
|
||||
忽略
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import * as Diff from 'diff';
|
||||
|
||||
// Helper to get pure text content from markdown container
|
||||
function getContentText() {
|
||||
const container = document.querySelector('.markdown-content');
|
||||
return container ? container.textContent || '' : '';
|
||||
}
|
||||
|
||||
// Helper to store content hash/text
|
||||
const STORAGE_PREFIX = 'post-content-cache-';
|
||||
|
||||
async function initDiffCheck() {
|
||||
// Only run on post pages
|
||||
const postContainer = document.getElementById('post-container');
|
||||
if (!postContainer) return;
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
const storageKey = STORAGE_PREFIX + currentPath;
|
||||
const currentText = getContentText();
|
||||
|
||||
// Developer Mode Check
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isDevMode = urlParams.has('debug-diff');
|
||||
|
||||
if (isDevMode) {
|
||||
console.log('[Diff] Developer mode active');
|
||||
// Mock old text by removing a random paragraph or adding dummy text
|
||||
const paragraphs = currentText.split('\n\n');
|
||||
// Simulate that the middle paragraph is new
|
||||
const middleIndex = Math.floor(paragraphs.length / 2);
|
||||
const oldText = paragraphs.filter((_, i) => i !== middleIndex).join('\n\n');
|
||||
|
||||
highlightDiff(oldText, currentText);
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedText = localStorage.getItem(storageKey);
|
||||
|
||||
if (!cachedText) {
|
||||
// First visit, cache current text
|
||||
localStorage.setItem(storageKey, currentText);
|
||||
} else if (cachedText !== currentText) {
|
||||
// Content changed
|
||||
console.log('[Diff] Content change detected');
|
||||
highlightDiff(cachedText, currentText);
|
||||
// Update cache
|
||||
localStorage.setItem(storageKey, currentText);
|
||||
}
|
||||
}
|
||||
|
||||
function highlightDiff(oldText, newText) {
|
||||
const diff = Diff.diffWords(oldText, newText);
|
||||
const container = document.querySelector('.markdown-content');
|
||||
if (!container) return;
|
||||
|
||||
// We can't easily replace the HTML because it would break structure/components/images
|
||||
// Strategy: Find the first significant added text chunk and scroll to it/highlight it via a Range/Mark
|
||||
// But since we are in a static site, the DOM structure might have changed significantly.
|
||||
// A safer visual approach for this specific request "highlight updates":
|
||||
// Since we cannot reliably reconstruct the HTML from the Diff output without a complex parser,
|
||||
// we will try to match the text node in the DOM.
|
||||
|
||||
// Filter for added parts that are significant (e.g., > 10 chars) to avoid noise
|
||||
const addedParts = diff.filter(part => part.added && part.value.trim().length > 10);
|
||||
|
||||
if (addedParts.length === 0) return;
|
||||
|
||||
// Show Notification
|
||||
const notification = document.getElementById('post-update-notification');
|
||||
if (notification) {
|
||||
notification.classList.remove('translate-x-full', 'opacity-0', 'pointer-events-none');
|
||||
|
||||
document.getElementById('close-diff-toast')?.addEventListener('click', () => {
|
||||
notification.classList.add('translate-x-full', 'opacity-0', 'pointer-events-none');
|
||||
});
|
||||
|
||||
document.getElementById('scroll-to-diff')?.addEventListener('click', () => {
|
||||
scrollToFirstDiff(addedParts[0].value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToFirstDiff(textToFind) {
|
||||
// Try to find the text node containing this text
|
||||
// This is a naive implementation; complex markdown structures might split text across nodes
|
||||
// But for a "jump to" feature it often suffices to find a substring
|
||||
|
||||
const container = document.querySelector('.markdown-content');
|
||||
if (!container) return;
|
||||
|
||||
// Clean up search text (remove newlines, extra spaces)
|
||||
const searchStr = textToFind.trim().substring(0, 50); // Search for the first 50 chars
|
||||
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
|
||||
let node;
|
||||
while (node = walker.nextNode()) {
|
||||
if (node.textContent && node.textContent.includes(searchStr)) {
|
||||
// Found it!
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
// Highlight
|
||||
const span = document.createElement('span');
|
||||
span.style.backgroundColor = 'rgba(255, 255, 0, 0.3)';
|
||||
span.style.transition = 'background-color 1s';
|
||||
span.className = 'diff-highlight-flash';
|
||||
|
||||
// Wrap the text node (careful not to break DOM if possible, but this is a leaf text node)
|
||||
// For safety, let's just scroll parent into view and add a class
|
||||
const parent = node.parentElement;
|
||||
if (parent) {
|
||||
parent.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
parent.classList.add('animate-pulse'); // Tailwind pulse
|
||||
setTimeout(() => parent.classList.remove('animate-pulse'), 2000);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on load and swup navigation
|
||||
document.addEventListener('DOMContentLoaded', initDiffCheck);
|
||||
document.addEventListener('swup:contentReplaced', initDiffCheck);
|
||||
|
||||
</script>
|
||||
@@ -5,6 +5,7 @@ import "@fontsource/roboto/700.css";
|
||||
|
||||
import { profileConfig, siteConfig } from "@/config";
|
||||
import ConfigCarrier from "@components/ConfigCarrier.astro";
|
||||
import NewPostNotification from "@components/widget/NewPostNotification.astro";
|
||||
import {
|
||||
AUTO_MODE,
|
||||
BANNER_HEIGHT,
|
||||
@@ -248,6 +249,7 @@ const bannerOffset =
|
||||
</noscript>
|
||||
<div id="bg-box"></div>
|
||||
<ConfigCarrier></ConfigCarrier>
|
||||
<NewPostNotification></NewPostNotification>
|
||||
<slot />
|
||||
|
||||
<!-- increase the page height during page transition to prevent the scrolling animation from jumping -->
|
||||
|
||||
Reference in New Issue
Block a user