first commit

This commit is contained in:
2026-01-25 11:33:37 -04:00
commit 6f42a58ee5
148 changed files with 32974 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
---
title: Example Blog Post
excerpt: This is a brief excerpt that appears on the homepage
---
This is the first paragraph of your blog post content.
This is the second paragraph, separated by a double newline.
You can write as much content as you want here, using standard markdown formatting if desired. The content will be stored as-is and displayed on the blog page.
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env php
<?php
/**
* Add a new blog post from a markdown file
* Usage: php add-blog-post.php post.md
*
* Markdown format:
* ---
* title: Your Post Title
* excerpt: Brief excerpt for homepage
* ---
*
* Your full blog post content here...
*
* Use double newlines for paragraphs.
*/
$blogFile = __DIR__ . '/../data/posts.json';
if ($argc < 2) {
echo "Usage: php add-blog-post.php post.md\n";
exit(1);
}
$markdownFile = $argv[1];
if (!file_exists($markdownFile)) {
echo "Error: File '$markdownFile' not found\n";
exit(1);
}
$content = file_get_contents($markdownFile);
// Parse frontmatter
if (!preg_match('/^---\s*\n(.*?)\n---\s*\n(.*)$/s', $content, $matches)) {
echo "Error: Invalid markdown format. Expected frontmatter with --- delimiters.\n";
echo "Format:\n---\ntitle: Your Title\nexcerpt: Your excerpt\n---\n\nContent...\n";
exit(1);
}
$frontmatter = $matches[1];
$postContent = trim($matches[2]);
// Parse frontmatter fields
$title = null;
$excerpt = null;
foreach (explode("\n", $frontmatter) as $line) {
if (preg_match('/^title:\s*(.+)$/i', trim($line), $m)) {
$title = trim($m[1]);
} elseif (preg_match('/^excerpt:\s*(.+)$/i', trim($line), $m)) {
$excerpt = trim($m[1]);
}
}
if (!$title || !$excerpt) {
echo "Error: Missing required fields. Need 'title:' and 'excerpt:' in frontmatter.\n";
exit(1);
}
if (empty($postContent)) {
echo "Error: Post content is required\n";
exit(1);
}
// Load existing posts
$posts = [];
if (file_exists($blogFile)) {
$json = file_get_contents($blogFile);
$posts = json_decode($json, true) ?: [];
}
// Get next ID
$nextId = 0;
if (!empty($posts)) {
$ids = array_column($posts, 'id');
$nextId = max($ids) + 1;
}
// Get today's date
$date = date('Y-m-d');
// Create new post
$newPost = [
'id' => $nextId,
'title' => $title,
'date' => $date,
'excerpt' => $excerpt,
'content' => $postContent
];
// Add to array
$posts[] = $newPost;
// Write back to file
file_put_contents($blogFile, json_encode($posts, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
echo "Blog post added successfully!\n";
echo "ID: $nextId\n";
echo "Date: $date\n";
echo "Title: $title\n";
+127
View File
@@ -0,0 +1,127 @@
<?php
// Timezone: where RSS feeds go to die and be reborn
date_default_timezone_set('UTC'); // Adjust to your server's actual timezone
// RSS Feed with analytics tracking
header('Content-Type: application/xml; charset=utf-8');
// Track RSS feed access and active subscribers
$dataDir = '/var/www/data/analytics';
$date = date('Y-m-d');
$hour = date('H');
$summaryFile = $dataDir . '/summary_' . $date . '.json';
$readersFile = $dataDir . '/rss_readers.json';
// Track unique RSS readers
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
$ip = $_SERVER['REMOTE_ADDR'] ?? 'Unknown';
$readerId = md5($userAgent . $ip); // Unique identifier for this reader
$now = time();
// Load existing readers data
$readers = [];
if (file_exists($readersFile)) {
$readers = json_decode(file_get_contents($readersFile), true) ?: [];
}
// Update or add this reader's last fetch time
$readers[$readerId] = [
'lastFetch' => $now,
'userAgent' => substr($userAgent, 0, 200), // Limit length
'ip' => $ip,
'firstSeen' => $readers[$readerId]['firstSeen'] ?? $now
];
// Clean up old readers (haven't fetched in 30+ days to keep file size manageable)
$thirtyDaysAgo = $now - (30 * 24 * 60 * 60);
foreach ($readers as $id => $reader) {
if ($reader['lastFetch'] < $thirtyDaysAgo) {
unset($readers[$id]);
}
}
// Save readers data
file_put_contents($readersFile, json_encode($readers, JSON_PRETTY_PRINT));
// Calculate active subscribers (fetched in last 7 days)
$sevenDaysAgo = $now - (7 * 24 * 60 * 60);
$activeSubscribers = 0;
foreach ($readers as $reader) {
if ($reader['lastFetch'] >= $sevenDaysAgo) {
$activeSubscribers++;
}
}
// Update summary - track RSS feed fetch (for historical data)
$summary = [];
if (file_exists($summaryFile)) {
$summary = json_decode(file_get_contents($summaryFile), true) ?: [];
}
if (!isset($summary['rss'])) {
$summary['rss'] = 0;
}
$summary['rss']++;
$summary['activeRssSubscribers'] = $activeSubscribers; // Store active count
// Ensure directory is writable
$result = file_put_contents($summaryFile, json_encode($summary, JSON_PRETTY_PRINT));
if ($result === false) {
error_log("Failed to write RSS tracking to $summaryFile");
}
// Generate and output RSS feed
$postsFile = __DIR__ . '/../data/posts.json';
$posts = [];
if (file_exists($postsFile)) {
$posts = json_decode(file_get_contents($postsFile), true) ?: [];
}
// Sort posts by date (newest first)
usort($posts, function($a, $b) {
return strtotime($b['date']) - strtotime($a['date']);
});
// Escape XML
function escapeXml($str) {
if (!$str) return '';
return htmlspecialchars($str, ENT_XML1, 'UTF-8');
}
// Format content
function formatContent($content) {
return nl2br(htmlspecialchars($content, ENT_XML1, 'UTF-8'));
}
// Generate RSS items
$rssItems = '';
foreach ($posts as $post) {
$title = escapeXml($post['title']);
$excerpt = escapeXml($post['excerpt']);
$content = formatContent($post['content']);
$pubDate = date('r', strtotime($post['date']));
$link = 'https://example.com/blog/post.html?id=' . $post['id'];
$rssItems .= " <item>\n";
$rssItems .= " <title>{$title}</title>\n";
$rssItems .= " <link>{$link}</link>\n";
$rssItems .= " <guid>{$link}</guid>\n";
$rssItems .= " <pubDate>{$pubDate}</pubDate>\n";
$rssItems .= " <description><![CDATA[{$excerpt}<br/><br/>{$content}]]></description>\n";
$rssItems .= " </item>\n";
}
// Output RSS XML
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
echo '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">' . "\n";
echo " <channel>\n";
echo " <title>My Blog</title>\n";
echo " <link>https://example.com/blog</link>\n";
echo " <description>Blog posts</description>\n";
echo " <language>en-us</language>\n";
echo " <lastBuildDate>" . date('r') . "</lastBuildDate>\n";
echo " <atom:link href=\"https://example.com/blog/api/feed.php\" rel=\"self\" type=\"application/rss+xml\"/>\n";
echo $rssItems;
echo " </channel>\n";
echo "</rss>\n";
?>
+14
View File
@@ -0,0 +1,14 @@
<?php
// Serve up blog posts like they're going out of style
// No authentication, no rate limiting, just pure chaos
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *'); // We're generous like that, or just naive
$postsFile = __DIR__ . '/../data/posts.json';
if (file_exists($postsFile)) {
readfile($postsFile); // Straight from the source, no middleman, no safety net
} else {
http_response_code(404);
echo json_encode(['error' => 'Posts not found']); // The void stares back, and it's hungry
}
?>
+9
View File
@@ -0,0 +1,9 @@
[
{
"id": 0,
"title": "Getting Started with This Website",
"date": "2025-01-01",
"excerpt": "Welcome to my website. This is a sample blog post to help you get started with customizing your own site.",
"content": "Welcome to my website! This is a sample blog post that demonstrates how the blog system works.\n\nYou can customize this template to fit your needs. The blog supports simple text formatting and will display your content in a clean, readable format.\n\nTo add new blog posts, you can edit the posts.json file directly or use the admin interface. Each post needs a unique ID, title, date, excerpt, and content.\n\nFeel free to replace this content with your own thoughts, tutorials, or whatever you'd like to share with your visitors.\n\nHappy blogging!"
}
]
+55
View File
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self' data:; img-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self' https://duckduckgo.com;">
<meta http-equiv="X-Content-Type-Options" content="nosniff">
<link rel="stylesheet" href="../assets/css/style.css">
<link rel="icon" type="image/x-icon" href="../favicon.ico">
<link rel="alternate" type="application/rss+xml" title="Blog RSS Feed" href="/blog/api/feed.php">
<script>
// Apply theme immediately to prevent flash
(function() {
const theme = localStorage.getItem('theme') ||
(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<title>Blog - Launch Pad</title>
</head>
<body>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark mode">
<svg class="theme-icon theme-icon-moon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
<svg class="theme-icon theme-icon-sun" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
</button>
<br/><br/>
<div class="name">
__ _______________________ _________._________________________
/ \ / \_ _____/\______ \/ _____/| \__ ___/\_ _____/
\ \/\/ /| __)_ | | _/\_____ \ | | | | | __)_
\ / | \ | | \/ \| | | | | \
\__/\ / /_______ / |______ /_______ /|___| |____| /_______ /
\/ \/ \/ \/ \/
</div>
<div class="blog-page-header">
<div class="blog-header-content">
<a href="/" class="back-link" title="Back to Home">
<svg xmlns="http://www.w3.org/2000/svg" width="42" height="42" viewBox="0 0 24 24" class="home-icon"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>
</a>
<h1 class="blog-page-title">Blog Posts</h1>
<a href="/blog/api/feed.php" class="rss-icon-link" id="rssLink" title="Subscribe to RSS Feed">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 333333 333333" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" width="32" height="32" class="rss-icon"><path d="M44382 244427C19901 244427 6 264387 6 288717c0 24452 19893 44205 44376 44205 24556 0 44437-19754 44437-44205 0-24328-19875-44290-44437-44290zM49 113241v63895c41608 0 80707 16271 110159 45748 29422 29391 45667 68681 45667 110448h64167c0-121341-98731-220072-219993-220072v-20zM130 0v63907c148372 0 269117 120869 269117 269407l64080 6C333327 149577 183829 7 135 7l-6-6z" fill="currentColor"/></svg>
</a>
</div>
</div>
<div class="blog-posts-container" id="blogPostsContainer">
<!-- Blog posts will be dynamically loaded here -->
</div>
<script async type="text/javascript" src="js/analytics.js"></script>
<script async type="text/javascript" src="js/reactions.js"></script>
<script async type="text/javascript" src="js/blog.js"></script>
<script src="../assets/js/theme.js"></script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
// Analytics tracking
(function() {
// Generate or retrieve visitor ID
function getVisitorId() {
let visitorId = localStorage.getItem('visitorId');
if (!visitorId) {
visitorId = 'visitor_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('visitorId', visitorId);
}
return visitorId;
}
// Track page view
function trackPageView() {
const visitorId = getVisitorId();
const page = window.location.pathname + window.location.hash;
fetch('/api/track.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'pageview',
page: page,
visitorId: visitorId
})
}).catch(err => console.error('Analytics error:', err));
}
// Track page view on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', trackPageView);
} else {
trackPageView();
}
// Track RSS link clicks
const rssLink = document.getElementById('rssLink');
if (rssLink) {
rssLink.addEventListener('click', function() {
const visitorId = getVisitorId();
fetch('/api/track.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'rss_click',
page: '/blog/feed.xml',
visitorId: visitorId
})
}).catch(err => console.error('RSS tracking error:', err));
});
}
})();
+61
View File
@@ -0,0 +1,61 @@
// Summon the blog posts from their JSON tomb
async function loadBlogPosts() {
try {
// Use relative path - this should work when blog.js is loaded from /blog/index.html
const apiPath = 'api/posts.php';
const response = await fetch(apiPath);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const posts = await response.json();
// Validate that we got an array
if (!Array.isArray(posts)) {
throw new Error('Invalid response format: expected array');
}
const container = document.getElementById("blogPostsContainer");
if (!container) {
throw new Error('Blog posts container not found');
}
// Newest first - we're not savages reading in chronological order
const sortedPosts = posts.sort((a, b) => new Date(b.date) - new Date(a.date));
// Out with the old (loading message probably)
container.innerHTML = '';
if (sortedPosts.length === 0) {
container.innerHTML = '<p class="error">No blog posts found. Add some posts to get started!</p>';
return;
}
sortedPosts.forEach(post => {
const postElement = document.createElement('div');
postElement.className = 'blog-post-preview';
postElement.id = `post-preview-${post.id}`;
postElement.innerHTML = `
<h2 class="blog-post-title"><a href="post.html?id=${post.id}" class="blog-post-link">${post.title}</a></h2>
<p class="blog-post-date">${post.date}</p>
<p class="blog-post-excerpt">${post.excerpt}</p>
<a href="post.html?id=${post.id}" class="blog-read-more">Read more →</a>
`;
container.appendChild(postElement);
});
} catch (error) {
console.error('Error loading blog posts:', error);
const container = document.getElementById("blogPostsContainer");
if (container) {
container.innerHTML =
'<p class="error">Error loading blog posts. Make sure you\'re running a PHP server and the posts.json file exists.</p>';
}
// Fail gracefully - no one likes a drama queen
}
}
// Start the blog parade when the page finishes loading
window.addEventListener('load', loadBlogPosts);
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
// Script to generate RSS feed from blog-posts.json
const fs = require('fs');
const path = require('path');
const postsPath = path.join(__dirname, 'blog-posts.json');
const feedPath = path.join(__dirname, 'feed.xml');
try {
const posts = JSON.parse(fs.readFileSync(postsPath, 'utf8'));
// Sort posts by date (newest first)
const sortedPosts = posts.sort((a, b) => new Date(b.date) - new Date(a.date));
// Escape XML special characters
const escapeXml = (str) => {
if (!str) return '';
return str.toString()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
};
// Convert content newlines to HTML breaks
const formatContent = (content) => {
return content.split('\n\n')
.map(p => p.trim())
.filter(p => p.length > 0)
.join('<br/><br/>');
};
// Generate RSS items
const rssItems = sortedPosts.map(post => {
const title = escapeXml(post.title);
const excerpt = escapeXml(post.excerpt);
const content = formatContent(post.content);
const pubDate = new Date(post.date + 'T00:00:00Z').toUTCString();
const link = `https://example.com/blog/post.html?id=${post.id}`;
return ` <item>
<title>${title}</title>
<link>${link}</link>
<guid>${link}</guid>
<pubDate>${pubDate}</pubDate>
<description><![CDATA[${excerpt}<br/><br/>${content}]]></description>
</item>`;
}).join('\n');
// Generate RSS XML
const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>My Blog</title>
<link>https://example.com/blog</link>
<description>Blog posts</description>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<atom:link href="https://example.com/blog/api/feed.php" rel="self" type="application/rss+xml"/>
${rssItems}
</channel>
</rss>`;
// Write RSS feed file
fs.writeFileSync(feedPath, rssXml, 'utf8');
console.log('RSS feed generated successfully at', feedPath);
} catch (error) {
console.error('Error generating RSS feed:', error);
process.exit(1);
}
+122
View File
@@ -0,0 +1,122 @@
// Load and display a single blog post
async function loadBlogPost() {
try {
// Use relative path - this should work when post.js is loaded from /blog/post.html
const apiPath = 'api/posts.php';
const response = await fetch(apiPath);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const posts = await response.json();
// Validate that we got an array
if (!Array.isArray(posts)) {
throw new Error('Invalid response format: expected array');
}
// Get post ID from URL query parameter
const urlParams = new URLSearchParams(window.location.search);
const postId = parseInt(urlParams.get('id'));
if (isNaN(postId)) {
document.getElementById('blogPostContainer').innerHTML =
'<p class="error">Invalid post ID. Please select a post from the sidebar.</p>';
loadSidebarNavigation(posts);
return;
}
// Find the post
const post = posts.find(p => p.id === postId);
if (!post) {
document.getElementById('blogPostContainer').innerHTML =
'<p class="error">Post not found. Please select a post from the sidebar.</p>';
loadSidebarNavigation(posts);
return;
}
// Update page title
const pageTitleEl = document.getElementById('pageTitle');
if (pageTitleEl) {
pageTitleEl.textContent = `${post.title} - Launch Pad`;
}
const blogPostTitleEl = document.getElementById('blogPostTitle');
if (blogPostTitleEl) {
blogPostTitleEl.textContent = `Post-${post.id}`;
}
// Convert content newlines to paragraphs
const contentParagraphs = post.content.split('\n\n').filter(p => p.trim().length > 0);
const formattedContent = contentParagraphs.map(p => `<p>${p.trim()}</p>`).join('');
const container = document.getElementById('blogPostContainer');
container.innerHTML = `
<div class="blog-post" id="post-${post.id}">
<h2 class="blog-post-title">${post.title}</h2>
<p class="blog-post-date">${post.date}</p>
<p class="blog-post-excerpt">${post.excerpt}</p>
<div class="blog-post-content">${formattedContent}</div>
<div class="reactions-share-wrapper">
${window.blogReactions ? window.blogReactions.createReactionButtons(post.id) : ''}
<p class="blog-post-signature">-- Author --</p>
${window.blogReactions ? window.blogReactions.createShareButtons(post.id) : ''}
</div>
</div>
`;
// Load reactions for this post
if (window.blogReactions) {
window.blogReactions.loadReactions(post.id);
}
// Load sidebar navigation
loadSidebarNavigation(posts, postId);
// Align blog-sidebar top with blog-post-container top
setTimeout(() => {
const blogPostContainer = document.getElementById('blogPostContainer');
const blogSidebar = document.getElementById('blogSidebar');
if (blogPostContainer && blogSidebar) {
const containerTop = blogPostContainer.getBoundingClientRect().top;
const layoutTop = blogPostContainer.closest('.blog-layout').getBoundingClientRect().top;
const offset = containerTop - layoutTop;
blogSidebar.style.marginTop = offset + 'px';
}
}, 100);
} catch (error) {
console.error('Error loading blog post:', error);
document.getElementById('blogPostContainer').innerHTML =
'<p class="error">Error loading blog post. Please try again later.</p>';
}
}
// Load sidebar navigation with all posts
function loadSidebarNavigation(posts, currentPostId = null) {
const sidebarNav = document.getElementById('sidebarNav');
// Sort posts by date (newest first)
const sortedPosts = [...posts].sort((a, b) => new Date(b.date) - new Date(a.date));
// Clear existing navigation
sidebarNav.innerHTML = '';
sortedPosts.forEach(post => {
const navItem = document.createElement('a');
navItem.href = `post.html?id=${post.id}`;
navItem.className = 'sidebar-post-link';
navItem.textContent = `Post-${post.id}`;
// Highlight current post
if (currentPostId !== null && post.id === currentPostId) {
navItem.classList.add('active');
}
navItem.title = post.title;
sidebarNav.appendChild(navItem);
});
}
// Load post when page loads
window.addEventListener('load', loadBlogPost);
+200
View File
@@ -0,0 +1,200 @@
// Reaction and share functionality
(function() {
// Generate or retrieve visitor ID
function getVisitorId() {
let visitorId = localStorage.getItem('visitorId');
if (!visitorId) {
visitorId = 'visitor_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('visitorId', visitorId);
}
return visitorId;
}
// Load reactions for a post
async function loadReactions(postId) {
try {
const response = await fetch(`/api/reaction.php?postId=${postId}`);
const data = await response.json();
if (data.success) {
updateReactionButtons(postId, data.counts);
}
} catch (error) {
console.error('Error loading reactions:', error);
}
}
// Send reaction
async function sendReaction(postId, reaction) {
const visitorId = getVisitorId();
try {
const response = await fetch('/api/reaction.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
postId: postId,
reaction: reaction,
visitorId: visitorId
})
});
if (!response.ok) {
console.error('API error:', response.status, response.statusText);
return;
}
const data = await response.json();
console.log('Reaction response:', data); // Debug log
if (data.success && data.counts) {
updateReactionButtons(postId, data.counts);
} else {
console.error('Invalid response:', data);
}
} catch (error) {
console.error('Error sending reaction:', error);
}
}
// Update reaction button displays
function updateReactionButtons(postId, counts) {
const container = document.querySelector(`#post-${postId} .reactions-buttons`);
if (!container) return;
['like', 'love', 'helpful'].forEach(reaction => {
const button = container.querySelector(`.reaction-btn[data-reaction="${reaction}"]`);
if (button) {
const count = counts[reaction] || 0;
const countSpan = button.querySelector('.reaction-count');
if (countSpan) {
countSpan.textContent = count;
}
}
});
}
// Track share event
async function trackShare(postId, platform) {
const visitorId = getVisitorId();
try {
await fetch('/api/track.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'share',
page: `/blog/post.html?id=${postId}`,
visitorId: visitorId,
platform: platform
})
});
} catch (error) {
console.error('Error tracking share:', error);
}
}
// Inline SVG icons - using currentColor for theme support
const iconSVGs = {
like: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 -0.5 21 21" class="reaction-icon-img"><g stroke="none" stroke-width="1" fill-rule="evenodd"><g transform="translate(-219.000000, -760.000000)" fill="currentColor"><g transform="translate(56.000000, 160.000000)"><path fill="currentColor" d="M163,610.021159 L163,618.021159 C163,619.126159 163.93975,620.000159 165.1,620.000159 L167.199999,620.000159 L167.199999,608.000159 L165.1,608.000159 C163.93975,608.000159 163,608.916159 163,610.021159 M183.925446,611.355159 L182.100546,617.890159 C181.800246,619.131159 180.639996,620.000159 179.302297,620.000159 L169.299999,620.000159 L169.299999,608.021159 L171.104948,601.826159 C171.318098,600.509159 172.754498,599.625159 174.209798,600.157159 C175.080247,600.476159 175.599997,601.339159 175.599997,602.228159 L175.599997,607.021159 C175.599997,607.573159 176.070397,608.000159 176.649997,608.000159 L181.127196,608.000159 C182.974146,608.000159 184.340196,609.642159 183.925446,611.355159"/></g></g></g></svg>',
love: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 122.88 107.41" class="reaction-icon-img"><path fill="currentColor" d="M60.83,17.19C68.84,8.84,74.45,1.62,86.79,0.21c23.17-2.66,44.48,21.06,32.78,44.41 c-3.33,6.65-10.11,14.56-17.61,22.32c-8.23,8.52-17.34,16.87-23.72,23.2l-17.4,17.26L46.46,93.56C29.16,76.9,0.95,55.93,0.02,29.95 C-0.63,11.75,13.73,0.09,30.25,0.3C45.01,0.5,51.22,7.84,60.83,17.19L60.83,17.19L60.83,17.19z"/></svg>',
helpful: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 103.19 122.88" class="reaction-icon-img"><path fill="currentColor" d="M17.16 0h82.72a3.32 3.32 0 013.31 3.31v92.32c-.15 2.58-3.48 2.64-7.08 2.48H15.94c-4.98 0-9.05 4.07-9.05 9.05s4.07 9.05 9.05 9.05h80.17v-9.63h7.08v12.24c0 2.23-1.82 4.05-4.05 4.05H16.29C7.33 122.88 0 115.55 0 106.59V17.16C0 7.72 7.72 0 17.16 0zm3.19 13.4h2.86c1.46 0 2.66.97 2.66 2.15v67.47c0 1.18-1.2 2.15-2.66 2.15h-2.86c-1.46 0-2.66-.97-2.66-2.15V15.55c.01-1.19 1.2-2.15 2.66-2.15z" fill-rule="evenodd" clip-rule="evenodd"/></svg>',
mastodon: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" class="share-icon-img"><path fill="currentColor" d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>',
bluesky: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 512 452.265" class="share-icon-img"><path fill="currentColor" d="M110.985 30.441c58.695 44.217 121.837 133.856 145.013 181.961 23.177-48.105 86.323-137.744 145.017-181.961C443.376-1.455 512-26.142 512 52.402c0 15.68-8.962 131.775-14.223 150.628-18.273 65.515-84.873 82.228-144.112 72.116 103.55 17.679 129.889 76.237 73 134.799-108.041 111.223-155.288-27.905-167.386-63.554-3.488-10.262-2.991-10.498-6.56 0-12.098 35.649-59.342 174.777-167.383 63.554-56.889-58.562-30.551-117.12 72.999-134.799-59.239 10.112-125.84-6.601-144.112-72.116C8.962 184.177 0 68.082 0 52.402 0-26.142 68.633-1.455 110.985 30.441z"/></svg>',
copy: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" class="share-icon-img"><path fill="currentColor" d="M15.24 2H11.3458C9.58159 1.99999 8.18418 1.99997 7.09054 2.1476C5.96501 2.29953 5.05402 2.61964 4.33559 3.34096C3.61717 4.06227 3.29833 4.97692 3.14701 6.10697C2.99997 7.205 2.99999 8.60802 3 10.3793V16.2169C3 17.725 3.91995 19.0174 5.22717 19.5592C5.15989 18.6498 5.15994 17.3737 5.16 16.312L5.16 11.3976L5.16 11.3024C5.15993 10.0207 5.15986 8.91644 5.27828 8.03211C5.40519 7.08438 5.69139 6.17592 6.4253 5.43906C7.15921 4.70219 8.06404 4.41485 9.00798 4.28743C9.88877 4.16854 10.9887 4.1686 12.2652 4.16867L12.36 4.16868H15.24L15.3348 4.16867C16.6113 4.1686 17.7088 4.16854 18.5896 4.28743C18.0627 2.94779 16.7616 2 15.24 2Z"/><path fill="currentColor" d="M6.6001 11.3974C6.6001 8.67119 6.6001 7.3081 7.44363 6.46118C8.28716 5.61426 9.64481 5.61426 12.3601 5.61426H15.2401C17.9554 5.61426 19.313 5.61426 20.1566 6.46118C21.0001 7.3081 21.0001 8.6712 21.0001 11.3974V16.2167C21.0001 18.9429 21.0001 20.306 20.1566 21.1529C19.313 21.9998 17.9554 21.9998 15.2401 21.9998H12.3601C9.64481 21.9998 8.28716 21.9998 7.44363 21.1529C6.6001 20.306 6.6001 18.9429 6.6001 16.2167V11.3974Z"/></svg>'
};
// Create reaction buttons HTML
function createReactionButtons(postId) {
return `
<div class="reactions-container">
<div class="reactions-label">React</div>
<div class="reactions-buttons">
<button class="reaction-btn" data-post="${postId}" data-reaction="like" title="Like">
${iconSVGs.like}<span class="reaction-count">0</span>
</button>
<button class="reaction-btn" data-post="${postId}" data-reaction="love" title="Love">
${iconSVGs.love}<span class="reaction-count">0</span>
</button>
<button class="reaction-btn" data-post="${postId}" data-reaction="helpful" title="Helpful">
${iconSVGs.helpful}<span class="reaction-count">0</span>
</button>
</div>
</div>
`;
}
// Create share buttons HTML
function createShareButtons(postId) {
return `
<div class="share-container">
<div class="share-label">Share</div>
<div class="share-buttons">
<button class="share-btn" data-platform="mastodon" data-post="${postId}" title="Share on Mastodon">${iconSVGs.mastodon}</button>
<button class="share-btn" data-platform="bluesky" data-post="${postId}" title="Share on Bluesky">${iconSVGs.bluesky}</button>
<button class="share-btn" data-platform="copy" data-post="${postId}" title="Copy link">${iconSVGs.copy}</button>
</div>
</div>
`;
}
// Handle reaction clicks
document.addEventListener('click', function(e) {
if (e.target.closest('.reaction-btn')) {
const button = e.target.closest('.reaction-btn');
const postId = button.dataset.post;
const reaction = button.dataset.reaction;
sendReaction(postId, reaction);
}
});
// Handle share clicks
document.addEventListener('click', function(e) {
if (e.target.closest('.share-btn')) {
const button = e.target.closest('.share-btn');
const platform = button.dataset.platform;
const postId = button.dataset.post;
const postTitle = document.querySelector(`#post-${postId} .blog-post-title`)?.textContent || '';
// Construct URL without port (for .onion compatibility)
const origin = window.location.protocol + '//' + window.location.hostname;
const url = origin + '/blog/post.html?id=' + postId;
if (platform === 'mastodon') {
trackShare(postId, 'mastodon');
// Open Mastodon share - user can paste the URL
const mastodonUrl = `https://defcon.social/share?text=${encodeURIComponent(postTitle + ' ' + url)}`;
window.open(mastodonUrl, '_blank', 'width=550,height=420');
} else if (platform === 'bluesky') {
trackShare(postId, 'bluesky');
// Open Bluesky share
const blueskyUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent(postTitle + ' ' + url)}`;
window.open(blueskyUrl, '_blank', 'width=550,height=420');
} else if (platform === 'copy') {
trackShare(postId, 'copy');
navigator.clipboard.writeText(url).then(() => {
const icon = button.querySelector('.share-icon-img');
if (icon) {
const originalHTML = icon.outerHTML;
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark';
const checkColor = isDarkMode ? '#59C99C' : 'currentColor';
icon.outerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" class="share-icon-img"><path fill="${checkColor}" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`;
setTimeout(() => {
const checkIcon = button.querySelector('.share-icon-img');
if (checkIcon) {
checkIcon.outerHTML = originalHTML;
}
}, 2000);
}
});
}
}
});
// Export functions for use in blog.js
window.blogReactions = {
createReactionButtons: createReactionButtons,
createShareButtons: createShareButtons,
loadReactions: loadReactions
};
})();
+106
View File
@@ -0,0 +1,106 @@
// Generate RSS feed from blog posts
async function generateRSSFeed() {
try {
const response = await fetch('/blog/blog-posts.json');
const posts = await response.json();
// Sort posts by date (newest first)
const sortedPosts = posts.sort((a, b) => new Date(b.date) - new Date(a.date));
// Build RSS XML
const rssItems = sortedPosts.map(post => {
// Escape HTML in content for XML
const escapeXml = (str) => {
return str.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
};
const content = escapeXml(post.content);
const title = escapeXml(post.title);
const excerpt = escapeXml(post.excerpt);
const pubDate = new Date(post.date).toUTCString();
return ` <item>
<title>${title}</title>
<link>https://example.com/blogpost.html?id=${post.id}</link>
<guid>https://example.com/blogpost.html?id=${post.id}</guid>
<pubDate>${pubDate}</pubDate>
<description><![CDATA[${excerpt}<br/><br/>${content}]]></description>
</item>`;
}).join('\n');
const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>My Blog</title>
<link>https://example.com/blog</link>
<description>Blog posts</description>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<atom:link href="https://example.com/blog/feed.xml" rel="self" type="application/rss+xml"/>
${rssItems}
</channel>
</rss>`;
return rssXml;
} catch (error) {
console.error('Error generating RSS feed:', error);
return null;
}
}
// If this script is run directly (not imported), generate and serve the feed
if (typeof window === 'undefined') {
// Node.js environment - generate RSS file
const fs = require('fs');
const path = require('path');
async function writeRSSFile() {
const posts = JSON.parse(fs.readFileSync(path.join(__dirname, 'blog-posts.json'), 'utf8'));
const sortedPosts = posts.sort((a, b) => new Date(b.date) - new Date(a.date));
const escapeXml = (str) => {
return str.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
};
const rssItems = sortedPosts.map(post => {
const content = escapeXml(post.content);
const title = escapeXml(post.title);
const excerpt = escapeXml(post.excerpt);
const pubDate = new Date(post.date).toUTCString();
return ` <item>
<title>${title}</title>
<link>https://example.com/blogpost.html?id=${post.id}</link>
<guid>https://example.com/blogpost.html?id=${post.id}</guid>
<pubDate>${pubDate}</pubDate>
<description><![CDATA[${excerpt}<br/><br/>${content}]]></description>
</item>`;
}).join('\n');
const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>My Blog</title>
<link>https://example.com/blog</link>
<description>Blog posts</description>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<atom:link href="https://example.com/blog/feed.xml" rel="self" type="application/rss+xml"/>
${rssItems}
</channel>
</rss>`;
fs.writeFileSync(path.join(__dirname, 'feed.xml'), rssXml, 'utf8');
console.log('RSS feed generated successfully');
}
writeRSSFile();
}
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self' data:; img-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self' https://duckduckgo.com;">
<meta http-equiv="X-Content-Type-Options" content="nosniff">
<link rel="stylesheet" href="../assets/css/style.css">
<link rel="icon" type="image/x-icon" href="../favicon.ico">
<link rel="alternate" type="application/rss+xml" title="Blog RSS Feed" href="/blog/api/feed.php">
<script>
// Apply theme immediately to prevent flash
(function() {
const theme = localStorage.getItem('theme') ||
(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<title id="pageTitle">Blog Post - Launch Pad</title>
</head>
<body>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark mode">
<svg class="theme-icon theme-icon-moon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
<svg class="theme-icon theme-icon-sun" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
</button>
<br/><br/>
<div class="name">
__ _______________________ _________._________________________
/ \ / \_ _____/\______ \/ _____/| \__ ___/\_ _____/
\ \/\/ /| __)_ | | _/\_____ \ | | | | | __)_
\ / | \ | | \/ \| | | | | \
\__/\ / /_______ / |______ /_______ /|___| |____| /_______ /
\/ \/ \/ \/ \/
</div>
<div class="blog-layout">
<aside class="blog-sidebar" id="blogSidebar">
<nav class="sidebar-nav" id="sidebarNav">
<!-- Post links will be dynamically loaded here -->
</nav>
</aside>
<main class="blog-main-content">
<div class="blog-page-header">
<div class="blog-header-content">
<a href="/blog" class="back-link" title="Back to Blog">
<svg xmlns="http://www.w3.org/2000/svg" width="42" height="42" viewBox="0 0 24 24" class="home-icon"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>
</a>
<h1 class="blog-page-title" id="blogPostTitle">Loading...</h1>
</div>
</div>
<div class="blog-post-container" id="blogPostContainer">
<!-- Blog post will be dynamically loaded here -->
</div>
</main>
</div>
<script async type="text/javascript" src="js/analytics.js"></script>
<script async type="text/javascript" src="js/reactions.js"></script>
<script async type="text/javascript" src="js/post.js"></script>
<script src="../assets/js/theme.js"></script>
</body>
</html>