quash: query parameters for just, has, and do, and some general simplification

This commit is contained in:
Lewis Wynne 2026-03-26 02:25:50 +00:00
parent f2f4e2e704
commit 4720d408f4
14 changed files with 156 additions and 164 deletions

45
www/public/js/params.js Normal file
View file

@ -0,0 +1,45 @@
!function() {
var p = new URLSearchParams(location.search);
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}';
document.head.appendChild(Object.assign(document.createElement('style'), { textContent: css }));
}
var act = p.get('do');
var urls = window.__urls;
if (urls && urls.length) {
if (act === 'random') {
var url = urls[Math.floor(Math.random() * urls.length)];
location.replace(url.startsWith('http') ? url : location.origin + url);
}
if (act === 'newest') {
location.replace(urls[0].startsWith('http') ? urls[0] : location.origin + urls[0]);
}
}
if (act === 'admin') {
location.replace('/admin');
}
var has = p.get('has');
if (has) {
document.documentElement.dataset.has = has;
has = has.toLowerCase();
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('section[data-section] pre').forEach(function(pre) {
var lines = pre.innerHTML.split('\n');
pre.innerHTML = lines.filter(function(line) {
return !line.trim() || line.toLowerCase().indexOf(has) !== -1;
}).join('\n');
});
document.querySelectorAll('.guestbook-entry').forEach(function(entry) {
if (entry.textContent.toLowerCase().indexOf(has) === -1) {
entry.style.display = 'none';
}
});
});
}
}();

View file

@ -5,17 +5,25 @@ interface Props {
title: string;
showHeader?: boolean;
isHome?: boolean;
urls?: string[];
}
const { title, showHeader = true, isHome = false } = Astro.props;
const { title, showHeader = true, isHome = false, urls = [] } = 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>
<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" />
{urls.length > 0 && <script is:inline define:vars={{ urls }}>window.__urls = urls;</script>}
<script is:inline src="/js/params.js"></script>
</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>
<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></pre>
</header>
)}
<slot />

15
www/src/lib/rate-limit.ts Normal file
View file

@ -0,0 +1,15 @@
const requests = new Map<string, number[]>();
export function isRateLimited(key: string, maxRequests: number, windowMs: number): boolean {
const now = Date.now();
const timestamps = requests.get(key) ?? [];
const recent = timestamps.filter(t => now - t < windowMs);
if (recent.length >= maxRequests) {
return true;
}
recent.push(now);
requests.set(key, recent);
return false;
}

View file

@ -25,9 +25,9 @@ const related = post.data.related ? resolveRelatedPosts(post.data.related, allPo
<Content />
</article>
{related.length > 0 && (
<details open>
<summary>related</summary>
<section>
<span class="section-label">related</span>
<pre set:html={related.map(p => formatListItem(p.dates.created, `/${getSlug(p.id)}`, p.data.title)).join('\n')} />
</details>
</section>
)}
</Layout>

View file

@ -1,11 +1,17 @@
import type { APIRoute } from 'astro';
import { createEntry } from '../../lib/db';
import { jsonResponse, errorResponse } from '../../lib/api';
import { isRateLimited } from '../../lib/rate-limit';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
if (isRateLimited(ip, 3, 60_000)) {
return errorResponse('Too many requests, try again later', 429);
}
const data = await request.json();
const { name, message, url } = data;

View file

@ -1,16 +0,0 @@
---
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
import { formatListItem, extractDomain } from '../../lib/format';
const bookmarksCollection = await getCollection('bookmarks');
const bookmarks = bookmarksCollection
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
---
<Layout title="bookmarks - lewis m.w.">
<details open>
<summary>bookmarks</summary>
<pre set:html={bookmarks.map(b => formatListItem(b.data.date, b.data.url, b.data.title, { suffix: `<span class="muted">(${extractDomain(b.data.url)})</span>` })).join('\n')} />
</details>
</Layout>

View file

@ -1,35 +0,0 @@
---
import Layout from '../../layouts/Layout.astro';
import { getApprovedEntries, type GuestbookEntry } from '../../lib/db';
import { formatDate } from '../../lib/format';
let guestbookEntries: GuestbookEntry[] = [];
try {
guestbookEntries = await getApprovedEntries();
} catch {
// DB not available during dev without env vars
}
---
<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>
import { initGuestbookSign } from '../../scripts/guestbook-sign';
initGuestbookSign();
</script>
</Layout>

View file

@ -22,56 +22,57 @@ try {
} catch {
// DB not available during dev without env vars
}
const urls = [
...posts.map(post => ({ url: `/${getSlug(post.id)}`, date: post.dates.created.getTime() })),
...txtFiles.map(f => ({ url: `/${f.name}`, date: f.date.getTime() })),
...bookmarksCollection.map(b => ({ url: b.data.url, date: b.data.date.getTime() })),
].sort((a, b) => b.date - a.date).map(e => e.url);
---
<Layout title="lewis m.w." isHome>
<Layout title="lewis m.w." isHome urls={urls}>
{sortedCategories.map(category => {
const categoryPosts = grouped[category];
return (
<details open>
<summary>{category}</summary>
<pre set:html={[
...categoryPosts.slice(0, 10).map(post => formatListItem(post.dates.created, `/${getSlug(post.id)}`, post.data.title, { pinned: post.data.pinned })),
...(categoryPosts.length > 10 ? [`<a href="/md/">+${categoryPosts.length - 10} more</a>`] : [])
].join('\n')} />
</details>
<section data-section={category}>
<a class="section-label" href={`?just=${category}`}>{category}</a>
<pre set:html={categoryPosts.map(post => formatListItem(post.dates.created, `/${getSlug(post.id)}`, post.data.title, { pinned: post.data.pinned })).join('\n')} />
</section>
);
})}
<details open>
<summary>txt</summary>
<pre set:html={[
...txtFiles.slice(0, 10).map(f => formatListItem(f.date, `/${f.name}`, f.name, { pinned: f.pinned })),
...(txtFiles.length > 10 ? [`<a href="/txt/">+${txtFiles.length - 10} more</a>`] : [])
].join('\n')} />
</details>
<section data-section="txt">
<a class="section-label" href="?just=txt">txt</a>
<pre set:html={txtFiles.map(f => formatListItem(f.date, `/${f.name}`, f.name, { pinned: f.pinned })).join('\n')} />
</section>
<details open>
<summary>bookmarks</summary>
<pre set:html={[
...bookmarks.slice(0, 10).map(b => formatListItem(b.data.date, b.data.url, b.data.title, { suffix: `<span class="muted">(${extractDomain(b.data.url)})</span>` })),
...(bookmarks.length > 10 ? [`<a href="/bookmarks/">+${bookmarks.length - 10} more</a>`] : [])
].join('\n')} />
</details>
<section data-section="bookmarks">
<a class="section-label" href="?just=bookmarks">bookmarks</a>
<pre set:html={bookmarks.map(b => formatListItem(b.data.date, b.data.url, b.data.title, { suffix: `<span class="muted">(${extractDomain(b.data.url)})</span>` })).join('\n')} />
</section>
<details open>
<summary>guestbook</summary>
<section data-section="guestbook">
<a class="section-label" href="?just=guestbook">guestbook</a>
<div class="guestbook-entries">
{guestbookEntries.slice(0, 10).map(e => (
{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>{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>
<form id="guestbook-form" class="guestbook-form">
<input type="text" name="name" placeholder="name" required maxlength="100" /><br />
<input type="text" name="message" placeholder="message" required maxlength="500" /><br />
<input type="url" name="url" placeholder="url (optional)" maxlength="200" /><br />
<button type="submit">sign</button>
<span id="guestbook-status"></span>
</form>
</section>
<script>
import { initGuestbookSign } from '../scripts/guestbook-sign';
initGuestbookSign();
import { initGuestbookForm } from '../scripts/guestbook-sign';
initGuestbookForm();
</script>
</Layout>

View file

@ -1,19 +0,0 @@
---
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
import { formatListItem } from '../../lib/format';
import { organizePostsByCategory, getSlug, enrichPostsWithDates } from '../../lib/md';
const rawPosts = await getCollection('md');
const posts = enrichPostsWithDates(rawPosts);
const { grouped, categories: sortedCategories } = organizePostsByCategory(posts);
---
<Layout title="md - lewis m.w.">
{sortedCategories.map(category => (
<details open>
<summary>{category}</summary>
<pre set:html={grouped[category].map(post => formatListItem(post.dates.created, `/${getSlug(post.id)}`, post.data.title, { pinned: post.data.pinned })).join('\n')} />
</details>
))}
</Layout>

View file

@ -1,24 +0,0 @@
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
import { getSlug } from '../lib/md';
import { getTxtFileNames } from '../lib/txt';
export const prerender = false;
export async function GET(context: APIContext) {
const site = context.site?.origin ?? 'https://wynne.rs';
const posts = await getCollection('md');
const bookmarks = await getCollection('bookmarks');
const txtFiles = getTxtFileNames();
const urls = [
...posts.map(post => `/${getSlug(post.id)}`),
...txtFiles.map(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);
}

View file

@ -15,10 +15,7 @@ export async function GET(context: APIContext) {
const urls = [
'/',
...posts.map(post => `/${getSlug(post.id)}`),
'/txt',
...txtFiles.map(txt => `/${txt}`),
'/bookmarks',
'/guestbook',
].map(p => `${site}${p}`);
return new Response([...urls, ...SUBDOMAINS].join('\n'), {

View file

@ -1,14 +0,0 @@
---
import Layout from '../../layouts/Layout.astro';
import { formatListItem } from '../../lib/format';
import { getTxtFiles } from '../../lib/txt';
const txtFiles = getTxtFiles();
---
<Layout title="txt - lewis m.w.">
<details open>
<summary>txt</summary>
<pre set:html={txtFiles.map(f => formatListItem(f.date, `/${f.name}`, f.name, { pinned: f.pinned })).join('\n')} />
</details>
</Layout>

View file

@ -1,30 +1,42 @@
export function initGuestbookSign() {
document.getElementById('sign-guestbook')?.addEventListener('click', async (e) => {
e.preventDefault();
export function initGuestbookForm() {
const form = document.getElementById('guestbook-form') as HTMLFormElement | null;
if (!form) return;
const status = document.getElementById('guestbook-status')!;
const name = prompt('name:');
if (!name) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
status.textContent = '';
const message = prompt('message:');
if (!message) return;
const data = new FormData(form);
const name = (data.get('name') as string).trim();
const message = (data.get('message') as string).trim();
const url = (data.get('url') as string).trim() || null;
const url = prompt('url (optional):');
if (!name || !message) return;
const button = form.querySelector('button')!;
button.disabled = true;
try {
const res = await fetch('/api/guestbook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, message, url: url || null }),
body: JSON.stringify({ name, message, url }),
});
if (res.ok) {
status.textContent = ' thanks! pending approval.';
form.reset();
} else if (res.status === 429) {
status.textContent = ' too many requests, try later.';
} else {
status.textContent = ' error';
}
} catch {
status.textContent = ' failed';
} finally {
button.disabled = false;
}
});
}

View file

@ -72,21 +72,31 @@ div.grid .content {
display: block;
}
details {
section {
margin: 1rem 0;
}
summary {
cursor: pointer;
section .section-label {
font-family: monospace;
list-style: none;
}
summary::-webkit-details-marker {
.home-name-link {
display: none;
}
details pre {
html[data-just] .home-name {
display: none;
}
html[data-just] .home-name-link {
display: inline;
}
html[data-has] .guestbook-form {
display: none;
}
section pre {
margin: 0;
}
@ -99,3 +109,9 @@ details pre {
grid-template-columns: 8ch 1fr;
gap: 0 4ch;
}
.guestbook-form {
margin-top: 0.5rem;
margin-left: 12ch;
font-family: monospace;
}