Compare commits
10 commits
8209d036cd
...
f2acf36784
| Author | SHA1 | Date | |
|---|---|---|---|
| f2acf36784 | |||
| 66360b9c7a | |||
| 30212a2eaf | |||
| 917ef06879 | |||
| 3809e7c9dd | |||
| e4052fc145 | |||
| 20811f107b | |||
| c647fd62c3 | |||
| 5cc122bf39 | |||
| 384ca71f89 |
24 changed files with 171 additions and 215 deletions
25
.github/workflows/validate.yml
vendored
25
.github/workflows/validate.yml
vendored
|
|
@ -1,25 +0,0 @@
|
|||
name: Validate content
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'www/content/**'
|
||||
- 'www/public/*.txt'
|
||||
- 'www/public/config.yaml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'www/content/**'
|
||||
- 'www/public/*.txt'
|
||||
- 'www/public/config.yaml'
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm validate:www
|
||||
|
|
@ -6,7 +6,6 @@
|
|||
"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",
|
||||
"validate:www": "pnpm --filter @ily/www validate"
|
||||
"build:www": "pnpm --filter @ily/www build"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
title: hello
|
||||
date: 2023-02-26
|
||||
pinned: true
|
||||
date: 2026-02-26
|
||||
---
|
||||
|
||||
i've always had some sort of homepage. it was originally on bebo, then that died and i didn't ever get into other social media, so i made a website
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@
|
|||
"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",
|
||||
"validate": "node scripts/validate-content.js"
|
||||
"serve": "pnpm build && npx serve .vercel/output/static -l 4322"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/db": "^0.19.0",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
2026-03-27 - narrower layout (34rem), single-column entries, serif font, smaller muted text
|
||||
2026-03-26 - inline section labels, compact layout
|
||||
2026-02-07 - related posts !
|
||||
2026-01-31 - text files now live at cleaner URLs (/*.txt instead of /txt/*.txt)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
pinned: []
|
||||
descriptions:
|
||||
cv.txt: curriculum vitae
|
||||
now.txt: what i'm doing now
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@
|
|||
var just = p.get('just');
|
||||
if (just && /^[a-z0-9-]+$/.test(just)) {
|
||||
document.documentElement.dataset.just = just;
|
||||
var css = 'section[data-section]:not([data-section="' + just + '"]){display:none}'
|
||||
+ ' section[data-section="' + just + '"] .section-label{pointer-events:none;text-decoration:none;color:inherit}';
|
||||
var css = 'section[data-section]:not([data-section="' + just + '"]){display:none}';
|
||||
document.head.appendChild(Object.assign(document.createElement('style'), { textContent: css }));
|
||||
}
|
||||
|
||||
|
|
@ -30,7 +29,10 @@
|
|||
if (has) {
|
||||
document.documentElement.dataset.has = has;
|
||||
has = has.toLowerCase();
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (has) {
|
||||
document.querySelectorAll('section[data-section] .entry').forEach(function(entry) {
|
||||
if (entry.textContent.toLowerCase().indexOf(has) === -1) {
|
||||
entry.style.display = 'none';
|
||||
|
|
@ -41,6 +43,22 @@
|
|||
entry.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.section-label').forEach(function(a) {
|
||||
var link = new URLSearchParams(a.search);
|
||||
p.forEach(function(v, k) { if (!link.has(k)) link.set(k, v); });
|
||||
a.href = '?' + link.toString();
|
||||
});
|
||||
}
|
||||
|
||||
var find = document.getElementById('find');
|
||||
if (find) find.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var term = prompt('find:');
|
||||
if (!term) return;
|
||||
var q = new URLSearchParams(location.search);
|
||||
q.set('has', term);
|
||||
location.search = q.toString();
|
||||
});
|
||||
});
|
||||
}();
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
const contentDir = path.join(root, 'content');
|
||||
const publicDir = path.join(root, 'public');
|
||||
const errors = [];
|
||||
|
||||
const mdFiles = fs.readdirSync(contentDir).filter(f => f.endsWith('.md'));
|
||||
for (const file of mdFiles) {
|
||||
const content = fs.readFileSync(path.join(contentDir, file), 'utf8');
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) {
|
||||
errors.push(`${file}: missing frontmatter`);
|
||||
continue;
|
||||
}
|
||||
const frontmatter = match[1];
|
||||
if (!/^date:\s*.+/m.test(frontmatter)) {
|
||||
errors.push(`${file}: missing required 'date' field`);
|
||||
}
|
||||
}
|
||||
|
||||
const configPath = path.join(publicDir, 'config.yaml');
|
||||
const config = fs.existsSync(configPath)
|
||||
? yaml.load(fs.readFileSync(configPath, 'utf8'))
|
||||
: {};
|
||||
const configDates = config.dates || {};
|
||||
const txtFiles = fs.readdirSync(publicDir).filter(f => f.endsWith('.txt'));
|
||||
for (const file of txtFiles) {
|
||||
if (!configDates[file]) {
|
||||
errors.push(`${file}: missing date in config.yaml`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.error('Content validation failed:\n');
|
||||
for (const err of errors) console.error(` - ${err}`);
|
||||
console.error('');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`Validated ${mdFiles.length} posts and ${txtFiles.length} txt files.`);
|
||||
}
|
||||
|
|
@ -3,13 +3,12 @@ import { glob, file } from 'astro/loaders';
|
|||
import { z } from 'astro/zod';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
const md = defineCollection({
|
||||
const posts = defineCollection({
|
||||
loader: glob({ pattern: '**/*.md', base: './content' }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
date: z.coerce.date(),
|
||||
updated: z.coerce.date().optional(),
|
||||
pinned: z.boolean().optional(),
|
||||
category: z.string().optional(),
|
||||
related: z.array(z.string()).optional(),
|
||||
})
|
||||
|
|
@ -29,4 +28,4 @@ const bookmarks = defineCollection({
|
|||
})
|
||||
});
|
||||
|
||||
export const collections = { md, bookmarks };
|
||||
export const collections = { posts, bookmarks };
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ interface Props {
|
|||
urls?: string[];
|
||||
}
|
||||
|
||||
const { title, description = 'personal website of lewis m.w.', showHeader = true, isHome = false, urls = [] } = Astro.props;
|
||||
const { title, description = 'personal website of ' + title, showHeader = true, isHome = false, urls = [] } = Astro.props;
|
||||
---
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
|
@ -28,7 +28,18 @@ const { title, description = 'personal website of lewis m.w.', showHeader = true
|
|||
<body>
|
||||
{showHeader && (
|
||||
<header>
|
||||
<pre>{isHome ? <Fragment><span class="home-name">lewis m.w.</span><a class="home-name-link" href="/">lewis m.w.</a></Fragment> : <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="/?do=random">random</a> <a href="/?do=newest">newest</a></pre>
|
||||
<span class="header-name">
|
||||
<a href="/">{isHome ? title : 'lewis m.w.'}</a>
|
||||
</span>
|
||||
<span class="header-links">
|
||||
<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="/?do=random">random</a>
|
||||
<a href="/?do=newest">newest</a>
|
||||
<a id="find" href="/?has=">find</a>
|
||||
</span>
|
||||
</header>
|
||||
)}
|
||||
<slot />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { isAdmin } from './auth';
|
||||
|
||||
export function jsonResponse(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
|
|
@ -10,10 +8,3 @@ export function jsonResponse(data: unknown, status = 200): Response {
|
|||
export function errorResponse(message: string, status: number): Response {
|
||||
return jsonResponse({ error: message }, status);
|
||||
}
|
||||
|
||||
export function requireAdmin(session: { user?: { id?: string } } | null): Response | null {
|
||||
if (!session?.user?.id || !isAdmin(session.user.id)) {
|
||||
return errorResponse('Unauthorized', 403);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,26 @@
|
|||
import { getSession } from 'auth-astro/server';
|
||||
|
||||
type Session = { user?: { id?: string; name?: string | null } };
|
||||
export type Session = { user?: { id?: string; name?: string | null } };
|
||||
|
||||
export function isAdmin(userId: string | undefined): boolean {
|
||||
return userId === import.meta.env.ADMIN_GITHUB_ID;
|
||||
}
|
||||
export type AuthResult =
|
||||
| { status: 'admin'; session: Session }
|
||||
| { status: 'unauthenticated' }
|
||||
| { status: 'forbidden' }
|
||||
| { status: 'error' };
|
||||
|
||||
export async function requireAdminSession(request: Request): Promise<
|
||||
| { session: Session; error: null }
|
||||
| { session: null; error: Response | null }
|
||||
> {
|
||||
export async function getAdminSession(request: Request): Promise<AuthResult> {
|
||||
let session: Session | null;
|
||||
try {
|
||||
session = await getSession(request);
|
||||
} catch {
|
||||
return { session: null, error: new Response('Auth not configured', { status: 500 }) };
|
||||
return { status: 'error' };
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return { session: null, error: null };
|
||||
if (!session) return { status: 'unauthenticated' };
|
||||
|
||||
if (session.user?.id !== import.meta.env.ADMIN_GITHUB_ID) {
|
||||
return { status: 'forbidden' };
|
||||
}
|
||||
|
||||
if (!isAdmin(session.user?.id)) {
|
||||
return { session: null, error: new Response('Forbidden', { status: 403 }) };
|
||||
}
|
||||
|
||||
return { session, error: null };
|
||||
return { status: 'admin', session };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export const SUBDOMAINS = [
|
|||
];
|
||||
|
||||
export const SECTIONS = {
|
||||
plaintext: 'plaintext',
|
||||
files: 'files',
|
||||
bookmarks: 'bookmarks',
|
||||
guestbook: 'guestbook',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -26,25 +26,45 @@ export function formatDate(date: Date): string {
|
|||
return `${d}/${m}/${y}`;
|
||||
}
|
||||
|
||||
export function wordCount(markdown: string | undefined): string {
|
||||
if (!markdown) return '';
|
||||
const words = markdown
|
||||
.replace(/^---[\s\S]*?---/m, '')
|
||||
.replace(/^#+\s+.*$/gm, '')
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[*_~`]/g, '')
|
||||
.replace(/:[a-z]+\[([^\]]*)\]/g, '$1')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length;
|
||||
if (words < 100) return `${words} words`;
|
||||
const mins = Math.ceil(words / 200);
|
||||
return `${mins} min`;
|
||||
}
|
||||
|
||||
export function extractDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatListItem(
|
||||
date: Date,
|
||||
url: string,
|
||||
title: string,
|
||||
options?: { pinned?: boolean }
|
||||
options?: { suffix?: string }
|
||||
): string {
|
||||
const pinnedBadge = options?.pinned ? ' [pinned]' : '';
|
||||
return `<span class="list-meta"><span class="muted">${formatDate(date)}</span></span><span class="entry-content"><a href="${url}" title="${title}">${title}</a>${pinnedBadge}</span>`;
|
||||
const suffixHtml = options?.suffix ? `<span class="entry-suffix muted">${options.suffix}</span>` : '';
|
||||
return `<span class="list-meta"><span class="muted">${formatDate(date)}</span></span><span class="entry-content"><a href="${url}" title="${title}">${title}</a>${suffixHtml}</span>`;
|
||||
}
|
||||
|
||||
interface Sortable {
|
||||
date: Date;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export function sortByPinnedThenDate<T extends Sortable>(items: T[]): T[] {
|
||||
return items.slice().sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
return b.date.getTime() - a.date.getTime();
|
||||
});
|
||||
export function sortEntries<T>(items: T[], key?: (item: T) => Sortable): T[] {
|
||||
const get = key ?? (item => item as unknown as Sortable);
|
||||
return items.slice().sort((a, b) => get(b).date.getTime() - get(a).date.getTime());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,14 @@
|
|||
import type { CollectionEntry } from 'astro:content';
|
||||
import { DEFAULT_CATEGORY } from './consts';
|
||||
import { sortEntries } from './format';
|
||||
|
||||
type Post = CollectionEntry<'md'>;
|
||||
export type Post = CollectionEntry<'posts'> & { body?: string };
|
||||
|
||||
export function getSlug(postId: string): string {
|
||||
const parts = postId.split('/');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
function sortPosts(posts: Post[], { alphabetically = false } = {}): Post[] {
|
||||
return posts.slice().sort((a, b) => {
|
||||
if (a.data.pinned && !b.data.pinned) return -1;
|
||||
if (!a.data.pinned && b.data.pinned) return 1;
|
||||
if (alphabetically) return a.data.title.localeCompare(b.data.title);
|
||||
return b.data.date.getTime() - a.data.date.getTime();
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveRelatedPosts<T extends { id: string }>(
|
||||
slugs: string[],
|
||||
allPosts: T[],
|
||||
|
|
@ -25,7 +17,7 @@ export function resolveRelatedPosts<T extends { id: string }>(
|
|||
return slugs.flatMap(s => bySlug.get(s) ?? []);
|
||||
}
|
||||
|
||||
export function organizePostsByCategory(posts: Post[], { sortAlphabetically = false } = {}): {
|
||||
export function organizePostsByCategory(posts: Post[]): {
|
||||
grouped: Record<string, Post[]>;
|
||||
categories: string[];
|
||||
} {
|
||||
|
|
@ -43,7 +35,7 @@ export function organizePostsByCategory(posts: Post[], { sortAlphabetically = fa
|
|||
});
|
||||
|
||||
for (const category of categories) {
|
||||
grouped[category] = sortPosts(grouped[category], { alphabetically: sortAlphabetically });
|
||||
grouped[category] = sortEntries(grouped[category], p => p.data);
|
||||
}
|
||||
|
||||
return { grouped, categories };
|
||||
|
|
@ -1,17 +1,15 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
import { sortByPinnedThenDate } from './format';
|
||||
import { sortEntries } from './format';
|
||||
|
||||
export interface TxtFile {
|
||||
name: string;
|
||||
date: Date;
|
||||
pinned: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TxtConfig {
|
||||
pinned?: string[];
|
||||
descriptions?: Record<string, string>;
|
||||
dates?: Record<string, string>;
|
||||
}
|
||||
|
|
@ -32,7 +30,6 @@ export function getTxtFiles(): TxtFile[] {
|
|||
if (!fs.existsSync(txtDir)) return [];
|
||||
|
||||
const config = loadTxtConfig();
|
||||
const pinnedSet = new Set(config.pinned || []);
|
||||
const descriptions = config.descriptions || {};
|
||||
const dates = config.dates || {};
|
||||
|
||||
|
|
@ -41,9 +38,8 @@ export function getTxtFiles(): TxtFile[] {
|
|||
.map(name => ({
|
||||
name,
|
||||
date: dates[name] ? new Date(dates[name]) : new Date(0),
|
||||
pinned: pinnedSet.has(name),
|
||||
description: descriptions[name],
|
||||
}));
|
||||
return sortByPinnedThenDate(files);
|
||||
return sortEntries(files);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { formatDate, formatListItem, excerpt } from '../lib/format';
|
||||
import { getSlug, resolveRelatedPosts } from '../lib/md';
|
||||
import { formatDate, formatListItem, excerpt, wordCount } from '../lib/format';
|
||||
import { getSlug, resolveRelatedPosts, type Post } from '../lib/posts';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const allPosts = await getCollection('md');
|
||||
const allPosts = await getCollection('posts');
|
||||
return allPosts.map(post => ({
|
||||
params: { slug: getSlug(post.id) },
|
||||
props: { post, allPosts }
|
||||
|
|
@ -15,13 +15,13 @@ export async function getStaticPaths() {
|
|||
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 any).body) || undefined;
|
||||
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)})`}</p>
|
||||
<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 && (
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@
|
|||
export const prerender = false;
|
||||
|
||||
import { getPendingEntries, type GuestbookEntry } from '../lib/db';
|
||||
import { requireAdminSession } from '../lib/auth';
|
||||
import { getAdminSession } from '../lib/auth';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
const { session, error } = await requireAdminSession(Astro.request);
|
||||
if (error) return error;
|
||||
if (!session) return Astro.redirect('/api/auth/signin');
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,18 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { getSession } from 'auth-astro/server';
|
||||
import { jsonResponse, errorResponse, requireAdmin } from '../../lib/api';
|
||||
import { jsonResponse, errorResponse } from '../../lib/api';
|
||||
import { getAdminSession } from '../../lib/auth';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const session = await getSession(request);
|
||||
const authError = requireAdmin(session);
|
||||
if (authError) return authError;
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (!res.ok) return errorResponse('Failed to trigger deploy', 502);
|
||||
|
||||
return jsonResponse({ success: true });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,33 +1,27 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { getSession } from 'auth-astro/server';
|
||||
import { approveEntry, deleteEntry } from '../../../lib/db';
|
||||
import { jsonResponse, errorResponse, requireAdmin } from '../../../lib/api';
|
||||
import { jsonResponse, errorResponse } from '../../../lib/api';
|
||||
import { getAdminSession } from '../../../lib/auth';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const PATCH: APIRoute = async ({ params, request }) => {
|
||||
const session = await getSession(request);
|
||||
const authError = requireAdmin(session);
|
||||
if (authError) return authError;
|
||||
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);
|
||||
}
|
||||
if (isNaN(id)) return errorResponse('Invalid ID', 400);
|
||||
|
||||
await approveEntry(id);
|
||||
return jsonResponse({ success: true });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params, request }) => {
|
||||
const session = await getSession(request);
|
||||
const authError = requireAdmin(session);
|
||||
if (authError) return authError;
|
||||
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);
|
||||
}
|
||||
if (isNaN(id)) return errorResponse('Invalid ID', 400);
|
||||
|
||||
await deleteEntry(id);
|
||||
return jsonResponse({ success: true });
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import rss from '@astrojs/rss';
|
||||
import { getCollection } from 'astro:content';
|
||||
import type { APIContext } from 'astro';
|
||||
import { getSlug } from '../lib/md';
|
||||
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('md');
|
||||
const posts = await getCollection('posts');
|
||||
const txtFiles = getTxtFiles();
|
||||
|
||||
const items = [
|
||||
|
|
@ -14,7 +14,7 @@ export async function GET(context: APIContext) {
|
|||
title: post.data.title,
|
||||
pubDate: post.data.date,
|
||||
link: `/${getSlug(post.id)}`,
|
||||
description: excerpt((post as any).body) || post.data.title,
|
||||
description: excerpt((post as Post).body) || post.data.title,
|
||||
})),
|
||||
...txtFiles.map(txt => ({
|
||||
title: txt.name,
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
import { getCollection } from 'astro:content';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { getApprovedEntries, type GuestbookEntry } from '../lib/db';
|
||||
import { formatDate, formatListItem, escapeHtml } from '../lib/format';
|
||||
import { organizePostsByCategory, getSlug } from '../lib/md';
|
||||
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('md');
|
||||
const posts = await getCollection('posts');
|
||||
const { grouped, categories: sortedCategories } = organizePostsByCategory(posts);
|
||||
|
||||
const bookmarksCollection = await getCollection('bookmarks');
|
||||
|
|
@ -37,24 +37,24 @@ const urls = [
|
|||
<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, { pinned: post.data.pinned })}</span>`
|
||||
`<span class="entry">${formatListItem(post.data.date, `/${getSlug(post.id)}`, post.data.title, { suffix: wordCount(post.body) })}</span>`
|
||||
).join('')} />
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<section data-section={SECTIONS.plaintext}>
|
||||
<a class="section-label" href={`?just=${SECTIONS.plaintext}`}>{SECTIONS.plaintext}</a>
|
||||
<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, { pinned: f.pinned })}</span>`;
|
||||
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)}</span>`
|
||||
`<span class="entry">${formatListItem(b.data.date, b.data.url, b.data.title, { suffix: extractDomain(b.data.url) })}</span>`
|
||||
).join('')} />
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { getCollection } from 'astro:content';
|
||||
import type { APIContext } from 'astro';
|
||||
import { getSlug } from '../lib/md';
|
||||
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('md');
|
||||
const posts = await getCollection('posts');
|
||||
const txtFiles = getTxtFiles().map(f => f.name);
|
||||
|
||||
const urls = [
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
body {
|
||||
box-sizing: border-box;
|
||||
max-width: 48rem;
|
||||
max-width: 34rem;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
text-align: justify;
|
||||
font-family: 'Times New Roman', serif;
|
||||
}
|
||||
|
||||
img {
|
||||
|
|
@ -17,6 +18,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||
|
||||
.muted {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.left, .right {
|
||||
|
|
@ -24,7 +26,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (min-width: 63rem) {
|
||||
@media (min-width: 58rem) {
|
||||
.left, .right {
|
||||
display: inline;
|
||||
position: relative;
|
||||
|
|
@ -78,67 +80,77 @@ section {
|
|||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
section .section-label {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.home-name-link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-just] .home-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-just] .home-name-link {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
html[data-has] .guestbook-form {
|
||||
display: none;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.header-name {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-links {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
section pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.entry-list {
|
||||
columns: 2 24ch;
|
||||
column-gap: 3ch;
|
||||
font-family: monospace;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: grid;
|
||||
grid-template-columns: 10ch 1fr;
|
||||
grid-template-columns: 4rem 1fr;
|
||||
align-items: baseline;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.entry-content {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-content > a {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entry-suffix {
|
||||
flex: 1 10000 0%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: right;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.guestbook-entries {
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.guestbook-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 10ch 1fr;
|
||||
}
|
||||
|
||||
.guestbook-entry > span:last-child {
|
||||
white-space: normal;
|
||||
grid-template-columns: 4rem 1fr;
|
||||
align-items: baseline;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.guestbook-form {
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 10ch;
|
||||
font-family: monospace;
|
||||
margin-left: 4rem;
|
||||
}
|
||||
|
||||
html[data-compact] .list-meta {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue