Compare commits
No commits in common. "c0d1feaacde69cc0c1cc6b19138ab653a77f8db0" and "f2acf367848a6c04296579861c0dd8f096ba2567" have entirely different histories.
c0d1feaacd
...
f2acf36784
52 changed files with 7205 additions and 44 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,7 +1,6 @@
|
|||
node_modules
|
||||
dist
|
||||
.astro
|
||||
data
|
||||
.vercel
|
||||
pnpm-lock.yaml
|
||||
**/.env
|
||||
CLAUDE.md
|
||||
|
|
|
|||
14
db/seed.ts
14
db/seed.ts
|
|
@ -1,14 +0,0 @@
|
|||
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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
27
package.json
27
package.json
|
|
@ -1,24 +1,11 @@
|
|||
{
|
||||
"name": "wynne.rs",
|
||||
"type": "module",
|
||||
"name": "ily",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev --port 4322",
|
||||
"build": "ASTRO_DATABASE_FILE=data/guestbook.db astro build && node scripts/generate-stats.js",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"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"
|
||||
"dev:penfield": "pnpm --filter @ily/penfield dev",
|
||||
"dev:www": "pnpm --filter @ily/www dev",
|
||||
"build:penfield": "pnpm --filter @ily/penfield build",
|
||||
"build:www": "pnpm --filter @ily/www build"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
penfield/astro.config.mjs
Normal file
5
penfield/astro.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
|
||||
export default defineConfig({
|
||||
output: 'static'
|
||||
});
|
||||
13
penfield/package.json
Normal file
13
penfield/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
6
penfield/src/data/intros.ts
Normal file
6
penfield/src/data/intros.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
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>." },
|
||||
];
|
||||
17
penfield/src/data/moods.ts
Normal file
17
penfield/src/data/moods.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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" },
|
||||
];
|
||||
11
penfield/src/pages/index.astro
Normal file
11
penfield/src/pages/index.astro
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
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>
|
||||
5
penfield/vercel.json
Normal file
5
penfield/vercel.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"installCommand": "pnpm install",
|
||||
"buildCommand": "pnpm --filter @ily/penfield build",
|
||||
"outputDirectory": "dist"
|
||||
}
|
||||
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
packages:
|
||||
- 'www'
|
||||
- 'penfield'
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
import vercel from '@astrojs/vercel';
|
||||
import db from '@astrojs/db';
|
||||
import auth from 'auth-astro';
|
||||
import remarkDirective from 'remark-directive';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkSlug from 'remark-slug';
|
||||
|
|
@ -9,8 +10,8 @@ import remarkAside from './src/plugins/remark-aside.ts';
|
|||
|
||||
export default defineConfig({
|
||||
output: 'static',
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
integrations: [db()],
|
||||
adapter: vercel(),
|
||||
integrations: [db(), auth()],
|
||||
markdown: {
|
||||
remarkPlugins: [
|
||||
remarkGfm,
|
||||
25
www/auth.config.ts
Normal file
25
www/auth.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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;
|
||||
},
|
||||
},
|
||||
});
|
||||
54
www/db/seed.ts
Normal file
54
www/db/seed.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
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
Normal file
6861
www/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
www/package.json
Normal file
27
www/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ const posts = fs.existsSync(postsDir)
|
|||
let postWords = 0;
|
||||
for (const post of posts) {
|
||||
const content = fs.readFileSync(path.join(postsDir, post), 'utf-8');
|
||||
// Remove frontmatter
|
||||
const body = content.replace(/^---[\s\S]*?---/, '');
|
||||
postWords += countWords(body);
|
||||
}
|
||||
|
|
@ -40,19 +41,20 @@ const bookmarks = fs.existsSync(bookmarksFile)
|
|||
: [];
|
||||
|
||||
// Guestbook count - read from built JSON file
|
||||
const guestbookJsonFile = path.join(root, 'dist/client/guestbook-count.json');
|
||||
const guestbookJsonFile = path.join(root, '.vercel/output/static/guestbook-count.json');
|
||||
let guestbookCount = 0;
|
||||
if (fs.existsSync(guestbookJsonFile)) {
|
||||
const data = JSON.parse(fs.readFileSync(guestbookJsonFile, 'utf-8'));
|
||||
guestbookCount = data.count;
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const totalPages = 1 + posts.length + txtFiles.length;
|
||||
// Calculate totals (excluding stats.txt words for now, we'll add them after generating)
|
||||
const totalPages = 1 + posts.length + txtFiles.length; // home + individual post pages + txt files
|
||||
|
||||
// Read template from public/stats.txt and replace placeholders
|
||||
const template = fs.readFileSync(path.join(root, 'public/stats.txt'), 'utf-8');
|
||||
|
||||
// First pass: generate stats without stats.txt word count
|
||||
let stats = template
|
||||
.replace('[PAGES]', totalPages.toString())
|
||||
.replace('[POSTS]', posts.length.toString())
|
||||
|
|
@ -60,13 +62,15 @@ let stats = template
|
|||
.replace('[BOOKMARKS]', bookmarks.length.toString())
|
||||
.replace('[GUESTBOOK]', guestbookCount.toString());
|
||||
|
||||
// Count words in the stats file itself (before adding [WORDS])
|
||||
const statsWords = countWords(stats.replace('[WORDS]', '0'));
|
||||
const totalWords = postWords + txtWords + statsWords;
|
||||
|
||||
// Final pass: replace [WORDS] with actual total
|
||||
stats = stats.replace('[WORDS]', totalWords.toString());
|
||||
|
||||
// Write to build output
|
||||
const outputDir = path.join(root, 'dist/client');
|
||||
// Write to Vercel output
|
||||
const outputDir = path.join(root, '.vercel/output/static');
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
0
src/env.d.ts → www/src/env.d.ts
vendored
0
src/env.d.ts → www/src/env.d.ts
vendored
26
www/src/lib/auth.ts
Normal file
26
www/src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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 };
|
||||
}
|
||||
77
www/src/pages/admin.astro
Normal file
77
www/src/pages/admin.astro
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
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>
|
||||
18
www/src/pages/api/deploy.ts
Normal file
18
www/src/pages/api/deploy.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
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 });
|
||||
};
|
||||
28
www/src/pages/api/guestbook/[id].ts
Normal file
28
www/src/pages/api/guestbook/[id].ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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 });
|
||||
};
|
||||
8
www/vercel.json
Normal file
8
www/vercel.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"installCommand": "pnpm install",
|
||||
"buildCommand": "pnpm --filter @ily/www build",
|
||||
"outputDirectory": "dist",
|
||||
"redirects": [
|
||||
{ "source": "/txt/now.txt", "destination": "/now.txt", "permanent": true }
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue