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.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add OpenGraph image preview for game pages

- Add og:image, og:title, og:description meta tags to game page
- Add Twitter card meta tags for social sharing
- Create /og-image/[id] endpoint that generates SVG preview
- SVG includes game title, board size, status, and player handles
- Decorative mini Go board illustration in preview image

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

+200 -5
+1 -1
TODOS.md
··· 2 2 - [ ] new reaction lexicon that contains an optional emoji field, a required text field, an an optional stars field, and a mandatory "Move ID" field, referencing a move in a game that can be commented on by any logged in user who is spectating. These could be shown on the right side of the board, and moves can show a preview of the reaction emojis as badges 3 3 - [x] sharing a game using bsky post intents : The web URL endpoint is https://bsky.app/intent/compose, with the HTTP query parameter text. Remember to use URL-escaping on the query parameter value, and that the post length limit on Bluesky is 300 characters (more precisely, 300 Unicode Grapheme Clusters). 4 4 - [x] tag the players involved in the game and use a hashtag composed of the game ID 5 - - [ ] Opengraph image preview of the game 5 + - [x] Opengraph image preview of the game 6 6 - [x] reorganize the completed game section into a grid to save space, and add pagination
+11 -4
src/routes/game/[id]/+page.svelte
··· 373 373 374 374 <svelte:head> 375 375 <title>{gameTitle(data.gameRkey)} - Go Game</title> 376 + <meta property="og:title" content="{gameTitle(data.gameRkey)} - Cloud Go" /> 377 + <meta property="og:description" content="A {data.boardSize}x{data.boardSize} Go game on the AT Protocol. {data.status === 'active' ? 'Game in progress!' : data.status === 'waiting' ? 'Waiting for opponent.' : 'Game completed.'}" /> 378 + <meta property="og:type" content="website" /> 379 + <meta property="og:image" content="{typeof window !== 'undefined' ? window.location.origin : ''}/og-image/{data.gameRkey}" /> 380 + <meta property="og:image:width" content="1200" /> 381 + <meta property="og:image:height" content="630" /> 382 + <meta name="twitter:card" content="summary_large_image" /> 383 + <meta name="twitter:title" content="{gameTitle(data.gameRkey)} - Cloud Go" /> 384 + <meta name="twitter:description" content="A {data.boardSize}x{data.boardSize} Go game on the AT Protocol." /> 385 + <meta name="twitter:image" content="{typeof window !== 'undefined' ? window.location.origin : ''}/og-image/{data.gameRkey}" /> 376 386 </svelte:head> 377 387 378 388 <div class="container"> ··· 393 403 {/if} 394 404 {/if} 395 405 <a href={getShareUrl()} target="_blank" rel="noopener noreferrer" class="share-button"> 396 - <svg viewBox="0 0 24 24" fill="currentColor"> 397 - <path d="M12 2C6.477 2 2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.879V14.89h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.989C18.343 21.129 22 16.99 22 12c0-5.523-4.477-10-10-10z"/> 398 - </svg> 399 - Share on Bluesky 406 + 🦋 Share on Bluesky 400 407 </a> 401 408 <a href="/" class="back-link">← Back to games</a> 402 409 </div>
+188
src/routes/og-image/[id]/+server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { getDb } from '$lib/server/db'; 4 + import { gameTitle } from '$lib/game-titles'; 5 + import { resolveDidToHandle } from '$lib/atproto-client'; 6 + 7 + export const GET: RequestHandler = async ({ params }) => { 8 + const gameRkey = params.id; 9 + 10 + const db = getDb(); 11 + const game = db 12 + .selectFrom('games') 13 + .selectAll() 14 + .where('rkey', '=', gameRkey) 15 + .executeTakeFirst(); 16 + 17 + if (!game) { 18 + throw error(404, 'Game not found'); 19 + } 20 + 21 + // Resolve handles (with fallback to truncated DIDs) 22 + let playerOneHandle = game.player_one.slice(0, 20) + '...'; 23 + let playerTwoHandle = game.player_two ? game.player_two.slice(0, 20) + '...' : null; 24 + 25 + try { 26 + playerOneHandle = await resolveDidToHandle(game.player_one); 27 + if (game.player_two) { 28 + playerTwoHandle = await resolveDidToHandle(game.player_two); 29 + } 30 + } catch { 31 + // Keep fallback values 32 + } 33 + 34 + const title = gameTitle(gameRkey); 35 + const boardSize = game.board_size; 36 + const status = game.status; 37 + 38 + // Status text and colors 39 + let statusText = ''; 40 + let statusColor = '#718096'; 41 + if (status === 'active') { 42 + statusText = 'Game in Progress'; 43 + statusColor = '#059669'; 44 + } else if (status === 'waiting') { 45 + statusText = 'Waiting for Opponent'; 46 + statusColor = '#E5A878'; 47 + } else if (status === 'completed') { 48 + statusText = 'Completed'; 49 + statusColor = '#5A7A90'; 50 + } 51 + 52 + // Generate SVG 53 + const svg = ` 54 + <svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg"> 55 + <defs> 56 + <linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%"> 57 + <stop offset="0%" style="stop-color:#E8EFF4;stop-opacity:1" /> 58 + <stop offset="50%" style="stop-color:#F5F8FA;stop-opacity:1" /> 59 + <stop offset="100%" style="stop-color:#D4E5EF;stop-opacity:1" /> 60 + </linearGradient> 61 + <linearGradient id="titleGradient" x1="0%" y1="0%" x2="100%" y2="0%"> 62 + <stop offset="0%" style="stop-color:#455D6E;stop-opacity:1" /> 63 + <stop offset="100%" style="stop-color:#5A7A90;stop-opacity:1" /> 64 + </linearGradient> 65 + <linearGradient id="boardGradient" x1="0%" y1="0%" x2="100%" y2="100%"> 66 + <stop offset="0%" style="stop-color:#DEB887;stop-opacity:1" /> 67 + <stop offset="100%" style="stop-color:#C4A067;stop-opacity:1" /> 68 + </linearGradient> 69 + </defs> 70 + 71 + <!-- Background --> 72 + <rect width="1200" height="630" fill="url(#bgGradient)"/> 73 + 74 + <!-- Cloud decoration --> 75 + <ellipse cx="100" cy="100" rx="80" ry="50" fill="rgba(255,255,255,0.6)"/> 76 + <ellipse cx="150" cy="90" rx="60" ry="40" fill="rgba(255,255,255,0.7)"/> 77 + <ellipse cx="1100" cy="530" rx="90" ry="55" fill="rgba(255,255,255,0.5)"/> 78 + <ellipse cx="1050" cy="550" rx="70" ry="45" fill="rgba(255,255,255,0.6)"/> 79 + 80 + <!-- Main card --> 81 + <rect x="50" y="50" width="1100" height="530" rx="30" ry="30" fill="rgba(255,255,255,0.95)" 82 + stroke="rgba(212,229,239,0.5)" stroke-width="2"/> 83 + 84 + <!-- Mini board illustration --> 85 + <g transform="translate(750, 150)"> 86 + <rect width="350" height="350" rx="15" fill="url(#boardGradient)" stroke="#8B7355" stroke-width="3"/> 87 + <!-- Grid lines --> 88 + ${generateGridLines(350, 9)} 89 + <!-- Sample stones --> 90 + <circle cx="87.5" cy="87.5" r="16" fill="#1a1a1a" stroke="#333" stroke-width="1"/> 91 + <circle cx="175" cy="175" r="16" fill="#f5f5f5" stroke="#ccc" stroke-width="1"/> 92 + <circle cx="262.5" cy="87.5" r="16" fill="#1a1a1a" stroke="#333" stroke-width="1"/> 93 + <circle cx="87.5" cy="262.5" r="16" fill="#f5f5f5" stroke="#ccc" stroke-width="1"/> 94 + <circle cx="175" cy="87.5" r="16" fill="#f5f5f5" stroke="#ccc" stroke-width="1"/> 95 + <circle cx="262.5" cy="175" r="16" fill="#1a1a1a" stroke="#333" stroke-width="1"/> 96 + </g> 97 + 98 + <!-- Title --> 99 + <text x="100" y="150" font-family="Inter, system-ui, sans-serif" font-size="56" font-weight="700" fill="url(#titleGradient)"> 100 + ${escapeXml(title)} 101 + </text> 102 + 103 + <!-- Cloud Go branding --> 104 + <text x="100" y="200" font-family="Inter, system-ui, sans-serif" font-size="24" fill="#718096"> 105 + Cloud Go 106 + </text> 107 + 108 + <!-- Board size --> 109 + <text x="100" y="280" font-family="Inter, system-ui, sans-serif" font-size="32" font-weight="600" fill="#5A7A90"> 110 + ${boardSize}x${boardSize} Board 111 + </text> 112 + 113 + <!-- Status badge --> 114 + <rect x="100" y="310" width="${statusText.length * 14 + 40}" height="44" rx="22" fill="${statusColor}" opacity="0.15"/> 115 + <text x="120" y="341" font-family="Inter, system-ui, sans-serif" font-size="22" font-weight="600" fill="${statusColor}"> 116 + ${statusText} 117 + </text> 118 + 119 + <!-- Players --> 120 + <g transform="translate(100, 400)"> 121 + <circle cx="15" cy="0" r="15" fill="#1a1a1a"/> 122 + <text x="45" y="7" font-family="Inter, system-ui, sans-serif" font-size="22" fill="#455D6E"> 123 + ${escapeXml(playerOneHandle)} 124 + </text> 125 + </g> 126 + 127 + ${playerTwoHandle ? ` 128 + <g transform="translate(100, 460)"> 129 + <circle cx="15" cy="0" r="15" fill="#f5f5f5" stroke="#ccc" stroke-width="2"/> 130 + <text x="45" y="7" font-family="Inter, system-ui, sans-serif" font-size="22" fill="#455D6E"> 131 + ${escapeXml(playerTwoHandle)} 132 + </text> 133 + </g> 134 + ` : ` 135 + <g transform="translate(100, 460)"> 136 + <circle cx="15" cy="0" r="15" fill="#f5f5f5" stroke="#ccc" stroke-width="2"/> 137 + <text x="45" y="7" font-family="Inter, system-ui, sans-serif" font-size="22" fill="#9CA3AF" font-style="italic"> 138 + Waiting for opponent... 139 + </text> 140 + </g> 141 + `} 142 + </svg> 143 + `.trim(); 144 + 145 + return new Response(svg, { 146 + headers: { 147 + 'Content-Type': 'image/svg+xml', 148 + 'Cache-Control': 'public, max-age=3600', 149 + }, 150 + }); 151 + }; 152 + 153 + function generateGridLines(size: number, lines: number): string { 154 + const step = size / (lines - 1); 155 + let result = ''; 156 + 157 + for (let i = 0; i < lines; i++) { 158 + const pos = i * step; 159 + // Horizontal lines 160 + result += `<line x1="0" y1="${pos}" x2="${size}" y2="${pos}" stroke="#5C4830" stroke-width="1" opacity="0.6"/>`; 161 + // Vertical lines 162 + result += `<line x1="${pos}" y1="0" x2="${pos}" y2="${size}" stroke="#5C4830" stroke-width="1" opacity="0.6"/>`; 163 + } 164 + 165 + // Star points for 9x9 166 + const starPoints = [ 167 + [step * 2, step * 2], 168 + [step * 6, step * 2], 169 + [step * 2, step * 6], 170 + [step * 6, step * 6], 171 + [step * 4, step * 4], // center 172 + ]; 173 + 174 + for (const [x, y] of starPoints) { 175 + result += `<circle cx="${x}" cy="${y}" r="4" fill="#5C4830" opacity="0.8"/>`; 176 + } 177 + 178 + return result; 179 + } 180 + 181 + function escapeXml(str: string): string { 182 + return str 183 + .replace(/&/g, '&amp;') 184 + .replace(/</g, '&lt;') 185 + .replace(/>/g, '&gt;') 186 + .replace(/"/g, '&quot;') 187 + .replace(/'/g, '&apos;'); 188 + }