mirror of
https://github.com/davila7/claude-code-templates.git
synced 2026-09-19 01:30:23 +08:00
109f09870f
* feat(blog): Add SVG cover and fix header/footer for security-hooks-secrets article - Create SVG cover image with black background, terminal on left, security shield on right - Rewrite blog HTML to use external CSS (styles.css, blog.css) instead of inline styles - Fix header to match other blogs (container, header-content, header-actions with SVG icons) - Fix footer to match other blogs (footer-left, footer-right, proper links with icons) - Add Google Analytics, Hotjar, favicon, structured data, and SEO metadata - Add CodeCopy and MarkdownCopier JavaScript functionality - Update blog-articles.json to reference new SVG cover https://claude.ai/code/session_012BdwtCSYzJbaEWNApX7bB5 * fix(blog): Add table styles for article content in blog CSS Tables inside .article-content-full had no styling since the blog uses external CSS. Added proper th/td padding, borders, and hover states. https://claude.ai/code/session_012BdwtCSYzJbaEWNApX7bB5 * fix(blog): Update info box text to clarify blog explains the hook The blog explains how the hook works, not manual setup instructions. Updated text to encourage direct CLI installation while reading on. https://claude.ai/code/session_012BdwtCSYzJbaEWNApX7bB5 * fix(blog): Sort articles newest first in blog listing and featured carousel Reverse default sort order from ascending to descending by order field, so the most recently published article appears first in both the main list and the featured posts carousel. https://claude.ai/code/session_012BdwtCSYzJbaEWNApX7bB5 * feat(agents): Add blog-writer agent for creating blog articles from components Agent follows a 6-step workflow: 1. Read the component file 2. Ask user to confirm title, tags, difficulty, category, cover style 3. Create SVG cover (black bg, terminal left, icon right, white title) 4. Create HTML article with proper header/footer structure 5. Update blog-articles.json with highest order number 6. Final verification checklist Encodes all blog creation knowledge: external CSS only, installation first, info-box wording, CodeCopy/MarkdownCopier scripts, table styling, OG metadata, structured data, and article ordering. https://claude.ai/code/session_012BdwtCSYzJbaEWNApX7bB5 --------- Co-authored-by: Claude <noreply@anthropic.com>
570 lines
18 KiB
JavaScript
570 lines
18 KiB
JavaScript
/**
|
|
* Blog Articles Dynamic Loader
|
|
* Loads and renders blog articles from blog-articles.json
|
|
*/
|
|
|
|
class BlogLoader {
|
|
constructor() {
|
|
this.articles = [];
|
|
this.filteredArticles = [];
|
|
this.featuredArticles = [];
|
|
this.articlesContainer = null;
|
|
this.featuredCarousel = null;
|
|
this.searchInput = null;
|
|
this.sortSelect = null;
|
|
this.currentSearchTerm = '';
|
|
this.currentSortBy = 'date-desc';
|
|
this.carouselScrollPosition = 0;
|
|
}
|
|
|
|
/**
|
|
* Initialize the blog loader
|
|
*/
|
|
async init() {
|
|
try {
|
|
this.articlesContainer = document.querySelector('.articles-grid');
|
|
this.featuredCarousel = document.getElementById('featured-carousel');
|
|
this.searchInput = document.getElementById('blog-search');
|
|
this.sortSelect = document.getElementById('sort-by');
|
|
|
|
if (!this.articlesContainer) {
|
|
console.error('Articles container not found');
|
|
return;
|
|
}
|
|
|
|
// Show loading state
|
|
this.showLoading();
|
|
|
|
// Load articles from JSON
|
|
await this.loadArticles();
|
|
|
|
// Render featured posts
|
|
this.renderFeaturedPosts();
|
|
|
|
// Setup event listeners
|
|
this.setupEventListeners();
|
|
|
|
// Setup carousel navigation
|
|
this.setupCarouselNavigation();
|
|
|
|
// Render articles
|
|
this.renderArticles();
|
|
|
|
} catch (error) {
|
|
console.error('Error initializing blog loader:', error);
|
|
this.showError();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setup event listeners for search and sort
|
|
*/
|
|
setupEventListeners() {
|
|
// Search input
|
|
if (this.searchInput) {
|
|
this.searchInput.addEventListener('input', (e) => {
|
|
this.currentSearchTerm = e.target.value.trim().toLowerCase();
|
|
this.applyFiltersAndSort();
|
|
this.updateClearButton();
|
|
});
|
|
}
|
|
|
|
// Clear search button
|
|
const clearSearchBtn = document.getElementById('clear-search');
|
|
if (clearSearchBtn) {
|
|
clearSearchBtn.addEventListener('click', () => {
|
|
this.searchInput.value = '';
|
|
this.currentSearchTerm = '';
|
|
this.applyFiltersAndSort();
|
|
this.updateClearButton();
|
|
this.searchInput.focus();
|
|
});
|
|
}
|
|
|
|
// Sort select
|
|
if (this.sortSelect) {
|
|
this.sortSelect.addEventListener('change', (e) => {
|
|
this.currentSortBy = e.target.value;
|
|
this.applyFiltersAndSort();
|
|
});
|
|
}
|
|
|
|
// Clear filters button
|
|
const clearFiltersBtn = document.getElementById('clear-filters');
|
|
if (clearFiltersBtn) {
|
|
clearFiltersBtn.addEventListener('click', () => {
|
|
this.searchInput.value = '';
|
|
this.currentSearchTerm = '';
|
|
this.sortSelect.value = 'date-desc';
|
|
this.currentSortBy = 'date-desc';
|
|
this.applyFiltersAndSort();
|
|
this.updateClearButton();
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update clear button visibility
|
|
*/
|
|
updateClearButton() {
|
|
const clearSearchBtn = document.getElementById('clear-search');
|
|
if (clearSearchBtn) {
|
|
clearSearchBtn.style.display = this.currentSearchTerm ? 'flex' : 'none';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply filters and sorting
|
|
*/
|
|
applyFiltersAndSort() {
|
|
// Start with all articles
|
|
this.filteredArticles = [...this.articles];
|
|
|
|
// Apply search filter
|
|
if (this.currentSearchTerm) {
|
|
this.filteredArticles = this.filteredArticles.filter(article => {
|
|
const searchableText = [
|
|
article.title,
|
|
article.description,
|
|
article.category,
|
|
...article.tags
|
|
].join(' ').toLowerCase();
|
|
|
|
return searchableText.includes(this.currentSearchTerm);
|
|
});
|
|
}
|
|
|
|
// Apply sorting
|
|
this.sortArticles(this.currentSortBy);
|
|
|
|
// Update results info
|
|
this.updateResultsInfo();
|
|
|
|
// Re-render articles
|
|
this.renderArticles();
|
|
}
|
|
|
|
/**
|
|
* Sort articles based on selected option
|
|
*/
|
|
sortArticles(sortBy) {
|
|
const difficultyOrder = { basic: 1, intermediate: 2, advanced: 3 };
|
|
|
|
switch (sortBy) {
|
|
case 'difficulty-asc':
|
|
this.filteredArticles.sort((a, b) =>
|
|
difficultyOrder[a.difficulty] - difficultyOrder[b.difficulty]
|
|
);
|
|
break;
|
|
case 'difficulty-desc':
|
|
this.filteredArticles.sort((a, b) =>
|
|
difficultyOrder[b.difficulty] - difficultyOrder[a.difficulty]
|
|
);
|
|
break;
|
|
default:
|
|
// Keep original order from JSON (by order field, newest first)
|
|
this.filteredArticles.sort((a, b) => (b.order || 0) - (a.order || 0));
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update results info display
|
|
*/
|
|
updateResultsInfo() {
|
|
const resultsInfo = document.getElementById('results-info');
|
|
const resultsCount = document.getElementById('results-count');
|
|
const clearFiltersBtn = document.getElementById('clear-filters');
|
|
|
|
if (!resultsInfo || !resultsCount) return;
|
|
|
|
const isFiltered = this.currentSearchTerm || this.currentSortBy !== 'date-desc';
|
|
const total = this.articles.length;
|
|
const showing = this.filteredArticles.length;
|
|
|
|
if (this.currentSearchTerm && showing === 0) {
|
|
resultsInfo.style.display = 'flex';
|
|
resultsCount.textContent = `No articles found for "${this.currentSearchTerm}"`;
|
|
clearFiltersBtn.style.display = 'inline-flex';
|
|
} else if (this.currentSearchTerm) {
|
|
resultsInfo.style.display = 'flex';
|
|
resultsCount.textContent = `Showing ${showing} of ${total} articles`;
|
|
clearFiltersBtn.style.display = 'inline-flex';
|
|
} else {
|
|
resultsInfo.style.display = 'none';
|
|
clearFiltersBtn.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load articles from JSON file
|
|
*/
|
|
async loadArticles() {
|
|
try {
|
|
const response = await fetch('blog-articles.json');
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Sort articles by order field descending (newest first)
|
|
this.articles = data.articles.sort((a, b) => b.order - a.order);
|
|
this.filteredArticles = [...this.articles];
|
|
|
|
// Extract featured articles
|
|
this.featuredArticles = this.articles.filter(article => article.featured === true);
|
|
|
|
console.log(`Loaded ${this.articles.length} articles (${this.featuredArticles.length} featured)`);
|
|
|
|
} catch (error) {
|
|
console.error('Error loading articles:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render all articles to the DOM
|
|
*/
|
|
renderArticles() {
|
|
if (!this.filteredArticles || this.filteredArticles.length === 0) {
|
|
if (this.currentSearchTerm) {
|
|
this.showNoResults();
|
|
} else {
|
|
this.showEmpty();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Clear container
|
|
this.articlesContainer.innerHTML = '';
|
|
|
|
// Render each filtered article
|
|
this.filteredArticles.forEach(article => {
|
|
const articleCard = this.createArticleCard(article);
|
|
this.articlesContainer.appendChild(articleCard);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show no results message
|
|
*/
|
|
showNoResults() {
|
|
this.articlesContainer.innerHTML = `
|
|
<div style="text-align: center; padding: 4rem; color: #666; grid-column: 1 / -1;">
|
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="margin: 0 auto 1rem;">
|
|
<circle cx="11" cy="11" r="8"></circle>
|
|
<path d="m21 21-4.35-4.35"></path>
|
|
<line x1="11" y1="8" x2="11" y2="14"></line>
|
|
<line x1="11" y1="16" x2="11.01" y2="16"></line>
|
|
</svg>
|
|
<h3 style="margin-bottom: 0.5rem; color: #00D084;">No articles found</h3>
|
|
<p>Try adjusting your search terms or filters</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Create article card HTML element
|
|
* @param {Object} article - Article data
|
|
* @returns {HTMLElement} Article card element
|
|
*/
|
|
createArticleCard(article) {
|
|
const articleElement = document.createElement('article');
|
|
articleElement.className = 'article-card';
|
|
|
|
// Determine if it's external or local
|
|
const isExternal = article.url.startsWith('http');
|
|
const linkAttrs = isExternal
|
|
? 'target="_blank" rel="noopener noreferrer"'
|
|
: '';
|
|
|
|
// Create difficulty badge
|
|
const difficultyBadge = this.getDifficultyBadge(article.difficulty);
|
|
|
|
// Generate tags HTML
|
|
const tagsHTML = article.tags
|
|
.map(tag => `<span class="tag">${this.escapeHtml(tag)}</span>`)
|
|
.join('');
|
|
|
|
articleElement.innerHTML = `
|
|
<a href="${this.escapeHtml(article.url)}" ${linkAttrs} class="article-link">
|
|
<div class="article-image">
|
|
<img src="${this.escapeHtml(article.image)}"
|
|
alt="${this.escapeHtml(article.title)}"
|
|
loading="lazy">
|
|
<div class="article-category">${this.escapeHtml(article.category)}</div>
|
|
</div>
|
|
<div class="article-content">
|
|
<div class="article-meta">
|
|
<span class="read-time">${this.escapeHtml(article.readTime)}</span>
|
|
${difficultyBadge}
|
|
</div>
|
|
<h2>${this.escapeHtml(article.title)}</h2>
|
|
<p>${this.escapeHtml(article.description)}</p>
|
|
<div class="article-tags">
|
|
${tagsHTML}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
`;
|
|
|
|
return articleElement;
|
|
}
|
|
|
|
/**
|
|
* Get difficulty badge HTML
|
|
* @param {string} difficulty - Difficulty level (basic, intermediate, advanced)
|
|
* @returns {string} Badge HTML
|
|
*/
|
|
getDifficultyBadge(difficulty) {
|
|
const difficultyConfig = {
|
|
basic: {
|
|
label: 'Basic',
|
|
color: '#00D084',
|
|
textColor: '#000'
|
|
},
|
|
intermediate: {
|
|
label: 'Intermediate',
|
|
color: '#FFA500',
|
|
textColor: '#000'
|
|
},
|
|
advanced: {
|
|
label: 'Advanced',
|
|
color: '#FF4444',
|
|
textColor: '#FFF'
|
|
}
|
|
};
|
|
|
|
const config = difficultyConfig[difficulty] || difficultyConfig.basic;
|
|
|
|
return `<span class="tag difficulty-badge" style="margin-left: 8px; background: ${config.color}; color: ${config.textColor};">${config.label}</span>`;
|
|
}
|
|
|
|
/**
|
|
* Show loading state
|
|
*/
|
|
showLoading() {
|
|
this.articlesContainer.innerHTML = `
|
|
<div style="text-align: center; padding: 4rem; color: #00D084;">
|
|
<div class="loading-spinner"></div>
|
|
<p style="margin-top: 1rem;">Loading articles...</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Show error state
|
|
*/
|
|
showError() {
|
|
this.articlesContainer.innerHTML = `
|
|
<div style="text-align: center; padding: 4rem; color: #ff4444;">
|
|
<p>⚠️ Error loading articles. Please try again later.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Show empty state
|
|
*/
|
|
showEmpty() {
|
|
this.articlesContainer.innerHTML = `
|
|
<div style="text-align: center; padding: 4rem; color: #666;">
|
|
<p>No articles available at the moment.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Escape HTML to prevent XSS
|
|
* @param {string} text - Text to escape
|
|
* @returns {string} Escaped text
|
|
*/
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* Render featured posts carousel
|
|
*/
|
|
renderFeaturedPosts() {
|
|
if (!this.featuredCarousel || !this.featuredArticles || this.featuredArticles.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Show featured section
|
|
const featuredSection = document.getElementById('featured-posts-section');
|
|
if (featuredSection) {
|
|
featuredSection.style.display = 'block';
|
|
}
|
|
|
|
// Clear carousel
|
|
this.featuredCarousel.innerHTML = '';
|
|
|
|
// Render each featured article
|
|
this.featuredArticles.forEach(article => {
|
|
const featuredCard = this.createFeaturedCard(article);
|
|
this.featuredCarousel.appendChild(featuredCard);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create featured article card
|
|
* @param {Object} article - Article data
|
|
* @returns {HTMLElement} Featured card element
|
|
*/
|
|
createFeaturedCard(article) {
|
|
const cardElement = document.createElement('article');
|
|
cardElement.className = 'featured-card';
|
|
|
|
const isExternal = article.url.startsWith('http');
|
|
const linkAttrs = isExternal ? 'target="_blank" rel="noopener noreferrer"' : '';
|
|
|
|
const tagsHTML = article.tags
|
|
.slice(0, 3)
|
|
.map(tag => `<span class="tag">${this.escapeHtml(tag)}</span>`)
|
|
.join('');
|
|
|
|
cardElement.innerHTML = `
|
|
<div class="featured-star">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z"/>
|
|
</svg>
|
|
</div>
|
|
<a href="${this.escapeHtml(article.url)}" ${linkAttrs} class="featured-link">
|
|
<div class="featured-image">
|
|
<img src="${this.escapeHtml(article.image)}"
|
|
alt="${this.escapeHtml(article.title)}"
|
|
loading="lazy">
|
|
</div>
|
|
<div class="featured-content">
|
|
<div class="featured-meta">
|
|
<span class="read-time">${this.escapeHtml(article.readTime)}</span>
|
|
</div>
|
|
<h3>${this.escapeHtml(article.title)}</h3>
|
|
<p>${this.escapeHtml(article.description)}</p>
|
|
<div class="featured-tags">
|
|
${tagsHTML}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
`;
|
|
|
|
return cardElement;
|
|
}
|
|
|
|
/**
|
|
* Setup carousel navigation
|
|
*/
|
|
setupCarouselNavigation() {
|
|
const prevBtn = document.getElementById('carousel-prev');
|
|
const nextBtn = document.getElementById('carousel-next');
|
|
|
|
if (!prevBtn || !nextBtn || !this.featuredCarousel) return;
|
|
|
|
// Check if navigation is needed
|
|
this.checkCarouselNavigation(prevBtn, nextBtn);
|
|
|
|
// Scroll carousel on button click
|
|
prevBtn.addEventListener('click', () => {
|
|
this.featuredCarousel.scrollBy({
|
|
left: -280,
|
|
behavior: 'smooth'
|
|
});
|
|
});
|
|
|
|
nextBtn.addEventListener('click', () => {
|
|
this.featuredCarousel.scrollBy({
|
|
left: 280,
|
|
behavior: 'smooth'
|
|
});
|
|
});
|
|
|
|
// Update button states on scroll
|
|
this.featuredCarousel.addEventListener('scroll', () => {
|
|
this.updateCarouselButtons(prevBtn, nextBtn);
|
|
});
|
|
|
|
// Re-check on window resize
|
|
window.addEventListener('resize', () => {
|
|
this.checkCarouselNavigation(prevBtn, nextBtn);
|
|
});
|
|
|
|
// Initial button state
|
|
this.updateCarouselButtons(prevBtn, nextBtn);
|
|
}
|
|
|
|
/**
|
|
* Check if carousel navigation buttons should be visible
|
|
*/
|
|
checkCarouselNavigation(prevBtn, nextBtn) {
|
|
if (!this.featuredCarousel) return;
|
|
|
|
// Wait for images to load before checking
|
|
setTimeout(() => {
|
|
const isOverflowing = this.featuredCarousel.scrollWidth > this.featuredCarousel.clientWidth;
|
|
|
|
if (isOverflowing) {
|
|
prevBtn.style.display = 'flex';
|
|
nextBtn.style.display = 'flex';
|
|
} else {
|
|
prevBtn.style.display = 'none';
|
|
nextBtn.style.display = 'none';
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
/**
|
|
* Update carousel button states based on scroll position
|
|
*/
|
|
updateCarouselButtons(prevBtn, nextBtn) {
|
|
if (!this.featuredCarousel) return;
|
|
|
|
const scrollLeft = this.featuredCarousel.scrollLeft;
|
|
const maxScroll = this.featuredCarousel.scrollWidth - this.featuredCarousel.clientWidth;
|
|
|
|
// Disable/enable previous button
|
|
if (scrollLeft <= 0) {
|
|
prevBtn.disabled = true;
|
|
} else {
|
|
prevBtn.disabled = false;
|
|
}
|
|
|
|
// Disable/enable next button
|
|
if (scrollLeft >= maxScroll - 1) {
|
|
nextBtn.disabled = true;
|
|
} else {
|
|
nextBtn.disabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize blog loader when DOM is ready
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const blogLoader = new BlogLoader();
|
|
blogLoader.init();
|
|
});
|
|
|
|
// Add loading spinner CSS
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
.loading-spinner {
|
|
border: 3px solid rgba(0, 208, 132, 0.1);
|
|
border-top: 3px solid #00D084;
|
|
border-radius: 50%;
|
|
width: 40px;
|
|
height: 40px;
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
@keyframes spin {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|