update guestbook

nekomimi.pet 730671be 9ee5ecf1

verified
Changed files
+103 -257
src
-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
··· 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
··· 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 />