Add blog functionality with post viewer and listing
Deploy to Production / deploy (push) Successful in 6s
Deploy to Production / deploy (push) Successful in 6s
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title id="page-title">Post - pyserve</title>
|
||||
<link rel="stylesheet" href="../style.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script src="scripts/blog-post.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<div id="header">
|
||||
<h1>pyserve</h1>
|
||||
<div class="tagline">python application orchestrator</div>
|
||||
</div>
|
||||
|
||||
<div class="breadcrumb">
|
||||
<a href="../index.html">Home</a> / <a href="blog.html">Blog</a> / <span id="breadcrumb-title">Post</span>
|
||||
</div>
|
||||
|
||||
<div id="main-wrapper">
|
||||
<div id="content">
|
||||
<div id="post-loading" class="loading-state">
|
||||
<p>Loading post...</p>
|
||||
</div>
|
||||
|
||||
<article id="post-content" class="blog-post-full" style="display: none;">
|
||||
<div class="post-meta">
|
||||
<span class="post-date" id="post-date"></span>
|
||||
<span class="post-tag" id="post-tag"></span>
|
||||
</div>
|
||||
|
||||
<h1 id="post-title" class="post-title-full"></h1>
|
||||
|
||||
<div class="post-author-full" id="post-author-full" style="display: none;">
|
||||
<img src="" alt="" class="author-avatar-large" id="author-avatar">
|
||||
<div class="author-info-full">
|
||||
<span class="author-name-large" id="author-name"></span>
|
||||
<span class="author-login" id="author-login"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="post-body" class="post-body-full"></div>
|
||||
|
||||
<div class="post-footer">
|
||||
<a href="blog.html" class="back-link">← Back to Blog</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div id="post-error" class="error-state" style="display: none;">
|
||||
<h2>Post Not Found</h2>
|
||||
<p>The requested blog post could not be found.</p>
|
||||
<a href="blog.html" class="back-link">← Back to Blog</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside id="sidebar">
|
||||
<div class="partner-block">
|
||||
<h3>Share</h3>
|
||||
<div style="margin-top: 15px;">
|
||||
<button onclick="copyLink()" class="share-button">📋 Copy Link</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #2a2a2a;">
|
||||
<h4 style="color: #3cb371; font-size: 13px; margin-bottom: 10px;">Navigation</h4>
|
||||
<ul class="plain" style="font-size: 12px;">
|
||||
<li><a href="blog.html">All Posts</a></li>
|
||||
<li><a href="index.html">Documentation</a></li>
|
||||
<li><a href="https://git.pyserve.org/Shifty/docs.pyserve.org" target="_blank">Repository</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div id="footer">
|
||||
<p>pyserve © 2024-2025 | MIT License</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyLink() {
|
||||
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
const btn = event.target;
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = '✓ Copied!';
|
||||
btn.style.background = '#3cb371';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.style.background = '';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Blog - pyserve</title>
|
||||
<link rel="stylesheet" href="../style.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script src="scripts/blog.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<div id="header">
|
||||
<h1>pyserve</h1>
|
||||
<div class="tagline">python application orchestrator</div>
|
||||
</div>
|
||||
|
||||
<div class="breadcrumb">
|
||||
<a href="../index.html">Home</a> / Blog
|
||||
</div>
|
||||
|
||||
<div id="main-wrapper">
|
||||
<div id="content">
|
||||
<h2>Blog & Updates</h2>
|
||||
<p>Latest news, updates and technical articles about pyserve.</p>
|
||||
|
||||
<div id="blog-loading" class="loading-state">
|
||||
<p>Loading posts...</p>
|
||||
</div>
|
||||
|
||||
<div id="blog-posts" class="blog-posts"></div>
|
||||
|
||||
<div id="blog-error" class="error-state" style="display: none;">
|
||||
<p>Failed to load blog posts. Please try again later.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside id="sidebar">
|
||||
<div class="partner-block">
|
||||
<h3>About the Blog</h3>
|
||||
<p style="font-size: 12px; color: #999; line-height: 1.6;">
|
||||
Blog posts are powered by Git releases from
|
||||
<a href="https://git.pyserve.org/Shifty/docs.pyserve.org" target="_blank">docs.pyserve.org</a> repository.
|
||||
</p>
|
||||
|
||||
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #2a2a2a;">
|
||||
<h4 style="color: #3cb371; font-size: 13px; margin-bottom: 10px;">Categories</h4>
|
||||
<ul class="plain" style="font-size: 12px;">
|
||||
<li><a href="#releases">Releases</a></li>
|
||||
<li><a href="#tutorials">Tutorials</a></li>
|
||||
<li><a href="#updates">Updates</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div id="footer">
|
||||
<p>pyserve © 2024-2025 | MIT License</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,179 @@
|
||||
// Blog Post Viewer
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function getPostId() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('id');
|
||||
}
|
||||
|
||||
async function fetchPost(postId) {
|
||||
try {
|
||||
const response = await fetch(`/api/blog/post/${postId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Post API request failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const post = await response.json();
|
||||
console.log(`✓ Fetched post: ${post.name}`);
|
||||
return post;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Post API fetch error:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date to readable string
|
||||
*/
|
||||
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 '<p>No content available.</p>';
|
||||
|
||||
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(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
|
||||
.replace(/^#### (.*$)/gim, '<h4>$1</h4>')
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
.replace(/^---$/gm, '<hr>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width: 100%; height: auto;">')
|
||||
.replace(/^\* (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
|
||||
.replace(/^\d+\. (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
|
||||
.split('\n\n')
|
||||
.map(para => {
|
||||
para = para.trim();
|
||||
if (para.match(/^<(h[1-6]|ul|ol|pre|blockquote|hr)/)) {
|
||||
return para;
|
||||
}
|
||||
return para ? `<p>${para}</p>` : '';
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render post to DOM
|
||||
*/
|
||||
function renderPost(post) {
|
||||
const loading = document.getElementById('post-loading');
|
||||
const content = document.getElementById('post-content');
|
||||
const error = document.getElementById('post-error');
|
||||
|
||||
if (!post) {
|
||||
loading.style.display = 'none';
|
||||
error.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.style.display = 'none';
|
||||
content.style.display = 'block';
|
||||
|
||||
// Update page title
|
||||
document.getElementById('page-title').textContent = `${post.name} - pyserve`;
|
||||
document.getElementById('breadcrumb-title').textContent = post.name;
|
||||
|
||||
// Update meta
|
||||
const date = formatDate(post.published_at || post.created_at);
|
||||
document.getElementById('post-date').textContent = date;
|
||||
document.getElementById('post-tag').textContent = post.tag_name || 'post';
|
||||
|
||||
// Update title
|
||||
document.getElementById('post-title').textContent = post.name;
|
||||
|
||||
// Parse and render body
|
||||
const bodyHtml = parseMarkdown(post.body);
|
||||
document.getElementById('post-body').innerHTML = bodyHtml;
|
||||
|
||||
// Update author info
|
||||
if (post.author) {
|
||||
const authorSection = document.getElementById('post-author-full');
|
||||
const avatarImg = document.getElementById('author-avatar');
|
||||
const authorName = document.getElementById('author-name');
|
||||
const authorLogin = document.getElementById('author-login');
|
||||
|
||||
if (avatarImg) {
|
||||
avatarImg.src = post.author.avatar_url || 'https://via.placeholder.com/80';
|
||||
avatarImg.alt = post.author.full_name || post.author.login;
|
||||
}
|
||||
if (authorName) authorName.textContent = post.author.full_name || post.author.login;
|
||||
if (authorLogin) authorLogin.textContent = `@${post.author.login}`;
|
||||
|
||||
authorSection.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error state
|
||||
*/
|
||||
function showError() {
|
||||
const loading = document.getElementById('post-loading');
|
||||
const error = document.getElementById('post-error');
|
||||
|
||||
loading.style.display = 'none';
|
||||
error.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Main initialization
|
||||
*/
|
||||
async function init() {
|
||||
const postId = getPostId();
|
||||
|
||||
if (!postId) {
|
||||
showError();
|
||||
return;
|
||||
}
|
||||
|
||||
const post = await fetchPost(postId);
|
||||
|
||||
if (post) {
|
||||
renderPost(post);
|
||||
} else {
|
||||
showError();
|
||||
}
|
||||
}
|
||||
|
||||
// Start when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,218 @@
|
||||
// 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();
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user