guestbook

nekomimi.pet 145279c6 4d6fe8bf

verified
+2
bun-env.d.ts
···
// Generated by `bun init`
+
/// <reference path="src/guestbook.d.ts" />
+
declare module "*.svg" {
/**
* A path to the SVG file
+13
bun.lock
···
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+
"cutebook": "0.1.1",
"lucide-react": "^0.545.0",
"react": "^19",
"react-dom": "^19",
···
"@atcute/lexicons": ["@atcute/lexicons@1.2.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "esm-env": "^1.2.2" } }, "sha512-bgEhJq5Z70/0TbK5sx+tAkrR8FsCODNiL2gUEvS5PuJfPxmFmRYNWaMGehxSPaXWpU2+Oa9ckceHiYbrItDTkA=="],
+
"@atcute/multibase": ["@atcute/multibase@1.1.6", "", { "dependencies": { "@atcute/uint8array": "^1.0.5" } }, "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg=="],
+
+
"@atcute/oauth-browser-client": ["@atcute/oauth-browser-client@2.0.1", "", { "dependencies": { "@atcute/client": "^4.0.5", "@atcute/identity": "^1.1.1", "@atcute/identity-resolver": "^1.1.4", "@atcute/lexicons": "^1.2.2", "@atcute/multibase": "^1.1.6", "@atcute/uint8array": "^1.0.5", "nanoid": "^5.1.5" } }, "sha512-lG021GkeORG06zfFf4bH85egObjBEKHNgAWHvbtY/E2dX4wxo88hf370pJDx8acdnuUJLJ2VKPikJtZwo4Heeg=="],
+
"@atcute/tangled": ["@atcute/tangled@1.0.10", "", { "dependencies": { "@atcute/atproto": "^3.1.8", "@atcute/lexicons": "^1.2.2" } }, "sha512-DGconZIN5TpLBah+aHGbWI1tMsL7XzyVEbr/fW4CbcLWYKICU6SAUZ0YnZ+5GvltjlORWHUy7hfftvoh4zodIA=="],
+
+
"@atcute/uint8array": ["@atcute/uint8array@1.0.5", "", {}, "sha512-XLWWxoR2HNl2qU+FCr0rp1APwJXci7HnzbOQLxK55OaMNBXZ19+xNC5ii4QCsThsDxa4JS/JTzuiQLziITWf2Q=="],
"@atcute/util-fetch": ["@atcute/util-fetch@1.0.3", "", { "dependencies": { "@badrap/valita": "^0.4.6" } }, "sha512-f8zzTb/xlKIwv2OQ31DhShPUNCmIIleX6p7qIXwWwEUjX6x8skUtpdISSjnImq01LXpltGV5y8yhV4/Mlb7CRQ=="],
···
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+
"actor-typeahead": ["actor-typeahead@0.1.2", "", {}, "sha512-I97YqqNl7Kar0J/bIJvgY/KmHpssHcDElhfwVTLP7wRFlkxso2ZLBqiS2zol5A8UVUJbQK2JXYaqNpZXz8Uk2A=="],
+
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"atproto-ui": ["atproto-ui@0.11.3", "", { "dependencies": { "@atcute/atproto": "^3.1.7", "@atcute/bluesky": "^3.2.3", "@atcute/client": "^4.0.3", "@atcute/identity-resolver": "^1.1.3", "@atcute/tangled": "^1.0.10" }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["react-dom"] }, "sha512-NIBsORuo9lpCpr1SNKcKhNvqOVpsEy9IoHqFe1CM9gNTArpQL1hUcoP1Cou9a1O5qzCul9kaiu5xBHnB81I/WQ=="],
···
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
+
"cutebook": ["cutebook@0.1.1", "", { "dependencies": { "actor-typeahead": "^0.1.2" }, "peerDependencies": { "@atcute/client": "^4.0.0", "@atcute/identity-resolver": "^1.0.0", "@atcute/oauth-browser-client": "^2.0.0" } }, "sha512-Wh4fpQUFwVnmKnLA8MOnNRbPstYv2EeC8KG1d9P6MMzupjMP2GRaDnixzg1ADvH2wBuVcpGDbGm4zyhN+h3D8w=="],
+
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
···
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"lucide-react": ["lucide-react@0.545.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw=="],
+
+
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
+1
package.json
···
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+
"cutebook": "0.1.1",
"lucide-react": "^0.545.0",
"react": "^19",
"react-dom": "^19",
+13
public/client-metadata.json
···
+
{
+
"client_id": "https://nekomimi.pet/client-metadata.json",
+
"client_name": "nekomimi.pet",
+
"client_uri": "https://nekomimi.pet",
+
"redirect_uris": ["https://nekomimi.pet/guestbook"],
+
"scope": "atproto transition:generic",
+
"grant_types": ["authorization_code", "refresh_token"],
+
"response_types": ["code"],
+
"token_endpoint_auth_method": "none",
+
"application_type": "web",
+
"dpop_bound_access_tokens": true
+
}
+
+41 -3
src/App.tsx
···
import { Header } from "./components/sections/Header"
import { Work } from "./components/sections/Work"
import { Connect } from "./components/sections/Connect"
+
import { GuestbookPage } from "./components/sections/GuestbookPage"
import { sections } from "./data/portfolio"
export function App() {
const [activeSection, setActiveSection] = useState("")
+
const [currentPath, setCurrentPath] = useState(window.location.pathname)
const sectionsRef = useRef<(HTMLElement | null)[]>([])
+
// Handle SPA navigation
useEffect(() => {
+
const handlePopState = () => setCurrentPath(window.location.pathname)
+
window.addEventListener('popstate', handlePopState)
+
return () => window.removeEventListener('popstate', handlePopState)
+
}, [])
+
+
useEffect(() => {
+
if (currentPath === '/guestbook') return // Skip observer on guestbook page
+
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
···
})
return () => observer.disconnect()
-
}, [])
+
}, [currentPath])
-
+
// Guestbook page
+
if (currentPath === '/guestbook') {
+
return (
+
<div className="min-h-screen dark:bg-background text-foreground relative">
+
<div className="fixed top-6 left-6 z-50">
+
<button
+
onClick={() => {
+
window.history.pushState({}, '', '/')
+
setCurrentPath('/')
+
}}
+
className="px-4 py-2 rounded-full text-sm font-medium bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 shadow-md hover:shadow-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all flex items-center gap-2"
+
>
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
+
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
+
</svg>
+
Back
+
</button>
+
</div>
+
<GuestbookPage />
+
</div>
+
)
+
}
return (
<div className="min-h-screen dark:bg-background text-foreground relative">
···
<main>
<div className="max-w-4xl mx-auto px-6 sm:px-8 lg:px-16">
-
<Header sectionRef={(el) => (sectionsRef.current[0] = el)} />
+
<Header
+
sectionRef={(el) => (sectionsRef.current[0] = el)}
+
onGuestbookClick={() => {
+
window.history.pushState({}, '', '/guestbook')
+
setCurrentPath('/guestbook')
+
}}
+
/>
</div>
<Work sectionRef={(el) => (sectionsRef.current[1] = el)} />
<Connect sectionRef={(el) => (sectionsRef.current[2] = el)} />
+198
src/components/GuestbookEntries.tsx
···
+
import { useEffect, useState } from "react"
+
+
interface GuestbookEntry {
+
uri: string
+
author: string
+
authorHandle?: string
+
message: string
+
createdAt: string
+
}
+
+
interface ConstellationRecord {
+
did: string
+
collection: string
+
rkey: string
+
}
+
+
const COLORS = [
+
'#dc2626', // red
+
'#0d9488', // teal
+
'#059669', // emerald
+
'#84cc16', // lime
+
'#ec4899', // pink
+
'#3b82f6', // blue
+
'#8b5cf6', // violet
+
]
+
+
function getColorForIndex(index: number): string {
+
return COLORS[index % COLORS.length]!
+
}
+
+
interface GuestbookEntriesProps {
+
did: string
+
limit?: number
+
onRefresh?: (refresh: () => void) => void
+
}
+
+
export function GuestbookEntries({ did, limit = 50, onRefresh }: GuestbookEntriesProps) {
+
const [entries, setEntries] = useState<GuestbookEntry[]>([])
+
const [loading, setLoading] = useState(true)
+
const [error, setError] = useState<string | null>(null)
+
+
const fetchEntries = async () => {
+
setLoading(true)
+
setError(null)
+
+
try {
+
const url = new URL('/xrpc/blue.microcosm.links.getBacklinks', 'https://constellation.microcosm.blue')
+
url.searchParams.set('subject', did)
+
url.searchParams.set('source', 'pet.nkp.guestbook.sign:subject')
+
url.searchParams.set('limit', limit.toString())
+
+
const response = await fetch(url.toString())
+
if (!response.ok) throw new Error('Failed to fetch signatures')
+
+
const data = await response.json()
+
+
if (!data.records || !Array.isArray(data.records)) {
+
setEntries([])
+
setLoading(false)
+
return
+
}
+
+
const fetchedEntries: GuestbookEntry[] = []
+
+
for (const record of data.records as ConstellationRecord[]) {
+
try {
+
const recordUrl = new URL('/xrpc/com.atproto.repo.getRecord', 'https://slingshot.wisp.place')
+
recordUrl.searchParams.set('repo', record.did)
+
recordUrl.searchParams.set('collection', record.collection)
+
recordUrl.searchParams.set('rkey', record.rkey)
+
+
const recordResponse = await fetch(recordUrl.toString())
+
if (!recordResponse.ok) continue
+
+
const recordData = await recordResponse.json()
+
+
if (
+
recordData.value &&
+
recordData.value.$type === 'pet.nkp.guestbook.sign' &&
+
typeof recordData.value.message === 'string'
+
) {
+
let authorHandle: string | undefined
+
try {
+
const profileResponse = await fetch(
+
`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${record.did}`
+
)
+
if (profileResponse.ok) {
+
const profileData = await profileResponse.json()
+
authorHandle = profileData.handle
+
}
+
} catch {}
+
+
fetchedEntries.push({
+
uri: recordData.uri,
+
author: record.did,
+
authorHandle,
+
message: recordData.value.message,
+
createdAt: recordData.value.createdAt,
+
})
+
}
+
} catch {}
+
}
+
+
// Sort by date, newest first
+
fetchedEntries.sort((a, b) =>
+
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
+
)
+
+
setEntries(fetchedEntries)
+
} catch (err) {
+
setError(err instanceof Error ? err.message : 'Failed to load entries')
+
} finally {
+
setLoading(false)
+
}
+
}
+
+
useEffect(() => {
+
fetchEntries()
+
onRefresh?.(() => fetchEntries())
+
}, [did, limit])
+
+
const formatDate = (isoString: string) => {
+
const date = new Date(isoString)
+
return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })
+
}
+
+
const shortenDid = (did: string) => {
+
if (did.startsWith('did:plc:')) {
+
return `${did.slice(0, 12)}...`
+
}
+
return did
+
}
+
+
if (loading) {
+
return (
+
<div className="text-center py-12 text-gray-500">
+
Loading entries...
+
</div>
+
)
+
}
+
+
if (error) {
+
return (
+
<div className="text-center py-12 text-red-500">
+
{error}
+
</div>
+
)
+
}
+
+
if (entries.length === 0) {
+
return (
+
<div className="text-center py-12 text-gray-500">
+
No entries yet. Be the first to sign!
+
</div>
+
)
+
}
+
+
return (
+
<div className="space-y-4">
+
{entries.map((entry, index) => (
+
<div
+
key={entry.uri}
+
className="bg-gray-100 dark:bg-gray-800/50 rounded-lg p-4 border-l-4 transition-colors"
+
style={{ borderLeftColor: getColorForIndex(index) }}
+
>
+
<div className="flex justify-between items-start mb-1">
+
<a
+
href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="font-semibold text-gray-900 dark:text-gray-100 hover:underline"
+
>
+
{entry.authorHandle || shortenDid(entry.author)}
+
</a>
+
<a
+
href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
+
style={{ color: getColorForIndex(index) }}
+
>
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
+
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
+
</svg>
+
</a>
+
</div>
+
<p className="text-gray-800 dark:text-gray-200 mb-2">
+
{entry.message}
+
</p>
+
<span className="text-sm text-gray-500 dark:text-gray-400">
+
{formatDate(entry.createdAt)}
+
</span>
+
</div>
+
))}
+
</div>
+
)
+
}
+
+84
src/components/sections/GuestbookPage.tsx
···
+
/// <reference path="../../guestbook.d.ts" />
+
import { useEffect, useRef } from "react"
+
import { configureGuestbook } from "cutebook/register"
+
import { GuestbookEntries } from "../GuestbookEntries"
+
+
// Configure guestbook once
+
let configured = false
+
if (!configured) {
+
const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
+
const port = window.location.port || '3000'
+
+
// For dev, use loopback client format matching the demo
+
// Client ID uses http://localhost, redirect_uri uses 127.0.0.1
+
const scope = 'atproto transition:generic'
+
const redirectUri = isDev ? `http://127.0.0.1:${port}/guestbook` : 'https://nekomimi.pet/guestbook'
+
const clientId = isDev
+
? `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`
+
: 'https://nekomimi.pet/client-metadata.json'
+
+
configureGuestbook({
+
oauth: {
+
clientId,
+
redirectUri,
+
scope,
+
},
+
})
+
configured = true
+
}
+
+
export function GuestbookPage() {
+
const refreshRef = useRef<(() => void) | null>(null)
+
+
const handleSignCreated = () => {
+
refreshRef.current?.()
+
}
+
+
useEffect(() => {
+
const signElement = document.querySelector('guestbook-sign')
+
if (signElement) {
+
signElement.addEventListener('sign-created', handleSignCreated)
+
return () => signElement.removeEventListener('sign-created', handleSignCreated)
+
}
+
}, [])
+
+
return (
+
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 dark:from-background dark:to-background py-12 px-6">
+
<div className="max-w-xl mx-auto">
+
{/* Header */}
+
<header className="mb-12 text-center">
+
<div className="inline-block mb-4">
+
<span className="text-5xl">📖</span>
+
</div>
+
<h1 className="text-3xl font-light tracking-tight text-gray-900 dark:text-gray-100 mb-3">
+
Ana's Guestbook
+
</h1>
+
<p className="text-gray-500 dark:text-gray-400 font-mono text-sm">
+
Leave a message, say hello
+
</p>
+
</header>
+
+
{/* Sign Form */}
+
<div className="mb-12 bg-white dark:bg-gray-900/50 rounded-2xl shadow-sm border border-gray-200/50 dark:border-gray-800 p-6">
+
<guestbook-sign did="did:plc:ttdrpj45ibqunmfhdsb4zdwq"></guestbook-sign>
+
</div>
+
+
{/* Entries Header */}
+
<div className="flex items-center gap-3 mb-6">
+
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 dark:via-gray-700 to-transparent"></div>
+
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 uppercase tracking-widest">
+
Messages
+
</span>
+
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 dark:via-gray-700 to-transparent"></div>
+
</div>
+
+
<GuestbookEntries
+
did="did:plc:ttdrpj45ibqunmfhdsb4zdwq"
+
limit={50}
+
onRefresh={(refresh) => { refreshRef.current = refresh }}
+
/>
+
</div>
+
</div>
+
)
+
}
+
+21 -1
src/components/sections/Header.tsx
···
interface HeaderProps {
sectionRef: (el: HTMLElement | null) => void
+
onGuestbookClick?: () => void
}
-
export function Header({ sectionRef }: HeaderProps) {
+
export function Header({ sectionRef, onGuestbookClick }: HeaderProps) {
const scrollToWork = () => {
document.getElementById('work')?.scrollIntoView({ behavior: 'smooth' })
}
···
Read my blog
</a>
</div>
+
<button
+
onClick={onGuestbookClick}
+
className="glass glass-hover w-full px-6 py-3 rounded-lg transition-all duration-300 inline-flex items-center justify-center gap-2 text-sm text-gray-300 hover:text-white"
+
>
+
<svg
+
className="w-4 h-4"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
strokeWidth={2}
+
>
+
<path
+
strokeLinecap="round"
+
strokeLinejoin="round"
+
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
+
/>
+
</svg>
+
Sign my guestbook
+
</button>
</div>
</div>
</div>
+27
src/guestbook.d.ts
···
+
import type { GuestbookSignElement, GuestbookDisplayElement } from "cutebook"
+
import type { DetailedHTMLProps, HTMLAttributes } from "react"
+
+
declare module "react" {
+
namespace JSX {
+
interface IntrinsicElements {
+
'guestbook-sign': DetailedHTMLProps<HTMLAttributes<HTMLElement> & {
+
did?: string;
+
}, HTMLElement>;
+
'guestbook-display': DetailedHTMLProps<HTMLAttributes<HTMLElement> & {
+
did?: string;
+
limit?: string;
+
ref?: any;
+
}, HTMLElement>;
+
}
+
}
+
}
+
+
declare global {
+
interface HTMLElementTagNameMap {
+
'guestbook-sign': GuestbookSignElement;
+
'guestbook-display': GuestbookDisplayElement;
+
}
+
}
+
+
export {}
+
+12
src/index.ts
···
});
},
+
// Serve client-metadata.json for OAuth
+
"/client-metadata.json": async () => {
+
try {
+
const file = Bun.file("public/client-metadata.json");
+
return new Response(file, {
+
headers: { "Content-Type": "application/json" },
+
});
+
} catch {
+
return new Response("File not found", { status: 404 });
+
}
+
},
+
// Serve static files from public directory
"/nekomata.png": async () => {
try {