Complete WIP: Architecture refactor.
Mount JSX server side templating for blog posts. Send AppShell conditionally. Maintain support for HMR via HTMLbundles using Bun's native fullstack dev server under an /hmr path. This is only mounted in development and is supported by the onImport Bun plugin. Add DB creation on startup and load pages based on those records.
This commit is contained in:
@@ -1,59 +1,33 @@
|
||||
import React from 'react';
|
||||
import styles from '../public/styles.css' with { type: "text" };
|
||||
import headScript from '../public/head.js' with { type: "text" };
|
||||
import onLoadScript from '../public/onLoad.js' with { type: "text" };
|
||||
import React, { ReactNode } from 'react';
|
||||
import { minifyCSS, minifyJS } from './utils';
|
||||
|
||||
// Helper: Minify CSS using simple but effective regex
|
||||
function minifyCSS(css: string): string {
|
||||
return css
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove comments
|
||||
.replace(/\s+/g, ' ') // Collapse whitespace
|
||||
.replace(/\s*([{}:;,])\s*/g, '$1') // Remove space around delimiters
|
||||
.trim();
|
||||
}
|
||||
import styles from './styles.css' with { type: "text" };
|
||||
import headScript from './onLoad' with { type: "text" };
|
||||
|
||||
// Helper: Minify JS/TS using Bun.Transpiler
|
||||
function minifyJS(code: string): string {
|
||||
const transpiler = new Bun.Transpiler({
|
||||
loader: 'ts',
|
||||
minifyWhitespace: true,
|
||||
});
|
||||
import { ThemePicker } from './components/theme-picker';
|
||||
import { ProfileBadge } from './components/profile-badge';
|
||||
import { TagPicker } from './components/tag-picker';
|
||||
import { PostArchive } from './components/post-archive';
|
||||
|
||||
return transpiler.transformSync(code);
|
||||
}
|
||||
|
||||
export function AppShell(props: { post: any }) {
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html className={styles.root}>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Caleb's Blog</title>
|
||||
<style>{minifyCSS(styles)}</style>
|
||||
<script>
|
||||
{minifyJS(headScript)}
|
||||
</script>
|
||||
<script defer>
|
||||
{minifyJS(onLoadScript)}
|
||||
</script>
|
||||
<style dangerouslySetInnerHTML={{ __html: minifyCSS(styles) }} />
|
||||
</head>
|
||||
<body>
|
||||
<div dangerouslySetInnerHTML={{ __html: props.post }} />
|
||||
{children}
|
||||
<aside>
|
||||
<h3>About Me</h3>
|
||||
<p>I'm a software engineer</p>
|
||||
<ul>
|
||||
<li>Twitter</li>
|
||||
<li>GitHub</li>
|
||||
<li>LinkedIn</li>
|
||||
</ul>
|
||||
<h3>Categories</h3>
|
||||
<ul>
|
||||
<li>Web Development</li>
|
||||
<li>UI/UX Design</li>
|
||||
<li>Productivity</li>
|
||||
<li>Career</li>
|
||||
</ul>
|
||||
<ThemePicker />
|
||||
<ProfileBadge />
|
||||
<TagPicker />
|
||||
<PostArchive />
|
||||
</aside>
|
||||
</body>
|
||||
<script dangerouslySetInnerHTML={{ __html: minifyJS(headScript) }} />
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export function Blog() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Blog</h1>
|
||||
<a href="/">Home</a>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
24
src/frontend/clientJS/post-archive.ts
Normal file
24
src/frontend/clientJS/post-archive.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Post Archive toggle functionality
|
||||
function setupArchiveToggles() {
|
||||
document.querySelectorAll('.archive-year, .archive-month').forEach(toggle => {
|
||||
toggle.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
|
||||
const toggleIcon = this.querySelector('.archive-toggle');
|
||||
const content = this.nextElementSibling;
|
||||
|
||||
// Toggle expanded state
|
||||
if (toggleIcon) toggleIcon.classList.toggle('expanded');
|
||||
if (content) content.classList.toggle('expanded');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setupArchiveToggles();
|
||||
});
|
||||
} else {
|
||||
setupArchiveToggles();
|
||||
}
|
||||
102
src/frontend/clientJS/tag-picker.ts
Normal file
102
src/frontend/clientJS/tag-picker.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// Client-side tag picker toggle functionality
|
||||
class TagPickerManager {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.attachTagClickListeners();
|
||||
}
|
||||
|
||||
// Parse current URL to get active tags
|
||||
getActiveTags(): string[] {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.getAll('tag').map(tag => decodeURIComponent(tag).replace(/-/g, ' '));
|
||||
}
|
||||
|
||||
// Generate query string from tags array
|
||||
generateTagQueryString(tags: string[]): string {
|
||||
if (tags.length === 0) return '';
|
||||
const params = new URLSearchParams();
|
||||
tags.forEach(tag => {
|
||||
params.append('tag', tag.toLowerCase().replace(/\s+/g, '-'));
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
// Toggle a tag and update the page
|
||||
toggleTag(tagName: string) {
|
||||
const currentTags = this.getActiveTags();
|
||||
|
||||
// Toggle logic: if tag is active, remove it; otherwise add it
|
||||
const newTags = currentTags.includes(tagName)
|
||||
? currentTags.filter(t => t !== tagName)
|
||||
: [...currentTags, tagName];
|
||||
|
||||
// Navigate to new URL
|
||||
const queryString = this.generateTagQueryString(newTags);
|
||||
const newUrl = queryString ? `/?${queryString}` : '/';
|
||||
window.location.href = newUrl;
|
||||
}
|
||||
|
||||
// Attach click listeners to tag links
|
||||
attachTagClickListeners() {
|
||||
const tagLinks = document.querySelectorAll('.tag-pill, .post-tag');
|
||||
|
||||
tagLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const tagName = link.textContent?.trim();
|
||||
if (tagName) {
|
||||
this.toggleTag(tagName);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Update visual state of tags based on current URL
|
||||
updateTagVisualState() {
|
||||
const activeTags = this.getActiveTags();
|
||||
|
||||
// Update tag pills in sidebar
|
||||
document.querySelectorAll('.tag-pill').forEach(link => {
|
||||
const tagName = link.textContent?.trim();
|
||||
if (tagName && activeTags.includes(tagName)) {
|
||||
link.classList.add('active');
|
||||
} else {
|
||||
link.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Update post tags
|
||||
document.querySelectorAll('.post-tag').forEach(link => {
|
||||
const tagName = link.textContent?.trim();
|
||||
if (tagName && activeTags.includes(tagName)) {
|
||||
link.classList.add('active');
|
||||
} else {
|
||||
link.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Update clear filters button visibility
|
||||
const tagActions = document.querySelector('.tag-actions');
|
||||
if (tagActions) {
|
||||
tagActions.style.display = activeTags.length > 0 ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
const tagPickerManager = new TagPickerManager();
|
||||
|
||||
// Update visual state after page loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
tagPickerManager.updateTagVisualState();
|
||||
});
|
||||
|
||||
// Also call immediately if DOM is already loaded
|
||||
if (document.readyState === 'interactive' || document.readyState === 'complete') {
|
||||
tagPickerManager.updateTagVisualState();
|
||||
}
|
||||
|
||||
export { TagPickerManager };
|
||||
165
src/frontend/clientJS/theme-picker.ts
Normal file
165
src/frontend/clientJS/theme-picker.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
// Theme switching functionality
|
||||
const LIGHT_THEMES = ['latte', 'solarized-light', 'gruvbox-light'];
|
||||
const DARK_THEMES = ['frappe', 'macchiato', 'mocha', 'solarized-dark', 'gruvbox-dark', 'nord', 'dracula', 'one-dark', 'tokyo-night'];
|
||||
|
||||
const THEME_NAMES = {
|
||||
latte: 'Catppuccin Latte',
|
||||
frappe: 'Catppuccin Frappe',
|
||||
macchiato: 'Catppuccin Macchiato',
|
||||
mocha: 'Catppuccin Mocha',
|
||||
'solarized-light': 'Solarized Light',
|
||||
'solarized-dark': 'Solarized Dark',
|
||||
'gruvbox-light': 'Gruvbox Light',
|
||||
'gruvbox-dark': 'Gruvbox Dark',
|
||||
nord: 'Nord',
|
||||
dracula: 'Dracula',
|
||||
'one-dark': 'One Dark',
|
||||
'tokyo-night': 'Tokyo Night'
|
||||
};
|
||||
|
||||
let currentMode = '';
|
||||
let THEMES = { light: 'latte', dark: 'mocha' };
|
||||
|
||||
function initializeTheme() {
|
||||
const savedLightTheme = localStorage.getItem('theme-light') || 'latte';
|
||||
const savedDarkTheme = localStorage.getItem('theme-dark') || 'mocha';
|
||||
const savedMode = localStorage.getItem('theme-mode');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
THEMES.light = savedLightTheme;
|
||||
THEMES.dark = savedDarkTheme;
|
||||
currentMode = savedMode || (prefersDark ? 'dark' : 'light');
|
||||
|
||||
updateModeToggle();
|
||||
updateThemeDropdown();
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
const theme = THEMES[currentMode];
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
updateCurrentThemeDisplay();
|
||||
}
|
||||
|
||||
function updateThemeDropdown() {
|
||||
const menu = document.getElementById('themeDropdownMenu');
|
||||
const themes = currentMode === 'light' ? LIGHT_THEMES : DARK_THEMES;
|
||||
|
||||
if (menu) {
|
||||
menu.innerHTML = themes.map(theme =>
|
||||
'<div class="theme-option" data-theme="' + theme + '">' +
|
||||
' <div class="theme-name">' + THEME_NAMES[theme] + '</div>' +
|
||||
' <button class="theme-selector-btn" data-theme="' + theme + '"></button>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
|
||||
attachThemeOptionListeners();
|
||||
updateUI();
|
||||
}
|
||||
}
|
||||
|
||||
function attachThemeOptionListeners() {
|
||||
document.querySelectorAll('.theme-option').forEach(option => {
|
||||
option.addEventListener('click', function(e) {
|
||||
const theme = this.getAttribute('data-theme');
|
||||
if (theme) selectTheme(theme);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function selectTheme(theme: string) {
|
||||
THEMES[currentMode] = theme;
|
||||
localStorage.setItem('theme-' + currentMode, theme);
|
||||
updateUI();
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
function updateCurrentThemeDisplay() {
|
||||
const theme = THEMES[currentMode];
|
||||
const themeName = THEME_NAMES[theme];
|
||||
const display = document.getElementById('currentThemeDisplay');
|
||||
if (display) {
|
||||
display.textContent = themeName;
|
||||
}
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
const currentTheme = THEMES[currentMode];
|
||||
document.querySelectorAll('.theme-selector-btn').forEach(function(btn) {
|
||||
const theme = btn.getAttribute('data-theme');
|
||||
if (theme) {
|
||||
btn.classList.toggle('selected', currentTheme === theme);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateModeToggle() {
|
||||
const lightBtn = document.getElementById('lightModeBtn');
|
||||
const darkBtn = document.getElementById('darkModeBtn');
|
||||
|
||||
if (lightBtn) lightBtn.classList.toggle('active', currentMode === 'light');
|
||||
if (darkBtn) darkBtn.classList.toggle('active', currentMode === 'dark');
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
function setupEventListeners() {
|
||||
const lightBtn = document.getElementById('lightModeBtn');
|
||||
const darkBtn = document.getElementById('darkModeBtn');
|
||||
const trigger = document.getElementById('themeDropdownTrigger');
|
||||
const menu = document.getElementById('themeDropdownMenu');
|
||||
|
||||
if (lightBtn) {
|
||||
lightBtn.addEventListener('click', function() {
|
||||
currentMode = 'light';
|
||||
localStorage.setItem('theme-mode', 'light');
|
||||
updateModeToggle();
|
||||
updateThemeDropdown();
|
||||
applyTheme();
|
||||
});
|
||||
}
|
||||
|
||||
if (darkBtn) {
|
||||
darkBtn.addEventListener('click', function() {
|
||||
currentMode = 'dark';
|
||||
localStorage.setItem('theme-mode', 'dark');
|
||||
updateModeToggle();
|
||||
updateThemeDropdown();
|
||||
applyTheme();
|
||||
});
|
||||
}
|
||||
|
||||
if (trigger && menu) {
|
||||
trigger.addEventListener('click', function() {
|
||||
trigger.classList.toggle('open');
|
||||
menu.classList.toggle('open');
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
const target = e.target as Element;
|
||||
if (!target.closest('.theme-dropdown-wrapper')) {
|
||||
if (trigger) trigger.classList.remove('open');
|
||||
if (menu) menu.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||
if (localStorage.getItem('theme-mode') !== 'light' && localStorage.getItem('theme-mode') !== 'dark') {
|
||||
currentMode = e.matches ? 'dark' : 'light';
|
||||
updateModeToggle();
|
||||
updateThemeDropdown();
|
||||
applyTheme();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeTheme();
|
||||
setupEventListeners();
|
||||
});
|
||||
} else {
|
||||
initializeTheme();
|
||||
setupEventListeners();
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import postArchiveScript from '../clientJS/post-archive' with { type: "text" };
|
||||
import { minifyJS } from '../utils';
|
||||
|
||||
export function PostArchive() {
|
||||
const archiveData = [
|
||||
{
|
||||
year: "2025",
|
||||
count: "(8)",
|
||||
months: [
|
||||
{
|
||||
name: "October",
|
||||
count: "(3)",
|
||||
posts: [
|
||||
{ title: "Building a Modern Blog", href: "/blog/post-1" },
|
||||
{ title: "TypeScript Tips & Tricks", href: "/blog/post-2" },
|
||||
{ title: "Designing Better UIs", href: "/blog/post-3" }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "September",
|
||||
count: "(5)",
|
||||
posts: [
|
||||
{ title: "Getting Started with Bun", href: "/blog/post-4" },
|
||||
{ title: "Web Performance Optimization", href: "/blog/post-5" },
|
||||
{ title: "CSS Grid vs Flexbox", href: "/blog/post-6" },
|
||||
{ title: "JavaScript Best Practices", href: "/blog/post-7" },
|
||||
{ title: "Accessibility Matters", href: "/blog/post-8" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
year: "2024",
|
||||
count: "(12)",
|
||||
months: [
|
||||
{
|
||||
name: "December",
|
||||
count: "(4)",
|
||||
posts: [
|
||||
{ title: "Year in Review 2024", href: "/blog/post-9" },
|
||||
{ title: "Holiday Coding Projects", href: "/blog/post-10" },
|
||||
{ title: "New Year Resolutions", href: "/blog/post-11" },
|
||||
{ title: "Reflecting on Growth", href: "/blog/post-12" }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "June",
|
||||
count: "(8)",
|
||||
posts: [
|
||||
{ title: "Summer Tech Trends", href: "/blog/post-13" },
|
||||
{ title: "Remote Work Tips", href: "/blog/post-14" },
|
||||
{ title: "Learning Resources", href: "/blog/post-15" },
|
||||
{ title: "Code Review Best Practices", href: "/blog/post-16" },
|
||||
{ title: "Git Workflow Tips", href: "/blog/post-17" },
|
||||
{ title: "Docker for Beginners", href: "/blog/post-18" },
|
||||
{ title: "API Design Patterns", href: "/blog/post-19" },
|
||||
{ title: "Testing Strategies", href: "/blog/post-20" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="postList sheet-background">
|
||||
<h3>Posts</h3>
|
||||
<ul className="post-archive">
|
||||
{archiveData.map((yearData) => (
|
||||
<li key={yearData.year}>
|
||||
<div className="archive-year">
|
||||
<span className="archive-toggle">▶</span>
|
||||
<span>{yearData.year}</span>
|
||||
<span className="post-count">{yearData.count}</span>
|
||||
</div>
|
||||
<div className="archive-content">
|
||||
<ul className="archive-months">
|
||||
{yearData.months.map((monthData) => (
|
||||
<li key={monthData.name}>
|
||||
<div className="archive-month">
|
||||
<span className="archive-toggle">▶</span>
|
||||
<span>{monthData.name}</span>
|
||||
<span className="post-count">{monthData.count}</span>
|
||||
</div>
|
||||
<div className="archive-content">
|
||||
<ul className="archive-posts">
|
||||
{monthData.posts.map((post) => (
|
||||
<li key={post.href} className="archive-post">
|
||||
<a href={post.href}>{post.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<script dangerouslySetInnerHTML={{ __html: minifyJS(postArchiveScript) }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
|
||||
const urlLinkedIn = 'https://linkedin.com/in/CalebBraaten';
|
||||
const urlX = 'https://x.com/CalebBraaten';
|
||||
const urlGit = 'https://git.cbraaten.dev/Caleb';
|
||||
|
||||
export function ProfileBadge() {
|
||||
return (
|
||||
<div className="aboutMe sheet-background">
|
||||
<div className="profile-header">
|
||||
<img src="http://localhost:3000/profile-picture.webp" alt="Caleb Braaten" className="profile-picture" />
|
||||
<h3 className="profile-name">Caleb Braaten</h3>
|
||||
</div>
|
||||
<ul className="social-links">
|
||||
<li>
|
||||
<a href={urlLinkedIn} data-external target="_blank" rel="noopener noreferrer" aria-label="LinkedIn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={urlX} data-external target="_blank" rel="noopener noreferrer" aria-label="X (Twitter)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={urlGit} data-external target="_blank" rel="noopener noreferrer" aria-label="GitHub / Gitea">
|
||||
<div className="git-icon-wrapper">
|
||||
<svg className="icon-github" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
|
||||
</svg>
|
||||
<svg className="icon-gitea" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<g>
|
||||
<path id="teabag" d="M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8
|
||||
c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4
|
||||
c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z"/>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2
|
||||
c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5
|
||||
c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5
|
||||
c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3
|
||||
c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1
|
||||
C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4
|
||||
c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7
|
||||
S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55
|
||||
c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8
|
||||
l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z"/>
|
||||
<path d="M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4
|
||||
c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1
|
||||
c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9
|
||||
c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3
|
||||
c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3
|
||||
c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29
|
||||
c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8
|
||||
C343.2,346.5,335,363.3,326.8,380.1z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import React from 'react';
|
||||
import { minifyJS } from '../utils';
|
||||
|
||||
export function TagPicker({ availableTags = [], activeTags = [] }: {
|
||||
availableTags?: { name: string; post_count: number }[],
|
||||
activeTags?: string[]
|
||||
}) {
|
||||
return (
|
||||
<div className="tags sheet-background">
|
||||
<h3>Tags</h3>
|
||||
{availableTags.length > 0 ? (
|
||||
<ul className="tag-pills">
|
||||
{availableTags.map(tag => (
|
||||
<li key={tag.name}>
|
||||
<a
|
||||
href={`?tag=${tag.name.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
className="tag-pill"
|
||||
title={`${tag.post_count} post${tag.post_count !== 1 ? 's' : ''} (click to toggle)`}
|
||||
>
|
||||
{tag.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="no-tags-available">No tags available</p>
|
||||
)}
|
||||
<div className="tag-actions" style={{ display: 'none' }}>
|
||||
<a href="/" className="clear-tags-btn">
|
||||
Clear all filters
|
||||
</a>
|
||||
</div>
|
||||
<script dangerouslySetInnerHTML={{ __html: minifyJS(tagToggleScript) }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Client-side script for tag toggle functionality
|
||||
const tagToggleScript = `
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
class TagPickerManager {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.attachTagClickListeners();
|
||||
this.updateTagVisualState();
|
||||
}
|
||||
|
||||
// Parse current URL to get active tags
|
||||
getActiveTags() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.getAll('tag').map(tag => decodeURIComponent(tag).replace(/-/g, ' '));
|
||||
}
|
||||
|
||||
// Generate query string from tags array
|
||||
generateTagQueryString(tags) {
|
||||
if (tags.length === 0) return '';
|
||||
const params = new URLSearchParams();
|
||||
tags.forEach(tag => {
|
||||
params.append('tag', tag.toLowerCase().replace(/\\s+/g, '-'));
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
// Toggle a tag and update the page
|
||||
toggleTag(tagName) {
|
||||
const currentTags = this.getActiveTags();
|
||||
|
||||
// Toggle logic: if tag is active, remove it; otherwise add it
|
||||
const newTags = currentTags.includes(tagName)
|
||||
? currentTags.filter(t => t !== tagName)
|
||||
: [...currentTags, tagName];
|
||||
|
||||
// Navigate to new URL
|
||||
const queryString = this.generateTagQueryString(newTags);
|
||||
const newUrl = queryString ? \`/?\${queryString}\` : '/';
|
||||
|
||||
window.location.href = newUrl;
|
||||
}
|
||||
|
||||
// Attach click listeners to tag links
|
||||
attachTagClickListeners() {
|
||||
const tagLinks = document.querySelectorAll('.tag-pill, .post-tag');
|
||||
|
||||
tagLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const tagName = link.textContent?.trim();
|
||||
if (tagName) {
|
||||
this.toggleTag(tagName);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Update visual state of tags based on current URL
|
||||
updateTagVisualState() {
|
||||
const activeTags = this.getActiveTags();
|
||||
|
||||
// Update tag pills in sidebar
|
||||
document.querySelectorAll('.tag-pill').forEach(link => {
|
||||
const tagName = link.textContent?.trim();
|
||||
if (tagName && activeTags.includes(tagName)) {
|
||||
link.classList.add('active');
|
||||
} else {
|
||||
link.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Update post tags
|
||||
document.querySelectorAll('.post-tag').forEach(link => {
|
||||
const tagName = link.textContent?.trim();
|
||||
if (tagName && activeTags.includes(tagName)) {
|
||||
link.classList.add('active');
|
||||
} else {
|
||||
link.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Update clear filters button visibility
|
||||
const tagActions = document.querySelector('.tag-actions');
|
||||
if (tagActions) {
|
||||
tagActions.style.display = activeTags.length > 0 ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
function initializeTagPicker() {
|
||||
// Remove any existing instance
|
||||
if (window.tagPickerManager) {
|
||||
delete window.tagPickerManager;
|
||||
}
|
||||
|
||||
// Create new instance
|
||||
window.tagPickerManager = new TagPickerManager();
|
||||
}
|
||||
|
||||
// Wait for DOM to be ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeTagPicker);
|
||||
} else {
|
||||
initializeTagPicker();
|
||||
}
|
||||
})();
|
||||
`;
|
||||
@@ -1,13 +1,65 @@
|
||||
import React from 'react';
|
||||
import themePickerScript from '../clientJS/theme-picker' with { type: "text" };
|
||||
import { minifyJS } from '../utils';
|
||||
|
||||
const LIGHT_THEMES = ['latte', 'solarized-light', 'gruvbox-light'];
|
||||
const DARK_THEMES = ['frappe', 'macchiato', 'mocha', 'solarized-dark', 'gruvbox-dark', 'nord', 'dracula', 'one-dark', 'tokyo-night'];
|
||||
|
||||
const THEME_NAMES: Record<string, string> = {
|
||||
latte: 'Catppuccin Latte',
|
||||
frappe: 'Catppuccin Frappe',
|
||||
macchiato: 'Catppuccin Macchiato',
|
||||
mocha: 'Catppuccin Mocha',
|
||||
'solarized-light': 'Solarized Light',
|
||||
'solarized-dark': 'Solarized Dark',
|
||||
'gruvbox-light': 'Gruvbox Light',
|
||||
'gruvbox-dark': 'Gruvbox Dark',
|
||||
nord: 'Nord',
|
||||
dracula: 'Dracula',
|
||||
'one-dark': 'One Dark',
|
||||
'tokyo-night': 'Tokyo Night'
|
||||
};
|
||||
|
||||
export function ThemePicker() {
|
||||
|
||||
const randomNumber = Math.random();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Sub Component!</h1>
|
||||
<p>Some text here... maybe? with hot reloading! {randomNumber}</p>
|
||||
<div className="themePicker sheet-background">
|
||||
<label htmlFor="theme" className="hidden">Theme</label>
|
||||
<div className="theme-controls">
|
||||
<div className="theme-mode-toggle">
|
||||
<button
|
||||
className="mode-btn active"
|
||||
data-mode="light"
|
||||
id="lightModeBtn"
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
className="mode-btn"
|
||||
data-mode="dark"
|
||||
id="darkModeBtn"
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
<div className="theme-dropdown-wrapper">
|
||||
<button
|
||||
className="theme-dropdown-trigger"
|
||||
id="themeDropdownTrigger"
|
||||
>
|
||||
<span id="currentThemeDisplay">Catppuccin Latte</span>
|
||||
<span className="dropdown-arrow">▼</span>
|
||||
</button>
|
||||
<div className="theme-dropdown-menu" id="themeDropdownMenu">
|
||||
{LIGHT_THEMES.map(theme => (
|
||||
<div key={theme} className="theme-option" data-theme={theme}>
|
||||
<div className="theme-name">{THEME_NAMES[theme]}</div>
|
||||
<button className="theme-selector-btn" data-theme={theme}></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script dangerouslySetInnerHTML={{ __html: minifyJS(themePickerScript) }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export function Home() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Home</h1>
|
||||
<a href="/blog">Blog</a>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
<main>
|
||||
<h1>404 Not Found</h1>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
42
src/frontend/onLoad.ts
Normal file
42
src/frontend/onLoad.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Client-side script that runs on page load
|
||||
// Example: TypeScript with type annotations
|
||||
|
||||
async function loadContent(url: string) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'shell-loaded': 'true'
|
||||
}
|
||||
});
|
||||
const html = await response.text();
|
||||
const mainElement = document.querySelector('main');
|
||||
if (mainElement) {
|
||||
mainElement.outerHTML = html;
|
||||
// Re-attach handlers to new links after content swap
|
||||
attachLinkHandlers();
|
||||
}
|
||||
}
|
||||
|
||||
function attachLinkHandlers() {
|
||||
const links: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a:not([data-external])');
|
||||
|
||||
links.forEach(link => {
|
||||
console.log('Attaching listener to:', link.href);
|
||||
link.onclick = async (e) => {
|
||||
console.log('clicked', link.href);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
window.history.pushState({}, '', link.href);
|
||||
await loadContent(link.href);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for back/forward button clicks
|
||||
window.addEventListener('popstate', async (event) => {
|
||||
await loadContent(window.location.href);
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
attachLinkHandlers();
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ThemePicker } from "../components/theme-picker";
|
||||
|
||||
export function Blog() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Blog</h1>
|
||||
<a href="/">Home</a>
|
||||
<ThemePicker />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,143 @@
|
||||
import React from 'react';
|
||||
import { getRecentPosts, formatDate, calculateReadTime, getNumOfPosts } from '../../db/posts';
|
||||
import { parseTags } from '../../db/tags';
|
||||
import { type BlogPost } from '../../db/queries';
|
||||
|
||||
export function Home() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Home!</h1>
|
||||
<a href="/blog">Blog</a>
|
||||
</main>
|
||||
)
|
||||
export function Home({ searchParams }: { searchParams: Record<string, string> }) {
|
||||
const currentPage = parseInt(searchParams.page || "1", 10);
|
||||
const totalPages = Math.ceil(getNumOfPosts() / 10);
|
||||
const posts = getRecentPosts(10, ); // Get the 10 most recent posts
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="posts-list">
|
||||
{posts.length > 0 ? (
|
||||
posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<h2>No posts yet</h2>
|
||||
<p>Check back soon for new blog posts!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Pagination currentPage={currentPage} totalPages={totalPages} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
interface PostCardProps {
|
||||
post: BlogPost;
|
||||
}
|
||||
|
||||
function PostCard({ post }: PostCardProps) {
|
||||
const tags = parseTags(post.tags);
|
||||
const formattedDate = formatDate(post.date);
|
||||
|
||||
return (
|
||||
<article className="post-card">
|
||||
<header className="post-card-header">
|
||||
<h2 className="post-card-title">
|
||||
<a href={`${post.path}`} className="post-card-link">{post.title}</a>
|
||||
</h2>
|
||||
<div className="post-card-meta">
|
||||
<time dateTime={post.date}>{formattedDate}</time>
|
||||
<span className="meta-separator">•</span>
|
||||
<span className="read-time">5 min read</span>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="post-card-tags">
|
||||
{tags.map((tag, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={`/?tag=${tag.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
className="post-tag"
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{post.summary && (
|
||||
<div className="post-card-summary">
|
||||
<p>{post.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
<footer className="post-card-footer">
|
||||
<a href={`${post.path}`} className="read-more-link">Read more →</a>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Pagination({ currentPage, totalPages }: { currentPage: number; totalPages: number }) {
|
||||
// Calculate the range of page numbers to show
|
||||
let startPage: number;
|
||||
let endPage: number;
|
||||
|
||||
if (totalPages <= 9) {
|
||||
// If there are fewer than 9 pages, show all of them
|
||||
startPage = 1;
|
||||
endPage = totalPages;
|
||||
} else {
|
||||
// If we have more than 9 pages, calculate the window
|
||||
if (currentPage <= 5) {
|
||||
// When we're at the start, show pages 1-9
|
||||
startPage = 1;
|
||||
endPage = 9;
|
||||
} else if (currentPage >= totalPages - 4) {
|
||||
// When we're near the end, show the last 9 pages
|
||||
startPage = totalPages - 8;
|
||||
endPage = totalPages;
|
||||
} else {
|
||||
// Otherwise, center the window around the current page
|
||||
startPage = currentPage - 4;
|
||||
endPage = currentPage + 4;
|
||||
}
|
||||
}
|
||||
|
||||
const pages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||
|
||||
return (
|
||||
<nav className="pagination">
|
||||
{/* Previous button - always visible, disabled on first page */}
|
||||
<a
|
||||
href={`/?page=${currentPage - 1}`}
|
||||
className={`pagination-link ${currentPage === 1 ? 'disabled' : ''}`}
|
||||
style={{
|
||||
opacity: currentPage === 1 ? 0.5 : 1,
|
||||
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
|
||||
pointerEvents: currentPage === 1 ? 'none' : 'auto'
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
</a>
|
||||
|
||||
{/* Page numbers */}
|
||||
{pages.map((page) => (
|
||||
<a
|
||||
key={page}
|
||||
href={`/?page=${page}`}
|
||||
className={`pagination-link ${page === currentPage ? 'active' : ''}`}
|
||||
>
|
||||
{page}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* Next button - always visible, disabled on last page */}
|
||||
<a
|
||||
href={`/?page=${currentPage + 1}`}
|
||||
className={`pagination-link ${currentPage === totalPages ? 'disabled' : ''}`}
|
||||
style={{
|
||||
opacity: currentPage === totalPages ? 0.5 : 1,
|
||||
cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
|
||||
pointerEvents: currentPage === totalPages ? 'none' : 'auto'
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</a>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
45
src/frontend/pages/post.tsx
Normal file
45
src/frontend/pages/post.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PostProps {
|
||||
// HTML string for the blog post body
|
||||
children: string;
|
||||
meta: {
|
||||
title: string;
|
||||
date: Date;
|
||||
readingTime: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function Post({ children, meta }: PostProps) {
|
||||
return (
|
||||
<main>
|
||||
<article className="blog-post">
|
||||
<header className="post-header">
|
||||
<h1>{meta.title}</h1>
|
||||
<div className="post-meta">
|
||||
{meta.date && meta.date instanceof Date &&
|
||||
<>
|
||||
|
||||
<time dateTime={meta.date.toISOString()}>
|
||||
{meta.date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</time>
|
||||
</>
|
||||
}
|
||||
|
||||
{meta.readingTime &&
|
||||
<>
|
||||
<span className="meta-separator">•</span>
|
||||
<span>{meta.readingTime}</span>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
<div className="post-content" dangerouslySetInnerHTML={{ __html: children }} />
|
||||
</article>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
1204
src/frontend/styles.css
Normal file
1204
src/frontend/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
18
src/frontend/utils.ts
Normal file
18
src/frontend/utils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Minify CSS using simple but effective regex
|
||||
export function minifyCSS(css: string): string {
|
||||
return css
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove comments
|
||||
.replace(/\s+/g, ' ') // Collapse whitespace
|
||||
.replace(/\s*([{}:;,])\s*/g, '$1') // Remove space around delimiters
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Minify TypeScript/JavaScript code for client-side injection
|
||||
export function minifyJS(code: string): string {
|
||||
const transpiler = new Bun.Transpiler({
|
||||
loader: 'ts',
|
||||
minifyWhitespace: true,
|
||||
});
|
||||
|
||||
return transpiler.transformSync(code);
|
||||
}
|
||||
Reference in New Issue
Block a user