Compare commits

..

2 commits

Author SHA1 Message Date
lew
c0d1feaacd remove admin routes until TinyAuth is set up 2026-04-05 01:21:24 +01:00
lew
8a9c56c3d5 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
2026-04-05 01:04:11 +01:00
52 changed files with 44 additions and 7205 deletions

3
.gitignore vendored
View file

@ -1,6 +1,7 @@
node_modules node_modules
dist dist
.astro .astro
.vercel data
pnpm-lock.yaml pnpm-lock.yaml
**/.env **/.env
CLAUDE.md

View file

@ -1,7 +1,6 @@
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel'; import node from '@astrojs/node';
import db from '@astrojs/db'; import db from '@astrojs/db';
import auth from 'auth-astro';
import remarkDirective from 'remark-directive'; import remarkDirective from 'remark-directive';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import remarkSlug from 'remark-slug'; import remarkSlug from 'remark-slug';
@ -10,8 +9,8 @@ import remarkAside from './src/plugins/remark-aside.ts';
export default defineConfig({ export default defineConfig({
output: 'static', output: 'static',
adapter: vercel(), adapter: node({ mode: 'standalone' }),
integrations: [db(), auth()], integrations: [db()],
markdown: { markdown: {
remarkPlugins: [ remarkPlugins: [
remarkGfm, remarkGfm,

14
db/seed.ts Normal file
View file

@ -0,0 +1,14 @@
import { db, Guestbook } from 'astro:db';
export default async function seed() {
await db.insert(Guestbook).values([
{
id: 1,
name: 'test',
message: 'hello from dev',
url: null,
createdAt: new Date(),
approved: true,
},
]);
}

View file

@ -1,11 +1,24 @@
{ {
"name": "ily", "name": "wynne.rs",
"private": true, "type": "module",
"packageManager": "pnpm@10.28.0",
"scripts": { "scripts": {
"dev:penfield": "pnpm --filter @ily/penfield dev", "dev": "astro dev --port 4322",
"dev:www": "pnpm --filter @ily/www dev", "build": "ASTRO_DATABASE_FILE=data/guestbook.db astro build && node scripts/generate-stats.js",
"build:penfield": "pnpm --filter @ily/penfield build", "preview": "astro preview"
"build:www": "pnpm --filter @ily/www build" },
"dependencies": {
"@astrojs/db": "^0.20.1",
"@astrojs/node": "^10.0.4",
"@astrojs/rss": "^4.0.18",
"astro": "^6.1.3",
"js-yaml": "^4.1.1"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"remark-directive": "^3.0.0",
"remark-gfm": "^4.0.0",
"remark-slug": "^7.0.1",
"remark-smartypants": "^3.0.2",
"unist-util-visit": "^5.1.0"
} }
} }

View file

@ -1,5 +0,0 @@
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'static'
});

View file

@ -1,13 +0,0 @@
{
"name": "@ily/penfield",
"type": "module",
"packageManager": "pnpm@10.28.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"astro": "^5.16.13"
}
}

View file

@ -1,6 +0,0 @@
export const intros = [
{ text: "You wake up. Your <b>Penfield</b> thrums." },
{ text: "Your <b>Penfield Mood Organ</b> chimes." },
{ text: "Your <b>Penfield Mood Organ</b> wakes you." },
{ text: "You are awoken by your <b>Penfield Mood Organ</b>." },
];

View file

@ -1,17 +0,0 @@
export const moods = [
{ num: 3, text: "Desire to dial" },
{ num: 382, text: "Desire to engage in creative activity" },
{ num: 481, text: "Awareness of the manifold possibilities open in the future" },
{ num: 594, text: "Pleased acknowledgment of husband's superior wisdom" },
{ num: 670, text: "Long-deserved peace" },
{ num: 888, text: "Desire to watch TV, no matter what's on" },
{ num: 443, text: "Self-accusatory depression" },
{ num: 72, text: "Vague unease about tomorrow" },
{ num: 158, text: "Acceptance of routine" },
{ num: 291, text: "Brief contentment with material possessions" },
{ num: 407, text: "Suppressed awareness of mortality" },
{ num: 531, text: "Calm readiness to consume" },
{ num: 816, text: "Desire to return to bed" },
{ num: 952, text: "Resigned compliance" },
{ num: 64, text: "Faint hope that things will improve" },
];

View file

@ -1,11 +0,0 @@
---
import { intros } from '../data/intros';
import { moods } from '../data/moods';
const epoch = Math.floor(Date.now() / 3600000);
const start = Math.floor(new Date('2026-01-22T14:00:00Z').getTime() / 3600000);
const t = epoch; // for mood selection
const ring = epoch - start;
const intro: { text: string } = intros[t % intros.length];
const mood: { num: number, text: string } = moods[t % moods.length];
---
<!DOCTYPE html><link rel=icon href=data:,><style>body{margin:9%;text-align:center}</style><title>Penfield</title><p><b>Ring #{ring}.</b> <span set:html={intro.text} /></p><p><b>{mood.num}</b>. {mood.text}.</p>

View file

@ -1,5 +0,0 @@
{
"installCommand": "pnpm install",
"buildCommand": "pnpm --filter @ily/penfield build",
"outputDirectory": "dist"
}

View file

@ -1,3 +0,0 @@
packages:
- 'www'
- 'penfield'

View file

@ -18,7 +18,6 @@ const posts = fs.existsSync(postsDir)
let postWords = 0; let postWords = 0;
for (const post of posts) { for (const post of posts) {
const content = fs.readFileSync(path.join(postsDir, post), 'utf-8'); const content = fs.readFileSync(path.join(postsDir, post), 'utf-8');
// Remove frontmatter
const body = content.replace(/^---[\s\S]*?---/, ''); const body = content.replace(/^---[\s\S]*?---/, '');
postWords += countWords(body); postWords += countWords(body);
} }
@ -41,20 +40,19 @@ const bookmarks = fs.existsSync(bookmarksFile)
: []; : [];
// Guestbook count - read from built JSON file // Guestbook count - read from built JSON file
const guestbookJsonFile = path.join(root, '.vercel/output/static/guestbook-count.json'); const guestbookJsonFile = path.join(root, 'dist/client/guestbook-count.json');
let guestbookCount = 0; let guestbookCount = 0;
if (fs.existsSync(guestbookJsonFile)) { if (fs.existsSync(guestbookJsonFile)) {
const data = JSON.parse(fs.readFileSync(guestbookJsonFile, 'utf-8')); const data = JSON.parse(fs.readFileSync(guestbookJsonFile, 'utf-8'));
guestbookCount = data.count; guestbookCount = data.count;
} }
// Calculate totals (excluding stats.txt words for now, we'll add them after generating) // Calculate totals
const totalPages = 1 + posts.length + txtFiles.length; // home + individual post pages + txt files const totalPages = 1 + posts.length + txtFiles.length;
// Read template from public/stats.txt and replace placeholders // Read template from public/stats.txt and replace placeholders
const template = fs.readFileSync(path.join(root, 'public/stats.txt'), 'utf-8'); const template = fs.readFileSync(path.join(root, 'public/stats.txt'), 'utf-8');
// First pass: generate stats without stats.txt word count
let stats = template let stats = template
.replace('[PAGES]', totalPages.toString()) .replace('[PAGES]', totalPages.toString())
.replace('[POSTS]', posts.length.toString()) .replace('[POSTS]', posts.length.toString())
@ -62,15 +60,13 @@ let stats = template
.replace('[BOOKMARKS]', bookmarks.length.toString()) .replace('[BOOKMARKS]', bookmarks.length.toString())
.replace('[GUESTBOOK]', guestbookCount.toString()); .replace('[GUESTBOOK]', guestbookCount.toString());
// Count words in the stats file itself (before adding [WORDS])
const statsWords = countWords(stats.replace('[WORDS]', '0')); const statsWords = countWords(stats.replace('[WORDS]', '0'));
const totalWords = postWords + txtWords + statsWords; const totalWords = postWords + txtWords + statsWords;
// Final pass: replace [WORDS] with actual total
stats = stats.replace('[WORDS]', totalWords.toString()); stats = stats.replace('[WORDS]', totalWords.toString());
// Write to Vercel output // Write to build output
const outputDir = path.join(root, '.vercel/output/static'); const outputDir = path.join(root, 'dist/client');
if (!fs.existsSync(outputDir)) { if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(outputDir, { recursive: true });
} }

View file

View file

@ -1,25 +0,0 @@
import GitHub from '@auth/core/providers/github';
import { defineConfig } from 'auth-astro';
export default defineConfig({
providers: [
GitHub({
clientId: import.meta.env.GITHUB_CLIENT_ID,
clientSecret: import.meta.env.GITHUB_CLIENT_SECRET,
}),
],
callbacks: {
jwt({ token, account, profile }) {
if (account && profile) {
token.id = profile.id;
}
return token;
},
session({ session, token }) {
if (session.user && token.id) {
(session.user as any).id = String(token.id);
}
return session;
},
},
});

View file

@ -1,54 +0,0 @@
import { db, Guestbook } from 'astro:db';
export default async function seed() {
await db.insert(Guestbook).values([
{
id: 1,
name: 'lisek',
message: ':)',
url: null,
createdAt: new Date('2026-03-23'),
approved: true,
},
{
id: 2,
name: 'stripes',
message: 'yay signing',
url: null,
createdAt: new Date('2026-03-21'),
approved: true,
},
{
id: 4,
name: 'Evan',
message: 'Queue has four silent letters o_O',
url: null,
createdAt: new Date('2026-01-23'),
approved: true,
},
{
id: 5,
name: 'your good pal chev',
message: 'howdy howdy',
url: 'https://youtu.be/dQw4w9WgXcQ?si=lmJDP_U9yTySGD-_',
createdAt: new Date('2025-11-19'),
approved: true,
},
{
id: 6,
name: 'Farofa',
message: 'Thinking on what to write holdon',
url: null,
createdAt: new Date('2025-11-03'),
approved: true,
},
{
id: 10,
name: 'luna',
message: 'we love lewis from primal gaming',
url: null,
createdAt: new Date('2025-08-23'),
approved: true,
},
]);
}

6861
www/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,27 +0,0 @@
{
"name": "@ily/www",
"type": "module",
"scripts": {
"dev": "astro dev --port 4322",
"build": "astro build --remote && node scripts/generate-stats.js",
"preview": "astro preview",
"serve": "pnpm build && npx serve .vercel/output/static -l 4322"
},
"dependencies": {
"@astrojs/db": "^0.19.0",
"@astrojs/rss": "^4.0.15",
"@astrojs/vercel": "^9.0.4",
"@auth/core": "^0.37.4",
"astro": "^5.16.13",
"auth-astro": "^4.2.0",
"js-yaml": "^4.1.1"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"remark-directive": "^3.0.0",
"remark-gfm": "^4.0.0",
"remark-slug": "^7.0.1",
"remark-smartypants": "^3.0.2",
"unist-util-visit": "^5.1.0"
}
}

View file

@ -1,26 +0,0 @@
import { getSession } from 'auth-astro/server';
export type Session = { user?: { id?: string; name?: string | null } };
export type AuthResult =
| { status: 'admin'; session: Session }
| { status: 'unauthenticated' }
| { status: 'forbidden' }
| { status: 'error' };
export async function getAdminSession(request: Request): Promise<AuthResult> {
let session: Session | null;
try {
session = await getSession(request);
} catch {
return { status: 'error' };
}
if (!session) return { status: 'unauthenticated' };
if (session.user?.id !== import.meta.env.ADMIN_GITHUB_ID) {
return { status: 'forbidden' };
}
return { status: 'admin', session };
}

View file

@ -1,77 +0,0 @@
---
export const prerender = false;
import { getPendingEntries, type GuestbookEntry } from '../lib/db';
import { getAdminSession } from '../lib/auth';
import Layout from '../layouts/Layout.astro';
import { formatDate } from '../lib/format';
const auth = await getAdminSession(Astro.request);
if (auth.status === 'error') return new Response('Auth not configured', { status: 500 });
if (auth.status === 'unauthenticated') return Astro.redirect('/api/auth/signin');
if (auth.status !== 'admin') return new Response('Forbidden', { status: 403 });
const { session } = auth;
let entries: GuestbookEntry[] = [];
try {
entries = await getPendingEntries();
} catch {
// handle error
}
---
<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>

View file

@ -1,18 +0,0 @@
import type { APIRoute } from 'astro';
import { jsonResponse, errorResponse } from '../../lib/api';
import { getAdminSession } from '../../lib/auth';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const auth = await getAdminSession(request);
if (auth.status !== 'admin') return errorResponse('Unauthorized', 403);
const hookUrl = import.meta.env.VERCEL_DEPLOY_HOOK;
if (!hookUrl) return errorResponse('Deploy hook not configured', 500);
const res = await fetch(hookUrl, { method: 'POST' });
if (!res.ok) return errorResponse('Failed to trigger deploy', 502);
return jsonResponse({ success: true });
};

View file

@ -1,28 +0,0 @@
import type { APIRoute } from 'astro';
import { approveEntry, deleteEntry } from '../../../lib/db';
import { jsonResponse, errorResponse } from '../../../lib/api';
import { getAdminSession } from '../../../lib/auth';
export const prerender = false;
export const PATCH: APIRoute = async ({ params, request }) => {
const auth = await getAdminSession(request);
if (auth.status !== 'admin') return errorResponse('Unauthorized', 403);
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, request }) => {
const auth = await getAdminSession(request);
if (auth.status !== 'admin') return errorResponse('Unauthorized', 403);
const id = parseInt(params.id!, 10);
if (isNaN(id)) return errorResponse('Invalid ID', 400);
await deleteEntry(id);
return jsonResponse({ success: true });
};

View file

@ -1,8 +0,0 @@
{
"installCommand": "pnpm install",
"buildCommand": "pnpm --filter @ily/www build",
"outputDirectory": "dist",
"redirects": [
{ "source": "/txt/now.txt", "destination": "/now.txt", "permanent": true }
]
}