website/apps/www/src/pages/index.astro

183 lines
5.5 KiB
Text

---
import { getCollection } from 'astro:content';
import Layout from '../layouts/Layout.astro';
import yaml from 'js-yaml';
import bookmarksRaw from '../content/bookmarks.yaml?raw';
import fs from 'node:fs';
import path from 'node:path';
import { getApprovedEntries, type GuestbookEntry } from '../lib/db';
import { getGitDate } from '../utils';
interface Bookmark {
date: string;
title: string;
url: string;
}
interface TxtFile {
name: string;
date: Date;
pinned: boolean;
}
interface TxtConfig {
pinned?: string[];
}
const posts = await getCollection('posts');
// 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 bookmarks = (yaml.load(bookmarksRaw) as Bookmark[])
.sort((a, b) => new Date(b.date).getTime() - new Date(a.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 formatBookmarkDate(dateStr: string): string {
const date = new Date(dateStr);
return formatDate(date);
}
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">${formatBookmarkDate(b.date)}</span> <a href="${b.url}">${b.title}</a> <span class="muted">(${extractDomain(b.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>