feat: extracts some repeated logic out into lib/ files
This commit is contained in:
parent
38653f2aa1
commit
99c1539aad
22 changed files with 200 additions and 336 deletions
|
|
@ -11,7 +11,7 @@ function countWords(text) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count blog posts and their words
|
// Count blog posts and their words
|
||||||
const postsDir = path.join(root, 'src/content/posts');
|
const postsDir = path.join(root, 'src/content/md');
|
||||||
const posts = fs.existsSync(postsDir)
|
const posts = fs.existsSync(postsDir)
|
||||||
? fs.readdirSync(postsDir).filter(f => f.endsWith('.md'))
|
? fs.readdirSync(postsDir).filter(f => f.endsWith('.md'))
|
||||||
: [];
|
: [];
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,14 @@ import { glob, file } from 'astro/loaders';
|
||||||
import { z } from 'astro/zod';
|
import { z } from 'astro/zod';
|
||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
|
|
||||||
const posts = defineCollection({
|
const md = defineCollection({
|
||||||
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
|
loader: glob({ pattern: '**/*.md', base: './src/content/md' }),
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
date: z.coerce.date(),
|
date: z.coerce.date(),
|
||||||
pinned: z.boolean().optional(),
|
pinned: z.boolean().optional(),
|
||||||
category: z.string().optional(),
|
category: z.string().optional(),
|
||||||
draft: z.boolean().optional(),
|
draft: z.boolean().optional(),
|
||||||
slug: z.string(),
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -29,4 +28,4 @@ const bookmarks = defineCollection({
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const collections = { posts, bookmarks };
|
export const collections = { md, bookmarks };
|
||||||
|
|
|
||||||
19
www/src/lib/api.ts
Normal file
19
www/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { isAdmin } from './auth';
|
||||||
|
|
||||||
|
export function jsonResponse(data: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorResponse(message: string, status: number): Response {
|
||||||
|
return jsonResponse({ error: message }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAdmin(session: { user?: { id?: string } } | null): Response | null {
|
||||||
|
if (!session?.user?.id || !isAdmin(session.user.id)) {
|
||||||
|
return errorResponse('Unauthorized', 403);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
15
www/src/lib/format.ts
Normal file
15
www/src/lib/format.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
export function formatDate(date: Date): string {
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const y = String(date.getFullYear()).slice(-2);
|
||||||
|
return `${d}/${m}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDomain(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.hostname.replace(/^www\./, '');
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
www/src/lib/posts.ts
Normal file
35
www/src/lib/posts.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { CollectionEntry } from 'astro:content';
|
||||||
|
|
||||||
|
type Post = CollectionEntry<'md'>;
|
||||||
|
|
||||||
|
export function sortPosts(posts: Post[]): Post[] {
|
||||||
|
return posts.slice().sort((a, b) => {
|
||||||
|
if (a.data.pinned && !b.data.pinned) return -1;
|
||||||
|
if (!a.data.pinned && b.data.pinned) return 1;
|
||||||
|
return b.data.date.getTime() - a.data.date.getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function organizePostsByCategory(posts: Post[]): {
|
||||||
|
grouped: Record<string, Post[]>;
|
||||||
|
categories: string[];
|
||||||
|
} {
|
||||||
|
const grouped = posts.reduce((acc, post) => {
|
||||||
|
const category = post.data.category ?? 'md';
|
||||||
|
if (!acc[category]) acc[category] = [];
|
||||||
|
acc[category].push(post);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, Post[]>);
|
||||||
|
|
||||||
|
const categories = Object.keys(grouped).sort((a, b) => {
|
||||||
|
if (a === 'md') return -1;
|
||||||
|
if (b === 'md') return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
grouped[category] = sortPosts(grouped[category]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { grouped, categories };
|
||||||
|
}
|
||||||
53
www/src/lib/txt.ts
Normal file
53
www/src/lib/txt.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import yaml from 'js-yaml';
|
||||||
|
import { getGitDate } from '../utils';
|
||||||
|
|
||||||
|
export interface TxtFile {
|
||||||
|
name: string;
|
||||||
|
date: Date;
|
||||||
|
pinned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TxtConfig {
|
||||||
|
pinned?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTxtDir(): string {
|
||||||
|
return path.join(process.cwd(), 'public/txt');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadTxtConfig(): TxtConfig {
|
||||||
|
const configPath = path.join(getTxtDir(), 'config.yaml');
|
||||||
|
return fs.existsSync(configPath)
|
||||||
|
? yaml.load(fs.readFileSync(configPath, 'utf8')) as TxtConfig
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTxtFiles(): TxtFile[] {
|
||||||
|
const txtDir = getTxtDir();
|
||||||
|
if (!fs.existsSync(txtDir)) return [];
|
||||||
|
|
||||||
|
const config = loadTxtConfig();
|
||||||
|
const pinnedSet = new Set(config.pinned || []);
|
||||||
|
|
||||||
|
return fs.readdirSync(txtDir)
|
||||||
|
.filter(file => file.endsWith('.txt'))
|
||||||
|
.map(name => ({
|
||||||
|
name,
|
||||||
|
date: getGitDate(path.join(txtDir, name)),
|
||||||
|
pinned: pinnedSet.has(name),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.pinned && !b.pinned) return -1;
|
||||||
|
if (!a.pinned && b.pinned) return 1;
|
||||||
|
return b.date.getTime() - a.date.getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTxtFileNames(): string[] {
|
||||||
|
const txtDir = getTxtDir();
|
||||||
|
if (!fs.existsSync(txtDir)) return [];
|
||||||
|
|
||||||
|
return fs.readdirSync(txtDir).filter(file => file.endsWith('.txt'));
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { getSession } from 'auth-astro/server';
|
||||||
import { getPendingEntries, type GuestbookEntry } from '../lib/db';
|
import { getPendingEntries, type GuestbookEntry } from '../lib/db';
|
||||||
import { isAdmin } from '../lib/auth';
|
import { isAdmin } from '../lib/auth';
|
||||||
import Layout from '../layouts/Layout.astro';
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
import { formatDate } from '../lib/format';
|
||||||
|
|
||||||
let session;
|
let session;
|
||||||
try {
|
try {
|
||||||
|
|
@ -27,13 +28,6 @@ try {
|
||||||
} catch {
|
} catch {
|
||||||
// handle error
|
// handle error
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date: Date): string {
|
|
||||||
const d = String(date.getDate()).padStart(2, '0');
|
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const y = String(date.getFullYear()).slice(-2);
|
|
||||||
return `${d}/${m}/${y}`;
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
<Layout title="admin - guestbook" showHeader={false}>
|
<Layout title="admin - guestbook" showHeader={false}>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,23 @@
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { getSession } from 'auth-astro/server';
|
import { getSession } from 'auth-astro/server';
|
||||||
import { isAdmin } from '../../lib/auth';
|
import { jsonResponse, errorResponse, requireAdmin } from '../../lib/api';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
const session = await getSession(request);
|
const session = await getSession(request);
|
||||||
|
const authError = requireAdmin(session);
|
||||||
if (!session?.user?.id || !isAdmin(session.user.id)) {
|
if (authError) return authError;
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
||||||
status: 403,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const hookUrl = import.meta.env.VERCEL_DEPLOY_HOOK;
|
const hookUrl = import.meta.env.VERCEL_DEPLOY_HOOK;
|
||||||
if (!hookUrl) {
|
if (!hookUrl) {
|
||||||
return new Response(JSON.stringify({ error: 'Deploy hook not configured' }), {
|
return errorResponse('Deploy hook not configured', 500);
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(hookUrl, { method: 'POST' });
|
const res = await fetch(hookUrl, { method: 'POST' });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return new Response(JSON.stringify({ error: 'Failed to trigger deploy' }), {
|
return errorResponse('Failed to trigger deploy', 502);
|
||||||
status: 502,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true }), {
|
return jsonResponse({ success: true });
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { createEntry } from '../../lib/db';
|
import { createEntry } from '../../lib/db';
|
||||||
|
import { jsonResponse, errorResponse } from '../../lib/api';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
|
|
@ -9,22 +10,13 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
const { name, message, url } = data;
|
const { name, message, url } = data;
|
||||||
|
|
||||||
if (!name || !message) {
|
if (!name || !message) {
|
||||||
return new Response(JSON.stringify({ error: 'Name and message are required' }), {
|
return errorResponse('Name and message are required', 400);
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await createEntry(name.slice(0, 100), message.slice(0, 500), url?.slice(0, 200) || null);
|
await createEntry(name.slice(0, 100), message.slice(0, 500), url?.slice(0, 200) || null);
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true }), {
|
return jsonResponse({ success: true }, 201);
|
||||||
status: 201,
|
} catch {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
return errorResponse('Failed to create entry', 500);
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Failed to create entry' }), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,34 @@
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { getSession } from 'auth-astro/server';
|
import { getSession } from 'auth-astro/server';
|
||||||
import { approveEntry, deleteEntry } from '../../../lib/db';
|
import { approveEntry, deleteEntry } from '../../../lib/db';
|
||||||
import { isAdmin } from '../../../lib/auth';
|
import { jsonResponse, errorResponse, requireAdmin } from '../../../lib/api';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
export const PATCH: APIRoute = async ({ params, request }) => {
|
export const PATCH: APIRoute = async ({ params, request }) => {
|
||||||
const session = await getSession(request);
|
const session = await getSession(request);
|
||||||
|
const authError = requireAdmin(session);
|
||||||
if (!session?.user?.id || !isAdmin(session.user.id)) {
|
if (authError) return authError;
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
||||||
status: 403,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = parseInt(params.id!, 10);
|
const id = parseInt(params.id!, 10);
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
return new Response(JSON.stringify({ error: 'Invalid ID' }), {
|
return errorResponse('Invalid ID', 400);
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await approveEntry(id);
|
await approveEntry(id);
|
||||||
return new Response(JSON.stringify({ success: true }), {
|
return jsonResponse({ success: true });
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: APIRoute = async ({ params, request }) => {
|
export const DELETE: APIRoute = async ({ params, request }) => {
|
||||||
const session = await getSession(request);
|
const session = await getSession(request);
|
||||||
|
const authError = requireAdmin(session);
|
||||||
if (!session?.user?.id || !isAdmin(session.user.id)) {
|
if (authError) return authError;
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
||||||
status: 403,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = parseInt(params.id!, 10);
|
const id = parseInt(params.id!, 10);
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
return new Response(JSON.stringify({ error: 'Invalid ID' }), {
|
return errorResponse('Invalid ID', 400);
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteEntry(id);
|
await deleteEntry(id);
|
||||||
return new Response(JSON.stringify({ success: true }), {
|
return jsonResponse({ success: true });
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,11 @@
|
||||||
---
|
---
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import Layout from '../../layouts/Layout.astro';
|
import Layout from '../../layouts/Layout.astro';
|
||||||
|
import { formatDate, extractDomain } from '../../lib/format';
|
||||||
|
|
||||||
const bookmarksCollection = await getCollection('bookmarks');
|
const bookmarksCollection = await getCollection('bookmarks');
|
||||||
const bookmarks = bookmarksCollection
|
const bookmarks = bookmarksCollection
|
||||||
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||||
|
|
||||||
function formatDate(date: Date): string {
|
|
||||||
const d = String(date.getDate()).padStart(2, '0');
|
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const y = String(date.getFullYear()).slice(-2);
|
|
||||||
return `${d}/${m}/${y}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractDomain(url: string): string {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
return parsed.hostname.replace(/^www\./, '');
|
|
||||||
} catch {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
<Layout title="bookmarks - lewis m.w.">
|
<Layout title="bookmarks - lewis m.w.">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { getSession } from 'auth-astro/server';
|
||||||
import { getCollection, render } from 'astro:content';
|
import { getCollection, render } from 'astro:content';
|
||||||
import { isAdmin } from '../../lib/auth';
|
import { isAdmin } from '../../lib/auth';
|
||||||
import Layout from '../../layouts/Layout.astro';
|
import Layout from '../../layouts/Layout.astro';
|
||||||
|
import { formatDate } from '../../lib/format';
|
||||||
|
import { getSlug } from '../../utils';
|
||||||
|
|
||||||
let session;
|
let session;
|
||||||
try {
|
try {
|
||||||
|
|
@ -22,21 +24,14 @@ if (!isAdmin(session.user?.id)) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const slug = Astro.params.slug;
|
const slug = Astro.params.slug;
|
||||||
const posts = await getCollection('posts', ({ data }) => data.draft === true);
|
const posts = await getCollection('md', ({ data }) => data.draft === true);
|
||||||
const post = posts.find(p => p.id === slug);
|
const post = posts.find(p => getSlug(p.id) === slug);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { Content } = await render(post);
|
const { Content } = await render(post);
|
||||||
|
|
||||||
function formatDate(date: Date): string {
|
|
||||||
const d = String(date.getDate()).padStart(2, '0');
|
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const y = String(date.getFullYear()).slice(-2);
|
|
||||||
return `${d}/${m}/${y}`;
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
<Layout title={`${post.data.title} - lewis m.w.`}>
|
<Layout title={`${post.data.title} - lewis m.w.`}>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import { getSession } from 'auth-astro/server';
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import { isAdmin } from '../../lib/auth';
|
import { isAdmin } from '../../lib/auth';
|
||||||
import Layout from '../../layouts/Layout.astro';
|
import Layout from '../../layouts/Layout.astro';
|
||||||
|
import { formatDate } from '../../lib/format';
|
||||||
|
import { organizePostsByCategory } from '../../lib/posts';
|
||||||
|
import { getSlug } from '../../utils';
|
||||||
|
|
||||||
let session;
|
let session;
|
||||||
try {
|
try {
|
||||||
|
|
@ -21,38 +24,8 @@ if (!isAdmin(session.user?.id)) {
|
||||||
return new Response('Forbidden', { status: 403 });
|
return new Response('Forbidden', { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const posts = await getCollection('posts', ({ data }) => data.draft === true);
|
const posts = await getCollection('md', ({ data }) => data.draft === true);
|
||||||
|
const { grouped, categories: sortedCategories } = organizePostsByCategory(posts);
|
||||||
// Group by category (default: "posts")
|
|
||||||
const grouped = posts.reduce((acc, post) => {
|
|
||||||
const category = post.data.category ?? 'posts';
|
|
||||||
if (!acc[category]) acc[category] = [];
|
|
||||||
acc[category].push(post);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, typeof posts>);
|
|
||||||
|
|
||||||
// Sort categories: "posts" first, then alphabetically
|
|
||||||
const sortedCategories = Object.keys(grouped).sort((a, b) => {
|
|
||||||
if (a === 'posts') return -1;
|
|
||||||
if (b === 'posts') return 1;
|
|
||||||
return a.localeCompare(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort posts within each category: pinned first, then by date descending
|
|
||||||
for (const category of sortedCategories) {
|
|
||||||
grouped[category].sort((a, b) => {
|
|
||||||
if (a.data.pinned && !b.data.pinned) return -1;
|
|
||||||
if (!a.data.pinned && b.data.pinned) return 1;
|
|
||||||
return b.data.date.getTime() - a.data.date.getTime();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(date: Date): string {
|
|
||||||
const d = String(date.getDate()).padStart(2, '0');
|
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const y = String(date.getFullYear()).slice(-2);
|
|
||||||
return `${d}/${m}/${y}`;
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
<Layout title="drafts - lewis m.w.">
|
<Layout title="drafts - lewis m.w.">
|
||||||
|
|
||||||
|
|
@ -64,7 +37,7 @@ function formatDate(date: Date): string {
|
||||||
sortedCategories.map(category => (
|
sortedCategories.map(category => (
|
||||||
<details open>
|
<details open>
|
||||||
<summary>{category}</summary>
|
<summary>{category}</summary>
|
||||||
<pre set:html={grouped[category].map(post => `<span class="muted">${formatDate(post.data.date)}</span> <a href="/draft/${post.id}">${post.data.title}</a>${post.data.pinned ? ' [pinned]' : ''}`).join('\n')} />
|
<pre set:html={grouped[category].map(post => `<span class="muted">${formatDate(post.data.date)}</span> <a href="/draft/${getSlug(post.id)}">${post.data.title}</a>${post.data.pinned ? ' [pinned]' : ''}`).join('\n')} />
|
||||||
</details>
|
</details>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,19 @@
|
||||||
import rss from '@astrojs/rss';
|
import rss from '@astrojs/rss';
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import type { APIContext } from 'astro';
|
import type { APIContext } from 'astro';
|
||||||
import { getGitDate } from '../utils';
|
import { getTxtFiles } from '../lib/txt';
|
||||||
|
import { getSlug } from '../utils';
|
||||||
interface TxtFile {
|
|
||||||
name: string;
|
|
||||||
date: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(context: APIContext) {
|
export async function GET(context: APIContext) {
|
||||||
const posts = await getCollection('posts', ({ data }) => data.draft !== true);
|
const posts = await getCollection('md', ({ data }) => data.draft !== true);
|
||||||
const bookmarks = await getCollection('bookmarks');
|
const bookmarks = await getCollection('bookmarks');
|
||||||
|
const txtFiles = getTxtFiles();
|
||||||
const txtDir = path.join(process.cwd(), 'public/txt');
|
|
||||||
const txtFiles: TxtFile[] = fs.existsSync(txtDir)
|
|
||||||
? fs.readdirSync(txtDir)
|
|
||||||
.filter(file => file.endsWith('.txt'))
|
|
||||||
.map(name => ({
|
|
||||||
name,
|
|
||||||
date: getGitDate(path.join(txtDir, name)),
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
...posts.map(post => ({
|
...posts.map(post => ({
|
||||||
title: post.data.title,
|
title: post.data.title,
|
||||||
pubDate: post.data.date,
|
pubDate: post.data.date,
|
||||||
link: `/md/${post.id}`,
|
link: `/md/${getSlug(post.id)}`,
|
||||||
description: post.data.title,
|
description: post.data.title,
|
||||||
})),
|
})),
|
||||||
...txtFiles.map(txt => ({
|
...txtFiles.map(txt => ({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
---
|
---
|
||||||
import Layout from '../../layouts/Layout.astro';
|
import Layout from '../../layouts/Layout.astro';
|
||||||
import { getApprovedEntries, type GuestbookEntry } from '../../lib/db';
|
import { getApprovedEntries, type GuestbookEntry } from '../../lib/db';
|
||||||
|
import { formatDate } from '../../lib/format';
|
||||||
|
|
||||||
let guestbookEntries: GuestbookEntry[] = [];
|
let guestbookEntries: GuestbookEntry[] = [];
|
||||||
try {
|
try {
|
||||||
|
|
@ -8,13 +9,6 @@ try {
|
||||||
} catch {
|
} catch {
|
||||||
// DB not available during dev without env vars
|
// DB not available during dev without env vars
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date: Date): string {
|
|
||||||
const d = String(date.getDate()).padStart(2, '0');
|
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const y = String(date.getFullYear()).slice(-2);
|
|
||||||
return `${d}/${m}/${y}`;
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
<Layout title="guestbook - lewis m.w.">
|
<Layout title="guestbook - lewis m.w.">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,20 @@
|
||||||
---
|
---
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import Layout from '../layouts/Layout.astro';
|
import Layout from '../layouts/Layout.astro';
|
||||||
import yaml from 'js-yaml';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { getApprovedEntries, type GuestbookEntry } from '../lib/db';
|
import { getApprovedEntries, type GuestbookEntry } from '../lib/db';
|
||||||
import { getGitDate } from '../utils';
|
import { formatDate, extractDomain } from '../lib/format';
|
||||||
|
import { organizePostsByCategory } from '../lib/posts';
|
||||||
|
import { getTxtFiles } from '../lib/txt';
|
||||||
|
import { getSlug } from '../utils';
|
||||||
|
|
||||||
interface TxtFile {
|
const posts = await getCollection('md', ({ data }) => data.draft !== true);
|
||||||
name: string;
|
const { grouped, categories: sortedCategories } = organizePostsByCategory(posts);
|
||||||
date: Date;
|
|
||||||
pinned: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TxtConfig {
|
|
||||||
pinned?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const posts = await getCollection('posts', ({ data }) => data.draft !== true);
|
|
||||||
|
|
||||||
// Group by category (default: "posts")
|
|
||||||
const grouped = posts.reduce((acc, post) => {
|
|
||||||
const category = post.data.category ?? 'posts';
|
|
||||||
if (!acc[category]) acc[category] = [];
|
|
||||||
acc[category].push(post);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, typeof posts>);
|
|
||||||
|
|
||||||
// Sort categories: "posts" first, then alphabetically
|
|
||||||
const sortedCategories = Object.keys(grouped).sort((a, b) => {
|
|
||||||
if (a === 'posts') return -1;
|
|
||||||
if (b === 'posts') return 1;
|
|
||||||
return a.localeCompare(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort posts within each category: pinned first, then by date descending
|
|
||||||
for (const category of sortedCategories) {
|
|
||||||
grouped[category].sort((a, b) => {
|
|
||||||
if (a.data.pinned && !b.data.pinned) return -1;
|
|
||||||
if (!a.data.pinned && b.data.pinned) return 1;
|
|
||||||
return b.data.date.getTime() - a.data.date.getTime();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const bookmarksCollection = await getCollection('bookmarks');
|
const bookmarksCollection = await getCollection('bookmarks');
|
||||||
const bookmarks = bookmarksCollection
|
const bookmarks = bookmarksCollection
|
||||||
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||||
|
|
||||||
// Auto-discover txt files from public/txt/
|
const txtFiles = getTxtFiles();
|
||||||
const txtDir = path.join(process.cwd(), 'public/txt');
|
|
||||||
const txtConfigPath = path.join(txtDir, 'config.yaml');
|
|
||||||
const txtConfig: TxtConfig = fs.existsSync(txtConfigPath)
|
|
||||||
? yaml.load(fs.readFileSync(txtConfigPath, 'utf8')) as TxtConfig
|
|
||||||
: {};
|
|
||||||
const pinnedSet = new Set(txtConfig.pinned || []);
|
|
||||||
|
|
||||||
const txtFiles: TxtFile[] = fs.existsSync(txtDir)
|
|
||||||
? fs.readdirSync(txtDir)
|
|
||||||
.filter(file => file.endsWith('.txt'))
|
|
||||||
.map(name => {
|
|
||||||
const filePath = path.join(txtDir, name);
|
|
||||||
return { name, date: getGitDate(filePath), pinned: pinnedSet.has(name) };
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (a.pinned && !b.pinned) return -1;
|
|
||||||
if (!a.pinned && b.pinned) return 1;
|
|
||||||
return b.date.getTime() - a.date.getTime();
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
let guestbookEntries: GuestbookEntry[] = [];
|
let guestbookEntries: GuestbookEntry[] = [];
|
||||||
try {
|
try {
|
||||||
|
|
@ -75,22 +22,6 @@ try {
|
||||||
} catch {
|
} catch {
|
||||||
// DB not available during dev without env vars
|
// DB not available during dev without env vars
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date: Date): string {
|
|
||||||
const d = String(date.getDate()).padStart(2, '0');
|
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const y = String(date.getFullYear()).slice(-2);
|
|
||||||
return `${d}/${m}/${y}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractDomain(url: string): string {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
return parsed.hostname.replace(/^www\./, '');
|
|
||||||
} catch {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
<Layout title="lewis m.w." isHome>
|
<Layout title="lewis m.w." isHome>
|
||||||
|
|
||||||
|
|
@ -100,7 +31,7 @@ function extractDomain(url: string): string {
|
||||||
<details open>
|
<details open>
|
||||||
<summary>{category}</summary>
|
<summary>{category}</summary>
|
||||||
<pre set:html={[
|
<pre set:html={[
|
||||||
...categoryPosts.slice(0, 10).map(post => `<span class="muted">${formatDate(post.data.date)}</span> <a href="/md/${post.id}">${post.data.title}</a>${post.data.pinned ? ' [pinned]' : ''}`),
|
...categoryPosts.slice(0, 10).map(post => `<span class="muted">${formatDate(post.data.date)}</span> <a href="/md/${getSlug(post.id)}">${post.data.title}</a>${post.data.pinned ? ' [pinned]' : ''}`),
|
||||||
...(categoryPosts.length > 10 ? [`<a href="/md/">+${categoryPosts.length - 10} more</a>`] : [])
|
...(categoryPosts.length > 10 ? [`<a href="/md/">+${categoryPosts.length - 10} more</a>`] : [])
|
||||||
].join('\n')} />
|
].join('\n')} />
|
||||||
</details>
|
</details>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,19 @@
|
||||||
---
|
---
|
||||||
import { getCollection, render } from 'astro:content';
|
import { getCollection, render } from 'astro:content';
|
||||||
import Layout from '../../layouts/Layout.astro';
|
import Layout from '../../layouts/Layout.astro';
|
||||||
|
import { formatDate } from '../../lib/format';
|
||||||
|
import { getSlug } from '../../utils';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const posts = await getCollection('posts', ({ data }) => data.draft !== true);
|
const posts = await getCollection('md', ({ data }) => data.draft !== true);
|
||||||
return posts.map(post => ({
|
return posts.map(post => ({
|
||||||
params: { slug: post.data.slug },
|
params: { slug: getSlug(post.id) },
|
||||||
props: { post }
|
props: { post }
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { post } = Astro.props;
|
const { post } = Astro.props;
|
||||||
const { Content } = await render(post);
|
const { Content } = await render(post);
|
||||||
|
|
||||||
function formatDate(date: Date): string {
|
|
||||||
const d = String(date.getDate()).padStart(2, '0');
|
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const y = String(date.getFullYear()).slice(-2);
|
|
||||||
return `${d}/${m}/${y}`;
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
<Layout title={`${post.data.title} - lewis m.w.`}>
|
<Layout title={`${post.data.title} - lewis m.w.`}>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,19 @@
|
||||||
---
|
---
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import Layout from '../../layouts/Layout.astro';
|
import Layout from '../../layouts/Layout.astro';
|
||||||
|
import { formatDate } from '../../lib/format';
|
||||||
|
import { organizePostsByCategory } from '../../lib/posts';
|
||||||
|
import { getSlug } from '../../utils';
|
||||||
|
|
||||||
const posts = await getCollection('posts', ({ data }) => data.draft !== true);
|
const posts = await getCollection('md', ({ data }) => data.draft !== true);
|
||||||
|
const { grouped, categories: sortedCategories } = organizePostsByCategory(posts);
|
||||||
// Group by category (default: "posts")
|
|
||||||
const grouped = posts.reduce((acc, post) => {
|
|
||||||
const category = post.data.category ?? 'posts';
|
|
||||||
if (!acc[category]) acc[category] = [];
|
|
||||||
acc[category].push(post);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, typeof posts>);
|
|
||||||
|
|
||||||
// Sort categories: "posts" first, then alphabetically
|
|
||||||
const sortedCategories = Object.keys(grouped).sort((a, b) => {
|
|
||||||
if (a === 'posts') return -1;
|
|
||||||
if (b === 'posts') return 1;
|
|
||||||
return a.localeCompare(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort posts within each category: pinned first, then by date descending
|
|
||||||
for (const category of sortedCategories) {
|
|
||||||
grouped[category].sort((a, b) => {
|
|
||||||
if (a.data.pinned && !b.data.pinned) return -1;
|
|
||||||
if (!a.data.pinned && b.data.pinned) return 1;
|
|
||||||
return b.data.date.getTime() - a.data.date.getTime();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(date: Date): string {
|
|
||||||
const d = String(date.getDate()).padStart(2, '0');
|
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const y = String(date.getFullYear()).slice(-2);
|
|
||||||
return `${d}/${m}/${y}`;
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
<Layout title="md - lewis m.w.">
|
<Layout title="md - lewis m.w.">
|
||||||
|
|
||||||
{sortedCategories.map(category => (
|
{sortedCategories.map(category => (
|
||||||
<details open>
|
<details open>
|
||||||
<summary>{category}</summary>
|
<summary>{category}</summary>
|
||||||
<pre set:html={grouped[category].map(post => `<span class="muted">${formatDate(post.data.date)}</span> <a href="/md/${post.data.slug}">${post.data.title}</a>${post.data.pinned ? ' [pinned]' : ''}`).join('\n')} />
|
<pre set:html={grouped[category].map(post => `<span class="muted">${formatDate(post.data.date)}</span> <a href="/md/${getSlug(post.id)}">${post.data.title}</a>${post.data.pinned ? ' [pinned]' : ''}`).join('\n')} />
|
||||||
</details>
|
</details>
|
||||||
))}
|
))}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,18 @@
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import type { APIContext } from 'astro';
|
import type { APIContext } from 'astro';
|
||||||
|
import { getTxtFileNames } from '../lib/txt';
|
||||||
|
import { getSlug } from '../utils';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
export async function GET(context: APIContext) {
|
export async function GET(context: APIContext) {
|
||||||
const site = context.site?.origin ?? 'https://wynne.rs';
|
const site = context.site?.origin ?? 'https://wynne.rs';
|
||||||
const posts = await getCollection('posts', ({ data }) => data.draft !== true);
|
const posts = await getCollection('md', ({ data }) => data.draft !== true);
|
||||||
const bookmarks = await getCollection('bookmarks');
|
const bookmarks = await getCollection('bookmarks');
|
||||||
|
const txtFiles = getTxtFileNames();
|
||||||
const txtDir = path.join(process.cwd(), 'public/txt');
|
|
||||||
const txtFiles = fs.existsSync(txtDir)
|
|
||||||
? fs.readdirSync(txtDir).filter(file => file.endsWith('.txt'))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const urls = [
|
const urls = [
|
||||||
...posts.map(post => `/md/${post.id}`),
|
...posts.map(post => `/md/${getSlug(post.id)}`),
|
||||||
...txtFiles.map(txt => `/txt/${txt}`),
|
...txtFiles.map(txt => `/txt/${txt}`),
|
||||||
...bookmarks.map(b => b.data.url),
|
...bookmarks.map(b => b.data.url),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import type { APIContext } from 'astro';
|
import type { APIContext } from 'astro';
|
||||||
|
import { getTxtFileNames } from '../lib/txt';
|
||||||
|
import { getSlug } from '../utils';
|
||||||
|
|
||||||
const SUBDOMAINS = [
|
const SUBDOMAINS = [
|
||||||
'https://penfield.wynne.rs/',
|
'https://penfield.wynne.rs/',
|
||||||
|
|
@ -9,17 +9,13 @@ const SUBDOMAINS = [
|
||||||
|
|
||||||
export async function GET(context: APIContext) {
|
export async function GET(context: APIContext) {
|
||||||
const site = context.site?.origin ?? 'https://wynne.rs';
|
const site = context.site?.origin ?? 'https://wynne.rs';
|
||||||
const posts = await getCollection('posts', ({ data }) => data.draft !== true);
|
const posts = await getCollection('md', ({ data }) => data.draft !== true);
|
||||||
|
const txtFiles = getTxtFileNames();
|
||||||
const txtDir = path.join(process.cwd(), 'public/txt');
|
|
||||||
const txtFiles = fs.existsSync(txtDir)
|
|
||||||
? fs.readdirSync(txtDir).filter(file => file.endsWith('.txt'))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const urls = [
|
const urls = [
|
||||||
'/',
|
'/',
|
||||||
'/md',
|
'/md',
|
||||||
...posts.map(post => `/md/${post.id}`),
|
...posts.map(post => `/md/${getSlug(post.id)}`),
|
||||||
'/txt',
|
'/txt',
|
||||||
...txtFiles.map(txt => `/txt/${txt}`),
|
...txtFiles.map(txt => `/txt/${txt}`),
|
||||||
'/bookmarks',
|
'/bookmarks',
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,9 @@
|
||||||
---
|
---
|
||||||
import Layout from '../../layouts/Layout.astro';
|
import Layout from '../../layouts/Layout.astro';
|
||||||
import fs from 'node:fs';
|
import { formatDate } from '../../lib/format';
|
||||||
import path from 'node:path';
|
import { getTxtFiles } from '../../lib/txt';
|
||||||
import yaml from 'js-yaml';
|
|
||||||
import { getGitDate } from '../../utils';
|
|
||||||
|
|
||||||
interface TxtFile {
|
const txtFiles = getTxtFiles();
|
||||||
name: string;
|
|
||||||
date: Date;
|
|
||||||
pinned: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TxtConfig {
|
|
||||||
pinned?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const txtDir = path.join(process.cwd(), 'public/txt');
|
|
||||||
const configPath = path.join(txtDir, 'config.yaml');
|
|
||||||
const config: TxtConfig = fs.existsSync(configPath)
|
|
||||||
? yaml.load(fs.readFileSync(configPath, 'utf8')) as TxtConfig
|
|
||||||
: {};
|
|
||||||
const pinnedSet = new Set(config.pinned || []);
|
|
||||||
|
|
||||||
const txtFiles: TxtFile[] = fs.existsSync(txtDir)
|
|
||||||
? fs.readdirSync(txtDir)
|
|
||||||
.filter(file => file.endsWith('.txt'))
|
|
||||||
.map(name => {
|
|
||||||
const filePath = path.join(txtDir, name);
|
|
||||||
return { name, date: getGitDate(filePath), pinned: pinnedSet.has(name) };
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (a.pinned && !b.pinned) return -1;
|
|
||||||
if (!a.pinned && b.pinned) return 1;
|
|
||||||
return b.date.getTime() - a.date.getTime();
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
function formatDate(date: Date): string {
|
|
||||||
const d = String(date.getDate()).padStart(2, '0');
|
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const y = String(date.getFullYear()).slice(-2);
|
|
||||||
return `${d}/${m}/${y}`;
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
<Layout title="txt - lewis m.w.">
|
<Layout title="txt - lewis m.w.">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
export function getSlug(postId: string): string {
|
||||||
|
const parts = postId.split('/');
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
export function getGitDate(filePath: string): Date {
|
export function getGitDate(filePath: string): Date {
|
||||||
try {
|
try {
|
||||||
const timestamp = execSync(`git log -1 --format=%cI -- "${filePath}"`, { encoding: 'utf8' }).trim();
|
const timestamp = execSync(`git log -1 --format=%cI -- "${filePath}"`, { encoding: 'utf8' }).trim();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue