feat: implement comprehensive blog system

Add dual-source blog system with Editor.js integration:
- Blog storage supporting repo-based (JSON) and database sources
- Admin panel with rich text editor using Editor.js
- Public news page with infinite scroll
- Individual blog post viewer page
- Categories management in admin
- Image upload functionality
- 4 SEO blog posts about WordPress with PluginCompass promotion
- 3 News blog posts about Plugin Compass
- API endpoints for CRUD operations
- Security and validation for admin operations

Closes blog feature request
This commit is contained in:
southseact-3d
2026-02-10 13:23:37 +00:00
parent 82ae9687b8
commit cfd8d9c706
17 changed files with 5126 additions and 10 deletions

View File

@@ -0,0 +1,428 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Blog Management - Admin</title>
<link rel="stylesheet" href="/styles.css" />
<!-- Editor.js -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/header@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/paragraph@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/list@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/link@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/image@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/embed@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/code@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/quote@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/table@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/marker@latest"></script>
<style>
.editor-wrapper {
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
min-height: 400px;
background: var(--bg-1);
}
.ce-block__content {
max-width: 100% !important;
}
.ce-toolbar__content {
max-width: 100% !important;
}
.blog-list {
display: grid;
gap: 12px;
}
.blog-item {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 12px;
align-items: center;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-1);
}
.blog-item.repo {
border-left: 4px solid var(--info);
}
.blog-item.database {
border-left: 4px solid var(--success);
}
.blog-meta {
font-size: 0.85em;
color: var(--muted);
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.blog-meta span {
display: flex;
align-items: center;
gap: 4px;
}
.type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75em;
font-weight: 500;
text-transform: uppercase;
}
.type-news {
background: var(--info-bg, #e0f2fe);
color: var(--info, #0284c7);
}
.type-seo {
background: var(--warning-bg, #fef3c7);
color: var(--warning, #d97706);
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75em;
font-weight: 500;
text-transform: uppercase;
}
.status-published {
background: var(--success-bg, #d1fae5);
color: var(--success, #059669);
}
.status-draft {
background: var(--muted-bg, #f3f4f6);
color: var(--muted, #6b7280);
}
.source-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75em;
font-weight: 500;
}
.source-repo {
background: #dbeafe;
color: #2563eb;
}
.source-database {
background: #d1fae5;
color: #059669;
}
.category-list {
display: grid;
gap: 8px;
}
.category-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-1);
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal {
background: var(--bg-1);
border-radius: 12px;
max-width: 900px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
padding: 24px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.modal-title {
font-size: 1.5em;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 1.5em;
cursor: pointer;
color: var(--muted);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
}
.blog-item {
grid-template-columns: 1fr;
gap: 8px;
}
}
.hidden { display: none !important; }
.filters {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.filters select, .filters input {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-1);
}
.tab-buttons {
display: flex;
gap: 8px;
margin-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.tab-button {
padding: 12px 20px;
background: none;
border: none;
cursor: pointer;
color: var(--muted);
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab-button.active {
color: var(--text);
border-bottom-color: var(--primary);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
<!-- PostHog Analytics -->
<script src="/posthog.js"></script>
</head>
<body data-page="blogs">
<div class="sidebar-overlay"></div>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">A</div>
<div>
<div class="brand-title">Admin</div>
<div class="brand-sub">Site management</div>
</div>
<button id="close-sidebar" class="ghost" style="margin-left: auto; display: none;">&times;</button>
</div>
<div class="sidebar-section">
<div class="section-heading">Navigation</div>
<a class="ghost" href="/admin/build">Build models</a>
<a class="ghost" href="/admin/plan">Plan models</a>
<a class="ghost" href="/admin/plans">Plans</a>
<a class="ghost" href="/admin/accounts">Accounts</a>
<a class="ghost" href="/admin/affiliates">Affiliates</a>
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
<a class="ghost" href="/admin/tracking">Tracking</a>
<a class="ghost" href="/admin/resources">Resources</a>
<a class="ghost" href="/admin/external-testing">External Testing</a>
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
<a class="ghost active" href="/admin/blogs">Blog Management</a>
<a class="ghost" href="/admin/login">Login</a>
</div>
</aside>
<main class="main">
<div class="admin-shell">
<div class="topbar" style="margin-bottom: 12px;">
<button id="menu-toggle">
<span></span><span></span><span></span>
</button>
<div>
<div class="pill">Admin</div>
<div class="title" style="margin-top: 6px;">Blog Management</div>
<div class="crumb">Manage news and SEO blog posts</div>
</div>
<div class="admin-actions">
<button id="refresh-blogs" class="ghost">Refresh</button>
<button id="new-post" class="primary">New Post</button>
<button id="admin-logout" class="ghost">Logout</button>
</div>
</div>
<div class="tab-buttons">
<button class="tab-button active" data-tab="posts">Blog Posts</button>
<button class="tab-button" data-tab="categories">Categories</button>
</div>
<!-- Posts Tab -->
<div id="posts-tab" class="tab-content active">
<div class="filters">
<select id="filter-type">
<option value="">All Types</option>
<option value="news">News</option>
<option value="seo">SEO</option>
</select>
<select id="filter-status">
<option value="">All Status</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
</select>
<select id="filter-source">
<option value="">All Sources</option>
<option value="database">Database</option>
<option value="repo">Repository</option>
</select>
<input type="text" id="filter-search" placeholder="Search posts..." />
</div>
<div id="blog-list" class="blog-list">
<p>Loading posts...</p>
</div>
</div>
<!-- Categories Tab -->
<div id="categories-tab" class="tab-content">
<div class="admin-card" style="margin-bottom: 16px;">
<header>
<h3>Add New Category</h3>
</header>
<form id="category-form" class="admin-form">
<div class="form-row">
<label>
Category Name
<input type="text" id="category-name" placeholder="e.g., Tutorials" required />
</label>
<label>
Slug
<input type="text" id="category-slug" placeholder="e.g., tutorials" required />
</label>
</div>
<label>
Description
<textarea id="category-description" rows="2" placeholder="Brief description..."></textarea>
</label>
<div class="admin-actions">
<button type="submit" class="primary">Add Category</button>
</div>
</form>
</div>
<div class="admin-card">
<header>
<h3>Categories</h3>
</header>
<div id="category-list" class="category-list">
<p>Loading categories...</p>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Blog Post Modal -->
<div id="post-modal" class="modal-overlay hidden">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title" id="modal-title">New Post</h2>
<button class="modal-close" id="modal-close">&times;</button>
</div>
<form id="post-form">
<input type="hidden" id="post-id" />
<div class="form-row">
<label>
Title
<input type="text" id="post-title" placeholder="Post title" required />
</label>
<label>
Slug
<input type="text" id="post-slug" placeholder="url-friendly-slug" required />
</label>
</div>
<div class="form-row">
<label>
Type
<select id="post-type" required>
<option value="news">News</option>
<option value="seo">SEO</option>
</select>
</label>
<label>
Status
<select id="post-status">
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</label>
</div>
<div class="form-row">
<label>
Category
<select id="post-category">
<option value="">No Category</option>
</select>
</label>
<label>
Author
<input type="text" id="post-author" placeholder="Author name" />
</label>
</div>
<label>
Excerpt
<textarea id="post-excerpt" rows="2" placeholder="Brief description of the post..."></textarea>
</label>
<div class="form-row">
<label>
Meta Title
<input type="text" id="post-meta-title" placeholder="SEO title" />
</label>
<label>
Meta Description
<input type="text" id="post-meta-description" placeholder="SEO description" />
</label>
</div>
<label>
Featured Image URL
<input type="text" id="post-featured-image" placeholder="/blogs/images/image.jpg" />
</label>
<label>
Tags (comma separated)
<input type="text" id="post-tags" placeholder="wordpress, tutorial, guide" />
</label>
<label style="margin-top: 16px;">
Content
<div id="editor" class="editor-wrapper"></div>
</label>
<div class="admin-actions" style="margin-top: 20px;">
<button type="submit" class="primary" id="save-post">Save Post</button>
<button type="button" class="ghost" id="cancel-post">Cancel</button>
<span id="post-source" class="source-badge" style="margin-left: auto;"></span>
</div>
</form>
</div>
</div>
<script src="/admin.js"></script>
<script src="/js/blog-admin.js"></script>
</body>
</html>

View File

@@ -45,6 +45,7 @@
<a class="ghost" href="/admin/resources">Resources</a>
<a class="ghost" href="/admin/external-testing">External Testing</a>
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
<a class="ghost" href="/admin/blogs">Blog Management</a>
<a class="ghost" href="/admin/login">Login</a>
</div>
</aside>

454
chat/public/blog.html Normal file
View File

@@ -0,0 +1,454 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Loading blog post...">
<title>Blog - Plugin Compass</title>
<link rel="icon" type="image/png" href="/assets/Plugin.png">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script>
(function applyTailwindConfig(cfg, attempts = 0) {
try {
if (typeof window.tailwind !== 'undefined') {
window.tailwind.config = cfg;
return;
}
} catch (e) {}
if (attempts >= 20) return;
setTimeout(() => applyTailwindConfig(cfg, attempts + 1), 100);
})({
theme: {
extend: {
fontFamily: { sans: ['Inter', 'sans-serif'] },
colors: {
brand: {
50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 300: '#a5b4fc',
400: '#818cf8', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca',
800: '#3730a3', 900: '#312e81'
}
}
}
}
});
</script>
<style>
body { font-family: 'Inter', sans-serif; }
/* Blog content styles */
.blog-content {
font-size: 1.125rem;
line-height: 1.75;
color: #374151;
}
.blog-content h2 {
font-size: 1.875rem;
font-weight: 700;
margin-top: 2.5rem;
margin-bottom: 1rem;
color: #111827;
}
.blog-content h3 {
font-size: 1.5rem;
font-weight: 600;
margin-top: 2rem;
margin-bottom: 0.75rem;
color: #111827;
}
.blog-content h4 {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
color: #111827;
}
.blog-content p {
margin-bottom: 1.25rem;
}
.blog-content ul, .blog-content ol {
margin-bottom: 1.25rem;
padding-left: 1.5rem;
}
.blog-content li {
margin-bottom: 0.5rem;
}
.blog-content a {
color: #4f46e5;
text-decoration: underline;
}
.blog-content a:hover {
color: #4338ca;
}
.blog-content blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1rem;
margin: 1.5rem 0;
font-style: italic;
color: #6b7280;
}
.blog-content blockquote cite {
display: block;
margin-top: 0.5rem;
font-size: 0.875rem;
font-style: normal;
color: #9ca3af;
}
.blog-content pre {
background: #f3f4f6;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1.25rem;
}
.blog-content code {
background: #f3f4f6;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.875rem;
}
.blog-content pre code {
background: none;
padding: 0;
}
.blog-content img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 1.5rem 0;
}
.blog-content figure {
margin: 1.5rem 0;
}
.blog-content figcaption {
text-align: center;
font-size: 0.875rem;
color: #6b7280;
margin-top: 0.5rem;
}
.blog-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.25rem;
}
.blog-content th, .blog-content td {
border: 1px solid #e5e7eb;
padding: 0.75rem;
text-align: left;
}
.blog-content th {
background: #f9fafb;
font-weight: 600;
}
.blog-content .embed {
position: relative;
padding-bottom: 56.25%;
height: 0;
overflow: hidden;
margin: 1.5rem 0;
}
.blog-content .embed iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
/* Loading animation */
.loading-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: .5; }
}
</style>
<!-- PostHog Analytics -->
<script src="/posthog.js"></script>
</head>
<body class="bg-gray-50 text-gray-900">
<!-- Navigation -->
<nav class="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<a href="/" class="flex items-center gap-2">
<img src="/assets/Plugin.png" alt="Plugin Compass" class="h-8 w-8">
<span class="font-bold text-xl text-gray-900">Plugin Compass</span>
</a>
</div>
<div class="flex items-center gap-6">
<a href="/" class="text-gray-600 hover:text-gray-900 font-medium">Home</a>
<a href="/news" class="text-gray-600 hover:text-gray-900 font-medium">News</a>
<a href="/features" class="text-gray-600 hover:text-gray-900 font-medium">Features</a>
<a href="/pricing" class="text-gray-600 hover:text-gray-900 font-medium">Pricing</a>
<a href="/builder" class="bg-brand-600 text-white px-4 py-2 rounded-lg hover:bg-brand-700 transition">Get Started</a>
</div>
</div>
</div>
</nav>
<!-- Loading State -->
<div id="loading-state" class="min-h-screen flex items-center justify-center">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-4xl text-brand-600 mb-4"></i>
<p class="text-gray-600">Loading article...</p>
</div>
</div>
<!-- Error State -->
<div id="error-state" class="min-h-screen flex items-center justify-center hidden">
<div class="text-center">
<i class="fas fa-exclamation-circle text-6xl text-red-400 mb-4"></i>
<h1 class="text-2xl font-bold text-gray-900 mb-2">Article Not Found</h1>
<p class="text-gray-600 mb-6">The blog post you're looking for doesn't exist or has been removed.</p>
<a href="/news" class="inline-flex items-center gap-2 text-brand-600 hover:text-brand-700 font-medium">
<i class="fas fa-arrow-left"></i>
Back to News
</a>
</div>
</div>
<!-- Article Content -->
<article id="article-content" class="hidden">
<!-- Hero Section -->
<header class="bg-white border-b border-gray-200">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="flex items-center gap-3 mb-6">
<span id="post-category" class="px-3 py-1 bg-brand-100 text-brand-700 rounded-full text-sm font-medium">Category</span>
<span id="post-type" class="text-gray-400 text-sm"></span>
</div>
<h1 id="post-title" class="text-4xl md:text-5xl font-bold text-gray-900 mb-6 leading-tight"></h1>
<div class="flex items-center gap-4 text-gray-600">
<div class="flex items-center gap-2">
<div class="w-10 h-10 rounded-full bg-brand-100 flex items-center justify-center">
<i class="fas fa-user text-brand-600"></i>
</div>
<div>
<p id="post-author" class="font-medium text-gray-900"></p>
<p id="post-date" class="text-sm"></p>
</div>
</div>
</div>
</div>
</header>
<!-- Featured Image -->
<div id="featured-image-container" class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 -mt-6 mb-12 hidden">
<img id="featured-image" src="" alt="" class="w-full h-64 md:h-96 object-cover rounded-xl shadow-lg">
</div>
<!-- Main Content -->
<main class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-16">
<div id="post-content" class="blog-content">
<!-- Content will be loaded here -->
</div>
<!-- Tags -->
<div id="post-tags" class="mt-12 pt-8 border-t border-gray-200">
<!-- Tags will be loaded here -->
</div>
<!-- Share -->
<div class="mt-8 flex items-center gap-4">
<span class="text-gray-600 font-medium">Share this article:</span>
<div class="flex gap-2">
<button onclick="shareTwitter()" class="w-10 h-10 rounded-full bg-blue-400 text-white flex items-center justify-center hover:bg-blue-500 transition">
<i class="fab fa-twitter"></i>
</button>
<button onclick="shareFacebook()" class="w-10 h-10 rounded-full bg-blue-600 text-white flex items-center justify-center hover:bg-blue-700 transition">
<i class="fab fa-facebook-f"></i>
</button>
<button onclick="shareLinkedIn()" class="w-10 h-10 rounded-full bg-blue-700 text-white flex items-center justify-center hover:bg-blue-800 transition">
<i class="fab fa-linkedin-in"></i>
</button>
<button onclick="copyLink()" class="w-10 h-10 rounded-full bg-gray-600 text-white flex items-center justify-center hover:bg-gray-700 transition" title="Copy link">
<i class="fas fa-link"></i>
</button>
</div>
</div>
</main>
</article>
<!-- Footer -->
<footer class="bg-gray-900 text-gray-300 py-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<div>
<div class="flex items-center gap-2 mb-4">
<img src="/assets/Plugin.png" alt="Plugin Compass" class="h-8 w-8">
<span class="font-bold text-white text-lg">Plugin Compass</span>
</div>
<p class="text-sm">Build custom WordPress plugins with AI. Replace expensive subscriptions with tailored solutions.</p>
</div>
<div>
<h4 class="font-semibold text-white mb-4">Product</h4>
<ul class="space-y-2 text-sm">
<li><a href="/features" class="hover:text-white">Features</a></li>
<li><a href="/pricing" class="hover:text-white">Pricing</a></li>
<li><a href="/builder" class="hover:text-white">Builder</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold text-white mb-4">Resources</h4>
<ul class="space-y-2 text-sm">
<li><a href="/news" class="hover:text-white">News</a></li>
<li><a href="/docs" class="hover:text-white">Documentation</a></li>
<li><a href="/faq" class="hover:text-white">FAQ</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold text-white mb-4">Legal</h4>
<ul class="space-y-2 text-sm">
<li><a href="/privacy" class="hover:text-white">Privacy Policy</a></li>
<li><a href="/terms" class="hover:text-white">Terms of Service</a></li>
</ul>
</div>
</div>
<div class="border-t border-gray-800 mt-8 pt-8 text-center text-sm">
<p>&copy; 2026 Plugin Compass. All rights reserved.</p>
</div>
</div>
</footer>
<script>
let postData = null;
// Initialize
document.addEventListener('DOMContentLoaded', async () => {
const slug = window.location.pathname.split('/blog/')[1];
if (!slug) {
showError();
return;
}
await loadPost(slug);
});
// Load post
async function loadPost(slug) {
try {
const response = await fetch(`/api/blogs/${slug}`);
if (!response.ok) {
showError();
return;
}
const data = await response.json();
postData = data.post;
if (!postData) {
showError();
return;
}
renderPost(postData);
} catch (error) {
console.error('Failed to load post:', error);
showError();
}
}
// Render post
function renderPost(post) {
// Update meta tags
document.title = `${post.title} - Plugin Compass`;
document.querySelector('meta[name="description"]').content = post.excerpt || post.meta_description || '';
// Update page content
document.getElementById('post-title').textContent = post.title;
document.getElementById('post-author').textContent = post.author || 'Plugin Compass Team';
document.getElementById('post-date').textContent = formatDate(post.published_at);
document.getElementById('post-category').textContent = post.category || 'Article';
document.getElementById('post-type').textContent = post.type === 'seo' ? 'SEO Article' : 'News';
// Featured image
if (post.featured_image) {
document.getElementById('featured-image').src = post.featured_image;
document.getElementById('featured-image').alt = post.title;
document.getElementById('featured-image-container').classList.remove('hidden');
}
// Content
document.getElementById('post-content').innerHTML = post.html || '<p>No content available.</p>';
// Tags
if (post.tags && post.tags.length > 0) {
document.getElementById('post-tags').innerHTML = `
<div class="flex flex-wrap gap-2">
${post.tags.map(tag => `
<span class="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm">${escapeHtml(tag)}</span>
`).join('')}
</div>
`;
}
// Show content, hide loading
document.getElementById('loading-state').classList.add('hidden');
document.getElementById('article-content').classList.remove('hidden');
}
// Show error state
function showError() {
document.getElementById('loading-state').classList.add('hidden');
document.getElementById('error-state').classList.remove('hidden');
}
// Share functions
function shareTwitter() {
const url = encodeURIComponent(window.location.href);
const text = encodeURIComponent(postData?.title || 'Check out this article');
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400');
}
function shareFacebook() {
const url = encodeURIComponent(window.location.href);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
}
function shareLinkedIn() {
const url = encodeURIComponent(window.location.href);
const title = encodeURIComponent(postData?.title || '');
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}&title=${title}`, '_blank', 'width=600,height=400');
}
function copyLink() {
navigator.clipboard.writeText(window.location.href).then(() => {
// Show feedback
const btn = event.target.closest('button');
const original = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check"></i>';
setTimeout(() => {
btn.innerHTML = original;
}, 2000);
});
}
// Utility functions
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
</script>
</body>
</html>

View File

@@ -253,7 +253,7 @@
</p>
<div class="flex items-center gap-4 mt-4">
<a href="/apps" class="inline-flex items-center px-6 py-3 bg-green-500 hover:bg-green-600 text-white rounded-full font-semibold shadow-md">
<a href="/apps" class="inline-flex items-center px-6 py-3 bg-green-700 hover:bg-green-600 text-white rounded-full font-semibold shadow-md">
Get started <i class="fa-solid fa-arrow-right ml-2"></i>
</a>
<a href="/features" class="inline-flex items-center px-6 py-3 border border-gray-300 bg-white rounded-full font-medium hover:bg-gray-50">

View File

@@ -0,0 +1,547 @@
/**
* Blog Admin JavaScript
* Handles blog post management with Editor.js integration
*/
let editor = null;
let categories = [];
let posts = [];
let currentEditingPost = null;
// Initialize
async function init() {
await loadCategories();
await loadPosts();
setupEventListeners();
setupTabs();
}
// Setup tab switching
function setupTabs() {
const tabButtons = document.querySelectorAll('.tab-button');
tabButtons.forEach(btn => {
btn.addEventListener('click', () => {
const tabName = btn.dataset.tab;
// Update buttons
tabButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Update content
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.getElementById(`${tabName}-tab`).classList.add('active');
});
});
}
// Setup event listeners
function setupEventListeners() {
// Refresh button
document.getElementById('refresh-blogs')?.addEventListener('click', async () => {
await loadPosts();
showStatus('Posts refreshed', 'success');
});
// New post button
document.getElementById('new-post')?.addEventListener('click', () => {
openModal();
});
// Modal close
document.getElementById('modal-close')?.addEventListener('click', closeModal);
document.getElementById('cancel-post')?.addEventListener('click', closeModal);
// Click outside modal to close
document.getElementById('post-modal')?.addEventListener('click', (e) => {
if (e.target.id === 'post-modal') closeModal();
});
// Post form submit
document.getElementById('post-form')?.addEventListener('submit', handlePostSubmit);
// Category form submit
document.getElementById('category-form')?.addEventListener('submit', handleCategorySubmit);
// Generate slug from title
document.getElementById('post-title')?.addEventListener('blur', () => {
const title = document.getElementById('post-title').value;
const slugInput = document.getElementById('post-slug');
if (title && !slugInput.value) {
slugInput.value = generateSlug(title);
}
});
// Filters
['filter-type', 'filter-status', 'filter-source'].forEach(id => {
document.getElementById(id)?.addEventListener('change', () => {
renderPostsList();
});
});
document.getElementById('filter-search')?.addEventListener('input', () => {
renderPostsList();
});
// Generate category slug
document.getElementById('category-name')?.addEventListener('blur', () => {
const name = document.getElementById('category-name').value;
const slugInput = document.getElementById('category-slug');
if (name && !slugInput.value) {
slugInput.value = generateSlug(name);
}
});
}
// Generate slug
function generateSlug(text) {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 100);
}
// Load categories from API
async function loadCategories() {
try {
const response = await fetch('/api/blogs/categories');
const data = await response.json();
categories = data.categories || [];
populateCategorySelect();
renderCategoriesList();
} catch (error) {
console.error('Failed to load categories:', error);
}
}
// Load posts from API
async function loadPosts() {
try {
const response = await fetch('/api/admin/blogs');
const data = await response.json();
posts = data.posts || [];
renderPostsList();
} catch (error) {
console.error('Failed to load posts:', error);
document.getElementById('blog-list').innerHTML = '<p class="error">Failed to load posts</p>';
}
}
// Populate category select dropdown
function populateCategorySelect() {
const select = document.getElementById('post-category');
select.innerHTML = '<option value="">No Category</option>';
categories.forEach(cat => {
const option = document.createElement('option');
option.value = cat.slug;
option.textContent = cat.name;
select.appendChild(option);
});
}
// Render posts list
function renderPostsList() {
const container = document.getElementById('blog-list');
const typeFilter = document.getElementById('filter-type')?.value;
const statusFilter = document.getElementById('filter-status')?.value;
const sourceFilter = document.getElementById('filter-source')?.value;
const searchFilter = document.getElementById('filter-search')?.value.toLowerCase();
let filtered = posts;
if (typeFilter) filtered = filtered.filter(p => p.type === typeFilter);
if (statusFilter) filtered = filtered.filter(p => p.status === statusFilter);
if (sourceFilter) filtered = filtered.filter(p => p.source === sourceFilter);
if (searchFilter) {
filtered = filtered.filter(p =>
p.title?.toLowerCase().includes(searchFilter) ||
p.excerpt?.toLowerCase().includes(searchFilter) ||
p.author?.toLowerCase().includes(searchFilter)
);
}
if (filtered.length === 0) {
container.innerHTML = '<p>No posts found</p>';
return;
}
container.innerHTML = filtered.map(post => `
<div class="blog-item ${post.source}">
<div>
<div style="display: flex; gap: 8px; align-items: center; margin-bottom: 4px;">
<strong>${escapeHtml(post.title)}</strong>
<span class="type-badge type-${post.type}">${post.type}</span>
<span class="status-badge status-${post.status}">${post.status}</span>
<span class="source-badge source-${post.source}">${post.source}</span>
</div>
<div class="blog-meta">
<span>By ${escapeHtml(post.author || 'Unknown')}</span>
<span>${formatDate(post.published_at || post.created_at)}</span>
${post.category ? `<span>Category: ${escapeHtml(post.category)}</span>` : ''}
${post.tags?.length ? `<span>Tags: ${post.tags.join(', ')}</span>` : ''}
</div>
</div>
<div style="display: flex; gap: 8px;">
<button class="ghost" onclick="editPost('${post.id}')" ${post.source === 'repo' ? 'disabled title="Repo posts are read-only"' : ''}>
${post.source === 'repo' ? 'View' : 'Edit'}
</button>
${post.source === 'database' ? `
<button class="ghost danger" onclick="deletePost('${post.id}')">Delete</button>
` : ''}
</div>
</div>
`).join('');
}
// Render categories list
function renderCategoriesList() {
const container = document.getElementById('category-list');
if (categories.length === 0) {
container.innerHTML = '<p>No categories found</p>';
return;
}
container.innerHTML = categories.map(cat => `
<div class="category-item">
<div>
<strong>${escapeHtml(cat.name)}</strong>
<span style="color: var(--muted); font-size: 0.85em; margin-left: 8px;">/${escapeHtml(cat.slug)}</span>
${cat.description ? `<p style="margin: 4px 0 0; font-size: 0.85em; color: var(--muted);">${escapeHtml(cat.description)}</p>` : ''}
</div>
<button class="ghost danger" onclick="deleteCategory('${cat.id}')">Delete</button>
</div>
`).join('');
}
// Open modal for new post
function openModal(post = null) {
currentEditingPost = post;
const modal = document.getElementById('post-modal');
const titleEl = document.getElementById('modal-title');
const sourceEl = document.getElementById('post-source');
if (post) {
titleEl.textContent = post.source === 'repo' ? 'View Post (Read-Only)' : 'Edit Post';
document.getElementById('post-id').value = post.id;
document.getElementById('post-title').value = post.title || '';
document.getElementById('post-slug').value = post.slug || '';
document.getElementById('post-type').value = post.type || 'news';
document.getElementById('post-status').value = post.status || 'draft';
document.getElementById('post-category').value = post.category || '';
document.getElementById('post-author').value = post.author || '';
document.getElementById('post-excerpt').value = post.excerpt || '';
document.getElementById('post-meta-title').value = post.meta_title || '';
document.getElementById('post-meta-description').value = post.meta_description || '';
document.getElementById('post-featured-image').value = post.featured_image || '';
document.getElementById('post-tags').value = (post.tags || []).join(', ');
sourceEl.textContent = post.source === 'repo' ? 'Read-Only (Repo)' : 'Editable (Database)';
sourceEl.className = `source-badge source-${post.source}`;
// Disable form for repo posts
const isRepo = post.source === 'repo';
document.querySelectorAll('#post-form input, #post-form select, #post-form textarea').forEach(el => {
el.disabled = isRepo;
});
document.getElementById('save-post').style.display = isRepo ? 'none' : 'inline-block';
} else {
titleEl.textContent = 'New Post';
document.getElementById('post-form').reset();
document.getElementById('post-id').value = '';
document.getElementById('post-status').value = 'draft';
document.getElementById('post-type').value = 'news';
sourceEl.textContent = 'New Post';
sourceEl.className = 'source-badge source-database';
document.querySelectorAll('#post-form input, #post-form select, #post-form textarea').forEach(el => {
el.disabled = false;
});
document.getElementById('save-post').style.display = 'inline-block';
}
modal.classList.remove('hidden');
// Initialize Editor.js
initEditor(post?.content || null);
}
// Close modal
function closeModal() {
document.getElementById('post-modal').classList.add('hidden');
if (editor) {
editor.destroy();
editor = null;
}
currentEditingPost = null;
}
// Initialize Editor.js
function initEditor(savedContent = null) {
if (editor) {
editor.destroy();
}
const editorConfig = {
holder: 'editor',
tools: {
header: {
class: Header,
config: {
levels: [2, 3, 4, 5, 6],
defaultLevel: 2
}
},
paragraph: {
class: Paragraph,
inlineToolbar: true
},
list: {
class: List,
inlineToolbar: true
},
link: {
class: LinkTool,
config: {
endpoint: '/api/fetch-url' // Optional: for link previews
}
},
image: {
class: ImageTool,
config: {
endpoints: {
byFile: '/api/admin/blogs/upload-image'
},
field: 'image',
types: 'image/*'
}
},
embed: {
class: Embed,
config: {
services: {
youtube: true,
vimeo: true,
twitter: true,
codepen: true
}
}
},
code: {
class: CodeTool
},
quote: {
class: Quote,
inlineToolbar: true,
config: {
quotePlaceholder: 'Enter a quote',
captionPlaceholder: 'Quote\'s author'
}
},
table: {
class: Table,
inlineToolbar: true,
config: {
rows: 2,
cols: 2
}
},
marker: {
class: Marker
}
},
placeholder: 'Start writing your post...',
data: savedContent || {
blocks: [
{
type: 'paragraph',
data: {
text: ''
}
}
]
},
readOnly: currentEditingPost?.source === 'repo'
};
editor = new EditorJS(editorConfig);
}
// Handle post form submission
async function handlePostSubmit(e) {
e.preventDefault();
if (!editor) {
showStatus('Editor not initialized', 'error');
return;
}
try {
const content = await editor.save();
const postId = document.getElementById('post-id').value;
const postData = {
title: document.getElementById('post-title').value,
slug: document.getElementById('post-slug').value,
type: document.getElementById('post-type').value,
status: document.getElementById('post-status').value,
category: document.getElementById('post-category').value,
author: document.getElementById('post-author').value,
excerpt: document.getElementById('post-excerpt').value,
meta_title: document.getElementById('post-meta-title').value,
meta_description: document.getElementById('post-meta-description').value,
featured_image: document.getElementById('post-featured-image').value,
tags: document.getElementById('post-tags').value.split(',').map(t => t.trim()).filter(Boolean),
content: content
};
let response;
if (postId) {
// Update existing post
response = await fetch(`/api/admin/blogs/${postId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(postData)
});
} else {
// Create new post
response = await fetch('/api/admin/blogs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(postData)
});
}
const result = await response.json();
if (response.ok) {
showStatus(postId ? 'Post updated successfully' : 'Post created successfully', 'success');
closeModal();
await loadPosts();
} else {
showStatus(result.error || 'Failed to save post', 'error');
}
} catch (error) {
console.error('Failed to save post:', error);
showStatus('Failed to save post', 'error');
}
}
// Handle category form submission
async function handleCategorySubmit(e) {
e.preventDefault();
const categoryData = {
name: document.getElementById('category-name').value,
slug: document.getElementById('category-slug').value,
description: document.getElementById('category-description').value
};
try {
const response = await fetch('/api/admin/blogs/categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(categoryData)
});
const result = await response.json();
if (response.ok) {
showStatus('Category created successfully', 'success');
document.getElementById('category-form').reset();
await loadCategories();
} else {
showStatus(result.error || 'Failed to create category', 'error');
}
} catch (error) {
console.error('Failed to create category:', error);
showStatus('Failed to create category', 'error');
}
}
// Edit post (global function for onclick)
window.editPost = function(postId) {
const post = posts.find(p => p.id === postId);
if (post) {
openModal(post);
}
};
// Delete post (global function for onclick)
window.deletePost = async function(postId) {
if (!confirm('Are you sure you want to delete this post? This cannot be undone.')) {
return;
}
try {
const response = await fetch(`/api/admin/blogs/${postId}`, {
method: 'DELETE'
});
if (response.ok) {
showStatus('Post deleted successfully', 'success');
await loadPosts();
} else {
const result = await response.json();
showStatus(result.error || 'Failed to delete post', 'error');
}
} catch (error) {
console.error('Failed to delete post:', error);
showStatus('Failed to delete post', 'error');
}
};
// Delete category (global function for onclick)
window.deleteCategory = async function(categoryId) {
if (!confirm('Are you sure you want to delete this category?')) {
return;
}
try {
const response = await fetch(`/api/admin/blogs/categories/${categoryId}`, {
method: 'DELETE'
});
if (response.ok) {
showStatus('Category deleted successfully', 'success');
await loadCategories();
} else {
const result = await response.json();
showStatus(result.error || 'Failed to delete category', 'error');
}
} catch (error) {
console.error('Failed to delete category:', error);
showStatus('Failed to delete category', 'error');
}
};
// Utility functions
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
function showStatus(message, type = 'info') {
// Use the existing showStatus function from admin.js if available
if (typeof window.showStatus === 'function' && window.showStatus !== showStatus) {
window.showStatus(message, type);
return;
}
// Simple fallback
console.log(`[${type}] ${message}`);
alert(message);
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', init);

291
chat/public/news.html Normal file
View File

@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Latest news, updates, and announcements from Plugin Compass - AI WordPress Plugin Builder">
<title>News & Updates - Plugin Compass</title>
<link rel="icon" type="image/png" href="/assets/Plugin.png">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script>
(function applyTailwindConfig(cfg, attempts = 0) {
try {
if (typeof window.tailwind !== 'undefined') {
window.tailwind.config = cfg;
return;
}
} catch (e) {}
if (attempts >= 20) return;
setTimeout(() => applyTailwindConfig(cfg, attempts + 1), 100);
})({
theme: {
extend: {
fontFamily: { sans: ['Inter', 'sans-serif'] },
colors: {
brand: {
50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 300: '#a5b4fc',
400: '#818cf8', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca',
800: '#3730a3', 900: '#312e81'
}
}
}
}
});
</script>
<style>
body { font-family: 'Inter', sans-serif; }
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.fade-in {
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<!-- PostHog Analytics -->
<script src="/posthog.js"></script>
</head>
<body class="bg-gray-50 text-gray-900">
<!-- Navigation -->
<nav class="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<a href="/" class="flex items-center gap-2">
<img src="/assets/Plugin.png" alt="Plugin Compass" class="h-8 w-8">
<span class="font-bold text-xl text-gray-900">Plugin Compass</span>
</a>
</div>
<div class="flex items-center gap-6">
<a href="/" class="text-gray-600 hover:text-gray-900 font-medium">Home</a>
<a href="/news" class="text-brand-600 font-medium">News</a>
<a href="/features" class="text-gray-600 hover:text-gray-900 font-medium">Features</a>
<a href="/pricing" class="text-gray-600 hover:text-gray-900 font-medium">Pricing</a>
<a href="/builder" class="bg-brand-600 text-white px-4 py-2 rounded-lg hover:bg-brand-700 transition">Get Started</a>
</div>
</div>
</div>
</nav>
<!-- Header -->
<header class="bg-gradient-to-br from-brand-600 to-brand-800 text-white py-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-4xl md:text-5xl font-bold mb-4">News & Updates</h1>
<p class="text-xl text-brand-100 max-w-2xl mx-auto">
Stay up to date with the latest features, improvements, and announcements from Plugin Compass
</p>
</div>
</header>
<!-- Blog Posts Grid -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div id="posts-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<!-- Posts will be loaded here -->
</div>
<!-- Loading Indicator -->
<div id="loading-indicator" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-3xl text-brand-600 loading-spinner"></i>
<p class="mt-4 text-gray-600">Loading posts...</p>
</div>
<!-- No More Posts -->
<div id="no-more-posts" class="text-center py-12 hidden">
<p class="text-gray-500">You've reached the end!</p>
</div>
<!-- Empty State -->
<div id="empty-state" class="text-center py-16 hidden">
<i class="fas fa-newspaper text-6xl text-gray-300 mb-4"></i>
<h3 class="text-xl font-semibold text-gray-700 mb-2">No posts yet</h3>
<p class="text-gray-500">Check back soon for news and updates!</p>
</div>
</main>
<!-- Footer -->
<footer class="bg-gray-900 text-gray-300 py-12 mt-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<div>
<div class="flex items-center gap-2 mb-4">
<img src="/assets/Plugin.png" alt="Plugin Compass" class="h-8 w-8">
<span class="font-bold text-white text-lg">Plugin Compass</span>
</div>
<p class="text-sm">Build custom WordPress plugins with AI. Replace expensive subscriptions with tailored solutions.</p>
</div>
<div>
<h4 class="font-semibold text-white mb-4">Product</h4>
<ul class="space-y-2 text-sm">
<li><a href="/features" class="hover:text-white">Features</a></li>
<li><a href="/pricing" class="hover:text-white">Pricing</a></li>
<li><a href="/builder" class="hover:text-white">Builder</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold text-white mb-4">Resources</h4>
<ul class="space-y-2 text-sm">
<li><a href="/news" class="hover:text-white">News</a></li>
<li><a href="/docs" class="hover:text-white">Documentation</a></li>
<li><a href="/faq" class="hover:text-white">FAQ</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold text-white mb-4">Legal</h4>
<ul class="space-y-2 text-sm">
<li><a href="/privacy" class="hover:text-white">Privacy Policy</a></li>
<li><a href="/terms" class="hover:text-white">Terms of Service</a></li>
</ul>
</div>
</div>
<div class="border-t border-gray-800 mt-8 pt-8 text-center text-sm">
<p>&copy; 2026 Plugin Compass. All rights reserved.</p>
</div>
</div>
</footer>
<script>
let posts = [];
let offset = 0;
let limit = 9;
let loading = false;
let hasMore = true;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadPosts();
setupInfiniteScroll();
});
// Load posts
async function loadPosts() {
if (loading || !hasMore) return;
loading = true;
document.getElementById('loading-indicator').classList.remove('hidden');
try {
const response = await fetch(`/api/blogs?type=news&status=published&limit=${limit}&offset=${offset}`);
const data = await response.json();
if (data.posts && data.posts.length > 0) {
posts = [...posts, ...data.posts];
renderPosts(data.posts);
offset += data.posts.length;
if (data.posts.length < limit) {
hasMore = false;
document.getElementById('no-more-posts').classList.remove('hidden');
}
} else {
hasMore = false;
if (posts.length === 0) {
document.getElementById('empty-state').classList.remove('hidden');
} else {
document.getElementById('no-more-posts').classList.remove('hidden');
}
}
} catch (error) {
console.error('Failed to load posts:', error);
} finally {
loading = false;
document.getElementById('loading-indicator').classList.add('hidden');
}
}
// Render posts
function renderPosts(newPosts) {
const container = document.getElementById('posts-container');
newPosts.forEach(post => {
const card = createPostCard(post);
container.appendChild(card);
});
}
// Create post card element
function createPostCard(post) {
const article = document.createElement('article');
article.className = 'bg-white rounded-xl shadow-md overflow-hidden hover:shadow-lg transition-shadow fade-in';
const imageHtml = post.featured_image
? `<div class="h-48 overflow-hidden"><img src="${escapeHtml(post.featured_image)}" alt="${escapeHtml(post.title)}" class="w-full h-full object-cover hover:scale-105 transition-transform duration-300"></div>`
: `<div class="h-48 bg-gradient-to-br from-brand-100 to-brand-200 flex items-center justify-center"><i class="fas fa-newspaper text-4xl text-brand-400"></i></div>`;
article.innerHTML = `
<a href="/blog/${escapeHtml(post.slug)}" class="block">
${imageHtml}
<div class="p-6">
<div class="flex items-center gap-3 mb-3">
<span class="px-3 py-1 bg-brand-100 text-brand-700 rounded-full text-xs font-medium">${escapeHtml(post.category || 'News')}</span>
<span class="text-gray-400 text-sm">${formatDate(post.published_at)}</span>
</div>
<h2 class="text-xl font-bold text-gray-900 mb-2 line-clamp-2 hover:text-brand-600 transition-colors">${escapeHtml(post.title)}</h2>
<p class="text-gray-600 line-clamp-3 mb-4">${escapeHtml(post.excerpt || '')}</p>
<div class="flex items-center justify-between pt-4 border-t border-gray-100">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-brand-100 flex items-center justify-center">
<i class="fas fa-user text-brand-600 text-sm"></i>
</div>
<span class="text-sm text-gray-600">${escapeHtml(post.author || 'Plugin Compass Team')}</span>
</div>
<span class="text-brand-600 font-medium text-sm">Read more →</span>
</div>
</div>
</a>
`;
return article;
}
// Setup infinite scroll
function setupInfiniteScroll() {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !loading && hasMore) {
loadPosts();
}
});
}, { rootMargin: '100px' });
observer.observe(document.getElementById('loading-indicator'));
}
// Utility functions
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
</script>
</body>
</html>