-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.)
+94
-64
src/components/GuestbookEntries.tsx
+94
-64
src/components/GuestbookEntries.tsx
···
39
39
const [loading, setLoading] = useState(true)
40
40
const [error, setError] = useState<string | null>(null)
41
41
42
-
const fetchEntries = async () => {
42
+
const fetchEntries = async (signal: AbortSignal) => {
43
43
setLoading(true)
44
44
setError(null)
45
45
···
49
49
url.searchParams.set('source', 'pet.nkp.guestbook.sign:subject')
50
50
url.searchParams.set('limit', limit.toString())
51
51
52
-
const response = await fetch(url.toString())
52
+
const response = await fetch(url.toString(), { signal })
53
53
if (!response.ok) throw new Error('Failed to fetch signatures')
54
54
55
55
const data = await response.json()
56
-
56
+
57
57
if (!data.records || !Array.isArray(data.records)) {
58
58
setEntries([])
59
59
setLoading(false)
60
60
return
61
61
}
62
62
63
-
const fetchedEntries: GuestbookEntry[] = []
64
-
const recordMap = new Map<string, any>()
65
-
const authorDids: string[] = []
66
-
67
-
// First pass: fetch all records and collect author DIDs
68
-
for (const record of data.records as ConstellationRecord[]) {
63
+
// Collect all entries first, then render once
64
+
const entryPromises = (data.records as ConstellationRecord[]).map(async (record) => {
69
65
try {
70
66
const recordUrl = new URL('/xrpc/com.atproto.repo.getRecord', 'https://slingshot.wisp.place')
71
67
recordUrl.searchParams.set('repo', record.did)
72
68
recordUrl.searchParams.set('collection', record.collection)
73
69
recordUrl.searchParams.set('rkey', record.rkey)
74
70
75
-
const recordResponse = await fetch(recordUrl.toString())
76
-
if (!recordResponse.ok) continue
71
+
const recordResponse = await fetch(recordUrl.toString(), { signal })
72
+
if (!recordResponse.ok) return null
77
73
78
74
const recordData = await recordResponse.json()
79
75
···
82
78
recordData.value.$type === 'pet.nkp.guestbook.sign' &&
83
79
typeof recordData.value.message === 'string'
84
80
) {
85
-
recordMap.set(record.did, recordData)
86
-
authorDids.push(record.did)
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
87
88
}
88
-
} catch {}
89
-
}
89
+
} catch (err) {
90
+
if (err instanceof Error && err.name === 'AbortError') throw err
91
+
}
92
+
return null
93
+
})
90
94
91
-
// Second pass: batch fetch all profiles at once
92
-
const authorHandles = new Map<string, string>()
93
-
if (authorDids.length > 0) {
94
-
try {
95
-
// Batch fetch profiles up to 25 at a time (API limit)
96
-
for (let i = 0; i < authorDids.length; i += 25) {
97
-
const batch = authorDids.slice(i, i + 25)
98
-
const profileUrl = new URL('/xrpc/app.bsky.actor.getProfiles', 'https://public.api.bsky.app')
99
-
batch.forEach(did => profileUrl.searchParams.append('actors', did))
95
+
const results = await Promise.all(entryPromises)
96
+
const validEntries = results.filter((e): e is GuestbookEntry => e !== null)
100
97
101
-
const profileResponse = await fetch(profileUrl.toString())
102
-
if (profileResponse.ok) {
103
-
const profilesData = await profileResponse.json()
104
-
if (profilesData.profiles && Array.isArray(profilesData.profiles)) {
105
-
profilesData.profiles.forEach((profile: any) => {
106
-
if (profile.handle) {
107
-
authorHandles.set(profile.did, profile.handle)
108
-
}
109
-
})
110
-
}
111
-
}
112
-
}
113
-
} catch {}
114
-
}
115
-
116
-
// Third pass: create entries with fetched profile data
117
-
for (const [did, recordData] of recordMap) {
118
-
const authorHandle = authorHandles.get(did)
119
-
fetchedEntries.push({
120
-
uri: recordData.uri,
121
-
author: did,
122
-
authorHandle,
123
-
message: recordData.value.message,
124
-
createdAt: recordData.value.createdAt,
125
-
})
126
-
}
127
-
128
-
// Sort by date, newest first
129
-
fetchedEntries.sort((a, b) =>
98
+
// Sort once and set all entries at once
99
+
validEntries.sort((a, b) =>
130
100
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
131
101
)
132
102
133
-
setEntries(fetchedEntries)
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
+
}
134
154
} catch (err) {
155
+
if (err instanceof Error && err.name === 'AbortError') return
135
156
setError(err instanceof Error ? err.message : 'Failed to load entries')
136
-
} finally {
137
157
setLoading(false)
138
158
}
139
159
}
140
160
141
161
useEffect(() => {
142
-
fetchEntries()
143
-
onRefresh?.(() => fetchEntries())
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()
144
171
}, [did, limit])
145
172
146
173
const formatDate = (isoString: string) => {
···
149
176
}
150
177
151
178
const shortenDid = (did: string) => {
152
-
if (did.startsWith('did:plc:')) {
153
-
return `${did.slice(0, 12)}...`
179
+
if (did.startsWith('did:')) {
180
+
const afterPrefix = did.indexOf(':', 4)
181
+
if (afterPrefix !== -1) {
182
+
return `${did.slice(0, afterPrefix + 9)}...`
183
+
}
154
184
}
155
185
return did
156
186
}
···
184
214
{entries.map((entry, index) => (
185
215
<div
186
216
key={entry.uri}
187
-
className="bg-gray-100 dark:bg-gray-800/50 rounded-lg p-4 border-l-4 transition-colors"
217
+
className="bg-gray-100 rounded-lg p-4 border-l-4 transition-colors"
188
218
style={{ borderLeftColor: getColorForIndex(index) }}
189
219
>
190
220
<div className="flex justify-between items-start mb-1">
···
192
222
href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`}
193
223
target="_blank"
194
224
rel="noopener noreferrer"
195
-
className="font-semibold text-gray-900 dark:text-gray-100 hover:underline"
225
+
className="font-semibold text-gray-900 hover:underline"
196
226
>
197
227
{entry.authorHandle || shortenDid(entry.author)}
198
228
</a>
···
200
230
href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`}
201
231
target="_blank"
202
232
rel="noopener noreferrer"
203
-
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
233
+
className="text-gray-400 hover:text-gray-600"
204
234
style={{ color: getColorForIndex(index) }}
205
235
>
206
236
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
···
208
238
</svg>
209
239
</a>
210
240
</div>
211
-
<p className="text-gray-800 dark:text-gray-200 mb-2">
241
+
<p className="text-gray-800 mb-2">
212
242
{entry.message}
213
243
</p>
214
-
<span className="text-sm text-gray-500 dark:text-gray-400">
244
+
<span className="text-sm text-gray-500">
215
245
{formatDate(entry.createdAt)}
216
246
</span>
217
247
</div>
+9
-9
src/components/sections/GuestbookPage.tsx
+9
-9
src/components/sections/GuestbookPage.tsx
···
43
43
}, [])
44
44
45
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">
46
+
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 py-12 px-6">
47
47
<div className="max-w-xl mx-auto">
48
48
{/* Header */}
49
49
<header className="mb-12 text-center">
50
50
<div className="inline-block mb-4">
51
51
<span className="text-5xl">📖</span>
52
52
</div>
53
-
<h1 className="text-3xl font-light tracking-tight text-gray-900 dark:text-gray-100 mb-3">
53
+
<h1 className="text-3xl font-light tracking-tight text-gray-900 mb-3">
54
54
Ana's Guestbook
55
55
</h1>
56
-
<p className="text-gray-500 dark:text-gray-400 font-mono text-sm">
56
+
<p className="text-gray-500 font-mono text-sm">
57
57
Leave a message, say hello
58
58
</p>
59
59
</header>
60
60
61
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">
62
+
<div className="mb-12 bg-white rounded-2xl shadow-sm border border-gray-200/50 p-6">
63
63
<guestbook-sign did="did:plc:ttdrpj45ibqunmfhdsb4zdwq"></guestbook-sign>
64
64
</div>
65
65
66
66
{/* Entries Header */}
67
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">
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
70
Messages
71
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>
72
+
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 to-transparent"></div>
73
73
</div>
74
74
75
-
<GuestbookEntries
76
-
did="did:plc:ttdrpj45ibqunmfhdsb4zdwq"
75
+
<GuestbookEntries
76
+
did="did:plc:ttdrpj45ibqunmfhdsb4zdwq"
77
77
limit={50}
78
78
onRefresh={(refresh) => { refreshRef.current = refresh }}
79
79
/>