diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 8b11062..df56369 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -3,3 +3,5 @@ export const PDS_URL = "https://arcnode.xyz"; export const DID = "did:plc:k23ujfuppr3hr4pxvtaz7jro"; export const HANDLE = "bkb.arcnode.xyz"; export const BLOG_COLLECTION = "com.whtwnd.blog.entry"; +export const ABOUT_COLLECTION = "xyz.arcnode.blog.about"; +export const ABOUT_RKEY = "self"; diff --git a/src/lib/pds.ts b/src/lib/pds.ts index d986c52..32bf03c 100644 --- a/src/lib/pds.ts +++ b/src/lib/pds.ts @@ -1,4 +1,4 @@ -import { PDS_URL, DID, HANDLE, BLOG_COLLECTION } from "./constants"; +import { PDS_URL, DID, HANDLE, BLOG_COLLECTION, ABOUT_COLLECTION, ABOUT_RKEY } from "./constants"; const RKEY_REGEX = /^[a-zA-Z0-9._~-]{1,512}$/; @@ -341,5 +341,44 @@ export async function getRawBlogEntry( } } +// --- About page content --- + +const ABOUT_TTL = 3600_000; // 1 hour +let aboutCache: CacheEntry | null = null; + +const DEFAULT_ABOUT = `Writing about the things I build, break, and think about — mostly around self-hosting, the AT Protocol, and whatever side project has my attention. + +This blog runs on [AT Protocol](https://atproto.com). Every post is an atproto record stored on my personal PDS at [arcnode.xyz](https://arcnode.xyz), rendered here with Astro. No database, no CMS — just protocol-native content. + +I'm a consultant by trade, a developer by habit. Currently building [ConsultPitch](https://consultpitch.com) and running my own infrastructure on Hetzner and Proxmox. + +Houston, TX`; + +export async function getAbout(): Promise { + if (isFresh(aboutCache)) return aboutCache.data; + + try { + const res = await fetch( + `${PDS_URL}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=${ABOUT_COLLECTION}&rkey=${ABOUT_RKEY}` + ); + + if (!res.ok) { + return aboutCache?.data ?? DEFAULT_ABOUT; + } + + const data = (await res.json()) as { value: Record }; + const content = (data.value.content as string) || DEFAULT_ABOUT; + + aboutCache = { data: content, expiresAt: Date.now() + ABOUT_TTL }; + return content; + } catch { + return aboutCache?.data ?? DEFAULT_ABOUT; + } +} + +export function invalidateAbout(): void { + aboutCache = null; +} + // Re-export constants for backward compatibility export { HANDLE, DID, PDS_URL } from "./constants"; diff --git a/src/pages/api/about.ts b/src/pages/api/about.ts new file mode 100644 index 0000000..48f8508 --- /dev/null +++ b/src/pages/api/about.ts @@ -0,0 +1,62 @@ +import type { APIRoute } from "astro"; +import { checkOrigin, checkAuth, parseJsonBody, createPdsSession } from "../../lib/api"; +import { invalidateAbout } from "../../lib/pds"; +import { PDS_URL, DID, ABOUT_COLLECTION, ABOUT_RKEY } from "../../lib/constants"; + +const MAX_CONTENT_LENGTH = 5_000; + +export const POST: APIRoute = async ({ request, cookies }) => { + const originErr = checkOrigin(request); + if (originErr) return originErr; + + const authErr = checkAuth(cookies); + if (authErr) return authErr; + + const [body, parseErr] = await parseJsonBody(request); + if (parseErr) return parseErr; + + const content = typeof body.content === "string" ? body.content.trim() : ""; + + if (!content || content.length > MAX_CONTENT_LENGTH) { + return new Response( + JSON.stringify({ error: `Content is required and must be under ${MAX_CONTENT_LENGTH} characters` }), + { status: 400 } + ); + } + + const [accessJwt, sessionErr] = await createPdsSession(); + if (sessionErr) return sessionErr; + + const putRes = await fetch( + `${PDS_URL}/xrpc/com.atproto.repo.putRecord`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessJwt}`, + }, + body: JSON.stringify({ + repo: DID, + collection: ABOUT_COLLECTION, + rkey: ABOUT_RKEY, + record: { + $type: ABOUT_COLLECTION, + content, + }, + }), + } + ); + + if (!putRes.ok) { + const err = await putRes.text(); + console.error("PDS putRecord (about) failed:", err); + return new Response( + JSON.stringify({ error: "Failed to save about content" }), + { status: 500 } + ); + } + + invalidateAbout(); + + return new Response(JSON.stringify({ success: true })); +}; diff --git a/src/pages/index.astro b/src/pages/index.astro index 0f413d3..29a1366 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,11 +1,22 @@ --- import Base from "../layouts/Base.astro"; -import { getBlogEntries, getProfile } from "../lib/pds"; +import { getBlogEntries, getProfile, getAbout } from "../lib/pds"; import { HANDLE } from "../lib/constants"; +import { isOwner } from "../lib/auth"; import { formatDate, excerpt } from "../lib/utils"; +import { marked } from "marked"; +import DOMPurify from "isomorphic-dompurify"; const profile = await getProfile(); const entries = await getBlogEntries(); +const aboutRaw = await getAbout(); + +const aboutHtml = DOMPurify.sanitize(await marked.parse(aboutRaw), { + ADD_ATTR: ["target"], +}); + +const sessionDid = Astro.cookies.get("session_did")?.value; +const isAuthor = sessionDid ? isOwner(sessionDid) : false; --- @@ -90,28 +101,38 @@ const entries = await getBlogEntries();

About

- -
-
-

- Writing about the things I build, break, and think about — mostly around - self-hosting, the AT Protocol, and whatever side project has my attention. -

-

- This blog runs on AT Protocol. - Every post is an atproto record stored on my personal PDS at - arcnode.xyz, - rendered here with Astro. No database, no CMS — just protocol-native content. -

-

- I'm a consultant by trade, a developer by habit. Currently building - ConsultPitch - and running my own infrastructure on Hetzner and Proxmox. -

-

- Houston, TX -

+
+ {isAuthor && ( + + )} + +
+ +
+ + {isAuthor && ( + + )}
@@ -126,6 +147,13 @@ const entries = await getBlogEntries(); const closeBtn = document.getElementById("about-close"); const backdrop = modal?.querySelector(".about-backdrop"); + const viewEl = document.getElementById("about-view"); + const editEl = document.getElementById("about-edit"); + const editBtn = document.getElementById("about-edit-btn"); + const textarea = document.getElementById("about-textarea") as HTMLTextAreaElement | null; + const saveBtn = document.getElementById("about-save"); + const cancelBtn = document.getElementById("about-cancel"); + if (!modal) return; function open() { @@ -136,6 +164,20 @@ const entries = await getBlogEntries(); function close() { modal!.classList.remove("is-open"); modal!.setAttribute("aria-hidden", "true"); + showView(); + } + + function showView() { + viewEl?.classList.remove("hidden"); + editEl?.classList.add("hidden"); + if (editBtn) editBtn.textContent = "edit"; + } + + function showEdit() { + viewEl?.classList.add("hidden"); + editEl?.classList.remove("hidden"); + if (editBtn) editBtn.textContent = "viewing"; + textarea?.focus(); } triggers.forEach((btn) => btn?.addEventListener("click", open)); @@ -145,6 +187,52 @@ const entries = await getBlogEntries(); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && modal!.classList.contains("is-open")) close(); }); + + // Edit toggle + editBtn?.addEventListener("click", () => { + if (editEl?.classList.contains("hidden")) { + showEdit(); + } else { + showView(); + } + }); + + cancelBtn?.addEventListener("click", showView); + + // Save + saveBtn?.addEventListener("click", async () => { + if (!textarea) return; + + const content = textarea.value.trim(); + if (!content) { + alert("About content cannot be empty."); + return; + } + + saveBtn.textContent = "saving..."; + (saveBtn as HTMLButtonElement).disabled = true; + + try { + const res = await fetch("/api/about", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); + + const data = await res.json(); + if (data.success) { + window.location.reload(); + } else { + alert(data.error || "Save failed"); + saveBtn.textContent = "save"; + (saveBtn as HTMLButtonElement).disabled = false; + } + } catch { + alert("Network error"); + saveBtn.textContent = "save"; + (saveBtn as HTMLButtonElement).disabled = false; + } + }); } initAboutModal(); diff --git a/src/styles/global.css b/src/styles/global.css index f053201..83f7ad9 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -338,3 +338,24 @@ body { .about-overlay.is-open .about-panel { transform: translateY(0); } + +/* About modal prose (rendered markdown) */ +.prose-about p { + margin-bottom: 1rem; +} + +.prose-about p:last-child { + margin-bottom: 0; +} + +.prose-about a { + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 3px; + text-decoration-color: color-mix(in srgb, var(--color-accent) 30%, transparent); + transition: text-decoration-color 0.2s; +} + +.prose-about a:hover { + text-decoration-color: var(--color-accent); +}