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
548 lines
16 KiB
JavaScript
548 lines
16 KiB
JavaScript
/**
|
|
* 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);
|