+1
.tangled/workflows/deploy.yml
+1
.tangled/workflows/deploy.yml
-184
CRUSH.md
-184
CRUSH.md
···
1
-
# CRUSH.md
2
-
3
-
## Project Overview
4
-
5
-
This is a personal portfolio website built with Bun, React, TypeScript, and Tailwind CSS. It uses the shadcn/ui component library and serves as both a portfolio and development environment for AT Protocol-related projects. The project demonstrates modern web development practices with a focus on decentralized technologies.
6
-
7
-
## Development Commands
8
-
9
-
### Core Commands
10
-
- `bun install` - Install dependencies
11
-
- `bun dev` - Start development server with hot reload and HMR
12
-
- `bun start` - Run production server
13
-
- `bun run build.ts` - Build for production (outputs to `dist/`)
14
-
- `bun run build.ts --help` - Show all build options
15
-
16
-
### Build System
17
-
The custom build script (`build.ts`) supports various options:
18
-
- `--outdir <path>` - Output directory (default: "dist")
19
-
- `--minify` - Enable minification
20
-
- `--sourcemap <type>` - Sourcemap type (none|linked|inline|external)
21
-
- `--external <list>` - External packages (comma separated)
22
-
23
-
The build automatically:
24
-
- Processes all HTML files in `src/` as entrypoints
25
-
- Copies `public/` folder to dist
26
-
- Uses Tailwind plugin for CSS processing
27
-
- Includes linked sourcemaps by default
28
-
29
-
## Architecture
30
-
31
-
### Project Structure
32
-
```
33
-
src/
34
-
โโโ components/
35
-
โ โโโ ui/ # shadcn/ui components (Button, Card, Input, etc.)
36
-
โ โโโ sections/ # Main page sections (Header, Work, Connect)
37
-
โ โโโ ... # Other React components
38
-
โโโ data/
39
-
โ โโโ portfolio.ts # Portfolio content and metadata
40
-
โโโ hooks/ # Custom React hooks
41
-
โโโ lib/ # Utility functions
42
-
โโโ styles/ # Global CSS and Tailwind config
43
-
```
44
-
45
-
### Server Architecture
46
-
- Uses Bun's built-in server (`src/index.ts`)
47
-
- Serves React SPA with API routes
48
-
- API routes use pattern matching (`/api/hello/:name`)
49
-
- CORS headers configured for cross-origin requests
50
-
- Development mode includes HMR and browser console echoing
51
-
52
-
### Key Files
53
-
- `src/index.ts` - Server entry point with API routes
54
-
- `src/App.tsx` - Main React component with intersection observer animations
55
-
- `src/data/portfolio.ts` - All portfolio content (personal info, work experience, skills)
56
-
- `build.ts` - Custom build script with extensive CLI options
57
-
- `styles/globals.css` - Tailwind imports, CSS variables, and custom animations
58
-
59
-
## Code Conventions
60
-
61
-
### TypeScript Configuration
62
-
- Strict mode enabled with `noUncheckedIndexedAccess`
63
-
- Path aliases: `@/*` maps to `./src/*`
64
-
- JSX: `react-jsx` transform
65
-
- Module resolution: `bundler` mode
66
-
- Target: `ESNext` with DOM libraries
67
-
68
-
### Component Patterns
69
-
- Uses shadcn/ui component library with `class-variance-authority`
70
-
- Utility function `cn()` combines `clsx` and `tailwind-merge`
71
-
- Components follow Radix UI patterns for accessibility
72
-
- File exports: Named exports for components, default for main App
73
-
74
-
### Styling
75
-
- Tailwind CSS v4 with custom CSS variables
76
-
- Dark theme by default with light mode support
77
-
- Glassmorphism effects with custom utilities
78
-
- Custom animations: `fade-in-up`, `bounce-slow`
79
-
- Fira Code monospace font throughout
80
-
81
-
### Import Aliases (from components.json)
82
-
```typescript
83
-
"@/components" โ "./src/components"
84
-
"@/lib/utils" โ "./src/lib/utils"
85
-
"@/components/ui" โ "./src/components/ui"
86
-
"@/lib" โ "./src/lib"
87
-
"@/hooks" โ "./src/hooks"
88
-
```
89
-
90
-
## UI Components
91
-
92
-
### shadcn/ui Integration
93
-
The project uses shadcn/ui with:
94
-
- Style variant: "new-york"
95
-
- Base color: "neutral"
96
-
- Icon library: Lucide React
97
-
- CSS variables enabled
98
-
- Custom CSS location: `styles/globals.css`
99
-
100
-
### Available UI Components
101
-
- Button (multiple variants: default, destructive, outline, secondary, ghost, link)
102
-
- Card
103
-
- Input
104
-
- Label
105
-
- Select
106
-
- Textarea
107
-
108
-
### Custom Components
109
-
- ThemeToggle (dark/light mode switching)
110
-
- SectionNav (navigation between portfolio sections)
111
-
- ProjectCard/WorkExperienceCard (portfolio item displays)
112
-
- SocialLink (social media links with icons)
113
-
114
-
## Content Management
115
-
116
-
Portfolio data is centralized in `src/data/portfolio.ts`:
117
-
- `personalInfo` - Name, title, description, availability, contact
118
-
- `currentRole` - Current employment status
119
-
- `skills` - Array of technical skills
120
-
- `workExperience` - Array of work history with projects
121
-
- `socialLinks` - Social media profiles
122
-
- `sections` - Page section identifiers
123
-
124
-
The description format supports rich text with bold styling and URLs:
125
-
```typescript
126
-
type DescriptionPart = {
127
-
text: string
128
-
bold?: boolean
129
-
url?: string
130
-
}
131
-
```
132
-
133
-
## Deployment
134
-
135
-
### Netlify Configuration
136
-
- Static site hosting
137
-
- CORS headers configured in `public/netlify.toml`
138
-
- AT Protocol DID file at `public/.well-known/atproto-did`
139
-
140
-
### Build Output
141
-
- Production builds output to `dist/`
142
-
- All HTML files in `src/` become entrypoints
143
-
- Public assets copied automatically
144
-
- Source maps linked for debugging
145
-
146
-
## Development Notes
147
-
148
-
### Hot Module Replacement
149
-
- Development server includes HMR
150
-
- Browser console logs echoed to server
151
-
- Automatic reloading on file changes
152
-
153
-
### Performance Features
154
-
- Intersection Observer for scroll-triggered animations
155
-
- Code splitting support in build configuration
156
-
- Minification enabled by default in production
157
-
- Lazy loading with `react` imports
158
-
159
-
### AT Protocol Integration
160
-
- Project showcases AT Protocol-related work
161
-
- Uses `atproto-ui` component library
162
-
- Bluesky and Tangled integration in portfolio
163
-
164
-
## Gotchas
165
-
166
-
### Build System
167
-
- Custom build script requires Bun runtime (not Node.js)
168
-
- HTML files in `src/` automatically become entrypoints
169
-
- Must use `--external` flag for libraries that shouldn't be bundled
170
-
171
-
### Styling
172
-
- Dark mode is default styling approach
173
-
- CSS variables are used extensively for theming
174
-
- Custom glassmorphism effects require SVG filters (defined in CSS)
175
-
176
-
### Server Routes
177
-
- API routes use Bun's pattern matching syntax
178
-
- All unmatched routes serve the main SPA (catch-all route)
179
-
- CORS headers pre-configured for API access
180
-
181
-
### Content Structure
182
-
- Portfolio content is TypeScript data, not markdown
183
-
- Rich text descriptions use specific object structure
184
-
- Projects support multiple links (live demo, GitHub, etc.)
+1
-21
README.md
+1
-21
README.md
···
1
-
# bun-react-tailwind-shadcn-template
2
-
3
-
To install dependencies:
4
-
5
-
```bash
6
-
bun install
7
-
```
8
-
9
-
To start a development server:
10
-
11
-
```bash
12
-
bun dev
13
-
```
14
-
15
-
To run for production:
16
-
17
-
```bash
18
-
bun start
19
-
```
20
-
21
-
This project was created using `bun init` in bun v1.3.1. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
1
+
https://nekomimi.pet
+2
bun-env.d.ts
+2
bun-env.d.ts
+13
bun.lock
+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
+1
package.json
+13
public/client-metadata.json
+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
+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)} />
+252
src/components/GuestbookEntries.tsx
+252
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 (signal: AbortSignal) => {
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(), { signal })
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
+
// Collect all entries first, then render once
64
+
const entryPromises = (data.records as ConstellationRecord[]).map(async (record) => {
65
+
try {
66
+
const recordUrl = new URL('/xrpc/com.atproto.repo.getRecord', 'https://slingshot.wisp.place')
67
+
recordUrl.searchParams.set('repo', record.did)
68
+
recordUrl.searchParams.set('collection', record.collection)
69
+
recordUrl.searchParams.set('rkey', record.rkey)
70
+
71
+
const recordResponse = await fetch(recordUrl.toString(), { signal })
72
+
if (!recordResponse.ok) return null
73
+
74
+
const recordData = await recordResponse.json()
75
+
76
+
if (
77
+
recordData.value &&
78
+
recordData.value.$type === 'pet.nkp.guestbook.sign' &&
79
+
typeof recordData.value.message === 'string'
80
+
) {
81
+
return {
82
+
uri: recordData.uri,
83
+
author: record.did,
84
+
authorHandle: undefined,
85
+
message: recordData.value.message,
86
+
createdAt: recordData.value.createdAt,
87
+
} as GuestbookEntry
88
+
}
89
+
} catch (err) {
90
+
if (err instanceof Error && err.name === 'AbortError') throw err
91
+
}
92
+
return null
93
+
})
94
+
95
+
const results = await Promise.all(entryPromises)
96
+
const validEntries = results.filter((e): e is GuestbookEntry => e !== null)
97
+
98
+
// Sort once and set all entries at once
99
+
validEntries.sort((a, b) =>
100
+
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
101
+
)
102
+
103
+
setEntries(validEntries)
104
+
setLoading(false)
105
+
106
+
// Batch fetch profiles asynchronously
107
+
if (validEntries.length > 0) {
108
+
const uniqueDids = Array.from(new Set(validEntries.map(e => e.author)))
109
+
110
+
// Batch fetch profiles up to 25 at a time (API limit)
111
+
const profilePromises = []
112
+
for (let i = 0; i < uniqueDids.length; i += 25) {
113
+
const batch = uniqueDids.slice(i, i + 25)
114
+
115
+
const profileUrl = new URL('/xrpc/app.bsky.actor.getProfiles', 'https://public.api.bsky.app')
116
+
batch.forEach(d => profileUrl.searchParams.append('actors', d))
117
+
118
+
profilePromises.push(
119
+
fetch(profileUrl.toString(), { signal })
120
+
.then(profileResponse => profileResponse.ok ? profileResponse.json() : null)
121
+
.then(profilesData => {
122
+
if (profilesData?.profiles && Array.isArray(profilesData.profiles)) {
123
+
const handles = new Map<string, string>()
124
+
profilesData.profiles.forEach((profile: any) => {
125
+
if (profile.handle) {
126
+
handles.set(profile.did, profile.handle)
127
+
}
128
+
})
129
+
return handles
130
+
}
131
+
return new Map<string, string>()
132
+
})
133
+
.catch((err) => {
134
+
if (err instanceof Error && err.name === 'AbortError') throw err
135
+
return new Map<string, string>()
136
+
})
137
+
)
138
+
}
139
+
140
+
// Wait for all profile batches, then update once
141
+
const handleMaps = await Promise.all(profilePromises)
142
+
const allHandles = new Map<string, string>()
143
+
handleMaps.forEach(map => {
144
+
map.forEach((handle, did) => allHandles.set(did, handle))
145
+
})
146
+
147
+
if (allHandles.size > 0) {
148
+
setEntries(prev => prev.map(entry => {
149
+
const handle = allHandles.get(entry.author)
150
+
return handle ? { ...entry, authorHandle: handle } : entry
151
+
}))
152
+
}
153
+
}
154
+
} catch (err) {
155
+
if (err instanceof Error && err.name === 'AbortError') return
156
+
setError(err instanceof Error ? err.message : 'Failed to load entries')
157
+
setLoading(false)
158
+
}
159
+
}
160
+
161
+
useEffect(() => {
162
+
const abortController = new AbortController()
163
+
fetchEntries(abortController.signal)
164
+
onRefresh?.(() => {
165
+
abortController.abort()
166
+
const newController = new AbortController()
167
+
fetchEntries(newController.signal)
168
+
})
169
+
170
+
return () => abortController.abort()
171
+
}, [did, limit])
172
+
173
+
const formatDate = (isoString: string) => {
174
+
const date = new Date(isoString)
175
+
return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })
176
+
}
177
+
178
+
const shortenDid = (did: string) => {
179
+
if (did.startsWith('did:')) {
180
+
const afterPrefix = did.indexOf(':', 4)
181
+
if (afterPrefix !== -1) {
182
+
return `${did.slice(0, afterPrefix + 9)}...`
183
+
}
184
+
}
185
+
return did
186
+
}
187
+
188
+
if (loading) {
189
+
return (
190
+
<div className="text-center py-12 text-gray-500">
191
+
Loading entries...
192
+
</div>
193
+
)
194
+
}
195
+
196
+
if (error) {
197
+
return (
198
+
<div className="text-center py-12 text-red-500">
199
+
{error}
200
+
</div>
201
+
)
202
+
}
203
+
204
+
if (entries.length === 0) {
205
+
return (
206
+
<div className="text-center py-12 text-gray-500">
207
+
No entries yet. Be the first to sign!
208
+
</div>
209
+
)
210
+
}
211
+
212
+
return (
213
+
<div className="space-y-4">
214
+
{entries.map((entry, index) => (
215
+
<div
216
+
key={entry.uri}
217
+
className="bg-gray-100 rounded-lg p-4 border-l-4 transition-colors"
218
+
style={{ borderLeftColor: getColorForIndex(index) }}
219
+
>
220
+
<div className="flex justify-between items-start mb-1">
221
+
<a
222
+
href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`}
223
+
target="_blank"
224
+
rel="noopener noreferrer"
225
+
className="font-semibold text-gray-900 hover:underline"
226
+
>
227
+
{entry.authorHandle || shortenDid(entry.author)}
228
+
</a>
229
+
<a
230
+
href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`}
231
+
target="_blank"
232
+
rel="noopener noreferrer"
233
+
className="text-gray-400 hover:text-gray-600"
234
+
style={{ color: getColorForIndex(index) }}
235
+
>
236
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
237
+
<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" />
238
+
</svg>
239
+
</a>
240
+
</div>
241
+
<p className="text-gray-800 mb-2">
242
+
{entry.message}
243
+
</p>
244
+
<span className="text-sm text-gray-500">
245
+
{formatDate(entry.createdAt)}
246
+
</span>
247
+
</div>
248
+
))}
249
+
</div>
250
+
)
251
+
}
252
+
+84
src/components/sections/GuestbookPage.tsx
+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 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 mb-3">
54
+
Ana's Guestbook
55
+
</h1>
56
+
<p className="text-gray-500 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 rounded-2xl shadow-sm border border-gray-200/50 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 to-transparent"></div>
69
+
<span className="text-xs font-mono text-gray-400 uppercase tracking-widest">
70
+
Messages
71
+
</span>
72
+
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 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
+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
+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
+
+1
-1
src/index.html
+1
-1
src/index.html
···
3
3
<head>
4
4
<meta charset="UTF-8" />
5
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
-
<title>Bun + React</title>
6
+
<title>nekomimi.pet</title>
7
7
<link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
<link
+12
src/index.ts
+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 {