refactor: moves apps outside of apps/ path
This commit is contained in:
parent
b2d1a5ae9e
commit
c85e2e2357
45 changed files with 4 additions and 3 deletions
1
www/src/content
Submodule
1
www/src/content
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit b0e2b5104e32f8cbfabc698415fbb9478d02534c
|
||||
31
www/src/content.config.ts
Normal file
31
www/src/content.config.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { defineCollection } from 'astro:content';
|
||||
import { glob, file } from 'astro/loaders';
|
||||
import { z } from 'astro/zod';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
const posts = defineCollection({
|
||||
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
date: z.coerce.date(),
|
||||
pinned: z.boolean().optional(),
|
||||
category: z.string().optional(),
|
||||
draft: z.boolean().optional(),
|
||||
})
|
||||
});
|
||||
|
||||
const bookmarks = defineCollection({
|
||||
loader: file('./src/content/bookmarks.yaml', {
|
||||
parser: (text) => {
|
||||
const data = yaml.load(text) as Array<Record<string, unknown>>;
|
||||
return data.map((item, i) => ({ id: String(i), ...item }));
|
||||
},
|
||||
}),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
url: z.string().url(),
|
||||
date: z.coerce.date(),
|
||||
})
|
||||
});
|
||||
|
||||
export const collections = { posts, bookmarks };
|
||||
10
www/src/env.d.ts
vendored
Normal file
10
www/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/// <reference types="astro/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VERCEL_DEPLOY_HOOK: string;
|
||||
readonly ADMIN_GITHUB_ID: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
23
www/src/layouts/Layout.astro
Normal file
23
www/src/layouts/Layout.astro
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
showHeader?: boolean;
|
||||
isHome?: boolean;
|
||||
}
|
||||
|
||||
const { title, showHeader = true, isHome = false } = Astro.props;
|
||||
---
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>{title}</title><link rel="alternate" type="application/rss+xml" title="wynne.rs" href="/feed.xml" /></head>
|
||||
<body>
|
||||
{showHeader && (
|
||||
<header>
|
||||
<pre>{isHome ? 'lewis m.w.' : <a href="/">lewis m.w.</a>} <a href="mailto:lewis@wynne.rs">mail</a> <a href="https://github.com/llywelwyn">gh</a> <a href="/feed.xml">rss</a> <a href="/sitemap.txt">sitemap</a> <a href="/random">random</a></pre>
|
||||
</header>
|
||||
)}
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
3
www/src/lib/auth.ts
Normal file
3
www/src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function isAdmin(userId: string | undefined): boolean {
|
||||
return userId === import.meta.env.ADMIN_GITHUB_ID;
|
||||
}
|
||||
29
www/src/lib/db.ts
Normal file
29
www/src/lib/db.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { db, Guestbook, eq, desc } from 'astro:db';
|
||||
|
||||
export type GuestbookEntry = typeof Guestbook.$inferSelect;
|
||||
|
||||
export async function getApprovedEntries(): Promise<GuestbookEntry[]> {
|
||||
return db.select().from(Guestbook).where(eq(Guestbook.approved, true)).orderBy(desc(Guestbook.createdAt));
|
||||
}
|
||||
|
||||
export async function getPendingEntries(): Promise<GuestbookEntry[]> {
|
||||
return db.select().from(Guestbook).where(eq(Guestbook.approved, false)).orderBy(desc(Guestbook.createdAt));
|
||||
}
|
||||
|
||||
export async function createEntry(name: string, message: string, url: string | null): Promise<void> {
|
||||
await db.insert(Guestbook).values({
|
||||
name,
|
||||
message,
|
||||
url,
|
||||
createdAt: new Date(),
|
||||
approved: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveEntry(id: number): Promise<void> {
|
||||
await db.update(Guestbook).set({ approved: true }).where(eq(Guestbook.id, id));
|
||||
}
|
||||
|
||||
export async function deleteEntry(id: number): Promise<void> {
|
||||
await db.delete(Guestbook).where(eq(Guestbook.id, id));
|
||||
}
|
||||
93
www/src/pages/admin.astro
Normal file
93
www/src/pages/admin.astro
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
export const prerender = false;
|
||||
|
||||
import { getSession } from 'auth-astro/server';
|
||||
import { getPendingEntries, type GuestbookEntry } from '../lib/db';
|
||||
import { isAdmin } from '../lib/auth';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = await getSession(Astro.request);
|
||||
} catch {
|
||||
return new Response('Auth not configured', { status: 500 });
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return Astro.redirect('/api/auth/signin');
|
||||
}
|
||||
|
||||
if (!isAdmin(session.user?.id)) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
let entries: GuestbookEntry[] = [];
|
||||
try {
|
||||
entries = await getPendingEntries();
|
||||
} catch {
|
||||
// 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}>
|
||||
|
||||
<h1>guestbook admin</h1>
|
||||
<p>logged in as {session.user?.name} <a href="/api/auth/signout">sign out</a></p>
|
||||
|
||||
<p><button id="deploy">redeploy site</button> <span id="deploy-status"></span></p>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p class="muted">no pending entries</p>
|
||||
) : (
|
||||
<ul id="entries">
|
||||
{entries.map(e => (
|
||||
<li data-id={e.id}>
|
||||
<span class="muted">{formatDate(e.createdAt)}</span>
|
||||
<strong>{e.name}</strong>
|
||||
{e.url && <a href={e.url}>{e.url}</a>}
|
||||
<p>{e.message}</p>
|
||||
<button class="approve">approve</button>
|
||||
<button class="reject">reject</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<script>
|
||||
document.getElementById('deploy')?.addEventListener('click', async (e) => {
|
||||
const btn = e.target as HTMLButtonElement;
|
||||
const status = document.getElementById('deploy-status');
|
||||
btn.disabled = true;
|
||||
if (status) status.textContent = 'deploying...';
|
||||
|
||||
const res = await fetch('/api/deploy', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
if (status) status.textContent = 'deploy triggered!';
|
||||
} else {
|
||||
const data = await res.json();
|
||||
if (status) status.textContent = data.error || 'deploy failed';
|
||||
}
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
document.querySelectorAll('#entries li').forEach(li => {
|
||||
const id = li.getAttribute('data-id');
|
||||
|
||||
li.querySelector('.approve')?.addEventListener('click', async () => {
|
||||
const res = await fetch(`/api/guestbook/${id}`, { method: 'PATCH' });
|
||||
if (res.ok) li.remove();
|
||||
});
|
||||
|
||||
li.querySelector('.reject')?.addEventListener('click', async () => {
|
||||
const res = await fetch(`/api/guestbook/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) li.remove();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</Layout>
|
||||
36
www/src/pages/api/deploy.ts
Normal file
36
www/src/pages/api/deploy.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { getSession } from 'auth-astro/server';
|
||||
import { isAdmin } from '../../lib/auth';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const session = await getSession(request);
|
||||
|
||||
if (!session?.user?.id || !isAdmin(session.user.id)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const hookUrl = import.meta.env.VERCEL_DEPLOY_HOOK;
|
||||
if (!hookUrl) {
|
||||
return new Response(JSON.stringify({ error: 'Deploy hook not configured' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch(hookUrl, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to trigger deploy' }), {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
30
www/src/pages/api/guestbook.ts
Normal file
30
www/src/pages/api/guestbook.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { createEntry } from '../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const { name, message, url } = data;
|
||||
|
||||
if (!name || !message) {
|
||||
return new Response(JSON.stringify({ error: 'Name and message are required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
await createEntry(name.slice(0, 100), message.slice(0, 500), url?.slice(0, 200) || null);
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to create entry' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
||||
54
www/src/pages/api/guestbook/[id].ts
Normal file
54
www/src/pages/api/guestbook/[id].ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { getSession } from 'auth-astro/server';
|
||||
import { approveEntry, deleteEntry } from '../../../lib/db';
|
||||
import { isAdmin } from '../../../lib/auth';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const PATCH: APIRoute = async ({ params, request }) => {
|
||||
const session = await getSession(request);
|
||||
|
||||
if (!session?.user?.id || !isAdmin(session.user.id)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const id = parseInt(params.id!, 10);
|
||||
if (isNaN(id)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid ID' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
await approveEntry(id);
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params, request }) => {
|
||||
const session = await getSession(request);
|
||||
|
||||
if (!session?.user?.id || !isAdmin(session.user.id)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const id = parseInt(params.id!, 10);
|
||||
if (isNaN(id)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid ID' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
await deleteEntry(id);
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
31
www/src/pages/bookmarks/index.astro
Normal file
31
www/src/pages/bookmarks/index.astro
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
|
||||
const bookmarksCollection = await getCollection('bookmarks');
|
||||
const bookmarks = bookmarksCollection
|
||||
.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.">
|
||||
|
||||
<details open>
|
||||
<summary>bookmarks</summary>
|
||||
<pre set:html={bookmarks.map(b => `<span class="muted">${formatDate(b.data.date)}</span> <a href="${b.data.url}">${b.data.title}</a> <span class="muted">(${extractDomain(b.data.url)})</span>`).join('\n')} />
|
||||
</details>
|
||||
</Layout>
|
||||
48
www/src/pages/draft/[slug].astro
Normal file
48
www/src/pages/draft/[slug].astro
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
export const prerender = false;
|
||||
|
||||
import { getSession } from 'auth-astro/server';
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import { isAdmin } from '../../lib/auth';
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = await getSession(Astro.request);
|
||||
} catch {
|
||||
return new Response('Auth not configured', { status: 500 });
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return Astro.redirect('/api/auth/signin');
|
||||
}
|
||||
|
||||
if (!isAdmin(session.user?.id)) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const slug = Astro.params.slug;
|
||||
const posts = await getCollection('posts', ({ data }) => data.draft === true);
|
||||
const post = posts.find(p => p.id === slug);
|
||||
|
||||
if (!post) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
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.`}>
|
||||
|
||||
<article>
|
||||
<h1>{post.data.title}</h1>
|
||||
<p class="muted" style="margin-top: 0;">{formatDate(post.data.date)}</p>
|
||||
<Content />
|
||||
</article>
|
||||
</Layout>
|
||||
71
www/src/pages/draft/index.astro
Normal file
71
www/src/pages/draft/index.astro
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
export const prerender = false;
|
||||
|
||||
import { getSession } from 'auth-astro/server';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isAdmin } from '../../lib/auth';
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = await getSession(Astro.request);
|
||||
} catch {
|
||||
return new Response('Auth not configured', { status: 500 });
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return Astro.redirect('/api/auth/signin');
|
||||
}
|
||||
|
||||
if (!isAdmin(session.user?.id)) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
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.">
|
||||
|
||||
<p class="muted">logged in as {session.user?.name} <a href="/api/auth/signout">sign out</a></p>
|
||||
|
||||
{sortedCategories.length === 0 ? (
|
||||
<p class="muted">no drafts</p>
|
||||
) : (
|
||||
sortedCategories.map(category => (
|
||||
<details open>
|
||||
<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')} />
|
||||
</details>
|
||||
))
|
||||
)}
|
||||
</Layout>
|
||||
54
www/src/pages/feed.xml.ts
Normal file
54
www/src/pages/feed.xml.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import rss from '@astrojs/rss';
|
||||
import { getCollection } from 'astro:content';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { APIContext } from 'astro';
|
||||
import { getGitDate } from '../utils';
|
||||
|
||||
interface TxtFile {
|
||||
name: string;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
const posts = await getCollection('posts', ({ data }) => data.draft !== true);
|
||||
const bookmarks = await getCollection('bookmarks');
|
||||
|
||||
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 = [
|
||||
...posts.map(post => ({
|
||||
title: post.data.title,
|
||||
pubDate: post.data.date,
|
||||
link: `/md/${post.id}`,
|
||||
description: post.data.title,
|
||||
})),
|
||||
...txtFiles.map(txt => ({
|
||||
title: txt.name,
|
||||
pubDate: txt.date,
|
||||
link: `/txt/${txt.name}`,
|
||||
description: txt.name,
|
||||
})),
|
||||
...bookmarks.map(b => ({
|
||||
title: b.data.title,
|
||||
pubDate: b.data.date,
|
||||
link: b.data.url,
|
||||
description: b.data.title,
|
||||
})),
|
||||
].sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
|
||||
|
||||
return rss({
|
||||
title: 'wynne.rs',
|
||||
description: '',
|
||||
site: context.site ?? 'https://wynne.rs',
|
||||
items,
|
||||
});
|
||||
}
|
||||
17
www/src/pages/guestbook-count.json.ts
Normal file
17
www/src/pages/guestbook-count.json.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { getApprovedEntries } from '../lib/db';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
let count = 0;
|
||||
try {
|
||||
const entries = await getApprovedEntries();
|
||||
count = entries.length;
|
||||
} catch {
|
||||
// DB not available
|
||||
}
|
||||
return new Response(JSON.stringify({ count }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
67
www/src/pages/guestbook/index.astro
Normal file
67
www/src/pages/guestbook/index.astro
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import { getApprovedEntries, type GuestbookEntry } from '../../lib/db';
|
||||
|
||||
let guestbookEntries: GuestbookEntry[] = [];
|
||||
try {
|
||||
guestbookEntries = await getApprovedEntries();
|
||||
} catch {
|
||||
// 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.">
|
||||
|
||||
<details open>
|
||||
<summary>guestbook</summary>
|
||||
<div class="guestbook-entries">
|
||||
{guestbookEntries.map(e => (
|
||||
<div class="guestbook-entry">
|
||||
<span class="muted">{formatDate(e.createdAt)}</span>
|
||||
<span><b set:html={e.url ? `<a href="${e.url}">${e.name}</a>` : e.name} /> {e.message}</span>
|
||||
</div>
|
||||
))}
|
||||
<div class="guestbook-entry">
|
||||
<span></span>
|
||||
<span><a href="#" id="sign-guestbook">sign</a><span id="guestbook-status"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<script>
|
||||
document.getElementById('sign-guestbook')?.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const status = document.getElementById('guestbook-status')!;
|
||||
|
||||
const name = prompt('name:');
|
||||
if (!name) return;
|
||||
|
||||
const message = prompt('message:');
|
||||
if (!message) return;
|
||||
|
||||
const url = prompt('url (optional):');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/guestbook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, message, url: url || null }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
status.textContent = ' thanks! pending approval.';
|
||||
} else {
|
||||
status.textContent = ' error';
|
||||
}
|
||||
} catch {
|
||||
status.textContent = ' failed';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</Layout>
|
||||
172
www/src/pages/index.astro
Normal file
172
www/src/pages/index.astro
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
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 { getGitDate } from '../utils';
|
||||
|
||||
interface TxtFile {
|
||||
name: string;
|
||||
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 bookmarks = bookmarksCollection
|
||||
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||
|
||||
// Auto-discover txt files from public/txt/
|
||||
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[] = [];
|
||||
try {
|
||||
guestbookEntries = await getApprovedEntries();
|
||||
} catch {
|
||||
// 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>
|
||||
|
||||
{sortedCategories.map(category => {
|
||||
const categoryPosts = grouped[category];
|
||||
return (
|
||||
<details open>
|
||||
<summary>{category}</summary>
|
||||
<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.length > 10 ? [`<a href="/md/">+${categoryPosts.length - 10} more</a>`] : [])
|
||||
].join('\n')} />
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
|
||||
<details open>
|
||||
<summary>txt</summary>
|
||||
<pre set:html={[
|
||||
...txtFiles.slice(0, 10).map(f => `<span class="muted">${formatDate(f.date)}</span> <a href="/txt/${f.name}">${f.name}</a>${f.pinned ? ' [pinned]' : ''}`),
|
||||
...(txtFiles.length > 10 ? [`<a href="/txt/">+${txtFiles.length - 10} more</a>`] : [])
|
||||
].join('\n')} />
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>bookmarks</summary>
|
||||
<pre set:html={[
|
||||
...bookmarks.slice(0, 10).map(b => `<span class="muted">${formatDate(b.data.date)}</span> <a href="${b.data.url}">${b.data.title}</a> <span class="muted">(${extractDomain(b.data.url)})</span>`),
|
||||
...(bookmarks.length > 10 ? [`<a href="/bookmarks/">+${bookmarks.length - 10} more</a>`] : [])
|
||||
].join('\n')} />
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>guestbook</summary>
|
||||
<div class="guestbook-entries">
|
||||
{guestbookEntries.slice(0, 10).map(e => (
|
||||
<div class="guestbook-entry">
|
||||
<span class="muted">{formatDate(e.createdAt)}</span>
|
||||
<span><b set:html={e.url ? `<a href="${e.url}">${e.name}</a>` : e.name} /> {e.message}</span>
|
||||
</div>
|
||||
))}
|
||||
<div class="guestbook-entry">
|
||||
<span>{guestbookEntries.length > 10 && <a href="/guestbook/">+{guestbookEntries.length - 10} more</a>}</span>
|
||||
<span><a href="#" id="sign-guestbook">sign</a><span id="guestbook-status"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<script>
|
||||
document.getElementById('sign-guestbook')?.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const status = document.getElementById('guestbook-status')!;
|
||||
|
||||
const name = prompt('name:');
|
||||
if (!name) return;
|
||||
|
||||
const message = prompt('message:');
|
||||
if (!message) return;
|
||||
|
||||
const url = prompt('url (optional):');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/guestbook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, message, url: url || null }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
status.textContent = ' thanks! pending approval.';
|
||||
} else {
|
||||
status.textContent = ' error';
|
||||
}
|
||||
} catch {
|
||||
status.textContent = ' failed';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</Layout>
|
||||
30
www/src/pages/md/[slug].astro
Normal file
30
www/src/pages/md/[slug].astro
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('posts', ({ data }) => data.draft !== true);
|
||||
return posts.map(post => ({
|
||||
params: { slug: post.id },
|
||||
props: { post }
|
||||
}));
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
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.`}>
|
||||
|
||||
<article>
|
||||
<h1>{post.data.title}</h1>
|
||||
<p class="muted" style="margin-top: 0;">{formatDate(post.data.date)}</p>
|
||||
<Content />
|
||||
</article>
|
||||
</Layout>
|
||||
46
www/src/pages/md/index.astro
Normal file
46
www/src/pages/md/index.astro
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
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.">
|
||||
|
||||
{sortedCategories.map(category => (
|
||||
<details open>
|
||||
<summary>{category}</summary>
|
||||
<pre set:html={grouped[category].map(post => `<span class="muted">${formatDate(post.data.date)}</span> <a href="/md/${post.id}">${post.data.title}</a>${post.data.pinned ? ' [pinned]' : ''}`).join('\n')} />
|
||||
</details>
|
||||
))}
|
||||
</Layout>
|
||||
28
www/src/pages/random.ts
Normal file
28
www/src/pages/random.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { getCollection } from 'astro:content';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { APIContext } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
const site = context.site?.origin ?? 'https://wynne.rs';
|
||||
const posts = await getCollection('posts', ({ data }) => data.draft !== true);
|
||||
const bookmarks = await getCollection('bookmarks');
|
||||
|
||||
const txtDir = path.join(process.cwd(), 'public/txt');
|
||||
const txtFiles = fs.existsSync(txtDir)
|
||||
? fs.readdirSync(txtDir).filter(file => file.endsWith('.txt'))
|
||||
: [];
|
||||
|
||||
const urls = [
|
||||
...posts.map(post => `/md/${post.id}`),
|
||||
...txtFiles.map(txt => `/txt/${txt}`),
|
||||
...bookmarks.map(b => b.data.url),
|
||||
];
|
||||
|
||||
const random = urls[Math.floor(Math.random() * urls.length)];
|
||||
const redirectUrl = random.startsWith('http') ? random : `${site}${random}`;
|
||||
|
||||
return Response.redirect(redirectUrl, 302);
|
||||
}
|
||||
32
www/src/pages/sitemap.txt.ts
Normal file
32
www/src/pages/sitemap.txt.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { getCollection } from 'astro:content';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { APIContext } from 'astro';
|
||||
|
||||
const SUBDOMAINS = [
|
||||
'https://penfield.wynne.rs/',
|
||||
];
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
const site = context.site?.origin ?? 'https://wynne.rs';
|
||||
const posts = await getCollection('posts', ({ data }) => data.draft !== true);
|
||||
|
||||
const txtDir = path.join(process.cwd(), 'public/txt');
|
||||
const txtFiles = fs.existsSync(txtDir)
|
||||
? fs.readdirSync(txtDir).filter(file => file.endsWith('.txt'))
|
||||
: [];
|
||||
|
||||
const urls = [
|
||||
'/',
|
||||
'/md',
|
||||
...posts.map(post => `/md/${post.id}`),
|
||||
'/txt',
|
||||
...txtFiles.map(txt => `/txt/${txt}`),
|
||||
'/bookmarks',
|
||||
'/guestbook',
|
||||
].map(p => `${site}${p}`);
|
||||
|
||||
return new Response([...urls, ...SUBDOMAINS].join('\n'), {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
52
www/src/pages/txt/index.astro
Normal file
52
www/src/pages/txt/index.astro
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
import { getGitDate } from '../../utils';
|
||||
|
||||
interface TxtFile {
|
||||
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.">
|
||||
|
||||
<details open>
|
||||
<summary>txt</summary>
|
||||
<pre set:html={txtFiles.map(f => `<span class="muted">${formatDate(f.date)}</span> <a href="/txt/${f.name}">${f.name}</a>${f.pinned ? ' [pinned]' : ''}`).join('\n')} />
|
||||
</details>
|
||||
</Layout>
|
||||
49
www/src/plugins/remark-aside.ts
Normal file
49
www/src/plugins/remark-aside.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { visit } from 'unist-util-visit';
|
||||
import type { Root } from 'mdast';
|
||||
|
||||
function nodeToText(node: any): string {
|
||||
if (node.type === 'text') return node.value;
|
||||
if (node.children) return node.children.map(nodeToText).join('');
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function remarkAside() {
|
||||
return (tree: Root) => {
|
||||
visit(tree, ['textDirective', 'leafDirective'], (node: any) => {
|
||||
if (node.name !== 'left' && node.name !== 'right') return;
|
||||
const data = node.data || (node.data = {});
|
||||
data.hName = 'span';
|
||||
data.hProperties = { className: [node.name] };
|
||||
});
|
||||
|
||||
visit(tree, 'containerDirective', (node: any) => {
|
||||
if (node.name !== 'grid') return;
|
||||
let html = '<div class="grid">';
|
||||
for (const child of node.children) {
|
||||
if (child.data?.directiveLabel) continue;
|
||||
if (child.type === 'paragraph' && child.children?.length > 0) {
|
||||
const firstChild = child.children[0];
|
||||
if (firstChild?.type === 'textDirective' && firstChild.name === 'label') {
|
||||
const labelText = nodeToText(firstChild);
|
||||
const href = firstChild.attributes?.href;
|
||||
const contentText = child.children
|
||||
.slice(1)
|
||||
.map(nodeToText)
|
||||
.join('')
|
||||
.replace(/^\s+/, '');
|
||||
if (href) {
|
||||
html += `<a class="link" href="${href}">${labelText}</a>`;
|
||||
} else {
|
||||
html += `<span class="label">${labelText}</span>`;
|
||||
}
|
||||
html += `<div class="content">${contentText}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
node.type = 'html';
|
||||
node.value = html;
|
||||
delete node.children;
|
||||
});
|
||||
};
|
||||
}
|
||||
96
www/src/styles/global.css
Normal file
96
www/src/styles/global.css
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
body {
|
||||
max-width: 38rem;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.left, .right {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (min-width: 63rem) {
|
||||
.left, .right {
|
||||
display: inline;
|
||||
position: relative;
|
||||
width: 10rem;
|
||||
margin-top: -2.25em;
|
||||
}
|
||||
.left {
|
||||
float: left;
|
||||
margin-left: -12rem;
|
||||
}
|
||||
.right {
|
||||
float: right;
|
||||
margin-right: -12rem;
|
||||
}
|
||||
}
|
||||
|
||||
div.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(2.375rem, min-content) 1fr;
|
||||
gap: 0.375rem 0.75rem;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 2rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
div.grid .label,
|
||||
div.grid .link {
|
||||
display: block;
|
||||
margin-top: 0.375rem;
|
||||
margin-block: 0;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
div.grid .label {
|
||||
text-align: right;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
div.grid .link {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
div.grid .content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
details {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
details pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.guestbook-entries {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.guestbook-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 8ch 1fr;
|
||||
gap: 0 4ch;
|
||||
}
|
||||
10
www/src/utils.ts
Normal file
10
www/src/utils.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { execSync } from 'node:child_process';
|
||||
|
||||
export function getGitDate(filePath: string): Date {
|
||||
try {
|
||||
const timestamp = execSync(`git log -1 --format=%cI -- "${filePath}"`, { encoding: 'utf8' }).trim();
|
||||
return timestamp ? new Date(timestamp) : new Date(0);
|
||||
} catch {
|
||||
return new Date(0);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue