guestbook

nekomimi.pet 145279c6 4d6fe8bf

verified
+2
bun-env.d.ts
··· 1 1 // Generated by `bun init` 2 2 3 + /// <reference path="src/guestbook.d.ts" /> 4 + 3 5 declare module "*.svg" { 4 6 /** 5 7 * A path to the SVG file
+13
bun.lock
··· 12 12 "bun-plugin-tailwind": "^0.1.2", 13 13 "class-variance-authority": "^0.7.1", 14 14 "clsx": "^2.1.1", 15 + "cutebook": "0.1.1", 15 16 "lucide-react": "^0.545.0", 16 17 "react": "^19", 17 18 "react-dom": "^19", ··· 39 40 40 41 "@atcute/lexicons": ["@atcute/lexicons@1.2.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "esm-env": "^1.2.2" } }, "sha512-bgEhJq5Z70/0TbK5sx+tAkrR8FsCODNiL2gUEvS5PuJfPxmFmRYNWaMGehxSPaXWpU2+Oa9ckceHiYbrItDTkA=="], 41 42 43 + "@atcute/multibase": ["@atcute/multibase@1.1.6", "", { "dependencies": { "@atcute/uint8array": "^1.0.5" } }, "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg=="], 44 + 45 + "@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=="], 46 + 42 47 "@atcute/tangled": ["@atcute/tangled@1.0.10", "", { "dependencies": { "@atcute/atproto": "^3.1.8", "@atcute/lexicons": "^1.2.2" } }, "sha512-DGconZIN5TpLBah+aHGbWI1tMsL7XzyVEbr/fW4CbcLWYKICU6SAUZ0YnZ+5GvltjlORWHUy7hfftvoh4zodIA=="], 48 + 49 + "@atcute/uint8array": ["@atcute/uint8array@1.0.5", "", {}, "sha512-XLWWxoR2HNl2qU+FCr0rp1APwJXci7HnzbOQLxK55OaMNBXZ19+xNC5ii4QCsThsDxa4JS/JTzuiQLziITWf2Q=="], 43 50 44 51 "@atcute/util-fetch": ["@atcute/util-fetch@1.0.3", "", { "dependencies": { "@badrap/valita": "^0.4.6" } }, "sha512-f8zzTb/xlKIwv2OQ31DhShPUNCmIIleX6p7qIXwWwEUjX6x8skUtpdISSjnImq01LXpltGV5y8yhV4/Mlb7CRQ=="], 45 52 ··· 139 146 140 147 "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], 141 148 149 + "actor-typeahead": ["actor-typeahead@0.1.2", "", {}, "sha512-I97YqqNl7Kar0J/bIJvgY/KmHpssHcDElhfwVTLP7wRFlkxso2ZLBqiS2zol5A8UVUJbQK2JXYaqNpZXz8Uk2A=="], 150 + 142 151 "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], 143 152 144 153 "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=="], ··· 155 164 156 165 "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 157 166 167 + "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=="], 168 + 158 169 "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], 159 170 160 171 "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], ··· 162 173 "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], 163 174 164 175 "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=="], 176 + 177 + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], 165 178 166 179 "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], 167 180
+1
package.json
··· 16 16 "bun-plugin-tailwind": "^0.1.2", 17 17 "class-variance-authority": "^0.7.1", 18 18 "clsx": "^2.1.1", 19 + "cutebook": "0.1.1", 19 20 "lucide-react": "^0.545.0", 20 21 "react": "^19", 21 22 "react-dom": "^19",
+13
public/client-metadata.json
··· 1 + { 2 + "client_id": "https://nekomimi.pet/client-metadata.json", 3 + "client_name": "nekomimi.pet", 4 + "client_uri": "https://nekomimi.pet", 5 + "redirect_uris": ["https://nekomimi.pet/guestbook"], 6 + "scope": "atproto transition:generic", 7 + "grant_types": ["authorization_code", "refresh_token"], 8 + "response_types": ["code"], 9 + "token_endpoint_auth_method": "none", 10 + "application_type": "web", 11 + "dpop_bound_access_tokens": true 12 + } 13 +
+41 -3
src/App.tsx
··· 4 4 import { Header } from "./components/sections/Header" 5 5 import { Work } from "./components/sections/Work" 6 6 import { Connect } from "./components/sections/Connect" 7 + import { GuestbookPage } from "./components/sections/GuestbookPage" 7 8 import { sections } from "./data/portfolio" 8 9 9 10 export function App() { 10 11 const [activeSection, setActiveSection] = useState("") 12 + const [currentPath, setCurrentPath] = useState(window.location.pathname) 11 13 const sectionsRef = useRef<(HTMLElement | null)[]>([]) 12 14 15 + // Handle SPA navigation 13 16 useEffect(() => { 17 + const handlePopState = () => setCurrentPath(window.location.pathname) 18 + window.addEventListener('popstate', handlePopState) 19 + return () => window.removeEventListener('popstate', handlePopState) 20 + }, []) 21 + 22 + useEffect(() => { 23 + if (currentPath === '/guestbook') return // Skip observer on guestbook page 24 + 14 25 const observer = new IntersectionObserver( 15 26 (entries) => { 16 27 entries.forEach((entry) => { ··· 28 39 }) 29 40 30 41 return () => observer.disconnect() 31 - }, []) 42 + }, [currentPath]) 32 43 33 - 44 + // Guestbook page 45 + if (currentPath === '/guestbook') { 46 + return ( 47 + <div className="min-h-screen dark:bg-background text-foreground relative"> 48 + <div className="fixed top-6 left-6 z-50"> 49 + <button 50 + onClick={() => { 51 + window.history.pushState({}, '', '/') 52 + setCurrentPath('/') 53 + }} 54 + 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" 55 + > 56 + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}> 57 + <path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" /> 58 + </svg> 59 + Back 60 + </button> 61 + </div> 62 + <GuestbookPage /> 63 + </div> 64 + ) 65 + } 34 66 35 67 return ( 36 68 <div className="min-h-screen dark:bg-background text-foreground relative"> ··· 38 70 39 71 <main> 40 72 <div className="max-w-4xl mx-auto px-6 sm:px-8 lg:px-16"> 41 - <Header sectionRef={(el) => (sectionsRef.current[0] = el)} /> 73 + <Header 74 + sectionRef={(el) => (sectionsRef.current[0] = el)} 75 + onGuestbookClick={() => { 76 + window.history.pushState({}, '', '/guestbook') 77 + setCurrentPath('/guestbook') 78 + }} 79 + /> 42 80 </div> 43 81 <Work sectionRef={(el) => (sectionsRef.current[1] = el)} /> 44 82 <Connect sectionRef={(el) => (sectionsRef.current[2] = el)} />
+198
src/components/GuestbookEntries.tsx
··· 1 + import { useEffect, useState } from "react" 2 + 3 + interface GuestbookEntry { 4 + uri: string 5 + author: string 6 + authorHandle?: string 7 + message: string 8 + createdAt: string 9 + } 10 + 11 + interface ConstellationRecord { 12 + did: string 13 + collection: string 14 + rkey: string 15 + } 16 + 17 + const COLORS = [ 18 + '#dc2626', // red 19 + '#0d9488', // teal 20 + '#059669', // emerald 21 + '#84cc16', // lime 22 + '#ec4899', // pink 23 + '#3b82f6', // blue 24 + '#8b5cf6', // violet 25 + ] 26 + 27 + function getColorForIndex(index: number): string { 28 + return COLORS[index % COLORS.length]! 29 + } 30 + 31 + interface GuestbookEntriesProps { 32 + did: string 33 + limit?: number 34 + onRefresh?: (refresh: () => void) => void 35 + } 36 + 37 + export function GuestbookEntries({ did, limit = 50, onRefresh }: GuestbookEntriesProps) { 38 + const [entries, setEntries] = useState<GuestbookEntry[]>([]) 39 + const [loading, setLoading] = useState(true) 40 + const [error, setError] = useState<string | null>(null) 41 + 42 + const fetchEntries = async () => { 43 + setLoading(true) 44 + setError(null) 45 + 46 + try { 47 + const url = new URL('/xrpc/blue.microcosm.links.getBacklinks', 'https://constellation.microcosm.blue') 48 + url.searchParams.set('subject', did) 49 + url.searchParams.set('source', 'pet.nkp.guestbook.sign:subject') 50 + url.searchParams.set('limit', limit.toString()) 51 + 52 + const response = await fetch(url.toString()) 53 + if (!response.ok) throw new Error('Failed to fetch signatures') 54 + 55 + const data = await response.json() 56 + 57 + if (!data.records || !Array.isArray(data.records)) { 58 + setEntries([]) 59 + setLoading(false) 60 + return 61 + } 62 + 63 + const fetchedEntries: GuestbookEntry[] = [] 64 + 65 + for (const record of data.records as ConstellationRecord[]) { 66 + try { 67 + const recordUrl = new URL('/xrpc/com.atproto.repo.getRecord', 'https://slingshot.wisp.place') 68 + recordUrl.searchParams.set('repo', record.did) 69 + recordUrl.searchParams.set('collection', record.collection) 70 + recordUrl.searchParams.set('rkey', record.rkey) 71 + 72 + const recordResponse = await fetch(recordUrl.toString()) 73 + if (!recordResponse.ok) continue 74 + 75 + const recordData = await recordResponse.json() 76 + 77 + if ( 78 + recordData.value && 79 + recordData.value.$type === 'pet.nkp.guestbook.sign' && 80 + typeof recordData.value.message === 'string' 81 + ) { 82 + let authorHandle: string | undefined 83 + try { 84 + const profileResponse = await fetch( 85 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${record.did}` 86 + ) 87 + if (profileResponse.ok) { 88 + const profileData = await profileResponse.json() 89 + authorHandle = profileData.handle 90 + } 91 + } catch {} 92 + 93 + fetchedEntries.push({ 94 + uri: recordData.uri, 95 + author: record.did, 96 + authorHandle, 97 + message: recordData.value.message, 98 + createdAt: recordData.value.createdAt, 99 + }) 100 + } 101 + } catch {} 102 + } 103 + 104 + // Sort by date, newest first 105 + fetchedEntries.sort((a, b) => 106 + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 107 + ) 108 + 109 + setEntries(fetchedEntries) 110 + } catch (err) { 111 + setError(err instanceof Error ? err.message : 'Failed to load entries') 112 + } finally { 113 + setLoading(false) 114 + } 115 + } 116 + 117 + useEffect(() => { 118 + fetchEntries() 119 + onRefresh?.(() => fetchEntries()) 120 + }, [did, limit]) 121 + 122 + const formatDate = (isoString: string) => { 123 + const date = new Date(isoString) 124 + return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric' }) 125 + } 126 + 127 + const shortenDid = (did: string) => { 128 + if (did.startsWith('did:plc:')) { 129 + return `${did.slice(0, 12)}...` 130 + } 131 + return did 132 + } 133 + 134 + if (loading) { 135 + return ( 136 + <div className="text-center py-12 text-gray-500"> 137 + Loading entries... 138 + </div> 139 + ) 140 + } 141 + 142 + if (error) { 143 + return ( 144 + <div className="text-center py-12 text-red-500"> 145 + {error} 146 + </div> 147 + ) 148 + } 149 + 150 + if (entries.length === 0) { 151 + return ( 152 + <div className="text-center py-12 text-gray-500"> 153 + No entries yet. Be the first to sign! 154 + </div> 155 + ) 156 + } 157 + 158 + return ( 159 + <div className="space-y-4"> 160 + {entries.map((entry, index) => ( 161 + <div 162 + key={entry.uri} 163 + className="bg-gray-100 dark:bg-gray-800/50 rounded-lg p-4 border-l-4 transition-colors" 164 + style={{ borderLeftColor: getColorForIndex(index) }} 165 + > 166 + <div className="flex justify-between items-start mb-1"> 167 + <a 168 + href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`} 169 + target="_blank" 170 + rel="noopener noreferrer" 171 + className="font-semibold text-gray-900 dark:text-gray-100 hover:underline" 172 + > 173 + {entry.authorHandle || shortenDid(entry.author)} 174 + </a> 175 + <a 176 + href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`} 177 + target="_blank" 178 + rel="noopener noreferrer" 179 + className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" 180 + style={{ color: getColorForIndex(index) }} 181 + > 182 + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}> 183 + <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" /> 184 + </svg> 185 + </a> 186 + </div> 187 + <p className="text-gray-800 dark:text-gray-200 mb-2"> 188 + {entry.message} 189 + </p> 190 + <span className="text-sm text-gray-500 dark:text-gray-400"> 191 + {formatDate(entry.createdAt)} 192 + </span> 193 + </div> 194 + ))} 195 + </div> 196 + ) 197 + } 198 +
+84
src/components/sections/GuestbookPage.tsx
··· 1 + /// <reference path="../../guestbook.d.ts" /> 2 + import { useEffect, useRef } from "react" 3 + import { configureGuestbook } from "cutebook/register" 4 + import { GuestbookEntries } from "../GuestbookEntries" 5 + 6 + // Configure guestbook once 7 + let configured = false 8 + if (!configured) { 9 + const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' 10 + const port = window.location.port || '3000' 11 + 12 + // For dev, use loopback client format matching the demo 13 + // Client ID uses http://localhost, redirect_uri uses 127.0.0.1 14 + const scope = 'atproto transition:generic' 15 + const redirectUri = isDev ? `http://127.0.0.1:${port}/guestbook` : 'https://nekomimi.pet/guestbook' 16 + const clientId = isDev 17 + ? `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}` 18 + : 'https://nekomimi.pet/client-metadata.json' 19 + 20 + configureGuestbook({ 21 + oauth: { 22 + clientId, 23 + redirectUri, 24 + scope, 25 + }, 26 + }) 27 + configured = true 28 + } 29 + 30 + export function GuestbookPage() { 31 + const refreshRef = useRef<(() => void) | null>(null) 32 + 33 + const handleSignCreated = () => { 34 + refreshRef.current?.() 35 + } 36 + 37 + useEffect(() => { 38 + const signElement = document.querySelector('guestbook-sign') 39 + if (signElement) { 40 + signElement.addEventListener('sign-created', handleSignCreated) 41 + return () => signElement.removeEventListener('sign-created', handleSignCreated) 42 + } 43 + }, []) 44 + 45 + return ( 46 + <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"> 47 + <div className="max-w-xl mx-auto"> 48 + {/* Header */} 49 + <header className="mb-12 text-center"> 50 + <div className="inline-block mb-4"> 51 + <span className="text-5xl">📖</span> 52 + </div> 53 + <h1 className="text-3xl font-light tracking-tight text-gray-900 dark:text-gray-100 mb-3"> 54 + Ana's Guestbook 55 + </h1> 56 + <p className="text-gray-500 dark:text-gray-400 font-mono text-sm"> 57 + Leave a message, say hello 58 + </p> 59 + </header> 60 + 61 + {/* Sign Form */} 62 + <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"> 63 + <guestbook-sign did="did:plc:ttdrpj45ibqunmfhdsb4zdwq"></guestbook-sign> 64 + </div> 65 + 66 + {/* Entries Header */} 67 + <div className="flex items-center gap-3 mb-6"> 68 + <div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 dark:via-gray-700 to-transparent"></div> 69 + <span className="text-xs font-mono text-gray-400 dark:text-gray-500 uppercase tracking-widest"> 70 + Messages 71 + </span> 72 + <div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 dark:via-gray-700 to-transparent"></div> 73 + </div> 74 + 75 + <GuestbookEntries 76 + did="did:plc:ttdrpj45ibqunmfhdsb4zdwq" 77 + limit={50} 78 + onRefresh={(refresh) => { refreshRef.current = refresh }} 79 + /> 80 + </div> 81 + </div> 82 + ) 83 + } 84 +
+21 -1
src/components/sections/Header.tsx
··· 4 4 5 5 interface HeaderProps { 6 6 sectionRef: (el: HTMLElement | null) => void 7 + onGuestbookClick?: () => void 7 8 } 8 9 9 - export function Header({ sectionRef }: HeaderProps) { 10 + export function Header({ sectionRef, onGuestbookClick }: HeaderProps) { 10 11 const scrollToWork = () => { 11 12 document.getElementById('work')?.scrollIntoView({ behavior: 'smooth' }) 12 13 } ··· 115 116 Read my blog 116 117 </a> 117 118 </div> 119 + <button 120 + onClick={onGuestbookClick} 121 + 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" 122 + > 123 + <svg 124 + className="w-4 h-4" 125 + fill="none" 126 + stroke="currentColor" 127 + viewBox="0 0 24 24" 128 + strokeWidth={2} 129 + > 130 + <path 131 + strokeLinecap="round" 132 + strokeLinejoin="round" 133 + 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" 134 + /> 135 + </svg> 136 + Sign my guestbook 137 + </button> 118 138 </div> 119 139 </div> 120 140 </div>
+27
src/guestbook.d.ts
··· 1 + import type { GuestbookSignElement, GuestbookDisplayElement } from "cutebook" 2 + import type { DetailedHTMLProps, HTMLAttributes } from "react" 3 + 4 + declare module "react" { 5 + namespace JSX { 6 + interface IntrinsicElements { 7 + 'guestbook-sign': DetailedHTMLProps<HTMLAttributes<HTMLElement> & { 8 + did?: string; 9 + }, HTMLElement>; 10 + 'guestbook-display': DetailedHTMLProps<HTMLAttributes<HTMLElement> & { 11 + did?: string; 12 + limit?: string; 13 + ref?: any; 14 + }, HTMLElement>; 15 + } 16 + } 17 + } 18 + 19 + declare global { 20 + interface HTMLElementTagNameMap { 21 + 'guestbook-sign': GuestbookSignElement; 22 + 'guestbook-display': GuestbookDisplayElement; 23 + } 24 + } 25 + 26 + export {} 27 +
+12
src/index.ts
··· 11 11 }); 12 12 }, 13 13 14 + // Serve client-metadata.json for OAuth 15 + "/client-metadata.json": async () => { 16 + try { 17 + const file = Bun.file("public/client-metadata.json"); 18 + return new Response(file, { 19 + headers: { "Content-Type": "application/json" }, 20 + }); 21 + } catch { 22 + return new Response("File not found", { status: 404 }); 23 + } 24 + }, 25 + 14 26 // Serve static files from public directory 15 27 "/nekomata.png": async () => { 16 28 try {