flatten monorepo, migrate off Vercel
- Remove penfield (split to own repo on Forgejo) - Move www/ contents to root, rename to wynne.rs - Swap @astrojs/vercel for @astrojs/node, upgrade to Astro 6 - Remove auth-astro/GitHub OAuth (TinyAuth at proxy layer) - Remove Vercel deploy webhook - Switch to local SQLite DB (drop Turso) - Update generate-stats.js for new build output paths
This commit is contained in:
parent
f2acf36784
commit
8a9c56c3d5
52 changed files with 45 additions and 7135 deletions
6
src/pages/404.astro
Normal file
6
src/pages/404.astro
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
---
|
||||
<Layout title="404 - lewis m.w.">
|
||||
<p>not found</p>
|
||||
</Layout>
|
||||
33
src/pages/[slug].astro
Normal file
33
src/pages/[slug].astro
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { formatDate, formatListItem, excerpt, wordCount } from '../lib/format';
|
||||
import { getSlug, resolveRelatedPosts, type Post } from '../lib/posts';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const allPosts = await getCollection('posts');
|
||||
return allPosts.map(post => ({
|
||||
params: { slug: getSlug(post.id) },
|
||||
props: { post, allPosts }
|
||||
}));
|
||||
}
|
||||
|
||||
const { post, allPosts } = Astro.props;
|
||||
const { Content } = await render(post);
|
||||
const related = post.data.related ? resolveRelatedPosts(post.data.related, allPosts) : [];
|
||||
const description = excerpt((post as Post).body) || undefined;
|
||||
---
|
||||
<Layout title={`${post.data.title} - lewis m.w.`} description={description}>
|
||||
|
||||
<article>
|
||||
<h1>{post.data.title}</h1>
|
||||
<p class="muted" style="margin-top: 0;">{formatDate(post.data.date)}{post.data.updated && ` (updated ${formatDate(post.data.updated)})`} · {wordCount((post as Post).body)}{post.data.category && ` · ${post.data.category}`}</p>
|
||||
<Content />
|
||||
</article>
|
||||
{related.length > 0 && (
|
||||
<section>
|
||||
<span class="section-label">related</span>
|
||||
<pre set:html={related.map(p => formatListItem(p.data.date, `/${getSlug(p.id)}`, p.data.title)).join('\n')} />
|
||||
</section>
|
||||
)}
|
||||
</Layout>
|
||||
51
src/pages/admin.astro
Normal file
51
src/pages/admin.astro
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
export const prerender = false;
|
||||
|
||||
import { getPendingEntries, type GuestbookEntry } from '../lib/db';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
let entries: GuestbookEntry[] = [];
|
||||
try {
|
||||
entries = await getPendingEntries();
|
||||
} catch {
|
||||
// handle error
|
||||
}
|
||||
---
|
||||
<Layout title="admin - guestbook" showHeader={false}>
|
||||
|
||||
<h1>guestbook admin</h1>
|
||||
|
||||
{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.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>
|
||||
28
src/pages/api/guestbook.ts
Normal file
28
src/pages/api/guestbook.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { createEntry } from '../../lib/db';
|
||||
import { jsonResponse, errorResponse } from '../../lib/api';
|
||||
import { isRateLimited } from '../../lib/rate-limit';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
||||
if (isRateLimited(ip, 3, 60_000)) {
|
||||
return errorResponse('Too many requests, try again later', 429);
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const { name, message, url } = data;
|
||||
|
||||
if (!name || !message) {
|
||||
return errorResponse('Name and message are required', 400);
|
||||
}
|
||||
|
||||
await createEntry(name.slice(0, 100), message.slice(0, 500), url?.slice(0, 200) || null);
|
||||
|
||||
return jsonResponse({ success: true }, 201);
|
||||
} catch {
|
||||
return errorResponse('Failed to create entry', 500);
|
||||
}
|
||||
};
|
||||
21
src/pages/api/guestbook/[id].ts
Normal file
21
src/pages/api/guestbook/[id].ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { approveEntry, deleteEntry } from '../../../lib/db';
|
||||
import { jsonResponse, errorResponse } from '../../../lib/api';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const PATCH: APIRoute = async ({ params }) => {
|
||||
const id = parseInt(params.id!, 10);
|
||||
if (isNaN(id)) return errorResponse('Invalid ID', 400);
|
||||
|
||||
await approveEntry(id);
|
||||
return jsonResponse({ success: true });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params }) => {
|
||||
const id = parseInt(params.id!, 10);
|
||||
if (isNaN(id)) return errorResponse('Invalid ID', 400);
|
||||
|
||||
await deleteEntry(id);
|
||||
return jsonResponse({ success: true });
|
||||
};
|
||||
33
src/pages/feed.xml.ts
Normal file
33
src/pages/feed.xml.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import rss from '@astrojs/rss';
|
||||
import { getCollection } from 'astro:content';
|
||||
import type { APIContext } from 'astro';
|
||||
import { getSlug, type Post } from '../lib/posts';
|
||||
import { getTxtFiles } from '../lib/txt';
|
||||
import { excerpt } from '../lib/format';
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
const posts = await getCollection('posts');
|
||||
const txtFiles = getTxtFiles();
|
||||
|
||||
const items = [
|
||||
...posts.map(post => ({
|
||||
title: post.data.title,
|
||||
pubDate: post.data.date,
|
||||
link: `/${getSlug(post.id)}`,
|
||||
description: excerpt((post as Post).body) || post.data.title,
|
||||
})),
|
||||
...txtFiles.map(txt => ({
|
||||
title: txt.name,
|
||||
pubDate: txt.date,
|
||||
link: `/${txt.name}`,
|
||||
description: txt.description || txt.name,
|
||||
})),
|
||||
].sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
|
||||
|
||||
return rss({
|
||||
title: 'wynne.rs',
|
||||
description: 'personal website of lewis m.w.',
|
||||
site: context.site ?? 'https://wynne.rs',
|
||||
items,
|
||||
});
|
||||
}
|
||||
17
src/pages/guestbook-count.json.ts
Normal file
17
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' },
|
||||
});
|
||||
};
|
||||
86
src/pages/index.astro
Normal file
86
src/pages/index.astro
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { getApprovedEntries, type GuestbookEntry } from '../lib/db';
|
||||
import { formatDate, formatListItem, extractDomain, wordCount, escapeHtml } from '../lib/format';
|
||||
import { organizePostsByCategory, getSlug } from '../lib/posts';
|
||||
import { getTxtFiles } from '../lib/txt';
|
||||
import { DEFAULT_CATEGORY, SECTIONS, SUBDOMAINS } from '../lib/consts';
|
||||
|
||||
const posts = await getCollection('posts');
|
||||
const { grouped, categories: sortedCategories } = organizePostsByCategory(posts);
|
||||
|
||||
const bookmarksCollection = await getCollection('bookmarks');
|
||||
const bookmarks = bookmarksCollection
|
||||
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||
|
||||
const txtFiles = getTxtFiles();
|
||||
|
||||
let guestbookEntries: GuestbookEntry[] = [];
|
||||
try {
|
||||
guestbookEntries = await getApprovedEntries();
|
||||
} catch {
|
||||
// DB not available during dev without env vars
|
||||
}
|
||||
|
||||
const urls = [
|
||||
...posts.map(post => ({ url: `/${getSlug(post.id)}`, date: post.data.date.getTime() })),
|
||||
...txtFiles.map(f => ({ url: `/${f.name}`, date: f.date.getTime() })),
|
||||
].sort((a, b) => b.date - a.date).map(e => e.url).concat(SUBDOMAINS);
|
||||
---
|
||||
<Layout title="lewis m.w." isHome urls={urls}>
|
||||
|
||||
{sortedCategories.map(category => {
|
||||
const categoryPosts = grouped[category];
|
||||
const isDefault = category === DEFAULT_CATEGORY;
|
||||
return (
|
||||
<section data-section={category}>
|
||||
{!isDefault && <a class="section-label" href={`?just=${category}`}>{category}</a>}
|
||||
<div class="entry-list" set:html={categoryPosts.map(post =>
|
||||
`<span class="entry">${formatListItem(post.data.date, `/${getSlug(post.id)}`, post.data.title, { suffix: wordCount(post.body) })}</span>`
|
||||
).join('')} />
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<section data-section={SECTIONS.files}>
|
||||
<a class="section-label" href={`?just=${SECTIONS.files}`}>{SECTIONS.files}</a>
|
||||
<div class="entry-list" set:html={txtFiles.map(f => {
|
||||
const name = f.name.replace(/\.txt$/, '');
|
||||
return `<span class="entry">${formatListItem(f.date, `/${f.name}`, name, { suffix: f.description })}</span>`;
|
||||
}).join('')} />
|
||||
</section>
|
||||
|
||||
<section data-section={SECTIONS.bookmarks}>
|
||||
<a class="section-label" href={`?just=${SECTIONS.bookmarks}`}>{SECTIONS.bookmarks}</a>
|
||||
<div class="entry-list" set:html={bookmarks.map(b =>
|
||||
`<span class="entry">${formatListItem(b.data.date, b.data.url, b.data.title, { suffix: extractDomain(b.data.url) })}</span>`
|
||||
).join('')} />
|
||||
</section>
|
||||
|
||||
<section data-section={SECTIONS.guestbook}>
|
||||
<a class="section-label" href={`?just=${SECTIONS.guestbook}`}>{SECTIONS.guestbook}</a>
|
||||
<div class="guestbook-entries" set:html={guestbookEntries.map(e => {
|
||||
const safeName = escapeHtml(e.name);
|
||||
const safeMessage = escapeHtml(e.message.replace(/\n/g, ' '));
|
||||
const nameHtml = e.url ? `<a href="${escapeHtml(e.url)}"><b>${safeName}</b></a>` : `<b>${safeName}</b>`;
|
||||
return `<span class="guestbook-entry"><span class="list-meta"><span class="muted">${formatDate(e.createdAt)}</span> </span><span>${nameHtml} ${safeMessage}</span></span>`;
|
||||
}).join('')} /></div>
|
||||
<form id="guestbook-form" class="guestbook-form">
|
||||
<label class="sr-only" for="gb-name">name</label>
|
||||
<input id="gb-name" type="text" name="name" placeholder="name" required maxlength="100" /><br />
|
||||
<label class="sr-only" for="gb-message">message</label>
|
||||
<input id="gb-message" type="text" name="message" placeholder="message" required maxlength="500" /><br />
|
||||
<label class="sr-only" for="gb-url">url</label>
|
||||
<input id="gb-url" type="url" name="url" placeholder="url (optional)" maxlength="200" /><br />
|
||||
<button type="submit">sign</button>
|
||||
<span id="guestbook-status"></span>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { initGuestbookForm } from '../scripts/guestbook-sign';
|
||||
initGuestbookForm();
|
||||
|
||||
</script>
|
||||
</Layout>
|
||||
21
src/pages/sitemap.txt.ts
Normal file
21
src/pages/sitemap.txt.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { getCollection } from 'astro:content';
|
||||
import type { APIContext } from 'astro';
|
||||
import { getSlug } from '../lib/posts';
|
||||
import { getTxtFiles } from '../lib/txt';
|
||||
import { SUBDOMAINS } from '../lib/consts';
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
const site = context.site?.origin ?? 'https://wynne.rs';
|
||||
const posts = await getCollection('posts');
|
||||
const txtFiles = getTxtFiles().map(f => f.name);
|
||||
|
||||
const urls = [
|
||||
'/',
|
||||
...posts.map(post => `/${getSlug(post.id)}`),
|
||||
...txtFiles.map(txt => `/${txt}`),
|
||||
].map(p => `${site}${p}`);
|
||||
|
||||
return new Response([...urls, ...SUBDOMAINS].join('\n'), {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue