feat: (WIP) add a Fediverse Statuses Component to be used in mdx pages
This commit is contained in:
parent
7eb6685063
commit
de0456f4d4
2 changed files with 626 additions and 1 deletions
623
src/components/shortcodes/FediStatuses.astro
Normal file
623
src/components/shortcodes/FediStatuses.astro
Normal file
|
@ -0,0 +1,623 @@
|
|||
---
|
||||
|
||||
---
|
||||
{/* WIP: a Fediverse Statuses Component*/}}
|
||||
{/* mainly made for Hugo, src code https://github.com/BlockG-ws/hugo-theme-laboratory/blob/master/layouts/_default/statuses.html */}
|
||||
{/* reworked some parts to make it work in astro */}}
|
||||
<h2>My Status</h2>
|
||||
<div class="container">
|
||||
<div id="posts"></div>
|
||||
<div class="loading">Loading...</div>
|
||||
<div class="error"></div>
|
||||
<div class="lightbox" id="lightbox"></div>
|
||||
</div>
|
||||
<style is:inline>
|
||||
div.container {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.container .post {
|
||||
border: 1px solid #ccc;
|
||||
margin: 10px 0;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.container .post a {
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loading.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
display: none;
|
||||
}
|
||||
/* custom emoji */
|
||||
.custom-emoji {
|
||||
height: 1.2em;
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
margin: 0 0.1em;
|
||||
}
|
||||
|
||||
.custom-emoji.emoji-error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* attachments */
|
||||
.reblogged-content .media-attachments {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.media-attachments {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.media-attachment {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media-attachment img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.media-attachment img:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.media-attachment.video {
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
|
||||
.media-attachment video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.media-attachment audio {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.media-description {
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* the lightbox */
|
||||
.lightbox {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lightbox.active {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lightbox img {
|
||||
max-width: 90%;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* responsive */
|
||||
@media (max-width: 600px) {
|
||||
.media-attachments {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.spoiler .hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* boosted toots */
|
||||
.reblog-header {
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.reblog-header .username {
|
||||
color: #2b90d9;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.reblog-header .username:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.reblogged-content {
|
||||
border-left: 3px solid #2b90d9;
|
||||
padding-left: 15px;
|
||||
margin: 10px 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.post-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.author-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.author-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.account-name {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* post actions */
|
||||
.post-actions {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #eee;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.post-actions .timestamp {
|
||||
color: #666;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.post-link {
|
||||
color: #666;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.post-link:hover {
|
||||
color: #2b90d9;
|
||||
}
|
||||
|
||||
.post-link svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
/* content warnings */
|
||||
.content-warning {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.spoiler-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.spoiler-text {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.spoiler-toggle {
|
||||
background-color: #4a4a4a;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.spoiler-toggle:hover {
|
||||
background-color: #666;
|
||||
}
|
||||
|
||||
.spoiler-content {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.spoiler-content[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* for static */
|
||||
.status-metadata {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
.visibility {
|
||||
text-transform: capitalize;
|
||||
color: #888;
|
||||
}
|
||||
.source {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
function createFetcher(instanceDomain, userId, extraParams={}) {
|
||||
const state = {
|
||||
instanceDomain,
|
||||
userId,
|
||||
extraParams,
|
||||
posts: [],
|
||||
loading: false,
|
||||
hasMore: true,
|
||||
maxId: null,
|
||||
emojiCache: new Map()
|
||||
};
|
||||
|
||||
const postsContainer = document.getElementById('posts');
|
||||
const loadingElement = document.querySelector('.loading');
|
||||
const errorElement = document.querySelector('.error');
|
||||
const lightbox = document.getElementById('lightbox');
|
||||
|
||||
// Setup lightbox
|
||||
lightbox.addEventListener('click', () => {
|
||||
lightbox.classList.remove('active');
|
||||
lightbox.innerHTML = '';
|
||||
});
|
||||
|
||||
// Setup content warning toggles
|
||||
postsContainer.addEventListener('click', (event) => {
|
||||
const toggleButton = event.target.closest('.spoiler-toggle');
|
||||
|
||||
if (toggleButton) {
|
||||
const spoilerWrapper = toggleButton.closest('.content-warning');
|
||||
const spoilerContent = spoilerWrapper.querySelector('.spoiler-content');
|
||||
|
||||
const isHidden = spoilerContent.hidden;
|
||||
spoilerContent.hidden = !isHidden;
|
||||
|
||||
toggleButton.setAttribute('aria-expanded', !isHidden);
|
||||
toggleButton.textContent = isHidden ? 'Hide content' : 'Show content';
|
||||
}
|
||||
});
|
||||
|
||||
// Throttle function to limit execution rate
|
||||
function throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Setup scroll listener for infinite loading
|
||||
window.addEventListener('scroll', throttle(() => {
|
||||
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 1000) {
|
||||
if (!state.loading && state.hasMore) {
|
||||
loadPosts();
|
||||
}
|
||||
}
|
||||
}, 200));
|
||||
|
||||
function showInLightbox(imageUrl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = imageUrl;
|
||||
lightbox.innerHTML = '';
|
||||
lightbox.appendChild(img);
|
||||
lightbox.classList.add('active');
|
||||
}
|
||||
|
||||
function updateEmojiCache(emojis) {
|
||||
emojis.forEach(emoji => {
|
||||
state.emojiCache.set(emoji.shortcode, emoji.url);
|
||||
});
|
||||
}
|
||||
|
||||
function replaceEmojis(content, emojis) {
|
||||
// Update emoji cache
|
||||
updateEmojiCache(emojis);
|
||||
|
||||
// Match all :shortcode: format text
|
||||
return content.replace(/:([a-zA-Z0-9_]+):/g, (match, shortcode) => {
|
||||
const emojiUrl = state.emojiCache.get(shortcode);
|
||||
if (emojiUrl) {
|
||||
return `<img
|
||||
class="custom-emoji"
|
||||
src="${emojiUrl}"
|
||||
alt=":${shortcode}:"
|
||||
title=":${shortcode}:"
|
||||
onerror="this.classList.add('emoji-error')"
|
||||
>`;
|
||||
}
|
||||
// Keep original text if emoji not found
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
function renderMediaAttachments(attachments) {
|
||||
if (!attachments || attachments.length === 0) return '';
|
||||
|
||||
const mediaHtml = attachments.map(attachment => {
|
||||
const description = attachment.description ?
|
||||
`<div class="media-description">${attachment.description}</div>` : '';
|
||||
|
||||
switch (attachment.type) {
|
||||
case 'image':
|
||||
return `
|
||||
<div class="media-attachment">
|
||||
<img
|
||||
src="${attachment.preview_url || attachment.url}"
|
||||
alt="${attachment.description || ''}"
|
||||
loading="lazy"
|
||||
onclick="window.fetcher.showInLightbox('${attachment.url}')"
|
||||
>
|
||||
${description}
|
||||
</div>`;
|
||||
case 'video':
|
||||
return `
|
||||
<div class="media-attachment video">
|
||||
<video
|
||||
controls
|
||||
preload="metadata"
|
||||
poster="${attachment.preview_url}"
|
||||
>
|
||||
<source src="${attachment.url}" type="video/mp4">
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
${description}
|
||||
</div>`;
|
||||
case 'audio':
|
||||
return `
|
||||
<div class="media-attachment">
|
||||
<audio controls preload="metadata">
|
||||
<source src="${attachment.url}">
|
||||
Your browser does not support audio playback.
|
||||
</audio>
|
||||
${description}
|
||||
</div>`;
|
||||
default:
|
||||
return `
|
||||
<div class="media-attachment">
|
||||
<a href="${attachment.url}" target="_blank">
|
||||
Download media
|
||||
</a>
|
||||
${description}
|
||||
</div>`;
|
||||
}
|
||||
}).join('');
|
||||
|
||||
return `<div class="media-attachments">${mediaHtml}</div>`;
|
||||
}
|
||||
|
||||
function renderAuthor(account) {
|
||||
return `
|
||||
<div class="post-author">
|
||||
<img
|
||||
class="author-avatar"
|
||||
src="${account.avatar}"
|
||||
alt="${account.display_name}"
|
||||
loading="lazy"
|
||||
>
|
||||
<div class="author-info">
|
||||
<span class="display-name">
|
||||
${replaceEmojis(account.display_name, account.emojis || [])}
|
||||
</span>
|
||||
<span class="account-name">@${account.acct}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPostActions(post) {
|
||||
const postUrl = post.url;
|
||||
return `
|
||||
<div class="post-actions">
|
||||
<span class="timestamp">${new Date(post.created_at).toLocaleString()}</span>
|
||||
<a href="${postUrl}"
|
||||
class="post-link"
|
||||
target="_blank"
|
||||
title="在新窗口打开帖子">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z" />
|
||||
</svg>
|
||||
打开原帖
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPostContent(post) {
|
||||
const processedContent = replaceEmojis(post.content, post.emojis || []);
|
||||
const mediaAttachments = renderMediaAttachments(post.media_attachments);
|
||||
|
||||
if (post.sensitive) {
|
||||
return `
|
||||
${renderAuthor(post.account)}
|
||||
<div class="content-warning">
|
||||
<div class="spoiler-header">
|
||||
<span class="spoiler-text">${replaceEmojis(post.spoiler_text, post.emojis || [])}</span>
|
||||
<button class="spoiler-toggle" aria-expanded="false">
|
||||
Show content
|
||||
</button>
|
||||
</div>
|
||||
<div class="spoiler-content" hidden>
|
||||
${replaceEmojis(post.content, post.emojis || [])}
|
||||
${mediaAttachments}
|
||||
</div>
|
||||
</div>
|
||||
${renderPostActions(post)}
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
${renderAuthor(post.account)}
|
||||
<div class="content">${processedContent}</div>
|
||||
${mediaAttachments}
|
||||
${renderPostActions(post)}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPosts(posts) {
|
||||
posts.forEach(post => {
|
||||
const postElement = document.createElement('div');
|
||||
postElement.className = 'post';
|
||||
|
||||
if (post.reblog) {
|
||||
// Reblogged post
|
||||
postElement.innerHTML = `
|
||||
<div class="reblog-header">
|
||||
<a href="${state.instanceDomain}/@${post.account.acct}"
|
||||
class="username"
|
||||
target="_blank">
|
||||
${post.account.display_name}
|
||||
</a>
|
||||
转发了
|
||||
</div>
|
||||
<div class="reblogged-content">
|
||||
${renderPostContent(post.reblog)}
|
||||
</div>
|
||||
<div class="post-actions">
|
||||
<span class="timestamp">${new Date(post.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Original post
|
||||
postElement.innerHTML = renderPostContent(post);
|
||||
}
|
||||
|
||||
postsContainer.appendChild(postElement);
|
||||
});
|
||||
}
|
||||
|
||||
function showLoading() {
|
||||
loadingElement.classList.add('visible');
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
loadingElement.classList.remove('visible');
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
errorElement.textContent = message;
|
||||
errorElement.style.display = 'block';
|
||||
}
|
||||
|
||||
async function loadPosts() {
|
||||
try {
|
||||
state.loading = true;
|
||||
showLoading();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
limit: '20'
|
||||
});
|
||||
|
||||
if (state.maxId) {
|
||||
params.append('max_id', state.maxId);
|
||||
}
|
||||
|
||||
// Add extra parameters
|
||||
for (const [key, value] of Object.entries(state.extraParams)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://${state.instanceDomain}/api/v1/accounts/${state.userId}/statuses?${params}`,
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch posts');
|
||||
}
|
||||
|
||||
const posts = await response.json();
|
||||
|
||||
if (posts.length === 0) {
|
||||
state.hasMore = false;
|
||||
return;
|
||||
}
|
||||
|
||||
state.maxId = posts[posts.length - 1].id;
|
||||
state.posts = [...state.posts, ...posts];
|
||||
renderPosts(posts);
|
||||
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
state.loading = false;
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize fetching
|
||||
loadPosts();
|
||||
|
||||
// Return the public API
|
||||
return {
|
||||
loadMore: loadPosts,
|
||||
showInLightbox
|
||||
};
|
||||
}
|
||||
window.fetcher = createFetcher('', '', {
|
||||
exclude_replies: true,
|
||||
exclude_reblogs: true,
|
||||
pinned: false,
|
||||
only_media: false,
|
||||
limit: 20
|
||||
});
|
||||
</script>
|
Loading…
Add table
Add a link
Reference in a new issue