···11-# CRUSH.md
22-33-## Project Overview
44-55-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.
66-77-## Development Commands
88-99-### Core Commands
1010-- `bun install` - Install dependencies
1111-- `bun dev` - Start development server with hot reload and HMR
1212-- `bun start` - Run production server
1313-- `bun run build.ts` - Build for production (outputs to `dist/`)
1414-- `bun run build.ts --help` - Show all build options
1515-1616-### Build System
1717-The custom build script (`build.ts`) supports various options:
1818-- `--outdir <path>` - Output directory (default: "dist")
1919-- `--minify` - Enable minification
2020-- `--sourcemap <type>` - Sourcemap type (none|linked|inline|external)
2121-- `--external <list>` - External packages (comma separated)
2222-2323-The build automatically:
2424-- Processes all HTML files in `src/` as entrypoints
2525-- Copies `public/` folder to dist
2626-- Uses Tailwind plugin for CSS processing
2727-- Includes linked sourcemaps by default
2828-2929-## Architecture
3030-3131-### Project Structure
3232-```
3333-src/
3434-├── components/
3535-│ ├── ui/ # shadcn/ui components (Button, Card, Input, etc.)
3636-│ ├── sections/ # Main page sections (Header, Work, Connect)
3737-│ └── ... # Other React components
3838-├── data/
3939-│ └── portfolio.ts # Portfolio content and metadata
4040-├── hooks/ # Custom React hooks
4141-├── lib/ # Utility functions
4242-└── styles/ # Global CSS and Tailwind config
4343-```
4444-4545-### Server Architecture
4646-- Uses Bun's built-in server (`src/index.ts`)
4747-- Serves React SPA with API routes
4848-- API routes use pattern matching (`/api/hello/:name`)
4949-- CORS headers configured for cross-origin requests
5050-- Development mode includes HMR and browser console echoing
5151-5252-### Key Files
5353-- `src/index.ts` - Server entry point with API routes
5454-- `src/App.tsx` - Main React component with intersection observer animations
5555-- `src/data/portfolio.ts` - All portfolio content (personal info, work experience, skills)
5656-- `build.ts` - Custom build script with extensive CLI options
5757-- `styles/globals.css` - Tailwind imports, CSS variables, and custom animations
5858-5959-## Code Conventions
6060-6161-### TypeScript Configuration
6262-- Strict mode enabled with `noUncheckedIndexedAccess`
6363-- Path aliases: `@/*` maps to `./src/*`
6464-- JSX: `react-jsx` transform
6565-- Module resolution: `bundler` mode
6666-- Target: `ESNext` with DOM libraries
6767-6868-### Component Patterns
6969-- Uses shadcn/ui component library with `class-variance-authority`
7070-- Utility function `cn()` combines `clsx` and `tailwind-merge`
7171-- Components follow Radix UI patterns for accessibility
7272-- File exports: Named exports for components, default for main App
7373-7474-### Styling
7575-- Tailwind CSS v4 with custom CSS variables
7676-- Dark theme by default with light mode support
7777-- Glassmorphism effects with custom utilities
7878-- Custom animations: `fade-in-up`, `bounce-slow`
7979-- Fira Code monospace font throughout
8080-8181-### Import Aliases (from components.json)
8282-```typescript
8383-"@/components" → "./src/components"
8484-"@/lib/utils" → "./src/lib/utils"
8585-"@/components/ui" → "./src/components/ui"
8686-"@/lib" → "./src/lib"
8787-"@/hooks" → "./src/hooks"
8888-```
8989-9090-## UI Components
9191-9292-### shadcn/ui Integration
9393-The project uses shadcn/ui with:
9494-- Style variant: "new-york"
9595-- Base color: "neutral"
9696-- Icon library: Lucide React
9797-- CSS variables enabled
9898-- Custom CSS location: `styles/globals.css`
9999-100100-### Available UI Components
101101-- Button (multiple variants: default, destructive, outline, secondary, ghost, link)
102102-- Card
103103-- Input
104104-- Label
105105-- Select
106106-- Textarea
107107-108108-### Custom Components
109109-- ThemeToggle (dark/light mode switching)
110110-- SectionNav (navigation between portfolio sections)
111111-- ProjectCard/WorkExperienceCard (portfolio item displays)
112112-- SocialLink (social media links with icons)
113113-114114-## Content Management
115115-116116-Portfolio data is centralized in `src/data/portfolio.ts`:
117117-- `personalInfo` - Name, title, description, availability, contact
118118-- `currentRole` - Current employment status
119119-- `skills` - Array of technical skills
120120-- `workExperience` - Array of work history with projects
121121-- `socialLinks` - Social media profiles
122122-- `sections` - Page section identifiers
123123-124124-The description format supports rich text with bold styling and URLs:
125125-```typescript
126126-type DescriptionPart = {
127127- text: string
128128- bold?: boolean
129129- url?: string
130130-}
131131-```
132132-133133-## Deployment
134134-135135-### Netlify Configuration
136136-- Static site hosting
137137-- CORS headers configured in `public/netlify.toml`
138138-- AT Protocol DID file at `public/.well-known/atproto-did`
139139-140140-### Build Output
141141-- Production builds output to `dist/`
142142-- All HTML files in `src/` become entrypoints
143143-- Public assets copied automatically
144144-- Source maps linked for debugging
145145-146146-## Development Notes
147147-148148-### Hot Module Replacement
149149-- Development server includes HMR
150150-- Browser console logs echoed to server
151151-- Automatic reloading on file changes
152152-153153-### Performance Features
154154-- Intersection Observer for scroll-triggered animations
155155-- Code splitting support in build configuration
156156-- Minification enabled by default in production
157157-- Lazy loading with `react` imports
158158-159159-### AT Protocol Integration
160160-- Project showcases AT Protocol-related work
161161-- Uses `atproto-ui` component library
162162-- Bluesky and Tangled integration in portfolio
163163-164164-## Gotchas
165165-166166-### Build System
167167-- Custom build script requires Bun runtime (not Node.js)
168168-- HTML files in `src/` automatically become entrypoints
169169-- Must use `--external` flag for libraries that shouldn't be bundled
170170-171171-### Styling
172172-- Dark mode is default styling approach
173173-- CSS variables are used extensively for theming
174174-- Custom glassmorphism effects require SVG filters (defined in CSS)
175175-176176-### Server Routes
177177-- API routes use Bun's pattern matching syntax
178178-- All unmatched routes serve the main SPA (catch-all route)
179179-- CORS headers pre-configured for API access
180180-181181-### Content Structure
182182-- Portfolio content is TypeScript data, not markdown
183183-- Rich text descriptions use specific object structure
184184-- Projects support multiple links (live demo, GitHub, etc.)
+94-64
src/components/GuestbookEntries.tsx
···3939 const [loading, setLoading] = useState(true)
4040 const [error, setError] = useState<string | null>(null)
41414242- const fetchEntries = async () => {
4242+ const fetchEntries = async (signal: AbortSignal) => {
4343 setLoading(true)
4444 setError(null)
4545···4949 url.searchParams.set('source', 'pet.nkp.guestbook.sign:subject')
5050 url.searchParams.set('limit', limit.toString())
51515252- const response = await fetch(url.toString())
5252+ const response = await fetch(url.toString(), { signal })
5353 if (!response.ok) throw new Error('Failed to fetch signatures')
54545555 const data = await response.json()
5656-5656+5757 if (!data.records || !Array.isArray(data.records)) {
5858 setEntries([])
5959 setLoading(false)
6060 return
6161 }
62626363- const fetchedEntries: GuestbookEntry[] = []
6464- const recordMap = new Map<string, any>()
6565- const authorDids: string[] = []
6666-6767- // First pass: fetch all records and collect author DIDs
6868- for (const record of data.records as ConstellationRecord[]) {
6363+ // Collect all entries first, then render once
6464+ const entryPromises = (data.records as ConstellationRecord[]).map(async (record) => {
6965 try {
7066 const recordUrl = new URL('/xrpc/com.atproto.repo.getRecord', 'https://slingshot.wisp.place')
7167 recordUrl.searchParams.set('repo', record.did)
7268 recordUrl.searchParams.set('collection', record.collection)
7369 recordUrl.searchParams.set('rkey', record.rkey)
74707575- const recordResponse = await fetch(recordUrl.toString())
7676- if (!recordResponse.ok) continue
7171+ const recordResponse = await fetch(recordUrl.toString(), { signal })
7272+ if (!recordResponse.ok) return null
77737874 const recordData = await recordResponse.json()
7975···8278 recordData.value.$type === 'pet.nkp.guestbook.sign' &&
8379 typeof recordData.value.message === 'string'
8480 ) {
8585- recordMap.set(record.did, recordData)
8686- authorDids.push(record.did)
8181+ return {
8282+ uri: recordData.uri,
8383+ author: record.did,
8484+ authorHandle: undefined,
8585+ message: recordData.value.message,
8686+ createdAt: recordData.value.createdAt,
8787+ } as GuestbookEntry
8788 }
8888- } catch {}
8989- }
8989+ } catch (err) {
9090+ if (err instanceof Error && err.name === 'AbortError') throw err
9191+ }
9292+ return null
9393+ })
90949191- // Second pass: batch fetch all profiles at once
9292- const authorHandles = new Map<string, string>()
9393- if (authorDids.length > 0) {
9494- try {
9595- // Batch fetch profiles up to 25 at a time (API limit)
9696- for (let i = 0; i < authorDids.length; i += 25) {
9797- const batch = authorDids.slice(i, i + 25)
9898- const profileUrl = new URL('/xrpc/app.bsky.actor.getProfiles', 'https://public.api.bsky.app')
9999- batch.forEach(did => profileUrl.searchParams.append('actors', did))
9595+ const results = await Promise.all(entryPromises)
9696+ const validEntries = results.filter((e): e is GuestbookEntry => e !== null)
10097101101- const profileResponse = await fetch(profileUrl.toString())
102102- if (profileResponse.ok) {
103103- const profilesData = await profileResponse.json()
104104- if (profilesData.profiles && Array.isArray(profilesData.profiles)) {
105105- profilesData.profiles.forEach((profile: any) => {
106106- if (profile.handle) {
107107- authorHandles.set(profile.did, profile.handle)
108108- }
109109- })
110110- }
111111- }
112112- }
113113- } catch {}
114114- }
115115-116116- // Third pass: create entries with fetched profile data
117117- for (const [did, recordData] of recordMap) {
118118- const authorHandle = authorHandles.get(did)
119119- fetchedEntries.push({
120120- uri: recordData.uri,
121121- author: did,
122122- authorHandle,
123123- message: recordData.value.message,
124124- createdAt: recordData.value.createdAt,
125125- })
126126- }
127127-128128- // Sort by date, newest first
129129- fetchedEntries.sort((a, b) =>
9898+ // Sort once and set all entries at once
9999+ validEntries.sort((a, b) =>
130100 new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
131101 )
132102133133- setEntries(fetchedEntries)
103103+ setEntries(validEntries)
104104+ setLoading(false)
105105+106106+ // Batch fetch profiles asynchronously
107107+ if (validEntries.length > 0) {
108108+ const uniqueDids = Array.from(new Set(validEntries.map(e => e.author)))
109109+110110+ // Batch fetch profiles up to 25 at a time (API limit)
111111+ const profilePromises = []
112112+ for (let i = 0; i < uniqueDids.length; i += 25) {
113113+ const batch = uniqueDids.slice(i, i + 25)
114114+115115+ const profileUrl = new URL('/xrpc/app.bsky.actor.getProfiles', 'https://public.api.bsky.app')
116116+ batch.forEach(d => profileUrl.searchParams.append('actors', d))
117117+118118+ profilePromises.push(
119119+ fetch(profileUrl.toString(), { signal })
120120+ .then(profileResponse => profileResponse.ok ? profileResponse.json() : null)
121121+ .then(profilesData => {
122122+ if (profilesData?.profiles && Array.isArray(profilesData.profiles)) {
123123+ const handles = new Map<string, string>()
124124+ profilesData.profiles.forEach((profile: any) => {
125125+ if (profile.handle) {
126126+ handles.set(profile.did, profile.handle)
127127+ }
128128+ })
129129+ return handles
130130+ }
131131+ return new Map<string, string>()
132132+ })
133133+ .catch((err) => {
134134+ if (err instanceof Error && err.name === 'AbortError') throw err
135135+ return new Map<string, string>()
136136+ })
137137+ )
138138+ }
139139+140140+ // Wait for all profile batches, then update once
141141+ const handleMaps = await Promise.all(profilePromises)
142142+ const allHandles = new Map<string, string>()
143143+ handleMaps.forEach(map => {
144144+ map.forEach((handle, did) => allHandles.set(did, handle))
145145+ })
146146+147147+ if (allHandles.size > 0) {
148148+ setEntries(prev => prev.map(entry => {
149149+ const handle = allHandles.get(entry.author)
150150+ return handle ? { ...entry, authorHandle: handle } : entry
151151+ }))
152152+ }
153153+ }
134154 } catch (err) {
155155+ if (err instanceof Error && err.name === 'AbortError') return
135156 setError(err instanceof Error ? err.message : 'Failed to load entries')
136136- } finally {
137157 setLoading(false)
138158 }
139159 }
140160141161 useEffect(() => {
142142- fetchEntries()
143143- onRefresh?.(() => fetchEntries())
162162+ const abortController = new AbortController()
163163+ fetchEntries(abortController.signal)
164164+ onRefresh?.(() => {
165165+ abortController.abort()
166166+ const newController = new AbortController()
167167+ fetchEntries(newController.signal)
168168+ })
169169+170170+ return () => abortController.abort()
144171 }, [did, limit])
145172146173 const formatDate = (isoString: string) => {
···149176 }
150177151178 const shortenDid = (did: string) => {
152152- if (did.startsWith('did:plc:')) {
153153- return `${did.slice(0, 12)}...`
179179+ if (did.startsWith('did:')) {
180180+ const afterPrefix = did.indexOf(':', 4)
181181+ if (afterPrefix !== -1) {
182182+ return `${did.slice(0, afterPrefix + 9)}...`
183183+ }
154184 }
155185 return did
156186 }
···184214 {entries.map((entry, index) => (
185215 <div
186216 key={entry.uri}
187187- className="bg-gray-100 dark:bg-gray-800/50 rounded-lg p-4 border-l-4 transition-colors"
217217+ className="bg-gray-100 rounded-lg p-4 border-l-4 transition-colors"
188218 style={{ borderLeftColor: getColorForIndex(index) }}
189219 >
190220 <div className="flex justify-between items-start mb-1">
···192222 href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`}
193223 target="_blank"
194224 rel="noopener noreferrer"
195195- className="font-semibold text-gray-900 dark:text-gray-100 hover:underline"
225225+ className="font-semibold text-gray-900 hover:underline"
196226 >
197227 {entry.authorHandle || shortenDid(entry.author)}
198228 </a>
···200230 href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`}
201231 target="_blank"
202232 rel="noopener noreferrer"
203203- className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
233233+ className="text-gray-400 hover:text-gray-600"
204234 style={{ color: getColorForIndex(index) }}
205235 >
206236 <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
···208238 </svg>
209239 </a>
210240 </div>
211211- <p className="text-gray-800 dark:text-gray-200 mb-2">
241241+ <p className="text-gray-800 mb-2">
212242 {entry.message}
213243 </p>
214214- <span className="text-sm text-gray-500 dark:text-gray-400">
244244+ <span className="text-sm text-gray-500">
215245 {formatDate(entry.createdAt)}
216246 </span>
217247 </div>