Compare commits
15 Commits
960f7ff4d0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a9f261189 | |||
| c4f0767b58 | |||
| 59d2f8b4e0 | |||
| 54d5a77329 | |||
| 9bf7827aa0 | |||
| 560020632f | |||
| 85946a2b40 | |||
| efff385570 | |||
| 6664e6e3d1 | |||
| 88b6d1bade | |||
| b0fd1b6d9e | |||
| d7fb16e24e | |||
| d5450a3c0a | |||
| 58fa014341 | |||
| cc21c06641 |
@@ -4,7 +4,7 @@ import { renderToString } from "react-dom/server";
|
|||||||
|
|
||||||
import matter from 'gray-matter';
|
import matter from 'gray-matter';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { addToDatabase } from "../src/db/index";
|
import { dbConnection } from "../src/db/index";
|
||||||
|
|
||||||
import { AppShell } from "../src/frontend/AppShell";
|
import { AppShell } from "../src/frontend/AppShell";
|
||||||
import { Post } from "../src/frontend/pages/post";
|
import { Post } from "../src/frontend/pages/post";
|
||||||
@@ -38,7 +38,7 @@ const markdownLoader: BunPlugin = {
|
|||||||
readingTime: data.readingTime || `${Math.ceil(content.split(/\s+/).length / 200)} min read`
|
readingTime: data.readingTime || `${Math.ceil(content.split(/\s+/).length / 200)} min read`
|
||||||
};
|
};
|
||||||
const renderedHtml = renderToString(<AppShell><Post meta={meta} children={bodyHtml} /></AppShell>);
|
const renderedHtml = renderToString(<AppShell><Post meta={meta} children={bodyHtml} /></AppShell>);
|
||||||
addToDatabase(args.path, meta, bodyHtml); // Load the post to the database for dynamic querying
|
dbConnection.addPost(args.path, meta, bodyHtml); // Load the post to the database for dynamic querying
|
||||||
|
|
||||||
// JSX Approach
|
// JSX Approach
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import matter from 'gray-matter';
|
import matter from 'gray-matter';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { addToDatabase } from "../src/db/index";
|
import { dbConnection } from "../src/db";
|
||||||
|
|
||||||
// When the server starts, import all the blog post metadata into the database
|
// When the server starts, import all the blog post metadata into the database
|
||||||
// Executed on startup because it's included in <root> ./bunfig.toml
|
// Executed on startup because it's included in <root> ./bunfig.toml
|
||||||
@@ -22,7 +22,7 @@ import { addToDatabase } from "../src/db/index";
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bodyHtml = await marked.parse(processedContent);
|
const bodyHtml = await marked.parse(processedContent);
|
||||||
addToDatabase(route, data, bodyHtml);
|
dbConnection.addPost(route, data, bodyHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Posts have been imported into db');
|
console.log('Posts have been imported into db');
|
||||||
|
|||||||
26
index.tsx
26
index.tsx
@@ -4,9 +4,8 @@ import { renderToString } from "react-dom/server";
|
|||||||
import { AppShell } from "./src/frontend/AppShell";
|
import { AppShell } from "./src/frontend/AppShell";
|
||||||
import { Home } from "./src/frontend/pages/home";
|
import { Home } from "./src/frontend/pages/home";
|
||||||
import { NotFound } from "./src/frontend/pages/not-found";
|
import { NotFound } from "./src/frontend/pages/not-found";
|
||||||
import demo from "./temp/appshell.html";
|
|
||||||
import { Post } from "./src/frontend/pages/post";
|
import { Post } from "./src/frontend/pages/post";
|
||||||
import { getPostWithTags } from "./src/db/index";
|
import { dbConnection } from "./src/db";
|
||||||
|
|
||||||
async function blogPosts(hmr: boolean) {
|
async function blogPosts(hmr: boolean) {
|
||||||
const glob = new Bun.Glob("**/*.md");
|
const glob = new Bun.Glob("**/*.md");
|
||||||
@@ -14,27 +13,33 @@ async function blogPosts(hmr: boolean) {
|
|||||||
for await (const file of glob.scan("./content")) {
|
for await (const file of glob.scan("./content")) {
|
||||||
const post = await import(`./content/${file}`, { with: { type: "html" } });
|
const post = await import(`./content/${file}`, { with: { type: "html" } });
|
||||||
const route = `/${file.replace(/\.md$/, "")}`;
|
const route = `/${file.replace(/\.md$/, "")}`;
|
||||||
|
dbConnection.getAllTags();
|
||||||
|
|
||||||
if (hmr) {
|
if (hmr) {
|
||||||
// Use Bun Importer plugin for hot reloading in the browser
|
// Use Bun Importer plugin for hot reloading in the browser
|
||||||
blogPosts[`/hmr${route}`] = post.default;
|
blogPosts[`/hmr${route}`] = post.default;
|
||||||
} else {
|
} else {
|
||||||
// Use the Database for sending just the HTML or the HTML and AppShell
|
// Use the Database for sending just the HTML or the HTML and AppShell
|
||||||
blogPosts[route] = (req: Request) => {
|
blogPosts[route] = async (req: Request) => {
|
||||||
const path = new URL(req.url).pathname;
|
const path = new URL(req.url).pathname;
|
||||||
const post = getPostWithTags(path);
|
const post = dbConnection.getPost(path);
|
||||||
if (!post)
|
if (!post)
|
||||||
return new Response(renderToString(<NotFound />), {
|
return new Response(renderToString(<NotFound />), {
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: { "Content-Type": "text/html" },
|
headers: { "Content-Type": "text/html" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get adjacent posts for navigation
|
||||||
|
const { previousPost, nextPost } = dbConnection.getAdjacentPosts(post.path);
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
title: post.title,
|
title: post.title,
|
||||||
summary: post.summary,
|
summary: post.summary,
|
||||||
date: new Date(post.date),
|
date: new Date(post.date),
|
||||||
readingTime: post.reading_time,
|
readingTime: post.reading_time,
|
||||||
tags: post.tags || [],
|
tags: post.tags || [],
|
||||||
|
previousPost,
|
||||||
|
nextPost,
|
||||||
};
|
};
|
||||||
|
|
||||||
// AppShell is already loaded, just send the <main> content
|
// AppShell is already loaded, just send the <main> content
|
||||||
@@ -53,7 +58,10 @@ async function blogPosts(hmr: boolean) {
|
|||||||
return new Response(
|
return new Response(
|
||||||
renderToString(
|
renderToString(
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Post meta={data} children={post.content} />
|
<Post
|
||||||
|
meta={data}
|
||||||
|
children={post.content}
|
||||||
|
/>
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
@@ -83,13 +91,12 @@ Bun.serve({
|
|||||||
...(await blogPosts(false)),
|
...(await blogPosts(false)),
|
||||||
|
|
||||||
// hot module replacement in development mode
|
// hot module replacement in development mode
|
||||||
...(process.env.NODE_ENV === "development" ? (await blogPosts(true)) : []),
|
...(process.env.NODE_ENV === "development" ? (await blogPosts(true)) : {}),
|
||||||
|
|
||||||
// Home page
|
// Home page
|
||||||
"/": (req: Request) => {
|
"/": (req: Request) => {
|
||||||
// Extract URL parameters from the request to pass to the component
|
// Extract URL parameters from the request to pass to the component
|
||||||
const url = new URL(req.url);
|
const searchParams = new URLSearchParams(req.url.split('?')[1]);
|
||||||
const searchParams = Object.fromEntries(url.searchParams.entries());
|
|
||||||
|
|
||||||
if (req.headers.get("shell-loaded") === "true") {
|
if (req.headers.get("shell-loaded") === "true") {
|
||||||
return new Response(renderToString(<Home searchParams={searchParams} />), {
|
return new Response(renderToString(<Home searchParams={searchParams} />), {
|
||||||
@@ -101,7 +108,7 @@ Bun.serve({
|
|||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
renderToString(
|
renderToString(
|
||||||
<AppShell>
|
<AppShell searchParams={searchParams}>
|
||||||
<Home searchParams={searchParams} />
|
<Home searchParams={searchParams} />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
),
|
),
|
||||||
@@ -112,7 +119,6 @@ Bun.serve({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
"/target": demo,
|
|
||||||
"/profile-picture.webp": () => {
|
"/profile-picture.webp": () => {
|
||||||
return new Response(Bun.file("./src/public/profile-picture.webp"), {
|
return new Response(Bun.file("./src/public/profile-picture.webp"), {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
66
src/db/db.ts
66
src/db/db.ts
@@ -1,66 +0,0 @@
|
|||||||
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();
|
|
||||||
431
src/db/index.ts
431
src/db/index.ts
@@ -1,8 +1,425 @@
|
|||||||
// Import the database connection
|
import { Database } from 'bun:sqlite';
|
||||||
import { db } from './db';
|
import path from 'path';
|
||||||
export { db } from './db';
|
|
||||||
|
|
||||||
// Export all functions related to database operations
|
/**
|
||||||
export * from './posts';
|
* Singleton database connection class for managing blog posts and tags
|
||||||
export * from './tags';
|
*/
|
||||||
export * from './queries';
|
class DatabaseConnection {
|
||||||
|
private static instance: DatabaseConnection;
|
||||||
|
private db: Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private constructor to initialize database connection and schema
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the singleton instance of the DatabaseConnection
|
||||||
|
*
|
||||||
|
* @returns The DatabaseConnection singleton instance
|
||||||
|
*/
|
||||||
|
public static getInstance(): DatabaseConnection {
|
||||||
|
if (!DatabaseConnection.instance) {
|
||||||
|
DatabaseConnection.instance = new DatabaseConnection();
|
||||||
|
}
|
||||||
|
return DatabaseConnection.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the database connection
|
||||||
|
*
|
||||||
|
* @returns The SQLite database instance
|
||||||
|
*/
|
||||||
|
public getDatabase(): Database {
|
||||||
|
return this.db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the database schema with posts, tags, and post_tags tables
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
url_normalized TEXT GENERATED ALWAYS AS (lower(replace(name, ' ', '-'))) STORED
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a post to the database with its metadata and content
|
||||||
|
*
|
||||||
|
* @param filePath - The path to the post file
|
||||||
|
* @param data - An object containing the post metadata (title, date, tags, etc.)
|
||||||
|
* @param content - The full content of the post
|
||||||
|
*/
|
||||||
|
public addPost(filePath: string, data: { [key: string]: any }, content: string) {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
// Use a transaction to ensure data consistency
|
||||||
|
const transaction = this.db.transaction(() => {
|
||||||
|
try {
|
||||||
|
// Extract common fields
|
||||||
|
const { title, date, readingTime, tags, excerpt } = data;
|
||||||
|
|
||||||
|
// Convert all values to strings or null explicitly (except tags)
|
||||||
|
// Ensure date is stored in ISO format for consistent sorting
|
||||||
|
const values = [
|
||||||
|
filePath ? String(filePath) : null,
|
||||||
|
title ? String(title) : null,
|
||||||
|
date ? (date instanceof Date ? date.toISOString().split('T')[0] : 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 = this.db.query(`
|
||||||
|
INSERT OR REPLACE INTO posts
|
||||||
|
(path, title, date, reading_time, summary, content)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
insertPost.run(...values);
|
||||||
|
|
||||||
|
// Get the post ID
|
||||||
|
const getPostId = this.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 = this.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 = this.db.query('INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)');
|
||||||
|
|
||||||
|
// Create or get tag and associate with post
|
||||||
|
const createOrGetTag = this.db.query(`
|
||||||
|
INSERT OR IGNORE INTO tags (name) VALUES (?)
|
||||||
|
RETURNING id
|
||||||
|
`);
|
||||||
|
|
||||||
|
const getTag = this.db.query('SELECT id FROM tags WHERE name = ?');
|
||||||
|
|
||||||
|
for (const tag of tags) {
|
||||||
|
let tagResult = createOrGetTag.get(String(tag)) as { id: number } | undefined;
|
||||||
|
|
||||||
|
// If no result from insert, the tag already exists, so get it
|
||||||
|
if (!tagResult) {
|
||||||
|
tagResult = getTag.get(String(tag)) as { id: number } | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tagResult) {
|
||||||
|
throw new Error(`Failed to retrieve tag ID for ${tag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
insertPostTag.run(postResult.id, tagResult.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total number of posts in the database
|
||||||
|
*
|
||||||
|
* @param tags - Optional array of tag names to filter posts by
|
||||||
|
* @returns The number of posts, optionally filtered by tags
|
||||||
|
*/
|
||||||
|
public getNumOfPosts(tags: string[] = []) {
|
||||||
|
if (tags.length === 0) {
|
||||||
|
const queryCount = this.db.query('SELECT COUNT(*) AS count FROM posts WHERE path NOT LIKE \'%.md\'');
|
||||||
|
const numPosts = queryCount.get() as { count: number };
|
||||||
|
return numPosts.count;
|
||||||
|
} else {
|
||||||
|
// Use url_normalized field for tag filtering
|
||||||
|
const placeholders = tags.map(() => '?').join(',');
|
||||||
|
|
||||||
|
// Get count of posts that have ALL the specified tags using url_normalized
|
||||||
|
const countQuery = this.db.query(`
|
||||||
|
SELECT COUNT(*) AS count FROM (
|
||||||
|
SELECT p.id, COUNT(DISTINCT t.id) as tag_count
|
||||||
|
FROM posts p
|
||||||
|
JOIN post_tags pt ON p.id = pt.post_id
|
||||||
|
JOIN tags t ON pt.tag_id = t.id
|
||||||
|
WHERE t.url_normalized IN (${placeholders})
|
||||||
|
AND p.path NOT LIKE '%.md'
|
||||||
|
GROUP BY p.id
|
||||||
|
HAVING COUNT(DISTINCT t.id) = ?
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const result = countQuery.get(...tags, tags.length) as { count: number };
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a paginated list of posts
|
||||||
|
*
|
||||||
|
* @param limit - Maximum number of posts to return (default: 10)
|
||||||
|
* @param offset - Number of posts to skip for pagination (default: 0)
|
||||||
|
* @param tags - Optional array of tag names to filter posts by
|
||||||
|
* @returns Array of posts with metadata and cleaned paths
|
||||||
|
*/
|
||||||
|
public getPosts(limit: number = 10, offset: number = 0, tags: string[] = []):
|
||||||
|
Array<{
|
||||||
|
id: number,
|
||||||
|
path: string,
|
||||||
|
title: string,
|
||||||
|
date: string,
|
||||||
|
readingTime: string,
|
||||||
|
summary: string,
|
||||||
|
content: string,
|
||||||
|
tags: string[],
|
||||||
|
}>
|
||||||
|
{
|
||||||
|
let posts;
|
||||||
|
|
||||||
|
if (tags.length === 0) {
|
||||||
|
// No tags specified, use the original query
|
||||||
|
const query = this.db.query(`
|
||||||
|
SELECT * FROM posts
|
||||||
|
WHERE path NOT LIKE '%.md'
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
posts = query.all(limit, offset) as any[];
|
||||||
|
} else {
|
||||||
|
// Filter by tags using url_normalized field
|
||||||
|
// and ensure posts have ALL the specified tags
|
||||||
|
const placeholders = tags.map(() => '?').join(',');
|
||||||
|
const tagFilter = this.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.url_normalized IN (${placeholders})
|
||||||
|
AND p.path NOT LIKE '%.md'
|
||||||
|
GROUP BY p.id
|
||||||
|
HAVING COUNT(DISTINCT t.id) = ?
|
||||||
|
ORDER BY p.date DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Use the tags directly since we're now comparing with url_normalized
|
||||||
|
posts = tagFilter.all(...tags, tags.length, limit, offset) as any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get tags for a post
|
||||||
|
const getPostTags = (postId: number): string[] => {
|
||||||
|
const query = this.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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add tags to each post and clean up paths
|
||||||
|
return posts.map(post => {
|
||||||
|
const basePost = post satisfies {
|
||||||
|
id: number,
|
||||||
|
path: string,
|
||||||
|
title: string,
|
||||||
|
date: string,
|
||||||
|
readingTime: string,
|
||||||
|
summary: string,
|
||||||
|
content: string
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...basePost,
|
||||||
|
tags: getPostTags(post.id),
|
||||||
|
path: post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '')
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a single post by its file path
|
||||||
|
*
|
||||||
|
* @param path - The file path of the post to retrieve
|
||||||
|
* @returns The post object with metadata and tags, or null if not found
|
||||||
|
*/
|
||||||
|
public getPost(path: string) {
|
||||||
|
const query = this.db.query(`
|
||||||
|
SELECT * FROM posts
|
||||||
|
WHERE path = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const post = query.get(path) as any;
|
||||||
|
if (!post) return null;
|
||||||
|
|
||||||
|
// Helper function to get tags for a post
|
||||||
|
const getPostTags = (postId: number): string[] => {
|
||||||
|
const query = this.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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get tags for this post
|
||||||
|
post.tags = getPostTags(post.id);
|
||||||
|
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the chronologically adjacent posts (previous and next) for navigation
|
||||||
|
*
|
||||||
|
* @param currentPostPath - The file path of the current post
|
||||||
|
* @returns Object with previousPost and nextPost properties, each containing title and path, or null if no adjacent post exists
|
||||||
|
*/
|
||||||
|
public getAdjacentPosts(currentPostPath: string) {
|
||||||
|
const allPostsQuery = this.db.query(`
|
||||||
|
SELECT path, title, date
|
||||||
|
FROM posts
|
||||||
|
WHERE path NOT LIKE '%.md'
|
||||||
|
ORDER BY date DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const allPosts = allPostsQuery.all() as any[];
|
||||||
|
|
||||||
|
// Find the current post index
|
||||||
|
const currentIndex = allPosts.findIndex(post => post.path === currentPostPath);
|
||||||
|
|
||||||
|
// If not found, return empty navigation
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
return { previousPost: null, nextPost: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get previous post (newer post, which comes before current in reverse chronological order)
|
||||||
|
let previousPost = null;
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
const prevPost = allPosts[currentIndex - 1];
|
||||||
|
// Clean up the path to match the URL structure
|
||||||
|
const cleanPath = prevPost.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '');
|
||||||
|
previousPost = {
|
||||||
|
title: prevPost.title,
|
||||||
|
path: cleanPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get next post (older post, which comes after current in reverse chronological order)
|
||||||
|
let nextPost = null;
|
||||||
|
if (currentIndex < allPosts.length - 1) {
|
||||||
|
const post = allPosts[currentIndex + 1];
|
||||||
|
// Clean up the path to match the URL structure
|
||||||
|
const cleanPath = post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '');
|
||||||
|
nextPost = {
|
||||||
|
title: post.title,
|
||||||
|
path: cleanPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previousPost, nextPost };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all unique tags from the database with their respective post counts.
|
||||||
|
*
|
||||||
|
* This method implements caching to avoid repeated database queries. After the first
|
||||||
|
* call, subsequent calls will return the cached results. Be aware that on db build, this
|
||||||
|
* likely won't be accurate because of the natural race condition and the server should be restarted.
|
||||||
|
*
|
||||||
|
* @returns {Array<{ name: string; post_count: number; urlNormalized: string }>}
|
||||||
|
* An array of tag objects containing:
|
||||||
|
* - name: The display name of the tag
|
||||||
|
* - post_count: Number of posts associated with this tag
|
||||||
|
* - urlNormalized: The URL-safe version of the tag name
|
||||||
|
*/
|
||||||
|
public getAllTags(): { name: string; post_count: number; urlNormalized: string }[] {
|
||||||
|
if (!this.getAllTagsCache) {
|
||||||
|
this.getAllTagsCache = this.initGetAllTags();
|
||||||
|
}
|
||||||
|
return this.getAllTagsCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAllTagsCache: { name: string; post_count: number; urlNormalized: string }[] | null = null
|
||||||
|
|
||||||
|
private initGetAllTags() {
|
||||||
|
const query = db.query(`
|
||||||
|
SELECT t.name, t.url_normalized, 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, t.url_normalized
|
||||||
|
ORDER BY post_count DESC, t.name ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const results = query.all() as { name: string; url_normalized: string; post_count: number }[];
|
||||||
|
return results.map(row => ({
|
||||||
|
name: row.name,
|
||||||
|
urlNormalized: row.url_normalized,
|
||||||
|
post_count: row.post_count
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SQLite database connection class
|
||||||
|
* Use this by default for using helper query functions
|
||||||
|
*/
|
||||||
|
export const dbConnection = DatabaseConnection.getInstance();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SQLite database connection instance
|
||||||
|
* Use this exported instance for direct database operations
|
||||||
|
*/
|
||||||
|
export const db = dbConnection.getDatabase();
|
||||||
|
|||||||
165
src/db/posts.ts
165
src/db/posts.ts
@@ -1,165 +0,0 @@
|
|||||||
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'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import { db } from './db';
|
|
||||||
import { parseTags } from './tags';
|
|
||||||
|
|
||||||
// Interface for archive data structure
|
|
||||||
export interface ArchiveMonth {
|
|
||||||
name: string;
|
|
||||||
count: string;
|
|
||||||
posts: Array<{
|
|
||||||
title: string;
|
|
||||||
href: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ArchiveYear {
|
|
||||||
year: string;
|
|
||||||
count: string;
|
|
||||||
months: ArchiveMonth[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get posts organized by year and month for the archive
|
|
||||||
export function getPostsByYearAndMonth(): ArchiveYear[] {
|
|
||||||
const query = db.query(`
|
|
||||||
SELECT * FROM posts
|
|
||||||
WHERE path NOT LIKE '%.md'
|
|
||||||
ORDER BY date DESC
|
|
||||||
`);
|
|
||||||
|
|
||||||
const posts = query.all() as any[];
|
|
||||||
|
|
||||||
// Group posts by year and month
|
|
||||||
const yearMap = new Map<string, ArchiveYear>();
|
|
||||||
|
|
||||||
const monthNames = [
|
|
||||||
"January", "February", "March", "April", "May", "June",
|
|
||||||
"July", "August", "September", "October", "November", "December"
|
|
||||||
];
|
|
||||||
|
|
||||||
// Process each post
|
|
||||||
posts.forEach(post => {
|
|
||||||
const date = new Date(post.date);
|
|
||||||
const year = String(date.getFullYear());
|
|
||||||
const monthName = monthNames[date.getMonth()];
|
|
||||||
|
|
||||||
// Create clean post href from path
|
|
||||||
const href = post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '');
|
|
||||||
|
|
||||||
// Initialize year if it doesn't exist
|
|
||||||
if (!yearMap.has(year)) {
|
|
||||||
yearMap.set(year, {
|
|
||||||
year,
|
|
||||||
count: "(0)",
|
|
||||||
months: []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const yearData = yearMap.get(year)!;
|
|
||||||
|
|
||||||
// Find or create month data
|
|
||||||
let monthData = yearData.months.find(m => m.name === monthName);
|
|
||||||
if (!monthData) {
|
|
||||||
monthData = {
|
|
||||||
name: monthName,
|
|
||||||
count: "(0)",
|
|
||||||
posts: []
|
|
||||||
};
|
|
||||||
yearData.months.push(monthData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add post to the month
|
|
||||||
monthData.posts.push({
|
|
||||||
title: post.title,
|
|
||||||
href
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update counts
|
|
||||||
monthData.count = `(${monthData.posts.length})`;
|
|
||||||
yearData.count = `(${yearData.months.reduce((total, m) => total + m.posts.length, 0)})`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert map to array and sort
|
|
||||||
return Array.from(yearMap.values()).sort((a, b) =>
|
|
||||||
parseInt(b.year) - parseInt(a.year)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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,7 +1,9 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { minifyCSS, minifyJS } from './utils';
|
import { minifyCSS, minifyJS } from './utils';
|
||||||
|
|
||||||
|
// @ts-expect-error - Importing as text, not a module
|
||||||
import styles from './styles.css' with { type: "text" };
|
import styles from './styles.css' with { type: "text" };
|
||||||
|
// @ts-expect-error - Importing as text, not a module
|
||||||
import headScript from './onLoad' with { type: "text" };
|
import headScript from './onLoad' with { type: "text" };
|
||||||
|
|
||||||
import { ThemePicker } from './components/theme-picker';
|
import { ThemePicker } from './components/theme-picker';
|
||||||
@@ -9,7 +11,7 @@ import { ProfileBadge } from './components/profile-badge';
|
|||||||
import { TagPicker } from './components/tag-picker';
|
import { TagPicker } from './components/tag-picker';
|
||||||
import { PostArchive } from './components/post-archive';
|
import { PostArchive } from './components/post-archive';
|
||||||
|
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children, searchParams }: { children: ReactNode, searchParams?: URLSearchParams }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -19,11 +21,17 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<style dangerouslySetInnerHTML={{ __html: minifyCSS(styles) }} />
|
<style dangerouslySetInnerHTML={{ __html: minifyCSS(styles) }} />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<input type="checkbox" id="menu-toggle" className="menu-toggle" />
|
||||||
|
<label htmlFor="menu-toggle" className="hamburger-button">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</label>
|
||||||
{children}
|
{children}
|
||||||
<aside>
|
<aside>
|
||||||
<ThemePicker />
|
<ThemePicker />
|
||||||
<ProfileBadge />
|
<ProfileBadge />
|
||||||
<TagPicker />
|
<TagPicker searchParams={searchParams} />
|
||||||
<PostArchive />
|
<PostArchive />
|
||||||
</aside>
|
</aside>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -245,3 +245,54 @@ window.addEventListener('popstate', () => {
|
|||||||
// Update the active post indicator after initializing the archive state
|
// Update the active post indicator after initializing the archive state
|
||||||
setTimeout(updateActivePost, 10); // Small delay to ensure DOM is updated
|
setTimeout(updateActivePost, 10); // Small delay to ensure DOM is updated
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for URL changes (including navigation via other components)
|
||||||
|
function setupUrlChangeListener() {
|
||||||
|
// Store the current URL to detect changes
|
||||||
|
let currentUrl = window.location.pathname;
|
||||||
|
|
||||||
|
// Use MutationObserver to detect DOM changes in the main element
|
||||||
|
// This helps catch navigation changes from other components
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
// Check if URL has changed
|
||||||
|
if (window.location.pathname !== currentUrl) {
|
||||||
|
currentUrl = window.location.pathname;
|
||||||
|
|
||||||
|
// Wait a bit for any DOM updates to complete
|
||||||
|
setTimeout(() => {
|
||||||
|
updateActivePost();
|
||||||
|
initializeArchiveState();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start observing the main element for changes
|
||||||
|
const mainElement = document.querySelector('main');
|
||||||
|
if (mainElement) {
|
||||||
|
observer.observe(mainElement, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also observe direct URL changes (e.g., when using browser back/forward)
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
if (window.location.pathname !== currentUrl) {
|
||||||
|
currentUrl = window.location.pathname;
|
||||||
|
updateActivePost();
|
||||||
|
initializeArchiveState();
|
||||||
|
}
|
||||||
|
}, 500); // Check every 500ms
|
||||||
|
|
||||||
|
// Clean up on page unload
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
observer.disconnect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the URL change listener
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
setupUrlChangeListener();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setupUrlChangeListener();
|
||||||
|
}
|
||||||
@@ -1,102 +1,119 @@
|
|||||||
// Client-side tag picker toggle functionality
|
// Add function to 'Clear all filters'
|
||||||
class TagPickerManager {
|
document.querySelector('.clear-tags-btn')
|
||||||
constructor() {
|
?.addEventListener('click', (e) => {
|
||||||
this.init();
|
const activeTags = document.querySelectorAll('a.tag-pill.active')
|
||||||
}
|
activeTags.forEach((activeTag) => {
|
||||||
|
activeTag.classList.remove('active')
|
||||||
|
})
|
||||||
|
updateTagUrls();
|
||||||
|
})
|
||||||
|
|
||||||
init() {
|
// Add function to toggle tag filters
|
||||||
this.attachTagClickListeners();
|
document.querySelectorAll('a.tag-pill')
|
||||||
}
|
.forEach((tagPill) => {
|
||||||
|
tagPill.addEventListener('click', (e) => toggleTagPill(e))
|
||||||
|
})
|
||||||
|
|
||||||
// Parse current URL to get active tags
|
function toggleTagPill(e: Event) {
|
||||||
getActiveTags(): string[] {
|
const target = e.currentTarget as HTMLElement;
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search)
|
||||||
return urlParams.getAll('tag').map(tag => decodeURIComponent(tag).replace(/-/g, ' '));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate query string from tags array
|
if (target.classList.contains('active')) {
|
||||||
generateTagQueryString(tags: string[]): string {
|
target.classList.remove('active')
|
||||||
if (tags.length === 0) return '';
|
searchParams.delete('tag', getURLSafeTagName(target.innerText))
|
||||||
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 {
|
} else {
|
||||||
link.classList.remove('active');
|
target.classList.add('active')
|
||||||
|
searchParams.append('tag', getURLSafeTagName(target.innerText))
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Update post tags
|
// Update tag urls after the loader in onLoad completes
|
||||||
document.querySelectorAll('.post-tag').forEach(link => {
|
setTimeout(() => updateTagUrls(), 0)
|
||||||
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
|
function updateTagUrls() {
|
||||||
const tagPickerManager = new TagPickerManager();
|
const activeTags = document.querySelectorAll('a.tag-pill.active')
|
||||||
|
const inactiveTags = document.querySelectorAll('a.tag-pill:not(.active)')
|
||||||
|
|
||||||
// Update visual state after page loads
|
let baseTagParams = '';
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
activeTags.forEach((val) => {
|
||||||
tagPickerManager.updateTagVisualState();
|
baseTagParams += `&tag=${getURLSafeTagName(val.innerHTML)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
inactiveTags.forEach((val) => {
|
||||||
|
val.setAttribute('href', `/?tag=${getURLSafeTagName(val.innerHTML)}${baseTagParams}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
activeTags.forEach((val) => {
|
||||||
|
const tagName = getURLSafeTagName(val.innerHTML)
|
||||||
|
|
||||||
|
const activeTagLink = baseTagParams.split(`&tag=${getURLSafeTagName(val.innerHTML)}`).join('')
|
||||||
|
|
||||||
|
if (activeTagLink.length > 1) {
|
||||||
|
val.setAttribute('href', `/?${activeTagLink.substring(1)}`)
|
||||||
|
} else {
|
||||||
|
val.setAttribute('href', '/')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function normalizing the user facing tag name into
|
||||||
|
// the url format for the tag name.
|
||||||
|
function getURLSafeTagName(tag: string) {
|
||||||
|
return tag.toLowerCase().split(" ").join("-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to sync the UI state with the current URL
|
||||||
|
function syncStateFromUrl() {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
const currentTags = searchParams.getAll('tag');
|
||||||
|
|
||||||
|
// Reset all tags to inactive
|
||||||
|
document.querySelectorAll('a.tag-pill').forEach(tagPill => {
|
||||||
|
tagPill.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set active tags based on current URL
|
||||||
|
let activeTagCount = 0
|
||||||
|
currentTags.forEach(tagUrl => {
|
||||||
|
document.querySelectorAll('a.tag-pill').forEach(tagPill => {
|
||||||
|
const pill = tagPill as HTMLElement;
|
||||||
|
if (getURLSafeTagName(pill.innerText) === tagUrl) {
|
||||||
|
pill.classList.add('active');
|
||||||
|
activeTagCount++
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there is an active tag, show the clear filters button
|
||||||
|
const tagActions = document.querySelector('.tag-actions') as HTMLElement
|
||||||
|
if (activeTagCount > 0) {
|
||||||
|
// Show the clear tags button
|
||||||
|
tagActions.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
// Hide the clear tags button
|
||||||
|
tagActions.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTagUrls();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook into the popstate event to sync state when navigating back/forward
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
syncStateFromUrl();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also call immediately if DOM is already loaded
|
// Hook into the navigation system to sync state after content loads
|
||||||
if (document.readyState === 'interactive' || document.readyState === 'complete') {
|
// We'll observe the main element for changes
|
||||||
tagPickerManager.updateTagVisualState();
|
const observer = new MutationObserver((mutations) => {
|
||||||
}
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.type === 'childList' && document.querySelector('main')) {
|
||||||
|
syncStateFromUrl();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export { TagPickerManager };
|
// Start observing the document body for changes
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// JS is enabled, show theme picker interface
|
||||||
|
const elem = document.querySelector(".theme-controls")?.classList.remove('hidden')
|
||||||
|
|
||||||
// Theme switching functionality
|
// Theme switching functionality
|
||||||
const LIGHT_THEMES = ['latte', 'solarized-light', 'gruvbox-light'];
|
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 DARK_THEMES = ['frappe', 'macchiato', 'mocha', 'solarized-dark', 'gruvbox-dark', 'nord', 'dracula', 'one-dark', 'tokyo-night'];
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import postArchiveScript from '../clientJS/post-archive' with { type: "text" };
|
|
||||||
import { getPostsByYearAndMonth } from '../../db';
|
|
||||||
import { minifyJS } from '../utils';
|
import { minifyJS } from '../utils';
|
||||||
|
import { db } from '../../db';
|
||||||
|
|
||||||
|
// @ts-expect-error - Importing as text, not a module
|
||||||
|
import postArchiveScript from '../clientJS/post-archive' with { type: "text" };
|
||||||
|
|
||||||
|
// Interface for archive data structure
|
||||||
|
export interface ArchiveMonth {
|
||||||
|
name: string;
|
||||||
|
count: string;
|
||||||
|
posts: Array<{
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveYear {
|
||||||
|
year: string;
|
||||||
|
count: string;
|
||||||
|
months: ArchiveMonth[];
|
||||||
|
}
|
||||||
|
|
||||||
// Read posts from database once and store in a closure
|
// Read posts from database once and store in a closure
|
||||||
const getArchiveData = (() => {
|
const getArchiveData = (() => {
|
||||||
@@ -15,13 +33,13 @@ const getArchiveData = (() => {
|
|||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
export function PostArchive() {
|
export function PostArchive() {
|
||||||
const archiveData = getArchiveData();
|
const archiveData = getArchiveData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="postList sheet-background">
|
<div className="postList sheet-background">
|
||||||
<h3>Posts</h3>
|
<h3>Posts</h3>
|
||||||
|
<div className="archive-container">
|
||||||
<ul className="post-archive">
|
<ul className="post-archive">
|
||||||
{archiveData.map((yearData) => (
|
{archiveData.map((yearData) => (
|
||||||
<li key={yearData.year}>
|
<li key={yearData.year}>
|
||||||
@@ -55,7 +73,74 @@ export function PostArchive() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
<script dangerouslySetInnerHTML={{ __html: minifyJS(postArchiveScript) }} />
|
<script dangerouslySetInnerHTML={{ __html: minifyJS(postArchiveScript) }} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to get posts organized by year and month for the archive
|
||||||
|
export function getPostsByYearAndMonth(): ArchiveYear[] {
|
||||||
|
const query = db.query(`
|
||||||
|
SELECT * FROM posts
|
||||||
|
WHERE path NOT LIKE '%.md'
|
||||||
|
ORDER BY date DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const posts = query.all() as any[];
|
||||||
|
|
||||||
|
// Group posts by year and month
|
||||||
|
const yearMap = new Map<string, ArchiveYear>();
|
||||||
|
|
||||||
|
const monthNames = [
|
||||||
|
"January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December"
|
||||||
|
];
|
||||||
|
|
||||||
|
// Process each post
|
||||||
|
posts.forEach(post => {
|
||||||
|
const date = new Date(post.date);
|
||||||
|
const year = String(date.getFullYear());
|
||||||
|
const monthName = monthNames[date.getMonth()];
|
||||||
|
|
||||||
|
// Create clean post href from path
|
||||||
|
const href = post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '');
|
||||||
|
|
||||||
|
// Initialize year if it doesn't exist
|
||||||
|
if (!yearMap.has(year)) {
|
||||||
|
yearMap.set(year, {
|
||||||
|
year,
|
||||||
|
count: "(0)",
|
||||||
|
months: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearData = yearMap.get(year)!;
|
||||||
|
|
||||||
|
// Find or create month data
|
||||||
|
let monthData = yearData.months.find(m => m.name === monthName);
|
||||||
|
if (!monthData) {
|
||||||
|
monthData = {
|
||||||
|
name: monthName,
|
||||||
|
count: "(0)",
|
||||||
|
posts: []
|
||||||
|
};
|
||||||
|
yearData.months.push(monthData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add post to the month
|
||||||
|
monthData.posts.push({
|
||||||
|
title: post.title,
|
||||||
|
href
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update counts
|
||||||
|
monthData.count = `(${monthData.posts.length})`;
|
||||||
|
yearData.count = `(${yearData.months.reduce((total, m) => total + m.posts.length, 0)})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert map to array and sort
|
||||||
|
return Array.from(yearMap.values()).sort((a, b) =>
|
||||||
|
parseInt(b.year) - parseInt(a.year)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,151 +1,63 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { minifyJS } from '../utils';
|
import { minifyJS } from '../utils';
|
||||||
|
import { dbConnection } from '../../db/index.js';
|
||||||
|
|
||||||
|
// @ts-expect-error - Importing as text, not a module
|
||||||
|
import tagPickerScript from '../clientJS/tag-picker.js' with { type: "text" };
|
||||||
|
|
||||||
|
const tags = dbConnection.getAllTags().map(tag => ({
|
||||||
|
name: tag.name,
|
||||||
|
post_count: tag.post_count,
|
||||||
|
urlNormalized: tag.urlNormalized
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function TagPicker({ searchParams }: { searchParams?: URLSearchParams }) {
|
||||||
|
const selectedTags = typeof searchParams === 'object' ? searchParams.getAll('tag') : [];
|
||||||
|
|
||||||
export function TagPicker({ availableTags = [], activeTags = [] }: {
|
|
||||||
availableTags?: { name: string; post_count: number }[],
|
|
||||||
activeTags?: string[]
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="tags sheet-background">
|
<div className="tags sheet-background">
|
||||||
<h3>Tags</h3>
|
<h3>Tags</h3>
|
||||||
{availableTags.length > 0 ? (
|
|
||||||
<ul className="tag-pills">
|
<ul className="tag-pills">
|
||||||
{availableTags.map(tag => (
|
{tags.map(tag => {
|
||||||
<li key={tag.name}>
|
let active = selectedTags.includes(tag.urlNormalized)
|
||||||
|
let hyperlink = `/?tag=${tag.urlNormalized}`;
|
||||||
|
|
||||||
|
for (const existingTag of selectedTags) {
|
||||||
|
hyperlink += `&tag=${existingTag}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the currently selected tag from the tag link url if applicable
|
||||||
|
if (active) {
|
||||||
|
hyperlink = hyperlink.split(`?tag=${tag.urlNormalized}`).join('')
|
||||||
|
hyperlink = hyperlink.split(`&tag=${tag.urlNormalized}`).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the hyperlink starts with a ? and not a & (or the root webpage)
|
||||||
|
hyperlink = '/?' + hyperlink.substring(2)
|
||||||
|
if(hyperlink.length == 2) hyperlink = '/'
|
||||||
|
|
||||||
|
return (
|
||||||
|
< li key={tag.name} >
|
||||||
<a
|
<a
|
||||||
href={`?tag=${tag.name.toLowerCase().replace(/\s+/g, '-')}`}
|
title={active ? `Remove ${tag.name} from filter` : `Add ${tag.name} to filter`}
|
||||||
className="tag-pill"
|
data-tag
|
||||||
title={`${tag.post_count} post${tag.post_count !== 1 ? 's' : ''} (click to toggle)`}
|
className={`tag-pill ${active ? 'active' : ''}`}
|
||||||
|
href={hyperlink}
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
|
||||||
<p className="no-tags-available">No tags available</p>
|
{/* Show clear tags button if there are selected tags */}
|
||||||
)}
|
<div className="tag-actions" style={{ display: selectedTags.length > 0 ? 'block' : 'none' }}>
|
||||||
<div className="tag-actions" style={{ display: 'none' }}>
|
|
||||||
<a href="/" className="clear-tags-btn">
|
<a href="/" className="clear-tags-btn">
|
||||||
Clear all filters
|
Clear all filters
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<script dangerouslySetInnerHTML={{ __html: minifyJS(tagToggleScript) }} />
|
<script dangerouslySetInnerHTML={{ __html: minifyJS(tagPickerScript) }} />
|
||||||
</div>
|
</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,7 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import themePickerScript from '../clientJS/theme-picker' with { type: "text" };
|
|
||||||
import { minifyJS } from '../utils';
|
import { minifyJS } from '../utils';
|
||||||
|
|
||||||
|
// @ts-expect-error - Importing as text, not a module
|
||||||
|
import themePickerScript from '../clientJS/theme-picker' with { type: "text" };
|
||||||
|
|
||||||
const LIGHT_THEMES = ['latte', 'solarized-light', 'gruvbox-light'];
|
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 DARK_THEMES = ['frappe', 'macchiato', 'mocha', 'solarized-dark', 'gruvbox-dark', 'nord', 'dracula', 'one-dark', 'tokyo-night'];
|
||||||
|
|
||||||
@@ -23,8 +25,9 @@ const THEME_NAMES: Record<string, string> = {
|
|||||||
export function ThemePicker() {
|
export function ThemePicker() {
|
||||||
return (
|
return (
|
||||||
<div className="themePicker sheet-background">
|
<div className="themePicker sheet-background">
|
||||||
|
<noscript>Enable Javascript to select a theme</noscript>
|
||||||
<label htmlFor="theme" className="hidden">Theme</label>
|
<label htmlFor="theme" className="hidden">Theme</label>
|
||||||
<div className="theme-controls">
|
<div className="theme-controls hidden">
|
||||||
<div className="theme-mode-toggle">
|
<div className="theme-mode-toggle">
|
||||||
<button
|
<button
|
||||||
className="mode-btn active"
|
className="mode-btn active"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Client-side script that runs on page load
|
// Client-side script that runs on page load
|
||||||
// Example: TypeScript with type annotations
|
// Example: TypeScript with type annotations
|
||||||
|
|
||||||
async function loadContent(url: string) {
|
async function loadContent(url: string, shouldScrollToTop: boolean = true) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'shell-loaded': 'true'
|
'shell-loaded': 'true'
|
||||||
@@ -13,6 +13,10 @@ async function loadContent(url: string) {
|
|||||||
mainElement.outerHTML = html;
|
mainElement.outerHTML = html;
|
||||||
// Re-attach handlers to new links after content swap
|
// Re-attach handlers to new links after content swap
|
||||||
attachLinkHandlers();
|
attachLinkHandlers();
|
||||||
|
// Smooth scroll to top after navigation if needed
|
||||||
|
if (shouldScrollToTop) {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,21 +24,20 @@ function attachLinkHandlers() {
|
|||||||
const links: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a:not([data-external])');
|
const links: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a:not([data-external])');
|
||||||
|
|
||||||
links.forEach(link => {
|
links.forEach(link => {
|
||||||
console.log('Attaching listener to:', link.href);
|
|
||||||
link.onclick = async (e) => {
|
link.onclick = async (e) => {
|
||||||
console.log('clicked', link.href);
|
console.log('clicked', link.href);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
window.history.pushState({}, '', link.href);
|
window.history.pushState({}, '', link.href);
|
||||||
await loadContent(link.href);
|
await loadContent(link.href, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for back/forward button clicks
|
// Listen for back/forward button clicks
|
||||||
window.addEventListener('popstate', async (event) => {
|
window.addEventListener('popstate', async (event) => {
|
||||||
await loadContent(window.location.href);
|
await loadContent(window.location.href, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
import React from 'react';
|
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({ searchParams }: { searchParams: Record<string, string> }) {
|
import { dbConnection } from '../../db';
|
||||||
const currentPage = parseInt(searchParams.page || "1", 10);
|
import { formatDate } from '../utils';
|
||||||
|
|
||||||
|
// Extract the post type from the database return type
|
||||||
|
type Post = {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
readingTime: string;
|
||||||
|
summary: string;
|
||||||
|
content: string;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Home({ searchParams }: { searchParams: URLSearchParams }) {
|
||||||
const postsPerPage = 10;
|
const postsPerPage = 10;
|
||||||
const totalPages = Math.ceil(getNumOfPosts() / postsPerPage);
|
const tags = searchParams.getAll('tag');
|
||||||
|
|
||||||
|
const currentPage = parseInt(searchParams.get('page') || "1", 10);
|
||||||
|
const totalPages = Math.ceil(dbConnection.getNumOfPosts(tags) / postsPerPage);
|
||||||
const offset = (currentPage - 1) * postsPerPage;
|
const offset = (currentPage - 1) * postsPerPage;
|
||||||
const posts = getRecentPosts(postsPerPage, offset); // Get posts for the current page
|
|
||||||
|
const posts = dbConnection.getPosts(postsPerPage, offset, tags); // Get posts for the current page
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
@@ -24,17 +39,13 @@ export function Home({ searchParams }: { searchParams: Record<string, string> })
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Pagination currentPage={currentPage} totalPages={totalPages} />
|
<Pagination searchParams={searchParams} currentPage={currentPage} totalPages={totalPages} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PostCardProps {
|
function PostCard({ post }: { post: Post }) {
|
||||||
post: BlogPost;
|
const tags = post.tags;
|
||||||
}
|
|
||||||
|
|
||||||
function PostCard({ post }: PostCardProps) {
|
|
||||||
const tags = parseTags(post.tags);
|
|
||||||
const formattedDate = formatDate(post.date);
|
const formattedDate = formatDate(post.date);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,7 +85,7 @@ function PostCard({ post }: PostCardProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Pagination({ currentPage, totalPages }: { currentPage: number; totalPages: number }) {
|
function Pagination({ currentPage, totalPages, searchParams }:{ currentPage: number; totalPages: number, searchParams: URLSearchParams }) {
|
||||||
// Calculate the range of page numbers to show
|
// Calculate the range of page numbers to show
|
||||||
let startPage: number;
|
let startPage: number;
|
||||||
let endPage: number;
|
let endPage: number;
|
||||||
@@ -102,11 +113,17 @@ function Pagination({ currentPage, totalPages }: { currentPage: number; totalPag
|
|||||||
|
|
||||||
const pages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
const pages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||||
|
|
||||||
|
let passThroughParams = ''
|
||||||
|
searchParams.forEach((val, key) => {
|
||||||
|
if(key != 'page')
|
||||||
|
passThroughParams += `&${key}=${val}`
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="pagination">
|
<nav className="pagination">
|
||||||
{/* Previous button - always visible, disabled on first page */}
|
{/* Previous button - always visible, disabled on first page */}
|
||||||
<a
|
<a
|
||||||
href={`/?page=${currentPage - 1}`}
|
href={`/?page=${currentPage - 1}${passThroughParams}`}
|
||||||
className={`pagination-link ${currentPage === 1 ? 'disabled' : ''}`}
|
className={`pagination-link ${currentPage === 1 ? 'disabled' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
opacity: currentPage === 1 ? 0.5 : 1,
|
opacity: currentPage === 1 ? 0.5 : 1,
|
||||||
@@ -121,7 +138,7 @@ function Pagination({ currentPage, totalPages }: { currentPage: number; totalPag
|
|||||||
{pages.map((page) => (
|
{pages.map((page) => (
|
||||||
<a
|
<a
|
||||||
key={page}
|
key={page}
|
||||||
href={`/?page=${page}`}
|
href={`/?page=${page}${passThroughParams}`}
|
||||||
className={`pagination-link ${page === currentPage ? 'active' : ''}`}
|
className={`pagination-link ${page === currentPage ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
{page}
|
{page}
|
||||||
@@ -130,7 +147,7 @@ function Pagination({ currentPage, totalPages }: { currentPage: number; totalPag
|
|||||||
|
|
||||||
{/* Next button - always visible, disabled on last page */}
|
{/* Next button - always visible, disabled on last page */}
|
||||||
<a
|
<a
|
||||||
href={`/?page=${currentPage + 1}`}
|
href={`/?page=${currentPage + 1}${passThroughParams}`}
|
||||||
className={`pagination-link ${currentPage === totalPages ? 'disabled' : ''}`}
|
className={`pagination-link ${currentPage === totalPages ? 'disabled' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
opacity: currentPage === totalPages ? 0.5 : 1,
|
opacity: currentPage === totalPages ? 0.5 : 1,
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
interface NavigationPost {
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface PostProps {
|
interface PostProps {
|
||||||
// HTML string for the blog post body
|
// HTML string for the blog post body
|
||||||
children: string;
|
children: string;
|
||||||
@@ -7,14 +12,23 @@ interface PostProps {
|
|||||||
title: string;
|
title: string;
|
||||||
date: Date;
|
date: Date;
|
||||||
readingTime: string;
|
readingTime: string;
|
||||||
|
previousPost?: NavigationPost | null;
|
||||||
|
nextPost?: NavigationPost | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Post({ children, meta }: PostProps) {
|
export function Post({ children, meta }: PostProps) {
|
||||||
|
const { previousPost, nextPost } = meta;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<article className="blog-post">
|
<article className="blog-post">
|
||||||
<header className="post-header">
|
<header className="post-header">
|
||||||
|
<div className="back-button">
|
||||||
|
<a href="/" className="back-link">
|
||||||
|
<span className="back-arrow">←</span> Back to Home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<h1>{meta.title}</h1>
|
<h1>{meta.title}</h1>
|
||||||
<div className="post-meta">
|
<div className="post-meta">
|
||||||
{meta.date && meta.date instanceof Date &&
|
{meta.date && meta.date instanceof Date &&
|
||||||
@@ -39,6 +53,22 @@ export function Post({ children, meta }: PostProps) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="post-content" dangerouslySetInnerHTML={{ __html: children }} />
|
<div className="post-content" dangerouslySetInnerHTML={{ __html: children }} />
|
||||||
|
<footer className="post-navigation">
|
||||||
|
<div className="post-nav-links">
|
||||||
|
{previousPost && (
|
||||||
|
<a href={previousPost.path} className="post-nav-link prev-nav">
|
||||||
|
<span className="nav-direction">← Previous</span>
|
||||||
|
<span className="nav-title">{previousPost.title}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{nextPost && (
|
||||||
|
<a href={nextPost.path} className="post-nav-link next-nav">
|
||||||
|
<span className="nav-direction">Next →</span>
|
||||||
|
<span className="nav-title">{nextPost.title}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
/* Default theme fallback for when JS is disabled */
|
||||||
|
html {
|
||||||
|
--bg-primary: #eff1f5;
|
||||||
|
--bg-secondary: #e6e9f0;
|
||||||
|
--text-primary: #4c4f69;
|
||||||
|
--text-secondary: #6c6f85;
|
||||||
|
--border-color: #dce0e8;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* Catppuccin Latte - Light theme */
|
/* Catppuccin Latte - Light theme */
|
||||||
html[data-theme="latte"] {
|
html[data-theme="latte"] {
|
||||||
--bg-primary: #eff1f5;
|
--bg-primary: #eff1f5;
|
||||||
@@ -127,7 +137,6 @@ html[data-theme="dark"] {
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow-x: hidden;
|
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
@@ -137,8 +146,41 @@ body {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hamburger Menu Styles */
|
||||||
|
.menu-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-button {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
width: 40px;
|
||||||
|
height: 35px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
box-sizing: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-button span {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
margin: 5px 0;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
@@ -158,7 +200,6 @@ aside {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
@@ -569,9 +610,6 @@ h1 {
|
|||||||
background-color: var(--text-primary);
|
background-color: var(--text-primary);
|
||||||
color: var(--bg-primary);
|
color: var(--bg-primary);
|
||||||
border-color: var(--text-primary);
|
border-color: var(--text-primary);
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tag actions for clearing filters */
|
/* Tag actions for clearing filters */
|
||||||
@@ -606,6 +644,12 @@ h1 {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.archive-container {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.post-archive {
|
.post-archive {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -715,6 +759,25 @@ h1 {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar styling for archive container */
|
||||||
|
.archive-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-container::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-container::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Blog Post Styles */
|
/* Blog Post Styles */
|
||||||
.blog-post {
|
.blog-post {
|
||||||
max-width: 750px;
|
max-width: 750px;
|
||||||
@@ -728,6 +791,28 @@ h1 {
|
|||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-arrow {
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.post-title {
|
.post-title {
|
||||||
font-size: 42px;
|
font-size: 42px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -914,128 +999,62 @@ h1 {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
.post-navigation {
|
||||||
@media (max-width: 1200px) {
|
margin-top: 48px;
|
||||||
aside {
|
padding-top: 24px;
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
body {
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
max-width: 100%;
|
|
||||||
width: 100%;
|
|
||||||
padding: 30px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
aside {
|
|
||||||
width: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
position: static;
|
|
||||||
max-height: none;
|
|
||||||
border-left: none;
|
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
padding: 20px;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blog-post {
|
|
||||||
max-width: 750px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
.post-nav-links {
|
||||||
main {
|
display: flex;
|
||||||
padding: 20px 16px;
|
justify-content: space-between;
|
||||||
}
|
gap: 16px;
|
||||||
|
|
||||||
aside {
|
|
||||||
padding: 20px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blog-post {
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-title {
|
|
||||||
font-size: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-content {
|
|
||||||
font-size: 17px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-content .lead {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-content h2 {
|
|
||||||
font-size: 26px;
|
|
||||||
margin: 36px 0 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-content h3 {
|
|
||||||
font-size: 21px;
|
|
||||||
margin: 28px 0 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-content blockquote {
|
|
||||||
font-size: 18px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-content pre {
|
|
||||||
padding: 16px;
|
|
||||||
margin: 24px -16px;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-picture {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-name {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-links a {
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-links svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
.post-nav-link {
|
||||||
.post-title {
|
display: flex;
|
||||||
font-size: 28px;
|
flex-direction: column;
|
||||||
}
|
padding: 12px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--sheet-background);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 48%;
|
||||||
|
}
|
||||||
|
|
||||||
.post-content h2 {
|
.post-nav-link:hover {
|
||||||
font-size: 24px;
|
background-color: var(--accent-color);
|
||||||
}
|
color: var(--accent-text-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
.post-content h3 {
|
.prev-nav {
|
||||||
font-size: 20px;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-pills {
|
.next-nav {
|
||||||
gap: 6px;
|
align-items: flex-end;
|
||||||
}
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-pill {
|
.nav-direction {
|
||||||
font-size: 12px;
|
font-size: 0.875rem;
|
||||||
padding: 4px 10px;
|
font-weight: 600;
|
||||||
}
|
margin-bottom: 4px;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-nav-link:hover .nav-direction {
|
||||||
|
color: var(--accent-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Home Page Styles */
|
/* Home Page Styles */
|
||||||
@@ -1175,48 +1194,163 @@ h1 {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive home page styles */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
.home-header {
|
body {
|
||||||
margin-bottom: 32px;
|
flex-direction: column-reverse;
|
||||||
padding-bottom: 24px;
|
overflow-x: hidden;
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
main {
|
||||||
font-size: 36px;
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
padding: 30px 20px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
aside {
|
||||||
font-size: 18px;
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding: 20px;
|
||||||
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-post {
|
||||||
|
max-width: 750px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show hamburger button and hide aside on mobile/tablet */
|
||||||
|
.hamburger-button {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: -100%;
|
||||||
|
width: 80%;
|
||||||
|
max-width: 340px;
|
||||||
|
height: 100vh;
|
||||||
|
padding-top: 80px;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
z-index: 999;
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
border-top: none;
|
||||||
|
transition: right 0.3s ease;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show aside when checkbox is checked */
|
||||||
|
.menu-toggle:checked ~ aside {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transform hamburger to X when checked */
|
||||||
|
.menu-toggle:checked ~ .hamburger-button span:nth-child(1) {
|
||||||
|
transform: rotate(45deg) translate(8px, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle:checked ~ .hamburger-button span:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle:checked ~ .hamburger-button span:nth-child(3) {
|
||||||
|
transform: rotate(-45deg) translate(9px, -9px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Retro list style for smaller screens when aside shifts off screen */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
.posts-list {
|
.posts-list {
|
||||||
gap: 24px;
|
gap: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-card {
|
.post-card {
|
||||||
padding: 24px;
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 24px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
transition: none;
|
||||||
|
overflow-x: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card:hover {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-card-title {
|
.post-card-title {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
font-weight: 600;
|
||||||
}
|
line-height: 1.4;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
@media (max-width: 480px) {
|
letter-spacing: 0;
|
||||||
.page-title {
|
|
||||||
font-size: 32px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-card {
|
.post-card-link {
|
||||||
padding: 20px;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-card-title {
|
.post-card-link:hover {
|
||||||
font-size: 22px;
|
color: var(--text-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-meta {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-tags {
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-summary {
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-card-summary p {
|
.post-card-summary p {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-card-footer {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-more-link {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-more-link:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,3 +16,22 @@ export function minifyJS(code: string): string {
|
|||||||
|
|
||||||
return transpiler.transformSync(code);
|
return transpiler.transformSync(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// Parse ISO date string (YYYY-MM-DD)
|
||||||
|
const date = new Date(dateString + 'T00:00:00');
|
||||||
|
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
1485
temp/appshell.html
1485
temp/appshell.html
File diff suppressed because it is too large
Load Diff
@@ -99,5 +99,17 @@
|
|||||||
/* Completeness */
|
/* Completeness */
|
||||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
}
|
},
|
||||||
|
"include": [
|
||||||
|
"index.tsx",
|
||||||
|
"src/**/*",
|
||||||
|
"bun_plugins/**/*",
|
||||||
|
"*.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"content"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user