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
6
www/.env.example
Normal file
6
www/.env.example
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
ASTRO_DB_REMOTE_URL=libsql://your-db.turso.io
|
||||
ASTRO_DB_APP_TOKEN=your-token
|
||||
GITHUB_CLIENT_ID=your-client-id
|
||||
GITHUB_CLIENT_SECRET=your-client-secret
|
||||
ADMIN_GITHUB_ID=your-github-user-id
|
||||
AUTH_SECRET=generate-with-openssl-rand-base64-32
|
||||
24
www/astro.config.mjs
Normal file
24
www/astro.config.mjs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
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';
|
||||
import remarkSmartypants from 'remark-smartypants';
|
||||
import remarkAside from './src/plugins/remark-aside.ts';
|
||||
|
||||
export default defineConfig({
|
||||
output: 'static',
|
||||
adapter: vercel(),
|
||||
integrations: [db(), auth()],
|
||||
markdown: {
|
||||
remarkPlugins: [
|
||||
remarkGfm,
|
||||
remarkDirective,
|
||||
remarkAside,
|
||||
remarkSlug,
|
||||
remarkSmartypants
|
||||
]
|
||||
}
|
||||
});
|
||||
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;
|
||||
},
|
||||
},
|
||||
});
|
||||
16
www/db/config.ts
Normal file
16
www/db/config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { defineDb, defineTable, column } from 'astro:db';
|
||||
|
||||
const Guestbook = defineTable({
|
||||
columns: {
|
||||
id: column.number({ primaryKey: true }),
|
||||
name: column.text(),
|
||||
message: column.text(),
|
||||
url: column.text({ optional: true }),
|
||||
createdAt: column.date({ default: new Date() }),
|
||||
approved: column.boolean({ default: false }),
|
||||
},
|
||||
});
|
||||
|
||||
export default defineDb({
|
||||
tables: { Guestbook },
|
||||
});
|
||||
22
www/db/seed.ts
Normal file
22
www/db/seed.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { db, Guestbook } from 'astro:db';
|
||||
|
||||
export default async function seed() {
|
||||
await db.insert(Guestbook).values([
|
||||
{
|
||||
id: 1,
|
||||
name: 'alice',
|
||||
message: 'love the site!',
|
||||
url: 'https://example.com',
|
||||
createdAt: new Date('2026-01-20'),
|
||||
approved: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'bob',
|
||||
message: 'great blog posts',
|
||||
url: null,
|
||||
createdAt: new Date('2026-01-18'),
|
||||
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"
|
||||
}
|
||||
}
|
||||
1
www/public/txt/changelog.txt
Normal file
1
www/public/txt/changelog.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
2026-01-23 - hello. we've got the skeleton of something here
|
||||
3
www/public/txt/config.yaml
Normal file
3
www/public/txt/config.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pinned:
|
||||
- now.txt
|
||||
- cv.txt
|
||||
6
www/public/txt/cv.txt
Normal file
6
www/public/txt/cv.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
lewis@wynne.rs
|
||||
|
||||
swe@thecodeguy 2025-now
|
||||
python@tcz 2024-2025
|
||||
freelance swe 2020-2024
|
||||
1st class cyber 2020
|
||||
26
www/public/txt/now.txt
Normal file
26
www/public/txt/now.txt
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
23/1/26
|
||||
|
||||
what would i tell somebody i haven't seen in a year
|
||||
|
||||
right now i'm in the middle of buying a house,
|
||||
though i'm at the start really. it's been waiting
|
||||
more than anything. the couple we're buying from
|
||||
are looking for something perfect and we're happy
|
||||
to wait. but hopefully soon things will move
|
||||
|
||||
waiting for the house has put most things on hold
|
||||
i don't want to join clubs where i am currently because
|
||||
i wont be here soon(hopefully)
|
||||
|
||||
i'm working as an engineer. most of my time is
|
||||
with a client in the voluntary sector. i maintain
|
||||
a site used to refer people in need to the services
|
||||
that can help them. it's old and full of legacy
|
||||
code, but it's getting there now that i'm working
|
||||
with it essentially full time
|
||||
|
||||
i spend 14 or so hours a week mentoring kid programmers,
|
||||
where i teach mostly python. some javascript too
|
||||
|
||||
cheers,
|
||||
lew
|
||||
11
www/public/txt/stats.txt
Normal file
11
www/public/txt/stats.txt
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
this site consists of [WORDS] words across [PAGES] pages
|
||||
|
||||
there are [POSTS] blog posts
|
||||
[TXT] txt files
|
||||
and [BOOKMARKS] bookmarks
|
||||
|
||||
[GUESTBOOK] people have signed the guestbook
|
||||
|
||||
this file was generated automatically on deploy
|
||||
cheers,
|
||||
lewis
|
||||
85
www/scripts/generate-stats.js
Normal file
85
www/scripts/generate-stats.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.join(__dirname, '..');
|
||||
|
||||
function countWords(text) {
|
||||
return text.split(/\s+/).filter(w => w.length > 0).length;
|
||||
}
|
||||
|
||||
// Count blog posts and their words
|
||||
const postsDir = path.join(root, 'src/content/posts');
|
||||
const posts = fs.existsSync(postsDir)
|
||||
? fs.readdirSync(postsDir).filter(f => f.endsWith('.md'))
|
||||
: [];
|
||||
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);
|
||||
}
|
||||
|
||||
// Count txt files and their words (excluding stats.txt which we're generating)
|
||||
const txtDir = path.join(root, 'public/txt');
|
||||
const txtFiles = fs.existsSync(txtDir)
|
||||
? fs.readdirSync(txtDir).filter(f => f.endsWith('.txt') && f !== 'stats.txt')
|
||||
: [];
|
||||
let txtWords = 0;
|
||||
for (const txt of txtFiles) {
|
||||
const content = fs.readFileSync(path.join(txtDir, txt), 'utf-8');
|
||||
txtWords += countWords(content);
|
||||
}
|
||||
|
||||
// Count bookmarks
|
||||
const bookmarksFile = path.join(root, 'src/content/bookmarks.yaml');
|
||||
const bookmarks = fs.existsSync(bookmarksFile)
|
||||
? yaml.load(fs.readFileSync(bookmarksFile, 'utf-8')) || []
|
||||
: [];
|
||||
|
||||
// Guestbook count - read from built JSON file
|
||||
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 (excluding stats.txt words for now, we'll add them after generating)
|
||||
const totalPages = 1 + 1 + posts.length + 1 + txtFiles.length + 1 + 1; // home, blog index, posts, txt index, txts, bookmarks, guestbook
|
||||
|
||||
// Read template from public/txt/stats.txt and replace placeholders
|
||||
const template = fs.readFileSync(path.join(root, 'public/txt/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())
|
||||
.replace('[TXT]', txtFiles.length.toString())
|
||||
.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 Vercel output
|
||||
const outputDir = path.join(root, '.vercel/output/static/txt');
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(path.join(outputDir, 'stats.txt'), stats);
|
||||
|
||||
console.log('Generated stats.txt');
|
||||
console.log(` Words: ${totalWords}`);
|
||||
console.log(` Pages: ${totalPages}`);
|
||||
console.log(` Posts: ${posts.length}`);
|
||||
console.log(` Txt files: ${txtFiles.length}`);
|
||||
console.log(` Bookmarks: ${bookmarks.length}`);
|
||||
console.log(` Guestbook: ${guestbookCount}`);
|
||||
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