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:
parent
3abd97702d
commit
f46f4667a1
@ -1,29 +0,0 @@
|
||||
import { main, type BunPlugin } from 'bun';
|
||||
import { loadMetadata } from "./utils";
|
||||
import matter from 'gray-matter';
|
||||
import { marked } from 'marked';
|
||||
import { AppShell } from "../src/frontend/AppShell";
|
||||
import AppShellPage from "../src/frontend/AppShell.html" with { type: "text" };
|
||||
import { renderToString } from "react-dom/server";
|
||||
|
||||
const markdownLoader: BunPlugin = {
|
||||
name: 'markdown-loader',
|
||||
setup(build) {
|
||||
// Plugin implementation
|
||||
build.onLoad({filter: /\.md$/}, async args => {
|
||||
console.log("Loading markdown file:", args.path);
|
||||
const {data, content } = matter(await Bun.file(args.path).text());
|
||||
loadMetadata(args.path, data);
|
||||
const html = marked.parse(content);
|
||||
|
||||
// JSX Approach
|
||||
console.log(renderToString(AppShell({ post: html })))
|
||||
return {
|
||||
contents: renderToString(AppShell({ post: html })),
|
||||
loader: 'html',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default markdownLoader;
|
||||
52
bun_plugins/onImport-markdown-loader.tsx
Normal file
52
bun_plugins/onImport-markdown-loader.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import type { BunPlugin } from 'bun';
|
||||
import React from 'react';
|
||||
import { renderToString } from "react-dom/server";
|
||||
|
||||
import matter from 'gray-matter';
|
||||
import { marked } from 'marked';
|
||||
import { addToDatabase } from "../src/db/index";
|
||||
|
||||
import { AppShell } from "../src/frontend/AppShell";
|
||||
import { Post } from "../src/frontend/pages/post";
|
||||
|
||||
// TODO: Add better type handling for if Markdown parsing fails
|
||||
const markdownLoader: BunPlugin = {
|
||||
name: 'markdown-loader',
|
||||
setup(build) {
|
||||
// Plugin implementation
|
||||
build.onLoad({filter: /\.md$/}, async args => {
|
||||
console.log("Loading markdown file:", args.path);
|
||||
const {data, content } = matter(await Bun.file(args.path).text());
|
||||
|
||||
// Remove the title from content if it matches the frontmatter title to avoid duplicate H1s
|
||||
let processedContent = content;
|
||||
if (data.title) {
|
||||
const titleHeadingRegex = new RegExp(`^#\\s+${data.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm');
|
||||
processedContent = content.replace(titleHeadingRegex, '').trim();
|
||||
}
|
||||
|
||||
const bodyHtml = await marked.parse(processedContent);
|
||||
// AppShell is required here for rendering. If used at route level
|
||||
// Bun will only see an htmlBundle and fail to load anything
|
||||
// Validate required fields
|
||||
if (!data.title || !data.date) {
|
||||
throw new Error(`Markdown files must include title and date in frontmatter: ${args.path}`);
|
||||
}
|
||||
const meta = {
|
||||
title: data.title,
|
||||
date: new Date(data.date),
|
||||
readingTime: data.readingTime || `${Math.ceil(content.split(/\s+/).length / 200)} min read`
|
||||
};
|
||||
const renderedHtml = renderToString(<AppShell><Post meta={meta} children={bodyHtml} /></AppShell>);
|
||||
addToDatabase(args.path, meta, bodyHtml); // Load the post to the database for dynamic querying
|
||||
|
||||
// JSX Approach
|
||||
return {
|
||||
contents: renderedHtml,
|
||||
loader: 'html',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default markdownLoader;
|
||||
@ -1,15 +1,28 @@
|
||||
import matter from 'gray-matter';
|
||||
import { loadMetadata } from "./utils";
|
||||
import { marked } from 'marked';
|
||||
import { addToDatabase } from "../src/db/index";
|
||||
|
||||
// When the server starts, import all the blog post metadata into the database
|
||||
// Executed on startup because it's included in <root> ./bunfig.toml
|
||||
|
||||
// Only import if not running in production
|
||||
(async () => {
|
||||
if (process.env.NODE_ENV === 'production') return;
|
||||
|
||||
const glob = new Bun.Glob("**/*.md");
|
||||
for await (const file of glob.scan("./content")) {
|
||||
const {data, content } = matter(await Bun.file(`./content/${file}`).text());
|
||||
const route = `/${file.replace(/\.md$/, "")}`;
|
||||
loadMetadata(route, data);
|
||||
|
||||
// Remove the title from content if it matches the frontmatter title to avoid duplicate H1s
|
||||
let processedContent = content;
|
||||
if (data.title) {
|
||||
const titleHeadingRegex = new RegExp(`^#\\s+${data.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm');
|
||||
processedContent = content.replace(titleHeadingRegex, '').trim();
|
||||
}
|
||||
|
||||
const bodyHtml = await marked.parse(processedContent);
|
||||
addToDatabase(route, data, bodyHtml);
|
||||
}
|
||||
|
||||
console.log('Posts have been imported into db');
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import path from 'path';
|
||||
|
||||
// Initialize the database if it doesn't exist
|
||||
const dbPath = path.join(process.cwd(), 'blog_metadata.sqlite');
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Create the posts table if it doesn't exist
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT UNIQUE NOT NULL,
|
||||
title TEXT,
|
||||
date TEXT,
|
||||
author TEXT,
|
||||
tags TEXT,
|
||||
published INTEGER,
|
||||
summary TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Load metadata from the blog post into a SQLite database
|
||||
// This allows us to index and make queries against the metadata
|
||||
// to support functions like search, filtering, and sorting
|
||||
export function loadMetadata(filePath: string, data: { [key: string]: any }) {
|
||||
if (!data) return;
|
||||
|
||||
try {
|
||||
// Extract common fields
|
||||
const { title, date, author, tags, published, summary } = data;
|
||||
|
||||
// Log the values for debugging
|
||||
// console.log('Metadata values:', {
|
||||
// filePath: typeof filePath,
|
||||
// title: typeof title,
|
||||
// date: typeof date,
|
||||
// author: typeof author,
|
||||
// tags: typeof tags,
|
||||
// published: typeof published,
|
||||
// summary: typeof summary
|
||||
// });
|
||||
|
||||
// Convert all values to strings or null explicitly
|
||||
const values = [
|
||||
filePath ? String(filePath) : null,
|
||||
title ? String(title) : null,
|
||||
date ? String(date) : null,
|
||||
author ? String(author) : null,
|
||||
tags ? JSON.stringify(tags) : null,
|
||||
published !== undefined ? (published ? 1 : 0) : null,
|
||||
summary ? String(summary) : null
|
||||
];
|
||||
|
||||
// Log the prepared values
|
||||
// console.log('Prepared values:', values);
|
||||
|
||||
// Query to insert or replace metadata
|
||||
const query = db.query(`
|
||||
INSERT OR REPLACE INTO posts
|
||||
(path, title, date, author, tags, published, summary, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
query.run(...values);
|
||||
|
||||
// console.log(`Stored metadata for: ${filePath}`);
|
||||
} catch (error) {
|
||||
// console.error(`Failed to store metadata for ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
preload = ["./bun_plugins/onStartup-post-importer.ts"]
|
||||
|
||||
[serve.static]
|
||||
plugins = ["./bun_plugins/onImport-markdown-loader.ts"]
|
||||
plugins = ["./bun_plugins/onImport-markdown-loader.tsx"]
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
title: 5 TypeScript Tips I Wish I Knew Earlier
|
||||
date: 2025-09-18
|
||||
readingTime: 10 minutes
|
||||
tags: [TypeScript, JavaScript, Productivity]
|
||||
excerpt: Five practical TypeScript tips that will make your code more type-safe and your development experience smoother. From utility types to const assertions.
|
||||
draft: false
|
||||
@ -8,7 +9,7 @@ draft: false
|
||||
|
||||
# 5 TypeScript Tips I Wish I Knew Earlier
|
||||
|
||||
TypeScript is an amazing tool, but it takes time to learn all its features. Here are five tips that significantly improved my TypeScript development.
|
||||
TypeScript is an amazing tool, but it takes time to learn all its features. Here are five tips that significantly improved my TypeScript development experience.
|
||||
|
||||
## 1. Use `satisfies` for Type Checking
|
||||
|
||||
@ -105,4 +106,3 @@ Use sparingly and only when you're absolutely sure.
|
||||
These tips have made my TypeScript code more robust and easier to maintain. The key is to leverage TypeScript's type system to catch errors at compile time rather than runtime.
|
||||
|
||||
What are your favorite TypeScript features? Let me know!
|
||||
|
||||
|
||||
@ -1,34 +1,82 @@
|
||||
---
|
||||
title: Your Post Title
|
||||
title: Modern Development Workflow
|
||||
date: 2025-10-21
|
||||
tags: [Web Development, TypeScript]
|
||||
excerpt: A brief summary of your post (2-3 sentences). This will appear in post listings and search results.
|
||||
tags: [Web Development, TypeScript, Productivity]
|
||||
excerpt: Explore modern development workflows that boost productivity and code quality. Learn about hot module replacement, automated testing, and modern tooling.
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Your Post Title
|
||||
# Modern Development Workflow
|
||||
|
||||
Your content here. You can use standard markdown syntax:
|
||||
In today's fast-paced development environment, having an efficient workflow is crucial for maintaining productivity and code quality.
|
||||
|
||||
## Section Heading
|
||||
## Hot Module Replacement
|
||||
|
||||
Write your paragraphs with proper spacing.
|
||||
|
||||
### Subsection
|
||||
|
||||
- Bullet points
|
||||
- Another point
|
||||
- And another
|
||||
- with HMR
|
||||
|
||||
**Bold text** and *italic text* are supported.
|
||||
Hot Module Replacement (HMR) revolutionizes the development experience:
|
||||
|
||||
```typescript
|
||||
// Code blocks work too
|
||||
const example = "Hello World";
|
||||
console.log(example);
|
||||
// Vite configuration with HMR
|
||||
export default {
|
||||
server: {
|
||||
hmr: {
|
||||
overlay: true
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
reactRefresh()
|
||||
]
|
||||
}
|
||||
|
||||
// During development, changes appear instantly
|
||||
const Component = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
return (
|
||||
<button onClick={() => setCount(count + 1)}>
|
||||
Count: {count}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
> Blockquotes for important callouts or quotes.
|
||||
## Tool Chain Essentials
|
||||
|
||||
This is just a template - delete this file or ignore it when writing your actual posts.
|
||||
A modern tool chain should include:
|
||||
|
||||
- **Fast builds**: Vite or Bun for lightning-fast compilation
|
||||
- **Type safety**: TypeScript for catching errors at compile time
|
||||
- **Code formatting**: Prettier for consistent code style
|
||||
- **Linting**: ESLint for maintaining code quality
|
||||
|
||||
## Automated Testing
|
||||
|
||||
Integrate testing into your workflow:
|
||||
|
||||
```typescript
|
||||
// Component testing with React Testing Library
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import Counter from './Counter';
|
||||
|
||||
test('increment works correctly', () => {
|
||||
render(<Counter />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Count: 1')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Git hooks**: Pre-commit hooks for code quality checks
|
||||
- **CI/CD**: Automated testing and deployment
|
||||
- **Documentation**: Keep README files up to date
|
||||
- **Code reviews**: Peer reviews for knowledge sharing
|
||||
|
||||
## Conclusion
|
||||
|
||||
A modern development workflow combines the right tools with good practices. Invest time in setting up your environment properly, and it will pay dividends in productivity throughout your project.
|
||||
|
||||
---
|
||||
|
||||
*This post demonstrates the power of modern development tools and workflows.*
|
||||
|
||||
128
index.tsx
128
index.tsx
@ -1,14 +1,69 @@
|
||||
import AppShellDemo from "./temp/appshell.html";
|
||||
import React from "react";
|
||||
// Database connection is now handled by the centralized db module
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { AppShell } from "./src/frontend/AppShell";
|
||||
import { Home } from "./src/frontend/pages/home";
|
||||
import { NotFound } from "./src/frontend/pages/not-found";
|
||||
import demo from "./temp/appshell.html";
|
||||
import { Post } from "./src/frontend/pages/post";
|
||||
import { getPostWithTags } from "./src/db/index";
|
||||
|
||||
async function blogPosts() {
|
||||
async function blogPosts(hmr: boolean) {
|
||||
const glob = new Bun.Glob("**/*.md");
|
||||
const blogPosts: Record<string, any> = {}
|
||||
const blogPosts: Record<string, any> = {};
|
||||
for await (const file of glob.scan("./content")) {
|
||||
const post = await import(`./content/${file}`, { with: { type: "html" } });
|
||||
const route = `/${file.replace(/\.md$/, "")}`;
|
||||
|
||||
blogPosts[route] = post.default;
|
||||
if (hmr) {
|
||||
// Use Bun Importer plugin for hot reloading in the browser
|
||||
blogPosts[`/hmr${route}`] = post.default;
|
||||
} else {
|
||||
// Use the Database for sending just the HTML or the HTML and AppShell
|
||||
blogPosts[route] = (req: Request) => {
|
||||
const path = new URL(req.url).pathname;
|
||||
const post = getPostWithTags(path);
|
||||
if (!post)
|
||||
return new Response(renderToString(<NotFound />), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
|
||||
const data = {
|
||||
title: post.title,
|
||||
summary: post.summary,
|
||||
date: new Date(post.date),
|
||||
readingTime: post.reading_time,
|
||||
tags: post.tags || [],
|
||||
};
|
||||
|
||||
// AppShell is already loaded, just send the <main> content
|
||||
if (req.headers.get("shell-loaded") === "true") {
|
||||
return new Response(
|
||||
renderToString(<Post meta={data} children={post.content} />),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// AppShell is not loaded, send the <AppShell> with the <main> content inside
|
||||
return new Response(
|
||||
renderToString(
|
||||
<AppShell>
|
||||
<Post meta={data} children={post.content} />
|
||||
</AppShell>,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(blogPosts).map((route) => {
|
||||
@ -20,19 +75,62 @@ async function blogPosts() {
|
||||
Bun.serve({
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true
|
||||
console: true,
|
||||
},
|
||||
|
||||
routes: {
|
||||
"/": AppShellDemo,
|
||||
... await blogPosts(),
|
||||
"/content/*": {
|
||||
async GET(req: Request) {
|
||||
// Having trouble using Bun Bundler alongside a custom route handler to send
|
||||
// different content depending on the request headers, will use /content subpath instead
|
||||
// (unless I can figure it out)
|
||||
return new Response("This will send the blog post content without the app shell")
|
||||
// standard mounting of blog posts
|
||||
...(await blogPosts(false)),
|
||||
|
||||
// hot module replacement in development mode
|
||||
...(process.env.NODE_ENV === "development" ? (await blogPosts(true)) : []),
|
||||
|
||||
// Home page
|
||||
"/": (req: Request) => {
|
||||
// Extract URL parameters from the request to pass to the component
|
||||
const url = new URL(req.url);
|
||||
const searchParams = Object.fromEntries(url.searchParams.entries());
|
||||
|
||||
if (req.headers.get("shell-loaded") === "true") {
|
||||
return new Response(renderToString(<Home searchParams={searchParams} />), {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
renderToString(
|
||||
<AppShell>
|
||||
<Home searchParams={searchParams} />
|
||||
</AppShell>,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
"/target": demo,
|
||||
"/profile-picture.webp": () => {
|
||||
return new Response(Bun.file("./src/public/profile-picture.webp"), {
|
||||
headers: {
|
||||
"Content-Type": "image/webp",
|
||||
},
|
||||
});
|
||||
},
|
||||
"/*": (req) => {
|
||||
if(req.headers.get("shell-loaded") === "true") {
|
||||
return new Response(renderToString(<NotFound />), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
}
|
||||
return new Response(renderToString(<AppShell><NotFound /></AppShell>), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
66
src/db/db.ts
Normal file
66
src/db/db.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import path from 'path';
|
||||
|
||||
// Singleton database connection
|
||||
class DatabaseConnection {
|
||||
private static instance: DatabaseConnection;
|
||||
private db: Database;
|
||||
|
||||
private constructor() {
|
||||
// Initialize the database if it doesn't exist
|
||||
const dbPath = path.join(process.cwd(), 'blog.sqlite');
|
||||
this.db = new Database(dbPath);
|
||||
|
||||
// Initialize database schema
|
||||
this.initializeDatabase();
|
||||
}
|
||||
|
||||
public static getInstance(): DatabaseConnection {
|
||||
if (!DatabaseConnection.instance) {
|
||||
DatabaseConnection.instance = new DatabaseConnection();
|
||||
}
|
||||
return DatabaseConnection.instance;
|
||||
}
|
||||
|
||||
public getDatabase(): Database {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
private initializeDatabase() {
|
||||
// Create the posts table if it doesn't exist
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT UNIQUE NOT NULL,
|
||||
title TEXT,
|
||||
date TEXT,
|
||||
reading_time TEXT,
|
||||
summary TEXT,
|
||||
content TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Create the tags table if it doesn't exist
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Create the post_tags junction table for many-to-many relationship
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS post_tags (
|
||||
post_id INTEGER,
|
||||
tag_id INTEGER,
|
||||
PRIMARY KEY (post_id, tag_id),
|
||||
FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the singleton database instance and export it
|
||||
const dbConnection = DatabaseConnection.getInstance();
|
||||
export const db = dbConnection.getDatabase();
|
||||
7
src/db/index.ts
Normal file
7
src/db/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// Import the database connection
|
||||
export { db } from './db';
|
||||
|
||||
// Export all functions related to database operations
|
||||
export * from './posts';
|
||||
export * from './tags';
|
||||
export * from './queries';
|
||||
165
src/db/posts.ts
Normal file
165
src/db/posts.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import { db } from './db';
|
||||
import { getOrCreateTag } from './tags';
|
||||
import { getPostTags } from './tags';
|
||||
|
||||
// Load the blog post into a SQLite database
|
||||
// This allows us to index and make queries against the metadata
|
||||
// to support functions like search, filtering, and sorting
|
||||
// as well as return either the post or the full AppShell with the post content
|
||||
export function addToDatabase(filePath: string, data: { [key: string]: any }, content: string) {
|
||||
if (!data) return;
|
||||
|
||||
// Use a transaction to ensure data consistency
|
||||
const transaction = db.transaction(() => {
|
||||
try {
|
||||
// Extract common fields
|
||||
const { title, date, readingTime, tags, excerpt } = data;
|
||||
|
||||
// Convert all values to strings or null explicitly (except tags)
|
||||
const values = [
|
||||
filePath ? String(filePath) : null,
|
||||
title ? String(title) : null,
|
||||
date ? String(date) : null,
|
||||
readingTime ? String(readingTime) : null,
|
||||
excerpt ? String(excerpt) : null,
|
||||
content ? String(content) : null
|
||||
];
|
||||
|
||||
// Query to insert or replace metadata (without tags)
|
||||
const insertPost = db.query(`
|
||||
INSERT OR REPLACE INTO posts
|
||||
(path, title, date, reading_time, summary, content)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
insertPost.run(...values);
|
||||
|
||||
// Get the post ID
|
||||
const getPostId = db.query('SELECT id FROM posts WHERE path = ?');
|
||||
const postResult = getPostId.get(filePath) as { id: number } | undefined;
|
||||
if (!postResult) {
|
||||
throw new Error(`Failed to retrieve post ID for ${filePath}`);
|
||||
}
|
||||
|
||||
// Delete existing tag associations for this post
|
||||
const deleteExistingTags = db.query('DELETE FROM post_tags WHERE post_id = ?');
|
||||
deleteExistingTags.run(postResult.id);
|
||||
|
||||
// If tags exist, process them
|
||||
if (tags && Array.isArray(tags)) {
|
||||
// Insert into junction table
|
||||
const insertPostTag = db.query('INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)');
|
||||
|
||||
for (const tag of tags) {
|
||||
const tagId = getOrCreateTag(String(tag));
|
||||
insertPostTag.run(postResult.id, tagId);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to store ${filePath}:`, error);
|
||||
throw error; // Re-throw to make the transaction fail
|
||||
}
|
||||
});
|
||||
|
||||
// Execute the transaction
|
||||
try {
|
||||
transaction();
|
||||
} catch (error) {
|
||||
console.error(`Transaction failed for ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the total number of posts
|
||||
|
||||
export function getNumOfPosts() {
|
||||
const queryCount = db.query('SELECT COUNT(*) AS count FROM posts');
|
||||
const numPosts = queryCount.get() as { count: number };
|
||||
|
||||
return numPosts.count;
|
||||
}
|
||||
|
||||
// Helper function to get post data with tags
|
||||
export function getPostWithTags(postPath: string) {
|
||||
const getPost = db.query(`
|
||||
SELECT * FROM posts WHERE path = ?
|
||||
`);
|
||||
|
||||
const post = getPost.get(postPath) as any;
|
||||
if (!post) return null;
|
||||
|
||||
// Get tags for this post
|
||||
if (post.id) {
|
||||
post.tags = getPostTags(post.id);
|
||||
}
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
// Get recent posts
|
||||
export function getRecentPosts(limit: number = 10, offset: number = 0) {
|
||||
const query = db.query(`
|
||||
SELECT * FROM posts
|
||||
WHERE path NOT LIKE '%.md'
|
||||
ORDER BY date DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
|
||||
const posts = query.all(limit, offset) as any[];
|
||||
|
||||
// Add tags to each post and clean up paths
|
||||
return posts.map(post => ({
|
||||
...post,
|
||||
tags: getPostTags(post.id),
|
||||
path: post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '')
|
||||
}));
|
||||
}
|
||||
|
||||
// Get all posts
|
||||
export function getAllPosts() {
|
||||
const query = db.query(`
|
||||
SELECT * FROM posts
|
||||
ORDER BY date DESC
|
||||
`);
|
||||
|
||||
const posts = query.all() as any[];
|
||||
|
||||
// Add tags to each post
|
||||
return posts.map(post => ({
|
||||
...post,
|
||||
tags: getPostTags(post.id)
|
||||
}));
|
||||
}
|
||||
|
||||
// Helper function to get a post by its path
|
||||
export function getPostByPath(path: string) {
|
||||
const query = db.query(`
|
||||
SELECT * FROM posts
|
||||
WHERE path = ?
|
||||
`);
|
||||
|
||||
const post = query.get(path) as any;
|
||||
if (!post) return null;
|
||||
|
||||
// Get tags for this post
|
||||
post.tags = getPostTags(post.id);
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
// Helper function to calculate read time
|
||||
export function calculateReadTime(content: string): number {
|
||||
const wordsPerMinute = 200;
|
||||
const words = content.split(/\s+/).length;
|
||||
return Math.max(1, Math.ceil(words / wordsPerMinute));
|
||||
}
|
||||
|
||||
// Helper function to format date for display
|
||||
export function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
109
src/db/queries.ts
Normal file
109
src/db/queries.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { db } from './db';
|
||||
import { parseTags } from './tags';
|
||||
|
||||
// Interface for blog post
|
||||
export interface BlogPost {
|
||||
id: number;
|
||||
path: string;
|
||||
title: string;
|
||||
date: string;
|
||||
author?: string;
|
||||
tags: string | string[];
|
||||
summary?: string;
|
||||
reading_time?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
// Get posts by a single tag
|
||||
export function getPostsByTag(tag: string): BlogPost[] {
|
||||
const query = db.query(`
|
||||
SELECT p.* FROM posts p
|
||||
JOIN post_tags pt ON p.id = pt.post_id
|
||||
JOIN tags t ON pt.tag_id = t.id
|
||||
WHERE t.name = ?
|
||||
ORDER BY p.date DESC
|
||||
`);
|
||||
|
||||
return query.all(tag) as BlogPost[];
|
||||
}
|
||||
|
||||
// Get posts by multiple tags (AND logic - posts must contain ALL tags)
|
||||
export function getPostsByTags(tags: string[]): BlogPost[] {
|
||||
if (tags.length === 0) {
|
||||
return getRecentPosts();
|
||||
}
|
||||
|
||||
// Build query for multiple tags using JOIN and GROUP BY
|
||||
const placeholders = tags.map(() => '?').join(',');
|
||||
const query = db.query(`
|
||||
SELECT p.* FROM posts p
|
||||
JOIN post_tags pt ON p.id = pt.post_id
|
||||
JOIN tags t ON pt.tag_id = t.id
|
||||
WHERE t.name IN (${placeholders})
|
||||
AND p.path NOT LIKE '%.md'
|
||||
GROUP BY p.id
|
||||
HAVING COUNT(DISTINCT t.id) = ?
|
||||
ORDER BY p.date DESC
|
||||
`);
|
||||
|
||||
return query.all(...tags, tags.length) as BlogPost[];
|
||||
}
|
||||
|
||||
// Get recent posts with optional tag filtering
|
||||
export function getRecentPostsByTags(tags: string[], limit: number = 10): BlogPost[] {
|
||||
if (tags.length === 0) {
|
||||
return getRecentPosts(limit);
|
||||
}
|
||||
|
||||
// Build query for posts with all specified tags
|
||||
const placeholders = tags.map(() => '?').join(',');
|
||||
const query = db.query(`
|
||||
SELECT p.* FROM posts p
|
||||
JOIN post_tags pt ON p.id = pt.post_id
|
||||
JOIN tags t ON pt.tag_id = t.id
|
||||
WHERE t.name IN (${placeholders})
|
||||
AND p.path NOT LIKE '%.md'
|
||||
GROUP BY p.id
|
||||
HAVING COUNT(DISTINCT t.id) = ?
|
||||
ORDER BY p.date DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
return query.all(...tags, tags.length, limit) as BlogPost[];
|
||||
}
|
||||
|
||||
// Helper function to get recent posts
|
||||
function getRecentPosts(limit: number = 10): BlogPost[] {
|
||||
const query = db.query(`
|
||||
SELECT * FROM posts
|
||||
WHERE path NOT LIKE '%.md'
|
||||
ORDER BY date DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
return query.all(limit) as BlogPost[];
|
||||
}
|
||||
|
||||
// Search posts by title or content
|
||||
export function searchPosts(query: string, limit: number = 20): BlogPost[] {
|
||||
const searchQuery = db.query(`
|
||||
SELECT * FROM posts
|
||||
WHERE (title LIKE ? OR content LIKE ?)
|
||||
ORDER BY date DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
const searchPattern = `%${query}%`;
|
||||
return searchQuery.all(searchPattern, searchPattern, limit) as BlogPost[];
|
||||
}
|
||||
|
||||
// Get posts by date range
|
||||
export function getPostsByDateRange(startDate: string, endDate: string): BlogPost[] {
|
||||
const dateQuery = db.query(`
|
||||
SELECT * FROM posts
|
||||
WHERE date BETWEEN ? AND ?
|
||||
ORDER BY date DESC
|
||||
`);
|
||||
|
||||
return dateQuery.all(startDate, endDate) as BlogPost[];
|
||||
}
|
||||
77
src/db/tags.ts
Normal file
77
src/db/tags.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { db } from './db';
|
||||
|
||||
// Helper function to get or create a tag and return its ID
|
||||
export function getOrCreateTag(tagName: string): number {
|
||||
// Try to find existing tag
|
||||
const findTag = db.query('SELECT id FROM tags WHERE name = ?');
|
||||
const existingTag = findTag.get(tagName) as { id: number } | undefined;
|
||||
|
||||
if (existingTag) {
|
||||
return existingTag.id;
|
||||
}
|
||||
|
||||
// Create new tag if it doesn't exist
|
||||
const insertTag = db.query('INSERT INTO tags (name) VALUES (?) RETURNING id');
|
||||
const result = insertTag.get(tagName) as { id: number };
|
||||
return result.id;
|
||||
}
|
||||
|
||||
// Helper function to get tags for a post
|
||||
export function getPostTags(postId: number): string[] {
|
||||
const query = db.query(`
|
||||
SELECT t.name FROM tags t
|
||||
JOIN post_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.post_id = ?
|
||||
`);
|
||||
|
||||
const results = query.all(postId) as { name: string }[];
|
||||
return results.map(row => row.name);
|
||||
}
|
||||
|
||||
// Helper function to parse tags
|
||||
export function parseTags(tags: string | string[]): string[] {
|
||||
// If tags is already an array, return it directly
|
||||
if (Array.isArray(tags)) {
|
||||
return tags;
|
||||
}
|
||||
|
||||
// If tags is a string, try to parse as JSON
|
||||
try {
|
||||
return JSON.parse(tags || '[]');
|
||||
} catch {
|
||||
// If parsing fails, assume it's a comma-separated string
|
||||
if (typeof tags === 'string' && tags.trim()) {
|
||||
return tags.split(',').map(tag => tag.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get all unique tags from database
|
||||
export function getAllTags(): { name: string; post_count: number }[] {
|
||||
const query = db.query(`
|
||||
SELECT t.name, COUNT(pt.post_id) as post_count
|
||||
FROM tags t
|
||||
JOIN post_tags pt ON t.id = pt.tag_id
|
||||
JOIN posts p ON pt.post_id = p.id
|
||||
GROUP BY t.id, t.name
|
||||
ORDER BY post_count DESC, t.name ASC
|
||||
`);
|
||||
|
||||
return query.all() as { name: string; post_count: number }[];
|
||||
}
|
||||
|
||||
// Update tag counts after changes (no longer needed with the new structure)
|
||||
// The post_count is calculated dynamically in getAllTags()
|
||||
// Keeping the function for backward compatibility
|
||||
export function updateTagCounts() {
|
||||
console.log("Tag counts are now calculated dynamically in getAllTags()");
|
||||
}
|
||||
|
||||
// Initialize tags table and populate with existing tags
|
||||
// This function is deprecated as we've restructured the database
|
||||
// The new database structure is initialized in index.ts
|
||||
// Kept for backward compatibility
|
||||
export function initializeTagsTable() {
|
||||
console.log("Tags table is now initialized in index.ts with the new structure");
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -17,9 +17,8 @@ async function loadContent(url: string) {
|
||||
}
|
||||
|
||||
function attachLinkHandlers() {
|
||||
const links: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a');
|
||||
console.log('Found links:', links.length);
|
||||
|
||||
const links: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a:not([data-external])');
|
||||
|
||||
links.forEach(link => {
|
||||
console.log('Attaching listener to:', link.href);
|
||||
link.onclick = async (e) => {
|
||||
@ -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);
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
// Client-side script that runs in <head>
|
||||
// Example: TypeScript with DOM types
|
||||
(() => {
|
||||
const logPageInfo = (): void => {
|
||||
console.log('Page loaded in <head>');
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
logPageInfo();
|
||||
}
|
||||
})();
|
||||
@ -1,97 +0,0 @@
|
||||
body, a {
|
||||
margin: 0px;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #f2f2f2;
|
||||
padding: 15px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
|
||||
header span {
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
nav a {
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0px;
|
||||
opacity: 75%;
|
||||
}
|
||||
|
||||
.post-spotlight {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 30px;
|
||||
padding: 4em;
|
||||
}
|
||||
|
||||
article {
|
||||
font-family: 'Arial Narrow Bold', sans-serif;
|
||||
}
|
||||
|
||||
article > h2 {
|
||||
font-size: 3em;
|
||||
font-weight: bold;
|
||||
margin: 15px;
|
||||
}
|
||||
|
||||
article > div {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
article > a {
|
||||
text-decoration: none;
|
||||
border: 1px solid black;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
background-color: #f2f2f2;
|
||||
color: black;
|
||||
text-align: end;
|
||||
|
||||
}
|
||||
|
||||
aside {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-evenly;
|
||||
align-items: center;
|
||||
margin: 15px;
|
||||
}
|
||||
|
||||
aside h3 {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
aside ul {
|
||||
list-style-type: none;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.about ul {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user