Илья Глазунов 4954a3e613
All checks were successful
Deploy to Production / deploy (push) Successful in 6s
Add blog functionality with post viewer and listing
2025-12-08 01:18:15 +03:00

219 lines
7.4 KiB
JavaScript

// Blog System - Powered by Gitea Releases
(function() {
'use strict';
const API_URL = '/api/blog/posts';
const CACHE_KEY = 'pyserve_blog_cache';
const CACHE_DURATION = 1800000;
async function fetchBlogPosts() {
try {
const response = await fetch(API_URL, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
console.warn(`Blog API request failed: ${response.status}`);
return null;
}
const posts = await response.json();
console.log(`✓ Fetched ${posts.length} blog posts`);
return posts;
} catch (error) {
console.error('Blog API fetch error:', error.message);
return null;
}
}
function getCachedPosts() {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) return null;
const data = JSON.parse(cached);
const now = Date.now();
if (now - data.timestamp < CACHE_DURATION) {
return data.posts;
}
localStorage.removeItem(CACHE_KEY);
return null;
} catch (error) {
console.error('Cache read error:', error);
return null;
}
}
function cachePosts(posts) {
try {
const data = {
posts: posts,
timestamp: Date.now()
};
localStorage.setItem(CACHE_KEY, JSON.stringify(data));
} catch (error) {
console.error('Cache write error:', error);
}
}
function formatDate(dateString) {
const date = new Date(dateString);
const options = { year: 'numeric', month: 'long', day: 'numeric' };
return date.toLocaleDateString('en-US', options);
}
function parseMarkdown(markdown) {
if (!markdown) return '';
if (typeof marked !== 'undefined' && typeof hljs !== 'undefined') {
marked.setOptions({
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return hljs.highlightAuto(code).value;
},
breaks: true,
gfm: true
});
return marked.parse(markdown);
}
let html = markdown
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
return '<p>' + html + '</p>';
}
function extractExcerpt(markdown, maxLength = 200) {
if (!markdown) return 'No description available.';
// Remove markdown formatting and get first paragraph
const plain = markdown
.replace(/^#+\s+.*/gm, '') // Remove headers
.replace(/\*\*(.+?)\*\*/g, '$1') // Remove bold
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`([^`]+)`/g, '$1') // Remove inline code
.trim();
const firstParagraph = plain.split('\n\n')[0] || plain.split('\n')[0];
if (firstParagraph.length > maxLength) {
return firstParagraph.substring(0, maxLength) + '...';
}
return firstParagraph || 'No description available.';
}
function createPostCard(post) {
const excerpt = extractExcerpt(post.body);
const date = formatDate(post.published_at || post.created_at);
const tag = post.tag_name || 'post';
const author = post.author || {};
const authorName = author.full_name || author.login || 'Unknown';
const authorLogin = author.login || '';
const authorAvatar = author.avatar_url || '';
return `
<article class="blog-post-card">
<div class="post-meta">
<span class="post-date">${date}</span>
<span class="post-tag">${tag}</span>
</div>
<h3 class="post-title">
<a href="blog-post.html?id=${tag}">${post.name || tag}</a>
</h3>
<div class="post-excerpt">${excerpt}</div>
<div class="post-author">
<img src="${authorAvatar}" alt="${authorName}" class="author-avatar" onerror="this.style.display='none'">
<div class="author-info">
<span class="author-name">${authorName}</span>
${authorLogin ? `<span class="author-login">@${authorLogin}</span>` : ''}
</div>
</div>
<a href="blog-post.html?id=${tag}" class="read-more">Read more →</a>
</article>
`;
}
function renderPosts(posts) {
const container = document.getElementById('blog-posts');
const loading = document.getElementById('blog-loading');
const error = document.getElementById('blog-error');
if (!container) return;
loading.style.display = 'none';
if (!posts || posts.length === 0) {
container.innerHTML = `
<div class="note">
<strong>No posts yet</strong>
<p>Check back soon for updates and articles!</p>
</div>
`;
return;
}
// Sort by date (newest first)
const sortedPosts = posts.sort((a, b) => {
const dateA = new Date(a.published_at || a.created_at);
const dateB = new Date(b.published_at || b.created_at);
return dateB - dateA;
});
// Render posts
container.innerHTML = sortedPosts.map(post => createPostCard(post)).join('');
}
function showError() {
const loading = document.getElementById('blog-loading');
const error = document.getElementById('blog-error');
if (loading) loading.style.display = 'none';
if (error) error.style.display = 'block';
}
async function init() {
// Try cached posts first
const cachedPosts = getCachedPosts();
if (cachedPosts) {
renderPosts(cachedPosts);
}
// Fetch fresh posts in background
const posts = await fetchBlogPosts();
if (posts) {
cachePosts(posts);
renderPosts(posts);
} else if (!cachedPosts) {
showError();
}
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();