extremely claude-assisted go game based on atproto! working on cleaning up and giving a more unique design, still has a bit of a slop vibe to it.

Add beginner mode with liberty display and enhanced profile page

- Added beginner mode toggle to game page with Go stone checkbox style
- Integrated tenuki library for liberty calculation
- Display liberty counts on stones with dynamic sizing and coloring:
- 1 liberty: Red with "1!" and pulsing animation (atari warning)
- 2-3 liberties: Progressive orange/yellow scaling
- 4+ liberties: Normal size with appropriate contrast
- Added wins/losses stats to profile page
- Created visual game history with opponent avatars
- Implemented segmented arc borders showing W/L/A record per opponent
- Sort opponents by most games, limit to 30 for performance
- Fixed profile page to reload data when navigating between profiles
- Handle resigned games as wins/losses in stats
- Treat completed games without scores as pending (yellow)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

+1876 -233
+12 -1
package-lock.json
··· 24 24 "dotenv": "^17.2.3", 25 25 "jgoboard": "github:jokkebk/jgoboard", 26 26 "kysely": "^0.27.0", 27 - "tenuki": "^0.3.1" 27 + "tenuki": "^0.3.1", 28 + "twemoji-parser": "^14.0.0" 28 29 }, 29 30 "devDependencies": { 30 31 "@sveltejs/adapter-auto": "^3.0.0", ··· 4232 4233 "node": "*" 4233 4234 } 4234 4235 }, 4236 + "node_modules/twemoji-parser": { 4237 + "version": "14.0.0", 4238 + "resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz", 4239 + "integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==" 4240 + }, 4235 4241 "node_modules/type-is": { 4236 4242 "version": "1.6.18", 4237 4243 "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", ··· 7205 7211 "requires": { 7206 7212 "safe-buffer": "^5.0.1" 7207 7213 } 7214 + }, 7215 + "twemoji-parser": { 7216 + "version": "14.0.0", 7217 + "resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz", 7218 + "integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==" 7208 7219 }, 7209 7220 "type-is": { 7210 7221 "version": "1.6.18",
+2 -1
package.json
··· 38 38 "dotenv": "^17.2.3", 39 39 "jgoboard": "github:jokkebk/jgoboard", 40 40 "kysely": "^0.27.0", 41 - "tenuki": "^0.3.1" 41 + "tenuki": "^0.3.1", 42 + "twemoji-parser": "^14.0.0" 42 43 } 43 44 }
+72 -48
src/lib/atproto-client.ts
··· 222 222 const pds = await resolvePdsHost(did); 223 223 if (!pds) continue; 224 224 225 - // Fetch moves 225 + // Fetch moves with pagination 226 226 try { 227 - const moveParams = new URLSearchParams({ 228 - repo: did, 229 - collection: 'boo.sky.go.move', 230 - limit: '100', 231 - }); 232 - const moveRes = await fetch( 233 - `${pds}/xrpc/com.atproto.repo.listRecords?${moveParams}` 234 - ); 235 - if (moveRes.ok) { 236 - const data = await moveRes.json(); 237 - for (const rec of data.records || []) { 238 - if (rec.value?.game === gameAtUri) { 239 - moves.push({ 240 - ...(rec.value as MoveRecord), 241 - uri: rec.uri, // Include the AT URI 242 - }); 227 + let cursor: string | undefined; 228 + do { 229 + const moveParams = new URLSearchParams({ 230 + repo: did, 231 + collection: 'boo.sky.go.move', 232 + limit: '100', 233 + }); 234 + if (cursor) moveParams.set('cursor', cursor); 235 + 236 + const moveRes = await fetch( 237 + `${pds}/xrpc/com.atproto.repo.listRecords?${moveParams}` 238 + ); 239 + if (moveRes.ok) { 240 + const data = await moveRes.json(); 241 + for (const rec of data.records || []) { 242 + if (rec.value?.game === gameAtUri) { 243 + moves.push({ 244 + ...(rec.value as MoveRecord), 245 + uri: rec.uri, // Include the AT URI 246 + }); 247 + } 243 248 } 249 + cursor = data.cursor; 250 + } else { 251 + break; 244 252 } 245 - } 253 + } while (cursor); 246 254 } catch (err) { 247 255 console.error('Failed to list move records from PDS for', did, err); 248 256 } 249 257 250 - // Fetch passes 258 + // Fetch passes with pagination 251 259 try { 252 - const passParams = new URLSearchParams({ 253 - repo: did, 254 - collection: 'boo.sky.go.pass', 255 - limit: '100', 256 - }); 257 - const passRes = await fetch( 258 - `${pds}/xrpc/com.atproto.repo.listRecords?${passParams}` 259 - ); 260 - if (passRes.ok) { 261 - const data = await passRes.json(); 262 - for (const rec of data.records || []) { 263 - if (rec.value?.game === gameAtUri) { 264 - passes.push(rec.value as PassRecord); 260 + let cursor: string | undefined; 261 + do { 262 + const passParams = new URLSearchParams({ 263 + repo: did, 264 + collection: 'boo.sky.go.pass', 265 + limit: '100', 266 + }); 267 + if (cursor) passParams.set('cursor', cursor); 268 + 269 + const passRes = await fetch( 270 + `${pds}/xrpc/com.atproto.repo.listRecords?${passParams}` 271 + ); 272 + if (passRes.ok) { 273 + const data = await passRes.json(); 274 + for (const rec of data.records || []) { 275 + if (rec.value?.game === gameAtUri) { 276 + passes.push(rec.value as PassRecord); 277 + } 265 278 } 279 + cursor = data.cursor; 280 + } else { 281 + break; 266 282 } 267 - } 283 + } while (cursor); 268 284 } catch (err) { 269 285 console.error('Failed to list pass records from PDS for', did, err); 270 286 } 271 287 272 - // Fetch resigns 288 + // Fetch resigns with pagination 273 289 try { 274 - const resignParams = new URLSearchParams({ 275 - repo: did, 276 - collection: 'boo.sky.go.resign', 277 - limit: '100', 278 - }); 279 - const resignRes = await fetch( 280 - `${pds}/xrpc/com.atproto.repo.listRecords?${resignParams}` 281 - ); 282 - if (resignRes.ok) { 283 - const data = await resignRes.json(); 284 - for (const rec of data.records || []) { 285 - if (rec.value?.game === gameAtUri) { 286 - resigns.push(rec.value as ResignRecord); 290 + let cursor: string | undefined; 291 + do { 292 + const resignParams = new URLSearchParams({ 293 + repo: did, 294 + collection: 'boo.sky.go.resign', 295 + limit: '100', 296 + }); 297 + if (cursor) resignParams.set('cursor', cursor); 298 + 299 + const resignRes = await fetch( 300 + `${pds}/xrpc/com.atproto.repo.listRecords?${resignParams}` 301 + ); 302 + if (resignRes.ok) { 303 + const data = await resignRes.json(); 304 + for (const rec of data.records || []) { 305 + if (rec.value?.game === gameAtUri) { 306 + resigns.push(rec.value as ResignRecord); 307 + } 287 308 } 309 + cursor = data.cursor; 310 + } else { 311 + break; 288 312 } 289 - } 313 + } while (cursor); 290 314 } catch (err) { 291 315 console.error('Failed to list resign records from PDS for', did, err); 292 316 }
+82 -1
src/lib/components/Board.svelte
··· 16 16 interactive?: boolean; 17 17 currentTurn?: 'black' | 'white'; 18 18 territoryData?: TerritoryData | null; 19 + libertyData?: Array<Array<number>> | null; 19 20 } 20 21 21 22 let { ··· 25 26 onPass = () => {}, 26 27 interactive = true, 27 28 currentTurn = 'black', 28 - territoryData = null 29 + territoryData = null, 30 + libertyData = null 29 31 }: Props = $props(); 30 32 31 33 let boardElement: HTMLDivElement; ··· 541 543 {/each} 542 544 </div> 543 545 {/if} 546 + {#if libertyData && board} 547 + {@const padding = gridSize * 0.8} 548 + {@const margin = gridSize * 0.8} 549 + {@const stoneRadius = gridSize * 0.38} 550 + {@const boardOffset = padding + margin + stoneRadius - 1} 551 + <div class="liberty-overlay"> 552 + {#each libertyData as row, y} 553 + {#each row as libertyCount, x} 554 + {#if libertyCount > 0} 555 + {@const coord = new JGO.Coordinate(x, y)} 556 + {@const stoneType = board.getType(coord)} 557 + {#if stoneType === JGO.BLACK || stoneType === JGO.WHITE} 558 + {@const baseFontSize = Math.max(10, gridSize * 0.35)} 559 + {@const sizeMultiplier = libertyCount === 1 ? 2.0 : libertyCount === 2 ? 1.5 : libertyCount === 3 ? 1.2 : 1.0} 560 + {@const fontSize = baseFontSize * sizeMultiplier} 561 + {@const isOnBlack = stoneType === JGO.BLACK} 562 + {@const color = libertyCount === 1 ? '#ff0000' : libertyCount === 2 ? '#ff6600' : libertyCount === 3 ? '#ff9900' : (isOnBlack ? 'white' : 'black')} 563 + {@const shadow = libertyCount === 1 564 + ? '0 0 4px rgba(255, 0, 0, 1), 0 0 8px rgba(255, 0, 0, 0.8), 0 0 12px rgba(255, 0, 0, 0.6), 2px 2px 4px rgba(0, 0, 0, 0.9)' 565 + : libertyCount <= 3 566 + ? '0 0 3px rgba(0, 0, 0, 1), 0 0 6px rgba(0, 0, 0, 0.8), 1px 1px 3px rgba(0, 0, 0, 0.9)' 567 + : isOnBlack 568 + ? '0 0 3px rgba(0, 0, 0, 0.9), 0 0 6px rgba(0, 0, 0, 0.7), 1px 1px 3px rgba(0, 0, 0, 0.8)' 569 + : '0 0 3px rgba(255, 255, 255, 0.9), 0 0 6px rgba(255, 255, 255, 0.7), 1px 1px 3px rgba(255, 255, 255, 0.8)'} 570 + <div 571 + class="liberty-number" 572 + class:atari={libertyCount === 1} 573 + style=" 574 + left: {boardOffset + x * gridSize + 5}px; 575 + top: {boardOffset + y * gridSize+ 5}px; 576 + font-size: {fontSize}px; 577 + color: {color}; 578 + text-shadow: {shadow}; 579 + " 580 + > 581 + {libertyCount === 1 ? '1!' : libertyCount} 582 + </div> 583 + {/if} 584 + {/if} 585 + {/each} 586 + {/each} 587 + </div> 588 + {/if} 544 589 </div> 545 590 </div> 546 591 ··· 670 715 .territory-marker.white { 671 716 background: rgba(255, 255, 255, 0.85); 672 717 box-shadow: 0 0 2px rgba(0, 0, 0, 0.3); 718 + } 719 + 720 + .liberty-overlay { 721 + position: absolute; 722 + top: 0; 723 + left: 0; 724 + right: 0; 725 + bottom: 0; 726 + pointer-events: none; 727 + } 728 + 729 + .liberty-number { 730 + position: absolute; 731 + transform: translate(-50%, -50%); 732 + font-weight: 700; 733 + text-align: center; 734 + line-height: 1; 735 + pointer-events: none; 736 + display: flex; 737 + align-items: center; 738 + justify-content: center; 739 + transition: all 0.2s ease; 740 + } 741 + 742 + .liberty-number.atari { 743 + font-weight: 900; 744 + animation: pulse-atari 1s ease-in-out infinite; 745 + } 746 + 747 + @keyframes pulse-atari { 748 + 0%, 100% { 749 + transform: translate(-50%, -50%) scale(1); 750 + } 751 + 50% { 752 + transform: translate(-50%, -50%) scale(1.1); 753 + } 673 754 } 674 755 </style>
+1 -1
src/lib/components/Footer.svelte
··· 20 20 <svg class="claude-logo" viewBox="0 0 256 257" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"> 21 21 <path d="M50.2278481,170.321013 L100.585316,142.063797 L101.427848,139.601013 L100.585316,138.24 L98.1225316,138.24 L89.6972152,137.721519 L60.921519,136.943797 L35.9696203,135.906835 L11.795443,134.610633 L5.70329114,133.31443 L0,125.796456 L0.583291139,122.037468 L5.70329114,118.602532 L13.0268354,119.250633 L29.2293671,120.352405 L53.5331646,122.037468 L71.161519,123.07443 L97.28,125.796456 L101.427848,125.796456 L102.011139,124.111392 L100.585316,123.07443 L99.4835443,122.037468 L74.3372152,104.992405 L47.116962,86.9751899 L32.8587342,76.6055696 L25.1463291,71.3559494 L21.2577215,66.4303797 L19.5726582,55.6718987 L26.5721519,47.9594937 L35.9696203,48.6075949 L38.3675949,49.2556962 L47.8946835,56.5792405 L68.2450633,72.3281013 L94.8172152,91.9007595 L98.7058228,95.1412658 L100.261266,94.0394937 L100.455696,93.2617722 L98.7058228,90.3453165 L84.2531646,64.2268354 L68.8283544,37.6546835 L61.958481,26.636962 L60.1437975,20.0263291 C59.4956962,17.3043038 59.0420253,15.0359494 59.0420253,12.2491139 L67.0136709,1.42582278 L71.4207595,-1.42108547e-14 L82.0496203,1.42582278 L86.521519,5.31443038 L93.1321519,20.4151899 L103.825823,44.2005063 L120.417215,76.5407595 L125.277975,86.1326582 L127.87038,95.0116456 L128.842532,97.7336709 L130.527595,97.7336709 L130.527595,96.1782278 L131.888608,77.9665823 L134.416203,55.6070886 L136.878987,26.8313924 L137.721519,18.7301266 L141.739747,9.00860759 L149.711392,3.75898734 L155.933165,6.74025316 L161.053165,14.0637975 L160.340253,18.7949367 L157.294177,38.5620253 L151.331646,69.5412658 L147.443038,90.2805063 L149.711392,90.2805063 L152.303797,87.6881013 L162.803038,73.7539241 L180.431392,51.718481 L188.208608,42.9691139 L197.282025,33.3124051 L203.114937,28.7108861 L214.132658,28.7108861 L222.233924,40.7655696 L218.604557,53.2091139 L207.262785,67.596962 L197.865316,79.7812658 L184.38481,97.9281013 L175.959494,112.44557 L176.737215,113.612152 L178.746329,113.417722 L209.207089,106.936709 L225.668861,103.955443 L245.306329,100.585316 L254.185316,104.733165 L255.157468,108.945823 L251.657722,117.56557 L230.659241,122.75038 L206.031392,127.675949 L169.348861,136.360506 L168.89519,136.684557 L169.413671,137.332658 L185.940253,138.888101 L193.004557,139.276962 L210.308861,139.276962 L242.519494,141.674937 L250.94481,147.248608 L256,154.053671 L255.157468,159.238481 L242.195443,165.849114 L224.696709,161.701266 L183.866329,151.979747 L169.867342,148.48 L167.923038,148.48 L167.923038,149.646582 L179.588861,161.053165 L200.976203,180.366582 L227.742785,205.253671 L229.103797,211.410633 L225.668861,216.271392 L222.039494,215.752911 L198.513418,198.059747 L189.44,190.088101 L168.89519,172.783797 L167.534177,172.783797 L167.534177,174.598481 L172.265316,181.533165 L197.282025,219.123038 L198.578228,230.659241 L196.763544,234.418228 L190.282532,236.686582 L183.153418,235.39038 L168.506329,214.84557 L153.40557,191.708354 L141.221266,170.969114 L139.730633,171.811646 L132.536709,249.259747 L129.166582,253.213165 L121.389367,256.19443 L114.908354,251.268861 L111.473418,243.297215 L114.908354,227.548354 L119.056203,207.003544 L122.426329,190.671392 L125.472405,170.385823 L127.287089,163.64557 L127.157468,163.191899 L125.666835,163.386329 L110.371646,184.38481 L87.1048101,215.817722 L68.6987342,235.52 L64.2916456,237.269873 L56.6440506,233.316456 L57.356962,226.252152 L61.6344304,219.96557 L87.1048101,187.560506 L102.46481,167.469367 L112.380759,155.868354 L112.315949,154.183291 L111.732658,154.183291 L44.0708861,198.124557 L32.0162025,199.68 L26.8313924,194.819241 L27.4794937,186.847595 L29.9422785,184.25519 L50.2926582,170.256203 L50.2278481,170.321013 Z" fill="#D97757"/> 22 22 </svg> 23 - <span style="color: #D97757">Claude</span> 23 + <span style="color: #D97757">Claude</span>! 24 24 </button> 25 25 </div> 26 26 </footer>
+12 -26
src/lib/components/Header.svelte
··· 42 42 <style> 43 43 .header-wrapper { 44 44 max-width: 1200px; 45 - margin: 2rem auto 3rem; 46 - padding: 0 2rem; 45 + margin: clamp(1rem, 3vw, 2rem) auto clamp(1.5rem, 4vw, 3rem); 46 + padding: 0 clamp(1rem, 3vw, 2rem); 47 47 } 48 48 49 49 .header { ··· 62 62 inset 0 1px 1px rgba(255, 255, 255, 0.9); 63 63 backdrop-filter: blur(8px); 64 64 position: relative; 65 + z-index: 100; 65 66 } 66 67 67 68 .header::before { ··· 83 84 display: flex; 84 85 align-items: center; 85 86 justify-content: space-between; 86 - padding: 1.5rem 2.5rem; 87 + padding: clamp(1rem, 2vw, 1.5rem) clamp(1.5rem, 3vw, 2.5rem); 87 88 max-width: 1200px; 88 89 margin: 0 auto; 89 90 } 90 91 91 92 .logo { 92 - font-size: 2.25rem; 93 + font-size: clamp(1.5rem, 4vw, 2.75rem); 93 94 font-weight: 700; 94 95 color: var(--sky-slate-dark); 95 96 text-decoration: none; 96 97 letter-spacing: -0.02em; 97 - transition: color 0.2s; 98 + transition: color 0.6s ease, transform 0.6s ease; 98 99 } 99 100 100 101 .logo:hover { 101 102 color: var(--sky-apricot-dark); 103 + filter: drop-shadow(0 0 8px rgba(229, 168, 120, 0.6)); 102 104 } 103 105 104 106 ··· 112 114 color: var(--sky-slate); 113 115 text-decoration: none; 114 116 font-weight: 500; 115 - font-size: 1.125rem; 117 + font-size: clamp(1rem, 2vw, 1.125rem); 116 118 padding: 0.75rem 1.25rem; 117 119 border-radius: 0.5rem; 118 - transition: all 0.2s; 120 + transition: all 0.6s ease; 119 121 } 120 122 121 123 .login-link:hover { 122 124 background: var(--sky-apricot-light); 123 125 color: var(--sky-apricot-dark); 126 + box-shadow: 0 0 12px rgba(229, 168, 120, 0.5); 124 127 } 125 128 126 129 .avatar-placeholder { ··· 141 144 } 142 145 143 146 @media (max-width: 768px) { 144 - .header-wrapper { 145 - padding: 0 1rem; 146 - margin: 1.5rem auto 2rem; 147 - } 148 - 149 - .header-content { 150 - padding: 1rem 1.5rem; 151 - } 152 - 153 - .logo { 154 - font-size: 1.5rem; 155 - } 156 - .login-link { 157 - font-size: 1rem; 158 - padding: 0.5rem 1rem; 159 - } 160 - 161 147 .avatar-placeholder { 162 - width: 100px; 163 - height: 100px; 148 + width: 60px; 149 + height: 60px; 164 150 } 165 151 } 166 152 </style>
+51 -11
src/lib/components/ProfileDropdown.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 + import { goto } from '$app/navigation'; 4 + import { cubicOut } from 'svelte/easing'; 5 + import type { TransitionConfig } from 'svelte/transition'; 3 6 4 7 let { avatar, handle, did }: { avatar: string | null; handle: string; did: string } = $props(); 5 8 6 9 let isOpen = $state(false); 7 10 let dropdownRef: HTMLDivElement | null = $state(null); 8 11 9 - function toggleDropdown() { 12 + function cloudMaterialize(node: Element): TransitionConfig { 13 + return { 14 + duration: 600, 15 + easing: cubicOut, 16 + css: (t: number) => { 17 + const opacity = t; 18 + const blur = (1 - t) * 8; 19 + const translateY = (1 - t) * -10; 20 + const scale = 0.95 + (t * 0.05); 21 + 22 + return ` 23 + opacity: ${opacity}; 24 + filter: blur(${blur}px); 25 + transform: translateY(${translateY}px) scale(${scale}); 26 + `; 27 + } 28 + }; 29 + } 30 + 31 + function toggleDropdown(event: MouseEvent) { 32 + event.stopPropagation(); 10 33 isOpen = !isOpen; 11 34 } 12 35 ··· 16 39 } 17 40 } 18 41 42 + function handleProfileClick(event: MouseEvent) { 43 + event.preventDefault(); 44 + event.stopPropagation(); 45 + isOpen = false; 46 + // Use goto for navigation to avoid any link issues 47 + goto(`/profile/${did}`); 48 + } 49 + 19 50 async function handleLogout() { 20 51 await fetch('/auth/logout', { method: 'POST' }); 21 52 window.location.reload(); ··· 41 72 </button> 42 73 43 74 {#if isOpen} 44 - <div class="dropdown-menu"> 45 - <a href="/profile/{did}" class="dropdown-item"> 75 + <div class="dropdown-menu" transition:cloudMaterialize> 76 + <button onclick={handleProfileClick} class="dropdown-item"> 46 77 View Cloud Go Profile 47 - </a> 78 + </button> 48 79 <button onclick={handleLogout} class="dropdown-item logout-btn"> 49 80 Logout 50 81 </button> ··· 63 94 cursor: pointer; 64 95 padding: 0; 65 96 border-radius: 50%; 66 - transition: transform 0.2s; 67 - } 68 - 69 - .avatar-button:hover { 70 - transform: scale(1.05); 97 + transition: all 0.6s ease; 71 98 } 72 99 73 100 .avatar-img { ··· 76 103 border-radius: 50%; 77 104 border: 2px solid var(--sky-blue-pale); 78 105 object-fit: cover; 106 + transition: all 0.6s ease; 107 + } 108 + 109 + .avatar-button:hover .avatar-img { 110 + border-color: var(--sky-apricot); 111 + box-shadow: 0 0 16px rgba(229, 168, 120, 0.6); 79 112 } 80 113 81 114 .avatar-fallback { ··· 90 123 font-weight: 600; 91 124 color: var(--sky-slate-dark); 92 125 font-size: 1rem; 126 + transition: all 0.6s ease; 127 + } 128 + 129 + .avatar-button:hover .avatar-fallback { 130 + border-color: var(--sky-apricot); 131 + box-shadow: 0 0 16px rgba(229, 168, 120, 0.6); 93 132 } 94 133 95 134 .dropdown-menu { ··· 101 140 border: 2px solid var(--sky-blue-pale); 102 141 border-radius: 0.75rem; 103 142 box-shadow: 0 8px 24px rgba(90, 122, 144, 0.15); 104 - z-index: 1000; 143 + z-index: 10000; 105 144 overflow: hidden; 106 145 } 107 146 ··· 117 156 font-size: 0.9rem; 118 157 font-weight: 500; 119 158 cursor: pointer; 120 - transition: background 0.2s; 159 + transition: all 0.6s ease; 121 160 border-bottom: 1px solid var(--sky-cloud); 122 161 } 123 162 ··· 127 166 128 167 .dropdown-item:hover { 129 168 background: var(--sky-apricot-light); 169 + box-shadow: 0 0 12px rgba(229, 168, 120, 0.4), inset 0 0 20px rgba(229, 168, 120, 0.1); 130 170 } 131 171 132 172 .logout-btn {
+24 -2
src/lib/components/TutorialPopup.svelte
··· 3 3 import { browser } from '$app/environment'; 4 4 import Board from './Board.svelte'; 5 5 import { loadSgf, type SgfData } from '$lib/sgf-parser'; 6 + import { cubicOut } from 'svelte/easing'; 7 + import { fade } from 'svelte/transition'; 8 + import type { TransitionConfig } from 'svelte/transition'; 6 9 7 10 interface Props { 8 11 isOpen?: boolean; ··· 184 187 } 185 188 } 186 189 190 + function cloudMaterialize(node: Element): TransitionConfig { 191 + return { 192 + duration: 800, 193 + easing: cubicOut, 194 + css: (t: number) => { 195 + const opacity = t; 196 + const blur = (1 - t) * 20; 197 + const translateY = (1 - t) * -20; 198 + const scale = 0.9 + (t * 0.1); 199 + 200 + return ` 201 + opacity: ${opacity}; 202 + filter: blur(${blur}px); 203 + transform: translateY(${translateY}px) scale(${scale}); 204 + `; 205 + } 206 + }; 207 + } 208 + 187 209 export function open() { 188 210 isOpen = true; 189 211 currentStep = 0; ··· 191 213 </script> 192 214 193 215 {#if isOpen} 194 - <div class="modal-overlay" onclick={handleClose} onkeydown={handleKeydown} role="button" tabindex="0"> 195 - <div class="modal-content cloud-card" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true"> 216 + <div class="modal-overlay" onclick={handleClose} onkeydown={handleKeydown} role="button" tabindex="0" transition:fade={{ duration: 600 }}> 217 + <div class="modal-content cloud-card" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" transition:cloudMaterialize> 196 218 <div class="modal-inner"> 197 219 <div class="modal-header"> 198 220 <h2>{steps[currentStep].title}</h2>
+95
src/lib/liberty-calculator.ts
··· 1 + import tenuki from 'tenuki'; 2 + import type { MoveRecord } from '$lib/types'; 3 + 4 + const { Game } = tenuki; 5 + 6 + /** 7 + * Calculate liberties for all stones on the board. 8 + * Returns a 2D array where each position contains the liberty count for that stone's group. 9 + */ 10 + export function calculateLiberties( 11 + moves: MoveRecord[], 12 + boardSize: number = 19 13 + ): Array<Array<number>> { 14 + const game = new Game({ 15 + boardSize, 16 + }); 17 + 18 + // Replay all moves 19 + for (const move of moves) { 20 + game.playAt(move.y, move.x); 21 + } 22 + 23 + // Get current board state 24 + const state: Array<Array<'empty' | 'black' | 'white'>> = []; 25 + for (let y = 0; y < boardSize; y++) { 26 + const row: Array<'empty' | 'black' | 'white'> = []; 27 + for (let x = 0; x < boardSize; x++) { 28 + const intersection = game.intersectionAt(y, x); 29 + row.push(intersection.value as 'empty' | 'black' | 'white'); 30 + } 31 + state.push(row); 32 + } 33 + 34 + // Initialize liberty counts 35 + const liberties: Array<Array<number>> = Array(boardSize).fill(0).map(() => Array(boardSize).fill(0)); 36 + const visited: Array<Array<boolean>> = Array(boardSize).fill(false).map(() => Array(boardSize).fill(false)); 37 + 38 + // Helper to get adjacent coordinates 39 + const getAdjacent = (y: number, x: number): Array<[number, number]> => { 40 + const adj: Array<[number, number]> = []; 41 + if (y > 0) adj.push([y - 1, x]); 42 + if (y < boardSize - 1) adj.push([y + 1, x]); 43 + if (x > 0) adj.push([y, x - 1]); 44 + if (x < boardSize - 1) adj.push([y, x + 1]); 45 + return adj; 46 + }; 47 + 48 + // Find all stones in a group and their liberties using flood fill 49 + const findGroup = (startY: number, startX: number): { stones: Array<[number, number]>, libertyCount: number } => { 50 + const color = state[startY][startX]; 51 + if (color === 'empty') return { stones: [], libertyCount: 0 }; 52 + 53 + const stones: Array<[number, number]> = []; 54 + const libertySet = new Set<string>(); 55 + const queue: Array<[number, number]> = [[startY, startX]]; 56 + const groupVisited = new Set<string>(); 57 + 58 + while (queue.length > 0) { 59 + const [y, x] = queue.shift()!; 60 + const key = `${y},${x}`; 61 + 62 + if (groupVisited.has(key)) continue; 63 + groupVisited.add(key); 64 + stones.push([y, x]); 65 + 66 + for (const [ny, nx] of getAdjacent(y, x)) { 67 + const adjKey = `${ny},${nx}`; 68 + if (state[ny][nx] === 'empty') { 69 + libertySet.add(adjKey); 70 + } else if (state[ny][nx] === color && !groupVisited.has(adjKey)) { 71 + queue.push([ny, nx]); 72 + } 73 + } 74 + } 75 + 76 + return { stones, libertyCount: libertySet.size }; 77 + }; 78 + 79 + // Calculate liberties for each group 80 + for (let y = 0; y < boardSize; y++) { 81 + for (let x = 0; x < boardSize; x++) { 82 + if (state[y][x] !== 'empty' && !visited[y][x]) { 83 + const { stones, libertyCount } = findGroup(y, x); 84 + 85 + // Set the liberty count for all stones in this group 86 + for (const [sy, sx] of stones) { 87 + liberties[sy][sx] = libertyCount; 88 + visited[sy][sx] = true; 89 + } 90 + } 91 + } 92 + } 93 + 94 + return liberties; 95 + }
+1 -1
src/lib/server/auth.ts
··· 51 51 client_name: "Cloud Go", 52 52 redirect_uris: [new URL("/auth/callback", publicUrl).href], 53 53 scope: 54 - "atproto repo:boo.sky.go.game?action=create repo:boo.sky.go.game?action=update repo:boo.sky.go.move?action=create repo:boo.sky.go.pass?action=create repo:boo.sky.go.resign?action=create repo:boo.sky.go.reaction?action=create", 54 + "atproto repo:app.bsky.feed.post?action=create com.atproto.repo.uploadBlob blob:image/png repo:boo.sky.go.game?action=create repo:boo.sky.go.game?action=update repo:boo.sky.go.move?action=create repo:boo.sky.go.pass?action=create repo:boo.sky.go.resign?action=create repo:boo.sky.go.reaction?action=create", 55 55 jwks_uri: new URL("/jwks.json", publicUrl).href, 56 56 }, 57 57
+91
src/lib/server/scoring.ts
··· 94 94 95 95 return state; 96 96 } 97 + 98 + /** 99 + * Calculate liberties for all stones on the board. 100 + * Returns a 2D array where each position contains the liberty count for that stone's group. 101 + */ 102 + export function calculateLiberties( 103 + moves: MoveRecord[], 104 + boardSize: number = 19 105 + ): Array<Array<number>> { 106 + const game = new Game({ 107 + boardSize, 108 + }); 109 + 110 + // Replay all moves 111 + for (const move of moves) { 112 + game.playAt(move.y, move.x); 113 + } 114 + 115 + // Get current board state 116 + const state: Array<Array<'empty' | 'black' | 'white'>> = []; 117 + for (let y = 0; y < boardSize; y++) { 118 + const row: Array<'empty' | 'black' | 'white'> = []; 119 + for (let x = 0; x < boardSize; x++) { 120 + const intersection = game.intersectionAt(y, x); 121 + row.push(intersection.value as 'empty' | 'black' | 'white'); 122 + } 123 + state.push(row); 124 + } 125 + 126 + // Initialize liberty counts 127 + const liberties: Array<Array<number>> = Array(boardSize).fill(0).map(() => Array(boardSize).fill(0)); 128 + const visited: Array<Array<boolean>> = Array(boardSize).fill(false).map(() => Array(boardSize).fill(false)); 129 + 130 + // Helper to get adjacent coordinates 131 + const getAdjacent = (y: number, x: number): Array<[number, number]> => { 132 + const adj: Array<[number, number]> = []; 133 + if (y > 0) adj.push([y - 1, x]); 134 + if (y < boardSize - 1) adj.push([y + 1, x]); 135 + if (x > 0) adj.push([y, x - 1]); 136 + if (x < boardSize - 1) adj.push([y, x + 1]); 137 + return adj; 138 + }; 139 + 140 + // Find all stones in a group and their liberties using flood fill 141 + const findGroup = (startY: number, startX: number): { stones: Array<[number, number]>, libertyCount: number } => { 142 + const color = state[startY][startX]; 143 + if (color === 'empty') return { stones: [], libertyCount: 0 }; 144 + 145 + const stones: Array<[number, number]> = []; 146 + const libertySet = new Set<string>(); 147 + const queue: Array<[number, number]> = [[startY, startX]]; 148 + const groupVisited = new Set<string>(); 149 + 150 + while (queue.length > 0) { 151 + const [y, x] = queue.shift()!; 152 + const key = `${y},${x}`; 153 + 154 + if (groupVisited.has(key)) continue; 155 + groupVisited.add(key); 156 + stones.push([y, x]); 157 + 158 + for (const [ny, nx] of getAdjacent(y, x)) { 159 + const adjKey = `${ny},${nx}`; 160 + if (state[ny][nx] === 'empty') { 161 + libertySet.add(adjKey); 162 + } else if (state[ny][nx] === color && !groupVisited.has(adjKey)) { 163 + queue.push([ny, nx]); 164 + } 165 + } 166 + } 167 + 168 + return { stones, libertyCount: libertySet.size }; 169 + }; 170 + 171 + // Calculate liberties for each group 172 + for (let y = 0; y < boardSize; y++) { 173 + for (let x = 0; x < boardSize; x++) { 174 + if (state[y][x] !== 'empty' && !visited[y][x]) { 175 + const { stones, libertyCount } = findGroup(y, x); 176 + 177 + // Set the liberty count for all stones in this group 178 + for (const [sy, sx] of stones) { 179 + liberties[sy][sx] = libertyCount; 180 + visited[sy][sx] = true; 181 + } 182 + } 183 + } 184 + } 185 + 186 + return liberties; 187 + }
+2 -1
src/routes/+layout.svelte
··· 22 22 <div class="cloud cloud-5"></div> 23 23 </div> 24 24 25 + <Header session={data.session} /> 26 + 25 27 <div class="page-content"> 26 - <Header session={data.session} /> 27 28 {@render children()} 28 29 </div> 29 30
+16 -13
src/routes/+page.svelte
··· 520 520 .container { 521 521 max-width: 1200px; 522 522 margin: 0 auto; 523 - padding: 2rem; 523 + padding: clamp(1rem, 3vw, 2rem); 524 524 } 525 525 526 526 .login-card { ··· 641 641 font-size: 1rem; 642 642 font-weight: 600; 643 643 cursor: pointer; 644 - transition: all 0.2s; 644 + transition: all 0.6s ease; 645 645 } 646 646 647 647 .button:disabled { ··· 656 656 } 657 657 658 658 .button-primary:hover:not(:disabled) { 659 - background: linear-gradient(135deg, var(--sky-apricot) 0%, var(--sky-apricot-dark) 100%); 660 - transform: translateY(-1px); 661 - box-shadow: 0 4px 12px rgba(229, 168, 120, 0.4); 659 + transform: translateY(-3px); 660 + box-shadow: 0 6px 16px rgba(229, 168, 120, 0.5); 661 + filter: brightness(1.05); 662 662 } 663 663 664 664 .button-secondary { ··· 670 670 .button-secondary:hover:not(:disabled) { 671 671 background: var(--sky-blue-pale); 672 672 color: var(--sky-slate-dark); 673 + transform: translateY(-2px); 673 674 } 674 675 675 676 .button-sm { ··· 691 692 border: 1px solid var(--sky-blue-pale); 692 693 border-radius: 0.75rem; 693 694 background: linear-gradient(135deg, var(--sky-white) 0%, var(--sky-cloud) 100%); 694 - transition: all 0.2s; 695 + transition: all 0.6s ease; 695 696 } 696 697 697 698 .game-item:hover { 698 699 border-color: var(--sky-apricot); 699 - box-shadow: 0 4px 12px rgba(90, 122, 144, 0.1); 700 + box-shadow: 0 6px 18px rgba(90, 122, 144, 0.12); 701 + transform: translateY(-2px); 700 702 } 701 703 702 704 .game-item.my-turn { ··· 831 833 .player-link { 832 834 color: var(--sky-slate); 833 835 text-decoration: none; 834 - transition: color 0.2s; 836 + transition: color 0.6s ease; 835 837 } 836 838 837 839 .player-link:hover { ··· 1001 1003 border: 1px solid var(--sky-blue-pale); 1002 1004 border-radius: 0.75rem; 1003 1005 background: linear-gradient(135deg, var(--sky-white) 0%, var(--sky-cloud) 100%); 1004 - transition: all 0.2s; 1006 + transition: all 0.6s ease; 1005 1007 } 1006 1008 1007 1009 .game-item-compact:hover { 1008 1010 border-color: var(--sky-apricot); 1009 - box-shadow: 0 4px 12px rgba(90, 122, 144, 0.1); 1011 + box-shadow: 0 6px 18px rgba(90, 122, 144, 0.12); 1012 + transform: translateY(-2px); 1010 1013 } 1011 1014 1012 1015 .game-item-compact .game-title { ··· 1069 1072 border: 1px solid var(--sky-blue-pale); 1070 1073 border-radius: 0.75rem; 1071 1074 background: linear-gradient(135deg, var(--sky-white) 0%, var(--sky-cloud) 100%); 1072 - transition: all 0.2s; 1075 + transition: all 0.6s ease; 1073 1076 text-decoration: none; 1074 1077 color: inherit; 1075 1078 opacity: 0.85; ··· 1078 1081 .archive-card:hover { 1079 1082 opacity: 1; 1080 1083 border-color: var(--sky-apricot); 1081 - box-shadow: 0 4px 12px rgba(90, 122, 144, 0.1); 1082 - transform: translateY(-2px); 1084 + box-shadow: 0 8px 20px rgba(90, 122, 144, 0.15); 1085 + transform: translateY(-4px) rotate(0deg); 1083 1086 } 1084 1087 1085 1088 .archive-card-header {
+192
src/routes/api/games/[id]/nudge-image/+server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { getDb } from '$lib/server/db'; 4 + import { resolveDidToHandle, fetchUserProfile } from '$lib/atproto-client'; 5 + import { Resvg } from '@resvg/resvg-js'; 6 + import { parse } from 'twemoji-parser'; 7 + 8 + async function fetchImageAsBase64(url: string): Promise<string | null> { 9 + try { 10 + const response = await fetch(url); 11 + if (!response.ok) return null; 12 + 13 + const arrayBuffer = await response.arrayBuffer(); 14 + const buffer = Buffer.from(arrayBuffer); 15 + const base64 = buffer.toString('base64'); 16 + const contentType = response.headers.get('content-type') || 'image/jpeg'; 17 + 18 + return `data:${contentType};base64,${base64}`; 19 + } catch (err) { 20 + console.error('Failed to fetch image:', err); 21 + return null; 22 + } 23 + } 24 + 25 + async function getEmojiSvg(emoji: string): Promise<string> { 26 + try { 27 + const parsed = parse(emoji); 28 + if (parsed.length === 0) { 29 + console.log('No emoji parsed'); 30 + return ''; 31 + } 32 + 33 + const url = parsed[0].url; 34 + const filename = url.split('/').pop() || ''; 35 + const codepoint = filename.replace(/\.(png|svg)$/, ''); // Remove any extension 36 + console.log('Emoji codepoint:', codepoint); 37 + 38 + const svgUrl = `https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/${codepoint}.svg`; 39 + console.log('Fetching from:', svgUrl); 40 + 41 + const response = await fetch(svgUrl); 42 + if (!response.ok) { 43 + console.log('Fetch failed:', response.status); 44 + return ''; 45 + } 46 + 47 + const svgContent = await response.text(); 48 + console.log('SVG content length:', svgContent.length); 49 + 50 + // Extract everything between <svg> tags 51 + const svgMatch = svgContent.match(/<svg[^>]*>([\s\S]*)<\/svg>/); 52 + if (svgMatch) { 53 + return svgMatch[1]; // Return inner SVG content 54 + } 55 + console.log('No SVG match found'); 56 + return ''; 57 + } catch (err) { 58 + console.error('Failed to fetch emoji SVG:', err); 59 + return ''; 60 + } 61 + } 62 + 63 + export const GET: RequestHandler = async ({ params }) => { 64 + const gameRkey = params.id; 65 + const db = getDb(); 66 + 67 + // Get game details 68 + const game = await db 69 + .selectFrom('games') 70 + .selectAll() 71 + .where('rkey', '=', gameRkey) 72 + .executeTakeFirst(); 73 + 74 + if (!game) { 75 + throw error(404, 'Game not found'); 76 + } 77 + 78 + // Fetch profiles for avatars 79 + const playerProfile = await fetchUserProfile(game.player_one); 80 + const opponentProfile = game.player_two ? await fetchUserProfile(game.player_two) : null; 81 + const playerHandle = await resolveDidToHandle(game.player_one); 82 + const opponentHandle = game.player_two ? await resolveDidToHandle(game.player_two) : 'Waiting...'; 83 + 84 + // Fetch angry emoji SVG 85 + const angryEmojiPath = await getEmojiSvg('😤'); 86 + 87 + // Fetch and convert avatars to base64 88 + const playerAvatarBase64 = playerProfile?.avatar ? await fetchImageAsBase64(playerProfile.avatar) : null; 89 + const opponentAvatarBase64 = opponentProfile?.avatar ? await fetchImageAsBase64(opponentProfile.avatar) : null; 90 + 91 + // Generate confrontational Go board image 92 + const svg = ` 93 + <svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"> 94 + <defs> 95 + <linearGradient id="skyBg" x1="0%" y1="0%" x2="0%" y2="100%"> 96 + <stop offset="0%" style="stop-color:#b8d8ff;stop-opacity:1" /> 97 + <stop offset="100%" style="stop-color:#ffffff;stop-opacity:1" /> 98 + </linearGradient> 99 + <linearGradient id="boardGradient" x1="0%" y1="0%" x2="100%" y2="100%"> 100 + <stop offset="0%" style="stop-color:#DEB887;stop-opacity:1" /> 101 + <stop offset="100%" style="stop-color:#C4A067;stop-opacity:1" /> 102 + </linearGradient> 103 + <filter id="angry-glow"> 104 + <feGaussianBlur stdDeviation="3" result="coloredBlur"/> 105 + <feMerge> 106 + <feMergeNode in="coloredBlur"/> 107 + <feMergeNode in="SourceGraphic"/> 108 + </feMerge> 109 + </filter> 110 + <clipPath id="circleClip"> 111 + <circle cx="0" cy="0" r="50"/> 112 + </clipPath> 113 + </defs> 114 + 115 + <!-- Sky gradient background --> 116 + <rect width="600" height="400" fill="url(#skyBg)"/> 117 + 118 + <!-- Go Board --> 119 + <rect x="150" y="50" width="300" height="300" rx="10" fill="url(#boardGradient)" stroke="#8B7355" stroke-width="2"/> 120 + 121 + <!-- Grid lines --> 122 + ${Array.from({ length: 9 }, (_, i) => { 123 + const xPos = 150 + i * 37.5; 124 + const yPos = 50 + i * 37.5; 125 + return `<line x1="150" y1="${yPos}" x2="450" y2="${yPos}" stroke="#5C4830" stroke-width="1" opacity="0.7"/> 126 + <line x1="${xPos}" y1="50" x2="${xPos}" y2="350" stroke="#5C4830" stroke-width="1" opacity="0.7"/>`; 127 + }).join('')} 128 + 129 + <!-- Star points --> 130 + <circle cx="225" cy="125" r="3" fill="#5C4830" opacity="0.8"/> 131 + <circle cx="375" cy="125" r="3" fill="#5C4830" opacity="0.8"/> 132 + <circle cx="300" cy="200" r="3" fill="#5C4830" opacity="0.8"/> 133 + <circle cx="225" cy="275" r="3" fill="#5C4830" opacity="0.8"/> 134 + <circle cx="375" cy="275" r="3" fill="#5C4830" opacity="0.8"/> 135 + 136 + <!-- Lightning bolt divider --> 137 + <path d="M 300 0 L 285 200 L 315 200 L 300 400" stroke="#ff6b6b" stroke-width="3" fill="none" opacity="0.6" filter="url(#angry-glow)"/> 138 + 139 + <!-- Player avatar (left side, tilted) --> 140 + <g transform="translate(100, 200) rotate(-15)"> 141 + <circle cx="0" cy="0" r="52" fill="#ff6b6b" opacity="0.3" filter="url(#angry-glow)"/> 142 + ${playerAvatarBase64 143 + ? `<clipPath id="playerClip"><circle cx="0" cy="0" r="50"/></clipPath> 144 + <image href="${playerAvatarBase64}" x="-50" y="-50" width="100" height="100" clip-path="url(#playerClip)"/>` 145 + : `<circle cx="0" cy="0" r="50" fill="#2d3748"/> 146 + <text x="0" y="15" font-family="Arial, sans-serif" font-size="40" fill="white" text-anchor="middle" font-weight="bold">${playerHandle.charAt(0).toUpperCase()}</text>`} 147 + <circle cx="0" cy="0" r="50" fill="none" stroke="#ff6b6b" stroke-width="4"/> 148 + </g> 149 + 150 + <!-- Opponent avatar (right side, tilted opposite) --> 151 + <g transform="translate(500, 200) rotate(15)"> 152 + <circle cx="0" cy="0" r="52" fill="#ff6b6b" opacity="0.3" filter="url(#angry-glow)"/> 153 + ${opponentAvatarBase64 154 + ? `<clipPath id="opponentClip"><circle cx="0" cy="0" r="50"/></clipPath> 155 + <image href="${opponentAvatarBase64}" x="-50" y="-50" width="100" height="100" clip-path="url(#opponentClip)"/>` 156 + : `<circle cx="0" cy="0" r="50" fill="#2d3748"/> 157 + <text x="0" y="15" font-family="Arial, sans-serif" font-size="40" fill="white" text-anchor="middle" font-weight="bold">${opponentHandle.charAt(0).toUpperCase()}</text>`} 158 + <circle cx="0" cy="0" r="50" fill="none" stroke="#ff6b6b" stroke-width="4"/> 159 + </g> 160 + 161 + <!-- Angry emoji overlays --> 162 + ${angryEmojiPath ? ` 163 + <g transform="translate(85, 110)"> 164 + <svg viewBox="0 0 36 36" width="30" height="30"> 165 + ${angryEmojiPath} 166 + </svg> 167 + </g> 168 + <g transform="translate(485, 110)"> 169 + <svg viewBox="0 0 36 36" width="30" height="30"> 170 + ${angryEmojiPath} 171 + </svg> 172 + </g>` : `<!-- No emoji path -->`} 173 + </svg> 174 + `.trim(); 175 + 176 + // Convert SVG to PNG 177 + const resvg = new Resvg(svg, { 178 + fitTo: { 179 + mode: 'width', 180 + value: 600, 181 + }, 182 + }); 183 + const pngData = resvg.render(); 184 + const pngBuffer = pngData.asPng(); 185 + 186 + return new Response(pngBuffer, { 187 + headers: { 188 + 'Content-Type': 'image/png', 189 + 'Cache-Control': 'public, max-age=300', 190 + }, 191 + }); 192 + };
+320
src/routes/api/games/[id]/nudge/+server.ts
··· 1 + import { json, error } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { getSession, getAgent } from '$lib/server/auth'; 4 + import { getDb } from '$lib/server/db'; 5 + import { resolveDidToHandle, fetchUserProfile } from '$lib/atproto-client'; 6 + import { gameTitle } from '$lib/game-titles'; 7 + import { Resvg } from '@resvg/resvg-js'; 8 + import { parse } from 'twemoji-parser'; 9 + 10 + async function fetchImageAsBase64(url: string): Promise<string | null> { 11 + try { 12 + const response = await fetch(url); 13 + if (!response.ok) return null; 14 + 15 + const arrayBuffer = await response.arrayBuffer(); 16 + const buffer = Buffer.from(arrayBuffer); 17 + const base64 = buffer.toString('base64'); 18 + const contentType = response.headers.get('content-type') || 'image/jpeg'; 19 + 20 + return `data:${contentType};base64,${base64}`; 21 + } catch (err) { 22 + console.error('Failed to fetch image:', err); 23 + return null; 24 + } 25 + } 26 + 27 + async function getEmojiSvg(emoji: string): Promise<string> { 28 + try { 29 + const parsed = parse(emoji); 30 + if (parsed.length === 0) { 31 + console.log('No emoji parsed'); 32 + return ''; 33 + } 34 + 35 + const url = parsed[0].url; 36 + const filename = url.split('/').pop() || ''; 37 + const codepoint = filename.replace(/\.(png|svg)$/, ''); // Remove any extension 38 + console.log('Emoji codepoint:', codepoint); 39 + 40 + const svgUrl = `https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/${codepoint}.svg`; 41 + console.log('Fetching from:', svgUrl); 42 + 43 + const response = await fetch(svgUrl); 44 + if (!response.ok) { 45 + console.log('Fetch failed:', response.status); 46 + return ''; 47 + } 48 + 49 + const svgContent = await response.text(); 50 + console.log('SVG content length:', svgContent.length); 51 + 52 + // Extract everything between <svg> tags 53 + const svgMatch = svgContent.match(/<svg[^>]*>([\s\S]*)<\/svg>/); 54 + if (svgMatch) { 55 + return svgMatch[1]; // Return inner SVG content 56 + } 57 + console.log('No SVG match found'); 58 + return ''; 59 + } catch (err) { 60 + console.error('Failed to fetch emoji SVG:', err); 61 + return ''; 62 + } 63 + } 64 + 65 + export const POST: RequestHandler = async (event) => { 66 + const session = await getSession(event); 67 + if (!session) { 68 + throw error(401, 'Not authenticated'); 69 + } 70 + 71 + const gameRkey = event.params.id; 72 + const db = getDb(); 73 + 74 + // Get game details 75 + const game = await db 76 + .selectFrom('games') 77 + .selectAll() 78 + .where('rkey', '=', gameRkey) 79 + .executeTakeFirst(); 80 + 81 + if (!game) { 82 + throw error(404, 'Game not found'); 83 + } 84 + 85 + // Verify the user is a player in this game 86 + if (session.did !== game.player_one && session.did !== game.player_two) { 87 + throw error(403, 'You are not a player in this game'); 88 + } 89 + 90 + // Determine opponent 91 + const opponentDid = session.did === game.player_one ? game.player_two : game.player_one; 92 + if (!opponentDid) { 93 + throw error(400, 'Game has no opponent yet'); 94 + } 95 + 96 + try { 97 + const agent = await getAgent(event); 98 + if (!agent) { 99 + throw error(401, 'Failed to get authenticated agent'); 100 + } 101 + 102 + // Resolve handles and fetch profiles 103 + const playerHandle = await resolveDidToHandle(session.did); 104 + const opponentHandle = await resolveDidToHandle(opponentDid); 105 + const playerProfile = await fetchUserProfile(session.did); 106 + const opponentProfile = await fetchUserProfile(opponentDid); 107 + const gameUrl = `${event.url.origin}/game/${gameRkey}`; 108 + const title = gameTitle(gameRkey); 109 + 110 + // Fetch angry emoji SVG 111 + const angryEmojiPath = await getEmojiSvg('😤'); 112 + 113 + // Fetch and convert avatars to base64 114 + const playerAvatarBase64 = playerProfile?.avatar ? await fetchImageAsBase64(playerProfile.avatar) : null; 115 + const opponentAvatarBase64 = opponentProfile?.avatar ? await fetchImageAsBase64(opponentProfile.avatar) : null; 116 + 117 + // Generate confrontational Go board image 118 + const svg = ` 119 + <svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"> 120 + <defs> 121 + <linearGradient id="skyBg" x1="0%" y1="0%" x2="0%" y2="100%"> 122 + <stop offset="0%" style="stop-color:#b8d8ff;stop-opacity:1" /> 123 + <stop offset="100%" style="stop-color:#ffffff;stop-opacity:1" /> 124 + </linearGradient> 125 + <linearGradient id="boardGradient" x1="0%" y1="0%" x2="100%" y2="100%"> 126 + <stop offset="0%" style="stop-color:#DEB887;stop-opacity:1" /> 127 + <stop offset="100%" style="stop-color:#C4A067;stop-opacity:1" /> 128 + </linearGradient> 129 + <filter id="angry-glow"> 130 + <feGaussianBlur stdDeviation="3" result="coloredBlur"/> 131 + <feMerge> 132 + <feMergeNode in="coloredBlur"/> 133 + <feMergeNode in="SourceGraphic"/> 134 + </feMerge> 135 + </filter> 136 + <clipPath id="circleClip"> 137 + <circle cx="0" cy="0" r="50"/> 138 + </clipPath> 139 + </defs> 140 + 141 + <!-- Sky gradient background --> 142 + <rect width="600" height="400" fill="url(#skyBg)"/> 143 + 144 + <!-- Go Board --> 145 + <rect x="150" y="50" width="300" height="300" rx="10" fill="url(#boardGradient)" stroke="#8B7355" stroke-width="2"/> 146 + 147 + <!-- Grid lines --> 148 + ${Array.from({ length: 9 }, (_, i) => { 149 + const xPos = 150 + i * 37.5; 150 + const yPos = 50 + i * 37.5; 151 + return `<line x1="150" y1="${yPos}" x2="450" y2="${yPos}" stroke="#5C4830" stroke-width="1" opacity="0.7"/> 152 + <line x1="${xPos}" y1="50" x2="${xPos}" y2="350" stroke="#5C4830" stroke-width="1" opacity="0.7"/>`; 153 + }).join('')} 154 + 155 + <!-- Star points --> 156 + <circle cx="225" cy="125" r="3" fill="#5C4830" opacity="0.8"/> 157 + <circle cx="375" cy="125" r="3" fill="#5C4830" opacity="0.8"/> 158 + <circle cx="300" cy="200" r="3" fill="#5C4830" opacity="0.8"/> 159 + <circle cx="225" cy="275" r="3" fill="#5C4830" opacity="0.8"/> 160 + <circle cx="375" cy="275" r="3" fill="#5C4830" opacity="0.8"/> 161 + 162 + <!-- Lightning bolt divider --> 163 + <path d="M 300 0 L 285 200 L 315 200 L 300 400" stroke="#ff6b6b" stroke-width="3" fill="none" opacity="0.6" filter="url(#angry-glow)"/> 164 + 165 + <!-- Player avatar (left side, tilted) --> 166 + <g transform="translate(100, 200) rotate(-15)"> 167 + <circle cx="0" cy="0" r="52" fill="#ff6b6b" opacity="0.3" filter="url(#angry-glow)"/> 168 + ${playerAvatarBase64 169 + ? `<clipPath id="playerClip"><circle cx="0" cy="0" r="50"/></clipPath> 170 + <image href="${playerAvatarBase64}" x="-50" y="-50" width="100" height="100" clip-path="url(#playerClip)"/>` 171 + : `<circle cx="0" cy="0" r="50" fill="#2d3748"/> 172 + <text x="0" y="15" font-family="Arial, sans-serif" font-size="40" fill="white" text-anchor="middle" font-weight="bold">${playerHandle.charAt(0).toUpperCase()}</text>`} 173 + <circle cx="0" cy="0" r="50" fill="none" stroke="#ff6b6b" stroke-width="4"/> 174 + </g> 175 + 176 + <!-- Opponent avatar (right side, tilted opposite) --> 177 + <g transform="translate(500, 200) rotate(15)"> 178 + <circle cx="0" cy="0" r="52" fill="#ff6b6b" opacity="0.3" filter="url(#angry-glow)"/> 179 + ${opponentAvatarBase64 180 + ? `<clipPath id="opponentClip"><circle cx="0" cy="0" r="50"/></clipPath> 181 + <image href="${opponentAvatarBase64}" x="-50" y="-50" width="100" height="100" clip-path="url(#opponentClip)"/>` 182 + : `<circle cx="0" cy="0" r="50" fill="#2d3748"/> 183 + <text x="0" y="15" font-family="Arial, sans-serif" font-size="40" fill="white" text-anchor="middle" font-weight="bold">${opponentHandle.charAt(0).toUpperCase()}</text>`} 184 + <circle cx="0" cy="0" r="50" fill="none" stroke="#ff6b6b" stroke-width="4"/> 185 + </g> 186 + 187 + <!-- Angry emoji overlays --> 188 + ${angryEmojiPath ? ` 189 + <g transform="translate(85, 110)"> 190 + <svg viewBox="0 0 36 36" width="30" height="30"> 191 + ${angryEmojiPath} 192 + </svg> 193 + </g> 194 + <g transform="translate(485, 110)"> 195 + <svg viewBox="0 0 36 36" width="30" height="30"> 196 + ${angryEmojiPath} 197 + </svg> 198 + </g>` : `<!-- No emoji path -->`} 199 + </svg> 200 + `.trim(); 201 + 202 + // Convert SVG to PNG 203 + const resvg = new Resvg(svg, { 204 + fitTo: { 205 + mode: 'width', 206 + value: 600, 207 + }, 208 + }); 209 + const pngData = resvg.render(); 210 + const pngBuffer = pngData.asPng(); 211 + 212 + // Verify PNG buffer is ready 213 + if (!pngBuffer || pngBuffer.length === 0) { 214 + throw new Error('PNG buffer is empty'); 215 + } 216 + console.log('PNG buffer size:', pngBuffer.length, 'bytes'); 217 + 218 + // Upload the blob using the handler directly with proper Content-Type 219 + const uploadResponse = await (agent as any).handler('/xrpc/com.atproto.repo.uploadBlob', { 220 + method: 'POST', 221 + headers: { 222 + 'Content-Type': 'image/png', 223 + }, 224 + body: pngBuffer, 225 + }); 226 + 227 + if (!uploadResponse.ok) { 228 + const errorText = await uploadResponse.text(); 229 + console.error('Blob upload failed:', uploadResponse.status, errorText); 230 + throw new Error(`Blob upload failed: ${uploadResponse.status}`); 231 + } 232 + 233 + const blobData = await uploadResponse.json(); 234 + console.log('Blob upload response:', JSON.stringify(blobData, null, 2)); 235 + 236 + // Check if blob is in the response 237 + const blob = blobData?.blob; 238 + if (!blob) { 239 + console.error('Could not find blob in response. Full response:', blobData); 240 + throw new Error('Blob upload returned invalid response'); 241 + } 242 + 243 + console.log('Blob uploaded successfully:', blob); 244 + 245 + // Build the post text 246 + const postText = `Hey @${opponentHandle}, @${playerHandle} is eagerly awaiting your move!! Keep the game going at ${gameUrl} #${title.replace(/\s+/g, '')}`; 247 + 248 + // Calculate byte positions for facets (facets should only cover @handle, not surrounding text) 249 + const encoder = new TextEncoder(); 250 + 251 + // Opponent mention facet: just @opponentHandle 252 + const opponentMentionStart = encoder.encode('Hey ').byteLength; 253 + const opponentMentionEnd = opponentMentionStart + encoder.encode(`@${opponentHandle}`).byteLength; 254 + 255 + // Player mention facet: just @playerHandle 256 + const beforePlayerMention = `Hey @${opponentHandle}, `; 257 + const playerMentionStart = encoder.encode(beforePlayerMention).byteLength; 258 + const playerMentionEnd = playerMentionStart + encoder.encode(`@${playerHandle}`).byteLength; 259 + 260 + // URL facet 261 + const beforeUrl = `Hey @${opponentHandle}, @${playerHandle} is eagerly awaiting your move!! Keep the game going at `; 262 + const urlStart = encoder.encode(beforeUrl).byteLength; 263 + const urlEnd = urlStart + encoder.encode(gameUrl).byteLength; 264 + 265 + // Hashtag facet 266 + const hashtag = `#${title.replace(/\s+/g, '')}`; 267 + const beforeHashtag = `Hey @${opponentHandle}, @${playerHandle} is eagerly awaiting your move!! Keep the game going at ${gameUrl} `; 268 + const hashtagStart = encoder.encode(beforeHashtag).byteLength; 269 + const hashtagEnd = hashtagStart + encoder.encode(hashtag).byteLength; 270 + 271 + // Create the post 272 + const result = await (agent as any).post('com.atproto.repo.createRecord', { 273 + input: { 274 + repo: session.did, 275 + collection: 'app.bsky.feed.post', 276 + record: { 277 + $type: 'app.bsky.feed.post', 278 + text: postText, 279 + facets: [ 280 + { 281 + index: { byteStart: opponentMentionStart, byteEnd: opponentMentionEnd }, 282 + features: [{ $type: 'app.bsky.richtext.facet#mention', did: opponentDid }], 283 + }, 284 + { 285 + index: { byteStart: playerMentionStart, byteEnd: playerMentionEnd }, 286 + features: [{ $type: 'app.bsky.richtext.facet#mention', did: session.did }], 287 + }, 288 + { 289 + index: { byteStart: urlStart, byteEnd: urlEnd }, 290 + features: [{ $type: 'app.bsky.richtext.facet#link', uri: gameUrl }], 291 + }, 292 + { 293 + index: { byteStart: hashtagStart, byteEnd: hashtagEnd }, 294 + features: [{ $type: 'app.bsky.richtext.facet#tag', tag: title.replace(/\s+/g, '') }], 295 + }, 296 + ], 297 + embed: { 298 + $type: 'app.bsky.embed.external', 299 + external: { 300 + uri: gameUrl, 301 + title: `${title} - Your turn!`, 302 + description: `${playerHandle} is waiting for ${opponentHandle} to make a move`, 303 + thumb: blob, 304 + }, 305 + }, 306 + createdAt: new Date().toISOString(), 307 + }, 308 + }, 309 + }); 310 + 311 + if (!result.ok) { 312 + throw new Error(`Failed to create post: ${result.data?.message || 'Unknown error'}`); 313 + } 314 + 315 + return json({ success: true, uri: result.data.uri }); 316 + } catch (err) { 317 + console.error('Failed to nudge opponent:', err); 318 + throw error(500, 'Failed to nudge opponent'); 319 + } 320 + };
+119
src/routes/api/games/[id]/reaction-image/+server.ts
··· 1 + import { error } from "@sveltejs/kit"; 2 + import type { RequestHandler } from "./$types"; 3 + import { Resvg } from "@resvg/resvg-js"; 4 + import { parse } from "twemoji-parser"; 5 + 6 + async function getEmojiSvg(emoji: string): Promise<string> { 7 + try { 8 + const parsed = parse(emoji); 9 + if (parsed.length === 0) { 10 + console.log("No emoji parsed"); 11 + return ""; 12 + } 13 + 14 + const url = parsed[0].url; 15 + const filename = url.split("/").pop() || ""; 16 + const codepoint = filename.replace(/\.(png|svg)$/, ""); 17 + console.log("Emoji codepoint:", codepoint); 18 + 19 + const svgUrl = `https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/${codepoint}.svg`; 20 + console.log("Fetching from:", svgUrl); 21 + 22 + const response = await fetch(svgUrl); 23 + if (!response.ok) { 24 + console.log("Fetch failed:", response.status); 25 + return ""; 26 + } 27 + 28 + const svgContent = await response.text(); 29 + console.log("SVG content length:", svgContent.length); 30 + 31 + const svgMatch = svgContent.match(/<svg[^>]*>([\s\S]*)<\/svg>/); 32 + if (svgMatch) { 33 + return svgMatch[1]; 34 + } 35 + console.log("No SVG match found"); 36 + return ""; 37 + } catch (err) { 38 + console.error("Failed to fetch emoji SVG:", err); 39 + return ""; 40 + } 41 + } 42 + 43 + export const GET: RequestHandler = async ({ params, url }) => { 44 + const gameRkey = params.id; 45 + const emoji = url.searchParams.get("emoji") || "🎮"; 46 + 47 + // Fetch emoji SVG 48 + const emojiPath = await getEmojiSvg(emoji); 49 + 50 + // Generate a cloud image with emojis around the text 51 + const svg = ` 52 + <svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"> 53 + <defs> 54 + <linearGradient id="cloudGradient" x1="0%" y1="0%" x2="0%" y2="100%"> 55 + <stop offset="0%" style="stop-color:#e8f4ff;stop-opacity:1" /> 56 + <stop offset="100%" style="stop-color:#b3d9ff;stop-opacity:1" /> 57 + </linearGradient> 58 + <linearGradient id="skyGradient" x1="0%" y1="0%" x2="0%" y2="100%"> 59 + <stop offset="0%" style="stop-color:#87ceeb;stop-opacity:1" /> 60 + <stop offset="100%" style="stop-color:#f0f8ff;stop-opacity:1" /> 61 + </linearGradient> 62 + </defs> 63 + 64 + <!-- Sky background --> 65 + <rect width="600" height="400" fill="url(#skyGradient)"/> 66 + 67 + <!-- Clouds --> 68 + <ellipse cx="300" cy="250" rx="200" ry="120" fill="url(#cloudGradient)" opacity="0.9"/> 69 + <ellipse cx="200" cy="280" rx="140" ry="90" fill="url(#cloudGradient)" opacity="0.85"/> 70 + <ellipse cx="400" cy="280" rx="140" ry="90" fill="url(#cloudGradient)" opacity="0.85"/> 71 + 72 + <!-- Cloud Go text --> 73 + 74 + <!-- Emojis around the text --> 75 + ${ 76 + emojiPath 77 + ? ` 78 + <g transform="translate(200, 220)"> 79 + <svg viewBox="0 0 36 36" width="40" height="40"> 80 + ${emojiPath} 81 + </svg> 82 + </g> 83 + <g transform="translate(360, 220)"> 84 + <svg viewBox="0 0 36 36" width="40" height="40"> 85 + ${emojiPath} 86 + </svg> 87 + </g> 88 + <g transform="translate(250, 280)"> 89 + <svg viewBox="0 0 36 36" width="40" height="40"> 90 + ${emojiPath} 91 + </svg> 92 + </g> 93 + <g transform="translate(310, 280)"> 94 + <svg viewBox="0 0 36 36" width="40" height="40"> 95 + ${emojiPath} 96 + </svg> 97 + </g>` 98 + : `<!-- No emoji path -->` 99 + } 100 + </svg> 101 + `.trim(); 102 + 103 + // Convert SVG to PNG 104 + const resvg = new Resvg(svg, { 105 + fitTo: { 106 + mode: "width", 107 + value: 600, 108 + }, 109 + }); 110 + const pngData = resvg.render(); 111 + const pngBuffer = pngData.asPng(); 112 + 113 + return new Response(pngBuffer, { 114 + headers: { 115 + "Content-Type": "image/png", 116 + "Cache-Control": "public, max-age=300", 117 + }, 118 + }); 119 + };
+251
src/routes/api/games/[id]/share-reaction/+server.ts
··· 1 + import { json, error } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { getSession, getAgent } from '$lib/server/auth'; 4 + import { Resvg } from '@resvg/resvg-js'; 5 + import { parse } from 'twemoji-parser'; 6 + import { fetchUserProfile, resolveDidToHandle } from '$lib/atproto-client'; 7 + 8 + async function fetchImageAsBase64(url: string): Promise<string | null> { 9 + try { 10 + const response = await fetch(url); 11 + if (!response.ok) return null; 12 + 13 + const arrayBuffer = await response.arrayBuffer(); 14 + const buffer = Buffer.from(arrayBuffer); 15 + const base64 = buffer.toString('base64'); 16 + const contentType = response.headers.get('content-type') || 'image/jpeg'; 17 + 18 + return `data:${contentType};base64,${base64}`; 19 + } catch (err) { 20 + console.error('Failed to fetch image:', err); 21 + return null; 22 + } 23 + } 24 + 25 + async function getEmojiSvg(emoji: string): Promise<string> { 26 + try { 27 + const parsed = parse(emoji); 28 + if (parsed.length === 0) { 29 + console.log('No emoji parsed'); 30 + return ''; 31 + } 32 + 33 + const url = parsed[0].url; 34 + const filename = url.split('/').pop() || ''; 35 + const codepoint = filename.replace(/\.(png|svg)$/, ''); 36 + console.log('Emoji codepoint:', codepoint); 37 + 38 + const svgUrl = `https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/${codepoint}.svg`; 39 + console.log('Fetching from:', svgUrl); 40 + 41 + const response = await fetch(svgUrl); 42 + if (!response.ok) { 43 + console.log('Fetch failed:', response.status); 44 + return ''; 45 + } 46 + 47 + const svgContent = await response.text(); 48 + console.log('SVG content length:', svgContent.length); 49 + 50 + const svgMatch = svgContent.match(/<svg[^>]*>([\s\S]*)<\/svg>/); 51 + if (svgMatch) { 52 + return svgMatch[1]; 53 + } 54 + console.log('No SVG match found'); 55 + return ''; 56 + } catch (err) { 57 + console.error('Failed to fetch emoji SVG:', err); 58 + return ''; 59 + } 60 + } 61 + 62 + export const POST: RequestHandler = async (event) => { 63 + const session = await getSession(event); 64 + if (!session) { 65 + throw error(401, 'Not authenticated'); 66 + } 67 + 68 + const { request } = event; 69 + const { text, emoji, gameUrl } = await request.json(); 70 + 71 + if (!text || !gameUrl) { 72 + throw error(400, 'Text and gameUrl are required'); 73 + } 74 + 75 + try { 76 + const agent = await getAgent(event); 77 + if (!agent) { 78 + throw error(401, 'Failed to get authenticated agent'); 79 + } 80 + 81 + // Format the post text with emoji 82 + const emojiText = emoji || '🎮'; 83 + const postText = `${emojiText} ${text} ${emojiText}\n\nView this game at ${gameUrl}`; 84 + 85 + // Fetch user profile and avatar 86 + const userProfile = await fetchUserProfile(session.did); 87 + const userHandle = await resolveDidToHandle(session.did); 88 + const userAvatarBase64 = userProfile?.avatar ? await fetchImageAsBase64(userProfile.avatar) : null; 89 + 90 + // Fetch emoji SVG 91 + const emojiPath = await getEmojiSvg(emojiText); 92 + 93 + // Generate a cloud image with emojis for the embed 94 + const svg = ` 95 + <svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"> 96 + <defs> 97 + <linearGradient id="cloudGradient" x1="0%" y1="0%" x2="0%" y2="100%"> 98 + <stop offset="0%" style="stop-color:#e8f4ff;stop-opacity:1" /> 99 + <stop offset="100%" style="stop-color:#b3d9ff;stop-opacity:1" /> 100 + </linearGradient> 101 + <linearGradient id="skyGradient" x1="0%" y1="0%" x2="0%" y2="100%"> 102 + <stop offset="0%" style="stop-color:#87ceeb;stop-opacity:1" /> 103 + <stop offset="100%" style="stop-color:#f0f8ff;stop-opacity:1" /> 104 + </linearGradient> 105 + </defs> 106 + 107 + <!-- Sky background --> 108 + <rect width="600" height="400" fill="url(#skyGradient)"/> 109 + 110 + <!-- Clouds --> 111 + <ellipse cx="300" cy="250" rx="200" ry="120" fill="url(#cloudGradient)" opacity="0.9"/> 112 + <ellipse cx="200" cy="280" rx="140" ry="90" fill="url(#cloudGradient)" opacity="0.85"/> 113 + <ellipse cx="400" cy="280" rx="140" ry="90" fill="url(#cloudGradient)" opacity="0.85"/> 114 + 115 + <!-- Cloud Go text --> 116 + 117 + <!-- User avatar --> 118 + <g transform="translate(300, 230)"> 119 + ${userAvatarBase64 120 + ? `<clipPath id="userClip"><circle cx="0" cy="0" r="70"/></clipPath> 121 + <image href="${userAvatarBase64}" x="-70" y="-70" width="140" height="140" clip-path="url(#userClip)"/>` 122 + : `<circle cx="0" cy="0" r="70" fill="#2d3748"/> 123 + <text x="0" y="20" font-family="Arial, sans-serif" font-size="56" fill="white" text-anchor="middle" font-weight="bold">${userHandle.charAt(0).toUpperCase()}</text>`} 124 + <circle cx="0" cy="0" r="70" fill="none" stroke="#5A7A90" stroke-width="4"/> 125 + </g> 126 + 127 + <!-- Emojis around the avatar --> 128 + ${emojiPath ? ` 129 + <g transform="translate(160, 155)"> 130 + <svg viewBox="0 0 36 36" width="60" height="60"> 131 + ${emojiPath} 132 + </svg> 133 + </g> 134 + <g transform="translate(380, 155)"> 135 + <svg viewBox="0 0 36 36" width="60" height="60"> 136 + ${emojiPath} 137 + </svg> 138 + </g> 139 + <g transform="translate(270, 325)"> 140 + <svg viewBox="0 0 36 36" width="60" height="60"> 141 + ${emojiPath} 142 + </svg> 143 + </g> 144 + <g transform="translate(160, 280)"> 145 + <svg viewBox="0 0 36 36" width="60" height="60"> 146 + ${emojiPath} 147 + </svg> 148 + </g> 149 + <g transform="translate(380, 280)"> 150 + <svg viewBox="0 0 36 36" width="60" height="60"> 151 + ${emojiPath} 152 + </svg> 153 + </g>` : `<!-- No emoji path -->`} 154 + </svg> 155 + `.trim(); 156 + 157 + // Convert SVG to PNG using resvg 158 + const resvg = new Resvg(svg, { 159 + fitTo: { 160 + mode: 'width', 161 + value: 600, 162 + }, 163 + }); 164 + const pngData = resvg.render(); 165 + const pngBuffer = pngData.asPng(); 166 + 167 + // Verify PNG buffer is ready 168 + if (!pngBuffer || pngBuffer.length === 0) { 169 + throw new Error('PNG buffer is empty'); 170 + } 171 + console.log('PNG buffer size:', pngBuffer.length, 'bytes'); 172 + 173 + // Upload the blob using the handler directly with proper Content-Type 174 + const uploadResponse = await (agent as any).handler('/xrpc/com.atproto.repo.uploadBlob', { 175 + method: 'POST', 176 + headers: { 177 + 'Content-Type': 'image/png', 178 + }, 179 + body: pngBuffer, 180 + }); 181 + 182 + if (!uploadResponse.ok) { 183 + const errorText = await uploadResponse.text(); 184 + console.error('Blob upload failed:', uploadResponse.status, errorText); 185 + throw new Error(`Blob upload failed: ${uploadResponse.status}`); 186 + } 187 + 188 + const blobData = await uploadResponse.json(); 189 + console.log('Blob upload response:', JSON.stringify(blobData, null, 2)); 190 + 191 + // Check if blob is in the response 192 + const blob = blobData?.blob; 193 + if (!blob) { 194 + console.error('Could not find blob in response. Full response:', blobData); 195 + throw new Error('Blob upload returned invalid response'); 196 + } 197 + 198 + console.log('Blob uploaded successfully:', blob); 199 + 200 + // Calculate byte positions for the URL facet 201 + const encoder = new TextEncoder(); 202 + const textBeforeUrl = `${emojiText} ${text} ${emojiText}\n\nView this game at `; 203 + const byteStart = encoder.encode(textBeforeUrl).byteLength; 204 + const byteEnd = byteStart + encoder.encode(gameUrl).byteLength; 205 + 206 + // Create a Bluesky post with link facet and external embed 207 + const result = await (agent as any).post('com.atproto.repo.createRecord', { 208 + input: { 209 + repo: session.did, 210 + collection: 'app.bsky.feed.post', 211 + record: { 212 + $type: 'app.bsky.feed.post', 213 + text: postText, 214 + facets: [ 215 + { 216 + index: { 217 + byteStart, 218 + byteEnd, 219 + }, 220 + features: [ 221 + { 222 + $type: 'app.bsky.richtext.facet#link', 223 + uri: gameUrl, 224 + }, 225 + ], 226 + }, 227 + ], 228 + embed: { 229 + $type: 'app.bsky.embed.external', 230 + external: { 231 + uri: gameUrl, 232 + title: 'Cloud Go Game', 233 + description: text, 234 + thumb: blob, 235 + }, 236 + }, 237 + createdAt: new Date().toISOString(), 238 + }, 239 + }, 240 + }); 241 + 242 + if (!result.ok) { 243 + throw new Error(`Failed to create post: ${result.data?.message || 'Unknown error'}`); 244 + } 245 + 246 + return json({ success: true, uri: result.data.uri }); 247 + } catch (err) { 248 + console.error('Failed to share to Bluesky:', err); 249 + throw error(500, 'Failed to share to Bluesky'); 250 + } 251 + };
+1 -1
src/routes/auth/login/+server.ts
··· 45 45 const { url } = await oauth.authorize({ 46 46 target, 47 47 scope: 48 - "atproto repo:boo.sky.go.game?action=create repo:boo.sky.go.game?action=update repo:boo.sky.go.move?action=create repo:boo.sky.go.pass?action=create repo:boo.sky.go.resign?action=create repo:boo.sky.go.reaction?action=create", 48 + "atproto repo:app.bsky.feed.post?action=create com.atproto.repo.uploadBlob blob:image/png repo:boo.sky.go.game?action=create repo:boo.sky.go.game?action=update repo:boo.sky.go.move?action=create repo:boo.sky.go.pass?action=create repo:boo.sky.go.resign?action=create repo:boo.sky.go.reaction?action=create", 49 49 state: { startedAt: Date.now() }, 50 50 }); 51 51
+211 -32
src/routes/game/[id]/+page.svelte
··· 15 15 import { browser } from '$app/environment'; 16 16 import { goto } from '$app/navigation'; 17 17 import { page } from '$app/stores'; 18 + import { calculateLiberties } from '$lib/liberty-calculator'; 18 19 19 20 let { data }: { data: PageData } = $props(); 20 21 21 22 let boardRef: any = $state(null); 22 23 let isSubmitting = $state(false); 24 + let isNudging = $state(false); 23 25 let showScoreInput = $state(false); 24 26 let blackScore = $state(0); 25 27 let whiteScore = $state(0); ··· 31 33 let jetstreamConnected = $state(false); 32 34 let jetstreamError = $state(false); 33 35 let reviewMoveIndex = $state<number | null>(null); 36 + let beginnerMode = $state(false); 37 + let libertyData = $state<Array<Array<number>> | null>(null); 34 38 35 39 // Reaction state 36 40 let reactions = $state<Map<string, ReactionWithAuthor[]>>(new Map()); 37 41 let showReactionForm = $state(false); 38 42 let reactionText = $state(''); 39 43 let reactionEmoji = $state(''); 40 - let reactionStars = $state<number | undefined>(undefined); 44 + let shareToBluesky = $state(false); 41 45 let isSubmittingReaction = $state(false); 42 46 let reactionHandles = $state<Record<string, string>>({}); 43 47 let showAllReactions = $state(false); ··· 73 77 data.session && (data.session.did === gamePlayerOne || data.session.did === gamePlayerTwo) 74 78 ); 75 79 80 + const currentTurn = $derived(() => { 81 + // Combine moves and passes, sort by moveNumber to get the last action 82 + const allActions = [...moves, ...passes].sort((a, b) => b.moveNumber - a.moveNumber); 83 + 84 + if (allActions.length === 0) { 85 + // No moves yet, black (player_one) goes first 86 + return 'black'; 87 + } 88 + 89 + // Get the last action's color and alternate 90 + const lastAction = allActions[0]; 91 + return lastAction.color === 'black' ? 'white' : 'black'; 92 + }); 93 + 76 94 const isMyTurn = $derived(() => { 77 95 if (!data.session || gameStatus !== 'active') return false; 78 96 79 - const totalActions = moves.length + passes.length; 80 - const nextColor = totalActions % 2 === 0 ? 'black' : 'white'; 97 + const nextColor = currentTurn(); 81 98 82 99 if (nextColor === 'black') { 83 100 return data.session.did === gamePlayerOne; 84 101 } else { 85 102 return data.session.did === gamePlayerTwo; 86 103 } 87 - }); 88 - 89 - const currentTurn = $derived(() => { 90 - const totalActions = moves.length + passes.length; 91 - return totalActions % 2 === 0 ? 'black' : 'white'; 92 104 }); 93 105 94 106 const capturedByBlack = $derived(() => { ··· 121 133 } 122 134 }); 123 135 136 + // Calculate liberties when beginner mode is enabled 137 + $effect(() => { 138 + if (beginnerMode && moves.length > 0 && !loadingMoves) { 139 + try { 140 + libertyData = calculateLiberties(moves, gameBoardSize); 141 + } catch (err) { 142 + console.error('Failed to calculate liberties:', err); 143 + libertyData = null; 144 + } 145 + } else { 146 + libertyData = null; 147 + } 148 + }); 149 + 124 150 // Get move URI for reactions - use actual URI if available, fallback to synthetic 125 151 function getMoveUri(move: MoveRecord): string { 126 152 if (move.uri) { ··· 272 298 if (response.ok) { 273 299 const result = await response.json(); 274 300 // Add the new move to local state (board already shows it visually) 301 + const moveColor = currentTurn(); // Use the corrected turn calculation 275 302 const newMove: MoveRecord = { 276 303 $type: 'boo.sky.go.move', 277 304 game: data.gameAtUri, ··· 279 306 moveNumber: moves.length + passes.length + 1, 280 307 x, 281 308 y, 282 - color: (moves.length + passes.length) % 2 === 0 ? 'black' : 'white', 309 + color: moveColor, 283 310 captureCount: captures, 284 311 createdAt: new Date().toISOString(), 285 312 uri: result.uri, ··· 310 337 if (response.ok) { 311 338 const result = await response.json(); 312 339 // Add the new pass to local state 340 + const passColor = currentTurn(); // Use the corrected turn calculation 313 341 const newPass: PassRecord = { 314 342 $type: 'boo.sky.go.pass', 315 343 game: data.gameAtUri, 316 344 player: data.session!.did, 317 345 moveNumber: moves.length + passes.length + 1, 318 - color: (moves.length + passes.length) % 2 === 0 ? 'black' : 'white', 346 + color: passColor, 319 347 createdAt: new Date().toISOString(), 320 348 }; 321 349 passes = [...passes, newPass]; ··· 413 441 } 414 442 } 415 443 444 + async function handleNudge() { 445 + if (isNudging) return; 446 + 447 + isNudging = true; 448 + try { 449 + const response = await fetch(`/api/games/${data.gameRkey}/nudge`, { 450 + method: 'POST', 451 + }); 452 + 453 + if (response.ok) { 454 + alert('Nudge posted to Bluesky! 📣'); 455 + } else { 456 + alert('Failed to post nudge'); 457 + } 458 + } catch (err) { 459 + console.error('Failed to nudge opponent:', err); 460 + alert('Failed to post nudge'); 461 + } finally { 462 + isNudging = false; 463 + } 464 + } 465 + 416 466 async function handleReactionSubmit() { 417 467 if (!selectedMove || !data.session || isSubmittingReaction) return; 418 468 if (!reactionText.trim()) { ··· 430 480 moveUri, 431 481 text: reactionText.trim(), 432 482 emoji: reactionEmoji || undefined, 433 - stars: reactionStars, 434 483 }), 435 484 }); 436 485 ··· 443 492 move: moveUri, 444 493 text: reactionText.trim(), 445 494 emoji: reactionEmoji || undefined, 446 - stars: reactionStars, 447 495 createdAt: new Date().toISOString(), 448 496 uri: result.uri, 449 497 author: data.session.did, ··· 459 507 }); 460 508 } 461 509 510 + // Share to Bluesky if checkbox was checked 511 + if (shareToBluesky && selectedMove) { 512 + const moveNumber = selectedMove.moveNumber; 513 + const gameUrl = `${window.location.origin}/game/${data.gameRkey}?move=${moveNumber}`; 514 + 515 + try { 516 + await fetch(`/api/games/${data.gameRkey}/share-reaction`, { 517 + method: 'POST', 518 + headers: { 'Content-Type': 'application/json' }, 519 + body: JSON.stringify({ 520 + text: reactionText.trim(), 521 + emoji: reactionEmoji || undefined, 522 + gameUrl, 523 + }), 524 + }); 525 + } catch (err) { 526 + console.error('Failed to share to Bluesky:', err); 527 + // Don't show error to user, reaction was still posted successfully 528 + } 529 + } 530 + 462 531 // Clear form 463 532 reactionText = ''; 464 533 reactionEmoji = ''; 465 - reactionStars = undefined; 534 + shareToBluesky = false; 466 535 showReactionForm = false; 467 536 } else { 468 537 alert('Failed to post reaction'); ··· 688 757 689 758 <div class="game-info"> 690 759 <div class="info-card cloud-card"> 691 - <h3>Game Info</h3> 760 + <div class="info-header"> 761 + <h3>Game Info</h3> 762 + {#if gameStatus === 'active' || gameStatus === 'completed'} 763 + <label class="beginner-mode-label"> 764 + <input 765 + type="checkbox" 766 + bind:checked={beginnerMode} 767 + class="go-stone-checkbox" 768 + /> 769 + <span>Beginner Mode</span> 770 + </label> 771 + {/if} 772 + </div> 692 773 <p> 693 774 <strong>Status:</strong> <span class="status-{gameStatus}">{gameStatus}</span> 694 775 {#if gameStatus === 'waiting' && data.session && data.session.did === gamePlayerOne} ··· 806 887 interactive={gameStatus === 'active' && isMyTurn() && reviewMoveIndex === null} 807 888 currentTurn={currentTurn()} 808 889 territoryData={gameStatus === 'completed' && gameBlackScore === null ? territoryData : null} 890 + libertyData={beginnerMode ? libertyData : null} 809 891 /> 810 892 {/if} 811 893 ··· 824 906 Resign 825 907 </button> 826 908 {:else} 909 + <button 910 + onclick={handleNudge} 911 + disabled={isNudging} 912 + class="nudge-button" 913 + > 914 + {isNudging ? 'Posting...' : 'Nudge Opponent 📣'} 915 + </button> 827 916 <button 828 917 onclick={handleCancel} 829 918 disabled={isSubmitting} ··· 1049 1138 bind:value={reactionEmoji} 1050 1139 maxlength="4" 1051 1140 /> 1052 - <select class="reaction-stars-select" bind:value={reactionStars}> 1053 - <option value={undefined}>Stars</option> 1054 - <option value={1}>1</option> 1055 - <option value={2}>2</option> 1056 - <option value={3}>3</option> 1057 - <option value={4}>4</option> 1058 - <option value={5}>5</option> 1059 - </select> 1141 + <textarea 1142 + class="reaction-text-input" 1143 + placeholder="Comment on this move..." 1144 + bind:value={reactionText} 1145 + maxlength="300" 1146 + rows="2" 1147 + ></textarea> 1060 1148 </div> 1061 - <textarea 1062 - class="reaction-text-input" 1063 - placeholder="Comment on this move..." 1064 - bind:value={reactionText} 1065 - maxlength="300" 1066 - rows="2" 1067 - ></textarea> 1149 + <label class="share-checkbox-label"> 1150 + <input 1151 + type="checkbox" 1152 + bind:checked={shareToBluesky} 1153 + class="share-checkbox" 1154 + /> 1155 + <span>Share to Bluesky</span> 1156 + </label> 1068 1157 <button 1069 1158 type="submit" 1070 1159 class="submit-reaction-btn" ··· 1393 1482 border: 1px solid var(--sky-blue-pale); 1394 1483 } 1395 1484 1485 + .info-header { 1486 + display: flex; 1487 + align-items: center; 1488 + justify-content: space-between; 1489 + gap: 1rem; 1490 + margin-bottom: 0.5rem; 1491 + } 1492 + 1396 1493 .info-card h3 { 1397 - margin-top: 0; 1494 + margin: 0; 1398 1495 color: var(--sky-slate-dark); 1399 1496 font-size: 1.125rem; 1400 1497 font-weight: 600; ··· 1446 1543 font-style: italic; 1447 1544 } 1448 1545 1546 + .beginner-mode-label { 1547 + display: flex; 1548 + align-items: center; 1549 + gap: 0.5rem; 1550 + color: var(--sky-slate); 1551 + font-size: 0.8rem; 1552 + cursor: pointer; 1553 + transition: color 0.2s; 1554 + white-space: nowrap; 1555 + } 1556 + 1557 + .beginner-mode-label:hover { 1558 + color: var(--sky-slate-dark); 1559 + } 1560 + 1561 + .go-stone-checkbox { 1562 + appearance: none; 1563 + -webkit-appearance: none; 1564 + width: 18px; 1565 + height: 18px; 1566 + border-radius: 50%; 1567 + border: 2px solid var(--sky-gray-light); 1568 + background: linear-gradient(135deg, #fafafa 0%, #e0e0e0 100%); 1569 + cursor: pointer; 1570 + transition: all 0.2s; 1571 + box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.8), 0 1px 2px rgba(0, 0, 0, 0.1); 1572 + flex-shrink: 0; 1573 + } 1574 + 1575 + .go-stone-checkbox:checked { 1576 + border-color: #1a1a1a; 1577 + background: radial-gradient(circle at 35% 35%, #4a4a4a 0%, #1a1a1a 100%); 1578 + box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.2), 0 1px 3px rgba(0, 0, 0, 0.3); 1579 + } 1580 + 1581 + .go-stone-checkbox:hover { 1582 + border-color: var(--sky-slate); 1583 + } 1584 + 1449 1585 .game-board-container { 1450 1586 display: flex; 1451 1587 justify-content: center; ··· 1901 2037 margin-top: 1rem; 1902 2038 display: flex; 1903 2039 justify-content: center; 2040 + gap: 0.5rem; 1904 2041 } 1905 2042 1906 2043 .cancel-game-button { ··· 1936 2073 1937 2074 .archive-button:hover:not(:disabled) { 1938 2075 background: var(--sky-blue-pale); 2076 + } 2077 + 2078 + .nudge-button { 2079 + padding: 0.5rem 1rem; 2080 + font-size: 0.875rem; 2081 + border: none; 2082 + border-radius: 0.5rem; 2083 + cursor: pointer; 2084 + font-weight: 600; 2085 + transition: all 0.6s ease; 2086 + background: linear-gradient(135deg, var(--sky-apricot-light) 0%, var(--sky-apricot) 100%); 2087 + color: var(--sky-slate-dark); 2088 + } 2089 + 2090 + .nudge-button:hover:not(:disabled) { 2091 + box-shadow: 0 0 12px rgba(229, 168, 120, 0.5); 2092 + } 2093 + 2094 + .nudge-button:disabled { 2095 + opacity: 0.5; 2096 + cursor: not-allowed; 1939 2097 } 1940 2098 1941 2099 .cancelled-card { ··· 2099 2257 } 2100 2258 2101 2259 .reaction-text-input { 2102 - width: 100%; 2260 + flex: 1; 2103 2261 padding: 0.5rem 0.75rem; 2104 2262 border: 1px solid var(--sky-blue-pale); 2105 2263 border-radius: 0.375rem; ··· 2116 2274 box-shadow: 0 0 0 2px var(--sky-apricot-light); 2117 2275 } 2118 2276 2277 + .share-checkbox-label { 2278 + display: flex; 2279 + align-items: center; 2280 + gap: 0.5rem; 2281 + font-size: 0.875rem; 2282 + color: var(--sky-slate); 2283 + cursor: pointer; 2284 + transition: color 0.3s ease; 2285 + } 2286 + 2287 + .share-checkbox-label:hover { 2288 + color: var(--sky-slate-dark); 2289 + } 2290 + 2291 + .share-checkbox { 2292 + width: 18px; 2293 + height: 18px; 2294 + cursor: pointer; 2295 + accent-color: var(--sky-apricot); 2296 + } 2297 + 2119 2298 .submit-reaction-btn { 2120 2299 align-self: flex-end; 2121 2300 padding: 0.5rem 1rem; ··· 2126 2305 font-size: 0.875rem; 2127 2306 font-weight: 600; 2128 2307 cursor: pointer; 2129 - transition: all 0.2s; 2308 + transition: all 0.6s ease; 2130 2309 } 2131 2310 2132 2311 .submit-reaction-btn:hover:not(:disabled) {
+3 -1
src/routes/oauth-client-metadata.json/+server.ts
··· 10 10 'Access-Control-Allow-Origin': '*', 11 11 'Access-Control-Allow-Methods': 'GET, OPTIONS', 12 12 'Access-Control-Allow-Headers': 'Content-Type', 13 - 'Cache-Control': 'public, max-age=60', 13 + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 14 + 'Pragma': 'no-cache', 15 + 'Expires': '0', 14 16 }); 15 17 16 18 return json(oauth.metadata);
+318 -93
src/routes/profile/[did]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import type { PageData } from './$types'; 3 3 import { onMount } from 'svelte'; 4 - import { fetchUserProfile, resolveDidToHandle, fetchMoveCount, type UserProfile } from '$lib/atproto-client'; 4 + import { fetchUserProfile, resolveDidToHandle, fetchMoveCount, fetchGameRecord, type UserProfile } from '$lib/atproto-client'; 5 5 6 6 let { data }: { data: PageData } = $props(); 7 7 8 8 let profile: UserProfile | null = $state(null); 9 9 let handles = $state<Record<string, string>>({}); 10 10 let moveCounts = $state<Record<string, number | null>>({}); 11 + let opponentProfiles = $state<Record<string, UserProfile | null>>({}); 12 + let gameWinners = $state<Record<string, string | null>>({}); 11 13 let archivePage = $state(1); 12 14 const ARCHIVE_PAGE_SIZE = 6; 15 + 16 + interface OpponentHistory { 17 + did: string; 18 + outcomes: Array<'won' | 'lost' | 'active'>; 19 + } 13 20 14 21 // Split games by status 15 22 const activeGames = $derived( ··· 52 59 return null; 53 60 } 54 61 55 - onMount(async () => { 62 + // Helper to determine if profile user won a game 63 + function didUserWin(game: typeof data.games[0]): boolean | null { 64 + if (game.status !== 'completed') return null; 65 + 66 + // Check if someone resigned 67 + const resignedBy = getResignedBy(game); 68 + if (resignedBy) { 69 + // Determine if the profile user was black or white 70 + const userIsBlack = game.player_one === data.profileDid; 71 + const userIsWhite = game.player_two === data.profileDid; 72 + 73 + if (!userIsBlack && !userIsWhite) return null; 74 + 75 + // If opponent resigned, user wins 76 + if (resignedBy === 'black' && userIsWhite) return true; 77 + if (resignedBy === 'white' && userIsBlack) return true; 78 + // If user resigned, user loses 79 + if (resignedBy === 'black' && userIsBlack) return false; 80 + if (resignedBy === 'white' && userIsWhite) return false; 81 + } 82 + 83 + // Check if we have the winner from the fetched game record 84 + const winner = gameWinners[game.id]; 85 + if (!winner) return null; 86 + 87 + return winner === data.profileDid; 88 + } 89 + 90 + // Calculate wins and losses 91 + const wins = $derived( 92 + archivedGames.filter(game => didUserWin(game) === true).length 93 + ); 94 + 95 + const losses = $derived( 96 + archivedGames.filter(game => didUserWin(game) === false).length 97 + ); 98 + 99 + // Build opponent history map 100 + const opponentHistory = $derived(() => { 101 + const historyMap = new Map<string, OpponentHistory>(); 102 + 103 + for (const game of data.games) { 104 + const opponentDid = getOpponentDid(game); 105 + if (!opponentDid) continue; 106 + 107 + if (!historyMap.has(opponentDid)) { 108 + historyMap.set(opponentDid, { did: opponentDid, outcomes: [] }); 109 + } 110 + 111 + const history = historyMap.get(opponentDid)!; 112 + 113 + if (game.status === 'active' || game.status === 'waiting') { 114 + history.outcomes.push('active'); 115 + } else if (game.status === 'completed') { 116 + const won = didUserWin(game); 117 + if (won === true) { 118 + history.outcomes.push('won'); 119 + } else if (won === false) { 120 + history.outcomes.push('lost'); 121 + } else { 122 + // Completed but no score/winner determined yet 123 + history.outcomes.push('active'); 124 + } 125 + } 126 + } 127 + 128 + // Sort by most games first, then limit to 30 129 + return Array.from(historyMap.values()) 130 + .sort((a, b) => b.outcomes.length - a.outcomes.length) 131 + .slice(0, 30); 132 + }); 133 + 134 + // Get the most recent outcome for border color 135 + function getOutcomeColor(history: OpponentHistory): 'won' | 'lost' | 'active' { 136 + if (history.outcomes.length === 0) return 'active'; 137 + 138 + // Check if any games are active 139 + if (history.outcomes.includes('active')) return 'active'; 140 + 141 + // Get the most recent completed game outcome 142 + const lastOutcome = history.outcomes[history.outcomes.length - 1]; 143 + return lastOutcome; 144 + } 145 + 146 + async function loadProfileData() { 147 + // Reset state 148 + profile = null; 149 + handles = {}; 150 + moveCounts = {}; 151 + opponentProfiles = {}; 152 + gameWinners = {}; 153 + archivePage = 1; 154 + 56 155 // Fetch profile 57 156 profile = await fetchUserProfile(data.profileDid); 58 157 ··· 64 163 if (game.player_two) dids.add(game.player_two); 65 164 } 66 165 67 - // Resolve handles 166 + // Resolve handles and fetch profiles 68 167 for (const did of dids) { 69 168 resolveDidToHandle(did).then((h) => { 70 169 handles = { ...handles, [did]: h }; 71 170 }); 171 + 172 + // Fetch opponent profiles for avatars 173 + if (did !== data.profileDid) { 174 + fetchUserProfile(did).then((p) => { 175 + opponentProfiles = { ...opponentProfiles, [did]: p }; 176 + }); 177 + } 72 178 } 73 179 74 - // Fetch move counts 180 + // Fetch move counts and game records for completed games 75 181 for (const game of data.games) { 76 182 fetchMoveCount(game.id).then((count) => { 77 183 moveCounts = { ...moveCounts, [game.id]: count }; 78 184 }); 185 + 186 + // Fetch game record for completed games to get winner 187 + if (game.status === 'completed') { 188 + fetchGameRecord(game.player_one, game.rkey).then((record) => { 189 + if (record && record.winner) { 190 + gameWinners = { ...gameWinners, [game.id]: record.winner }; 191 + } 192 + }); 193 + } 79 194 } 80 195 } 196 + } 197 + 198 + // Reload data when profile DID changes 199 + $effect(() => { 200 + // Watch for changes to profileDid 201 + data.profileDid; 202 + loadProfileData(); 203 + }); 204 + 205 + onMount(() => { 206 + loadProfileData(); 81 207 }); 82 208 </script> 83 209 ··· 119 245 <div class="stat-value">{archivedGames.length}</div> 120 246 <div class="stat-label">Completed</div> 121 247 </div> 248 + <div class="stat stat-wins"> 249 + <div class="stat-value">{wins}</div> 250 + <div class="stat-label">Wins</div> 251 + </div> 252 + <div class="stat stat-losses"> 253 + <div class="stat-value">{losses}</div> 254 + <div class="stat-label">Losses</div> 255 + </div> 122 256 </div> 123 257 </div> 124 258 125 - <div class="games-layout"> 126 - <!-- Active Games --> 127 - <div class="card current-games"> 128 - <h2>Active Games</h2> 129 - {#if activeGames.length > 0} 130 - <div class="games-list"> 131 - {#each activeGames as game} 132 - {@const opponentDid = getOpponentDid(game)} 133 - <div class="game-item"> 134 - <img 135 - src="/api/games/{game.rkey}/board?size=70" 136 - alt="Board preview" 137 - class="mini-board-img" 138 - loading="lazy" 139 - /> 140 - <div class="game-info"> 141 - <div class="game-title">{game.title}</div> 142 - <div> 143 - <strong>{game.board_size}x{game.board_size}</strong> board 144 - <span class="move-count">{moveCounts[game.id] != null ? `${moveCounts[game.id]} moves` : '...'}</span> 145 - </div> 146 - <div class="game-players"> 147 - {#if opponentDid} 148 - vs <a href="/profile/{opponentDid}" class="player-link">{handles[opponentDid] || opponentDid.slice(0, 20)}</a> 149 - {:else} 150 - Waiting for opponent 151 - {/if} 152 - </div> 259 + <!-- Game History / Opponents --> 260 + {#if opponentHistory().length > 0} 261 + <div class="card game-history"> 262 + <h2>Game History</h2> 263 + <p class="history-subtitle">Players you've faced</p> 264 + <div class="opponent-avatars"> 265 + {#each opponentHistory() as history} 266 + {@const opponentProfile = opponentProfiles[history.did]} 267 + {@const totalGames = history.outcomes.length} 268 + {@const anglePerGame = 360 / totalGames} 269 + <a 270 + href="/profile/{history.did}" 271 + class="opponent-avatar-link" 272 + title="{handles[history.did] || history.did} - {history.outcomes.filter(o => o === 'won').length}W {history.outcomes.filter(o => o === 'lost').length}L {history.outcomes.filter(o => o === 'active').length}A" 273 + > 274 + <svg class="opponent-avatar-wrapper" viewBox="0 0 60 60"> 275 + <!-- Draw arc segments for each game --> 276 + {#if totalGames === 1} 277 + <!-- Special case: single game - draw full circle --> 278 + {@const color = history.outcomes[0] === 'won' ? '#059669' : history.outcomes[0] === 'lost' ? '#dc2626' : '#eab308'} 279 + <circle cx="30" cy="30" r="27" fill={color} opacity="0.3" /> 280 + <circle cx="30" cy="30" r="27" stroke={color} stroke-width="3" fill="none" /> 281 + {:else} 282 + {#each history.outcomes as outcome, i} 283 + {@const startAngle = i * anglePerGame - 90} 284 + {@const endAngle = (i + 1) * anglePerGame - 90} 285 + {@const startRad = (startAngle * Math.PI) / 180} 286 + {@const endRad = (endAngle * Math.PI) / 180} 287 + {@const radius = 27} 288 + {@const centerX = 30} 289 + {@const centerY = 30} 290 + {@const x1 = centerX + radius * Math.cos(startRad)} 291 + {@const y1 = centerY + radius * Math.sin(startRad)} 292 + {@const x2 = centerX + radius * Math.cos(endRad)} 293 + {@const y2 = centerY + radius * Math.sin(endRad)} 294 + {@const largeArc = anglePerGame > 180 ? 1 : 0} 295 + {@const color = outcome === 'won' ? '#059669' : outcome === 'lost' ? '#dc2626' : '#eab308'} 296 + <path 297 + d="M {centerX} {centerY} L {x1} {y1} A {radius} {radius} 0 {largeArc} 1 {x2} {y2} Z" 298 + fill={color} 299 + opacity="0.3" 300 + /> 301 + <path 302 + d="M {x1} {y1} A {radius} {radius} 0 {largeArc} 1 {x2} {y2}" 303 + stroke={color} 304 + stroke-width="3" 305 + fill="none" 306 + /> 307 + {/each} 308 + {/if} 309 + 310 + <!-- Avatar circle --> 311 + <clipPath id="avatar-clip-{history.did}"> 312 + <circle cx="30" cy="30" r="22" /> 313 + </clipPath> 314 + 315 + {#if opponentProfile?.avatar} 316 + <image 317 + href={opponentProfile.avatar} 318 + x="8" 319 + y="8" 320 + width="44" 321 + height="44" 322 + clip-path="url(#avatar-clip-{history.did})" 323 + /> 324 + {:else} 325 + <circle cx="30" cy="30" r="22" fill="url(#gradient-{history.did})" /> 326 + <text 327 + x="30" 328 + y="30" 329 + text-anchor="middle" 330 + dominant-baseline="central" 331 + font-size="18" 332 + font-weight="700" 333 + fill="var(--sky-slate-dark)" 334 + > 335 + {(handles[history.did] || history.did).charAt(0).toUpperCase()} 336 + </text> 337 + {/if} 338 + 339 + <!-- Gradient for fallback --> 340 + <defs> 341 + <linearGradient id="gradient-{history.did}" x1="0%" y1="0%" x2="100%" y2="100%"> 342 + <stop offset="0%" style="stop-color:var(--sky-apricot-light)" /> 343 + <stop offset="100%" style="stop-color:var(--sky-rose-light)" /> 344 + </linearGradient> 345 + </defs> 346 + </svg> 347 + </a> 348 + {/each} 349 + </div> 350 + </div> 351 + {/if} 352 + 353 + <!-- Active Games --> 354 + <div class="card current-games"> 355 + <h2>Active Games</h2> 356 + {#if activeGames.length > 0} 357 + <div class="games-list"> 358 + {#each activeGames as game} 359 + {@const opponentDid = getOpponentDid(game)} 360 + <div class="game-item"> 361 + <img 362 + src="/api/games/{game.rkey}/board?size=70" 363 + alt="Board preview" 364 + class="mini-board-img" 365 + loading="lazy" 366 + /> 367 + <div class="game-info"> 368 + <div class="game-title">{game.title}</div> 369 + <div> 370 + <strong>{game.board_size}x{game.board_size}</strong> board 371 + <span class="move-count">{moveCounts[game.id] != null ? `${moveCounts[game.id]} moves` : '...'}</span> 372 + </div> 373 + <div class="game-players"> 374 + {#if opponentDid} 375 + vs <a href="/profile/{opponentDid}" class="player-link">{handles[opponentDid] || opponentDid.slice(0, 20)}</a> 376 + {:else} 377 + Waiting for opponent 378 + {/if} 153 379 </div> 154 - <a href="/game/{game.rkey}" class="button button-primary button-sm">View</a> 155 380 </div> 156 - {/each} 157 - </div> 158 - {:else} 159 - <p class="empty-state">No active games.</p> 160 - {/if} 161 - </div> 162 - 381 + <a href="/game/{game.rkey}" class="button button-primary button-sm">View</a> 382 + </div> 383 + {/each} 384 + </div> 385 + {:else} 386 + <p class="empty-state">No active games.</p> 387 + {/if} 163 388 </div> 164 389 165 390 <!-- Archive --> ··· 231 456 .container { 232 457 max-width: 1200px; 233 458 margin: 0 auto; 234 - padding: 2rem; 459 + padding: clamp(1rem, 3vw, 2rem); 235 460 } 236 461 237 462 .profile-header { ··· 318 543 letter-spacing: 0.05em; 319 544 } 320 545 546 + .stat-wins .stat-value { 547 + color: #059669; 548 + } 549 + 550 + .stat-losses .stat-value { 551 + color: #dc2626; 552 + } 553 + 321 554 .card { 322 555 background: linear-gradient( 323 556 135deg, ··· 359 592 font-weight: 600; 360 593 } 361 594 362 - .games-layout { 363 - display: grid; 364 - grid-template-columns: 1fr 1fr; 365 - gap: 1.5rem; 595 + .current-games { 366 596 margin-bottom: 1.5rem; 367 597 } 368 598 369 599 @media (max-width: 768px) { 370 - .games-layout { 371 - grid-template-columns: 1fr; 372 - } 373 - 374 600 .profile-info { 375 601 flex-direction: column; 376 602 align-items: center; ··· 396 622 border: 1px solid var(--sky-blue-pale); 397 623 border-radius: 0.75rem; 398 624 background: linear-gradient(135deg, var(--sky-white) 0%, var(--sky-cloud) 100%); 399 - transition: all 0.2s; 625 + transition: all 0.6s ease; 400 626 } 401 627 402 628 .game-item:hover { 403 629 border-color: var(--sky-apricot); 404 - box-shadow: 0 4px 12px rgba(90, 122, 144, 0.1); 630 + box-shadow: 0 6px 18px rgba(90, 122, 144, 0.12); 631 + transform: translateY(-2px); 405 632 } 406 633 407 634 .mini-board-img { ··· 455 682 font-size: 1rem; 456 683 font-weight: 600; 457 684 cursor: pointer; 458 - transition: all 0.2s; 685 + transition: all 0.6s ease; 459 686 text-decoration: none; 460 687 display: inline-block; 461 688 } ··· 467 694 } 468 695 469 696 .button-primary:hover { 470 - background: linear-gradient(135deg, var(--sky-apricot) 0%, var(--sky-apricot-dark) 100%); 471 - transform: translateY(-1px); 472 - box-shadow: 0 4px 12px rgba(229, 168, 120, 0.4); 697 + transform: translateY(-3px); 698 + box-shadow: 0 6px 16px rgba(229, 168, 120, 0.5); 699 + filter: brightness(1.05); 473 700 } 474 701 475 702 .button-secondary { ··· 481 708 .button-secondary:hover { 482 709 background: var(--sky-blue-pale); 483 710 color: var(--sky-slate-dark); 711 + transform: translateY(-2px); 484 712 } 485 713 486 714 .button-sm { ··· 495 723 font-style: italic; 496 724 } 497 725 498 - .waiting-games-grid { 499 - display: grid; 500 - grid-template-columns: 1fr; 501 - gap: 0.75rem; 502 - } 503 - 504 - .game-item-compact { 505 - display: flex; 506 - flex-wrap: wrap; 507 - align-items: center; 508 - gap: 1rem; 509 - padding: 1rem 1.25rem; 510 - border: 1px solid var(--sky-blue-pale); 511 - border-radius: 0.75rem; 512 - background: linear-gradient(135deg, var(--sky-white) 0%, var(--sky-cloud) 100%); 513 - transition: all 0.2s; 514 - } 515 - 516 - .game-item-compact:hover { 517 - border-color: var(--sky-apricot); 518 - box-shadow: 0 4px 12px rgba(90, 122, 144, 0.1); 519 - } 520 - 521 - .game-item-compact .game-title { 522 - flex: 1; 523 - min-width: 120px; 524 - } 525 - 526 - .game-meta { 527 - display: flex; 528 - flex-direction: column; 529 - gap: 0.25rem; 530 - font-size: 0.875rem; 531 - color: var(--sky-gray); 532 - flex: 1; 533 - min-width: 100px; 534 - } 535 - 536 726 .archive-section { 537 727 margin-top: 2rem; 538 728 border-top: 2px solid var(--sky-blue-pale); ··· 682 872 .pagination-info { 683 873 font-size: 0.875rem; 684 874 color: var(--sky-gray); 875 + } 876 + 877 + .game-history { 878 + margin-bottom: 2rem; 879 + } 880 + 881 + .history-subtitle { 882 + color: var(--sky-gray); 883 + font-size: 0.9rem; 884 + margin: 0.5rem 0 1.5rem 0; 885 + } 886 + 887 + .opponent-avatars { 888 + display: flex; 889 + flex-wrap: wrap; 890 + gap: 1rem; 891 + align-items: center; 892 + } 893 + 894 + .opponent-avatar-link { 895 + display: block; 896 + text-decoration: none; 897 + transition: all 0.3s ease; 898 + filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.1)); 899 + } 900 + 901 + .opponent-avatar-link:hover { 902 + transform: scale(1.15); 903 + filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.2)); 904 + } 905 + 906 + .opponent-avatar-wrapper { 907 + width: 60px; 908 + height: 60px; 909 + display: block; 685 910 } 686 911 </style>