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 some missed files, trash old astro stuff

+1112
+22
IntroPopup.md
··· 1 + # How to play Go 2 + 3 + - Go is an ancient game that's Chinese name "Weiqi", translates to the Surrounding Game. 4 + - Your goal in Go is to surround as much territory as possible, and you can do that by surrounding groups to capture them. 5 + - ( example diagrams side by side, one with a white stone flanked by 3 black stones, the second one showing the same formation with the white stone removed and a black stone completing the diamond surrounding the hole where white stone was. ) 6 + - This demonstrates how a single stone gets surrounded and captured 7 + - Stones that are orthogonal to eachother (connecting along the lines on the board) form stronger groups, where you must surround the entire group to capture it. 8 + - ( example diagrams where an L shape of 4 white stones are nearly surrounded, with a second diagram shows the black stones fully surrounding the perimeter where the white stones used to be ) 9 + - Here we can see the white stones were all surrounded by black stones and got captured together. 10 + - At the end of the game, you will count up the territory that each player surrounds on the board, and get bonus points for all of the opponents stones you captured. 11 + - ( example diagram showing a small 7x7 board with black stones occuyping the 4th column and white stones occupying the 5th column, territory calculation has been run and shows black dots on the left and white dots on the right of the groups ) 12 + - In this extremely boring game, you can see black controls more of the board and therefore wins by having more points! 13 + 14 + # How to use Cloud Go 15 + 16 + - Initiating player will always go first (for now). 17 + - Once someone accepts the match, the original initiator can make the first move. 18 + - You take turns playing the game, there are no time limits (yet). 19 + - If your opponent is taking too long or you think the game is a lost cause, you can resign. 20 + - You can click through the move list at the bottom or use the left and right arrow keys to navigate through the move history. 21 + - If you click on a move, you can add a comment with a reaction emoji and a star rating on it! (might remove the star ratings lol) 22 + - Once both players pass, the game will auto-calculate territories and show them to both players. Whoever is playing black will get the suggested scores and can choose to modify them or commit them to their repo!
+166
src/lib/components/Header.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { fetchUserProfile, type UserProfile } from '$lib/atproto-client'; 4 + import ProfileDropdown from './ProfileDropdown.svelte'; 5 + 6 + type Session = { did: string } | null; 7 + 8 + let { session }: { session: Session } = $props(); 9 + 10 + let userProfile: UserProfile | null = $state(null); 11 + 12 + onMount(async () => { 13 + if (session) { 14 + userProfile = await fetchUserProfile(session.did); 15 + } 16 + }); 17 + </script> 18 + 19 + <div class="header-wrapper"> 20 + <header class="header"> 21 + <div class="header-content"> 22 + <a href="/" class="logo">☁️ Cloud Go ☁️</a> 23 + 24 + <div class="header-right"> 25 + {#if session && userProfile} 26 + <ProfileDropdown 27 + avatar={userProfile.avatar || null} 28 + handle={userProfile.handle} 29 + did={session.did} 30 + /> 31 + {:else if session} 32 + <!-- Loading profile --> 33 + <div class="avatar-placeholder"></div> 34 + {:else} 35 + <a href="/" class="login-link">Login</a> 36 + {/if} 37 + </div> 38 + </div> 39 + </header> 40 + </div> 41 + 42 + <style> 43 + .header-wrapper { 44 + max-width: 1200px; 45 + margin: 2rem auto 3rem; 46 + padding: 0 2rem; 47 + } 48 + 49 + .header { 50 + background: linear-gradient( 51 + 135deg, 52 + rgba(255, 255, 255, 0.95) 0%, 53 + rgba(245, 248, 250, 0.9) 50%, 54 + rgba(232, 239, 244, 0.85) 100% 55 + ); 56 + border: none; 57 + border-radius: 2rem 2.5rem 2rem 2.2rem; 58 + box-shadow: 59 + 0 0 20px rgba(255, 255, 255, 0.8), 60 + 0 0 40px rgba(255, 255, 255, 0.4), 61 + 0 8px 32px rgba(90, 122, 144, 0.12), 62 + inset 0 1px 1px rgba(255, 255, 255, 0.9); 63 + backdrop-filter: blur(8px); 64 + position: relative; 65 + } 66 + 67 + .header::before { 68 + content: ''; 69 + position: absolute; 70 + inset: -2px; 71 + border-radius: inherit; 72 + background: linear-gradient( 73 + 135deg, 74 + rgba(255, 255, 255, 0.6) 0%, 75 + rgba(212, 229, 239, 0.3) 50%, 76 + rgba(255, 255, 255, 0.4) 100% 77 + ); 78 + filter: blur(4px); 79 + z-index: -1; 80 + } 81 + 82 + .header-content { 83 + display: flex; 84 + align-items: center; 85 + justify-content: space-between; 86 + padding: 1.5rem 2.5rem; 87 + max-width: 1200px; 88 + margin: 0 auto; 89 + } 90 + 91 + .logo { 92 + font-size: 2.25rem; 93 + font-weight: 700; 94 + color: var(--sky-slate-dark); 95 + text-decoration: none; 96 + letter-spacing: -0.02em; 97 + transition: color 0.2s; 98 + } 99 + 100 + .logo:hover { 101 + color: var(--sky-apricot-dark); 102 + } 103 + 104 + 105 + 106 + .header-right { 107 + display: flex; 108 + align-items: center; 109 + } 110 + 111 + .login-link { 112 + color: var(--sky-slate); 113 + text-decoration: none; 114 + font-weight: 500; 115 + font-size: 1.125rem; 116 + padding: 0.75rem 1.25rem; 117 + border-radius: 0.5rem; 118 + transition: all 0.2s; 119 + } 120 + 121 + .login-link:hover { 122 + background: var(--sky-apricot-light); 123 + color: var(--sky-apricot-dark); 124 + } 125 + 126 + .avatar-placeholder { 127 + width: 80px; 128 + height: 80px; 129 + border-radius: 50%; 130 + background: var(--sky-cloud); 131 + animation: pulse 1.5s ease-in-out infinite; 132 + } 133 + 134 + @keyframes pulse { 135 + 0%, 100% { 136 + opacity: 0.6; 137 + } 138 + 50% { 139 + opacity: 1; 140 + } 141 + } 142 + 143 + @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 + .avatar-placeholder { 162 + width: 100px; 163 + height: 100px; 164 + } 165 + } 166 + </style>
+135
src/lib/components/ProfileDropdown.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + 4 + let { avatar, handle, did }: { avatar: string | null; handle: string; did: string } = $props(); 5 + 6 + let isOpen = $state(false); 7 + let dropdownRef: HTMLDivElement | null = $state(null); 8 + 9 + function toggleDropdown() { 10 + isOpen = !isOpen; 11 + } 12 + 13 + function handleClickOutside(event: MouseEvent) { 14 + if (dropdownRef && !dropdownRef.contains(event.target as Node)) { 15 + isOpen = false; 16 + } 17 + } 18 + 19 + async function handleLogout() { 20 + await fetch('/auth/logout', { method: 'POST' }); 21 + window.location.reload(); 22 + } 23 + 24 + onMount(() => { 25 + document.addEventListener('click', handleClickOutside); 26 + return () => { 27 + document.removeEventListener('click', handleClickOutside); 28 + }; 29 + }); 30 + </script> 31 + 32 + <div class="profile-dropdown" bind:this={dropdownRef}> 33 + <button class="avatar-button" onclick={toggleDropdown} aria-label="Profile menu"> 34 + {#if avatar} 35 + <img src={avatar} alt={handle} class="avatar-img" /> 36 + {:else} 37 + <div class="avatar-fallback"> 38 + {handle.charAt(0).toUpperCase()} 39 + </div> 40 + {/if} 41 + </button> 42 + 43 + {#if isOpen} 44 + <div class="dropdown-menu"> 45 + <a href="/profile/{did}" class="dropdown-item"> 46 + View Cloud Go Profile 47 + </a> 48 + <button onclick={handleLogout} class="dropdown-item logout-btn"> 49 + Logout 50 + </button> 51 + </div> 52 + {/if} 53 + </div> 54 + 55 + <style> 56 + .profile-dropdown { 57 + position: relative; 58 + } 59 + 60 + .avatar-button { 61 + background: none; 62 + border: none; 63 + cursor: pointer; 64 + padding: 0; 65 + border-radius: 50%; 66 + transition: transform 0.2s; 67 + } 68 + 69 + .avatar-button:hover { 70 + transform: scale(1.05); 71 + } 72 + 73 + .avatar-img { 74 + width: 80px; 75 + height: 80px; 76 + border-radius: 50%; 77 + border: 2px solid var(--sky-blue-pale); 78 + object-fit: cover; 79 + } 80 + 81 + .avatar-fallback { 82 + width: 32px; 83 + height: 32px; 84 + border-radius: 50%; 85 + border: 2px solid var(--sky-blue-pale); 86 + background: linear-gradient(135deg, var(--sky-apricot-light), var(--sky-rose-light)); 87 + display: flex; 88 + align-items: center; 89 + justify-content: center; 90 + font-weight: 600; 91 + color: var(--sky-slate-dark); 92 + font-size: 1rem; 93 + } 94 + 95 + .dropdown-menu { 96 + position: absolute; 97 + top: calc(100% + 0.5rem); 98 + right: 0; 99 + min-width: 220px; 100 + background: var(--sky-white); 101 + border: 2px solid var(--sky-blue-pale); 102 + border-radius: 0.75rem; 103 + box-shadow: 0 8px 24px rgba(90, 122, 144, 0.15); 104 + z-index: 1000; 105 + overflow: hidden; 106 + } 107 + 108 + .dropdown-item { 109 + display: block; 110 + width: 100%; 111 + padding: 0.875rem 1.25rem; 112 + border: none; 113 + background: var(--sky-white); 114 + color: var(--sky-slate-dark); 115 + text-align: left; 116 + text-decoration: none; 117 + font-size: 0.9rem; 118 + font-weight: 500; 119 + cursor: pointer; 120 + transition: background 0.2s; 121 + border-bottom: 1px solid var(--sky-cloud); 122 + } 123 + 124 + .dropdown-item:last-child { 125 + border-bottom: none; 126 + } 127 + 128 + .dropdown-item:hover { 129 + background: var(--sky-apricot-light); 130 + } 131 + 132 + .logout-btn { 133 + font-family: inherit; 134 + } 135 + </style>
+9
src/routes/+layout.server.ts
··· 1 + import { getSession } from '$lib/server/auth'; 2 + import type { LayoutServerLoad } from './$types'; 3 + 4 + export const load: LayoutServerLoad = async (event) => { 5 + const session = await getSession(event); 6 + return { 7 + session: session ? { did: session.did } : null 8 + }; 9 + };
+68
src/routes/api/games/[id]/scoring-board/+server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { getDb } from '$lib/server/db'; 4 + import { fetchGameActionsFromPds } from '$lib/atproto-client'; 5 + import { generateBoardSvg, buildBoardStateFromMoves, calculateTerritory } from '$lib/server/board-svg'; 6 + 7 + /** 8 + * GET: Return an SVG of the board with territory visualization for scoring. 9 + * Supports query params: 10 + * - size: SVG size in pixels (default 300) 11 + */ 12 + export const GET: RequestHandler = async ({ params, url }) => { 13 + const { id: rkey } = params; 14 + const size = Math.min(600, Math.max(100, parseInt(url.searchParams.get('size') || '300', 10))); 15 + 16 + const db = getDb(); 17 + const game = await db 18 + .selectFrom('games') 19 + .selectAll() 20 + .where('rkey', '=', rkey) 21 + .executeTakeFirst(); 22 + 23 + if (!game) { 24 + throw error(404, 'Game not found'); 25 + } 26 + 27 + // Fetch moves 28 + let boardState: Array<Array<'black' | 'white' | null>> = Array.from( 29 + { length: game.board_size }, 30 + () => Array.from({ length: game.board_size }, () => null) 31 + ); 32 + 33 + try { 34 + const { moves } = await fetchGameActionsFromPds( 35 + game.player_one, 36 + game.player_two, 37 + game.id 38 + ); 39 + 40 + if (moves.length > 0) { 41 + const result = await buildBoardStateFromMoves( 42 + moves.map(m => ({ x: m.x, y: m.y, color: m.color })), 43 + game.board_size 44 + ); 45 + boardState = result.boardState; 46 + } 47 + } catch (err) { 48 + console.error('Failed to fetch moves for scoring board:', err); 49 + } 50 + 51 + // Calculate territory 52 + const territoryMap = calculateTerritory(boardState, game.board_size); 53 + 54 + const svg = generateBoardSvg( 55 + game.board_size, 56 + boardState, 57 + null, // No last move marker for scoring view 58 + { size, showLastMove: false, showTerritory: true }, 59 + territoryMap 60 + ); 61 + 62 + return new Response(svg, { 63 + headers: { 64 + 'Content-Type': 'image/svg+xml', 65 + 'Cache-Control': 'no-cache', // Don't cache scoring view 66 + }, 67 + }); 68 + };
+26
src/routes/profile/[did]/+page.server.ts
··· 1 + import { getSession } from '$lib/server/auth'; 2 + import { getDb } from '$lib/server/db'; 3 + import type { PageServerLoad } from './$types'; 4 + 5 + export const load: PageServerLoad = async (event) => { 6 + const session = await getSession(event); 7 + const { did } = event.params; 8 + const db = await getDb(); 9 + 10 + // Fetch all games where this DID is a player 11 + const games = await db 12 + .selectFrom('games') 13 + .selectAll() 14 + .where((eb) => eb.or([ 15 + eb('player_one', '=', did), 16 + eb('player_two', '=', did) 17 + ])) 18 + .orderBy('updated_at', 'desc') 19 + .execute(); 20 + 21 + return { 22 + session: session ? { did: session.did } : null, 23 + profileDid: did, 24 + games 25 + }; 26 + };
+686
src/routes/profile/[did]/+page.svelte
··· 1 + <script lang="ts"> 2 + import type { PageData } from './$types'; 3 + import { onMount } from 'svelte'; 4 + import { fetchUserProfile, resolveDidToHandle, fetchMoveCount, type UserProfile } from '$lib/atproto-client'; 5 + 6 + let { data }: { data: PageData } = $props(); 7 + 8 + let profile: UserProfile | null = $state(null); 9 + let handles = $state<Record<string, string>>({}); 10 + let moveCounts = $state<Record<string, number | null>>({}); 11 + let archivePage = $state(1); 12 + const ARCHIVE_PAGE_SIZE = 6; 13 + 14 + // Split games by status 15 + const activeGames = $derived( 16 + (data.games || []) 17 + .filter((g) => g.status === 'active') 18 + .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()) 19 + ); 20 + 21 + const waitingGames = $derived( 22 + (data.games || []) 23 + .filter((g) => g.status === 'waiting') 24 + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) 25 + ); 26 + 27 + const archivedGames = $derived( 28 + (data.games || []) 29 + .filter((g) => g.status === 'completed') 30 + .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()) 31 + ); 32 + 33 + const archiveTotalPages = $derived(Math.ceil(archivedGames.length / ARCHIVE_PAGE_SIZE)); 34 + 35 + const paginatedArchivedGames = $derived( 36 + archivedGames.slice((archivePage - 1) * ARCHIVE_PAGE_SIZE, archivePage * ARCHIVE_PAGE_SIZE) 37 + ); 38 + 39 + // Helper to get opponent DID 40 + function getOpponentDid(game: typeof data.games[0]): string | null { 41 + if (game.player_one === data.profileDid) { 42 + return game.player_two; 43 + } 44 + return game.player_one; 45 + } 46 + 47 + // Helper to extract resigned info 48 + function getResignedBy(game: typeof data.games[0]): 'black' | 'white' | null { 49 + if (game.last_action_type?.startsWith('resigned:')) { 50 + return game.last_action_type.split(':')[1] as 'black' | 'white'; 51 + } 52 + return null; 53 + } 54 + 55 + onMount(async () => { 56 + // Fetch profile 57 + profile = await fetchUserProfile(data.profileDid); 58 + 59 + if (data.games && data.games.length > 0) { 60 + // Collect unique DIDs 61 + const dids = new Set<string>(); 62 + for (const game of data.games) { 63 + dids.add(game.player_one); 64 + if (game.player_two) dids.add(game.player_two); 65 + } 66 + 67 + // Resolve handles 68 + for (const did of dids) { 69 + resolveDidToHandle(did).then((h) => { 70 + handles = { ...handles, [did]: h }; 71 + }); 72 + } 73 + 74 + // Fetch move counts 75 + for (const game of data.games) { 76 + fetchMoveCount(game.id).then((count) => { 77 + moveCounts = { ...moveCounts, [game.id]: count }; 78 + }); 79 + } 80 + } 81 + }); 82 + </script> 83 + 84 + <svelte:head> 85 + <title>{profile?.displayName || profile?.handle || 'Profile'} - Cloud Go</title> 86 + </svelte:head> 87 + 88 + <div class="container"> 89 + <!-- Profile Header --> 90 + <div class="profile-header card"> 91 + <div class="profile-info"> 92 + {#if profile?.avatar} 93 + <img src={profile.avatar} alt={profile.handle} class="profile-avatar" /> 94 + {:else} 95 + <div class="profile-avatar-fallback"> 96 + {(profile?.handle || data.profileDid).charAt(0).toUpperCase()} 97 + </div> 98 + {/if} 99 + <div class="profile-details"> 100 + <h1 class="profile-name">{profile?.displayName || profile?.handle || data.profileDid}</h1> 101 + {#if profile?.handle} 102 + <p class="profile-handle">@{profile.handle}</p> 103 + {/if} 104 + {#if profile?.description} 105 + <p class="profile-description">{profile.description}</p> 106 + {/if} 107 + </div> 108 + </div> 109 + <div class="profile-stats"> 110 + <div class="stat"> 111 + <div class="stat-value">{data.games.length}</div> 112 + <div class="stat-label">Total Games</div> 113 + </div> 114 + <div class="stat"> 115 + <div class="stat-value">{activeGames.length}</div> 116 + <div class="stat-label">Active</div> 117 + </div> 118 + <div class="stat"> 119 + <div class="stat-value">{archivedGames.length}</div> 120 + <div class="stat-label">Completed</div> 121 + </div> 122 + </div> 123 + </div> 124 + 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> 153 + </div> 154 + <a href="/game/{game.rkey}" class="button button-primary button-sm">View</a> 155 + </div> 156 + {/each} 157 + </div> 158 + {:else} 159 + <p class="empty-state">No active games.</p> 160 + {/if} 161 + </div> 162 + 163 + </div> 164 + 165 + <!-- Archive --> 166 + {#if archivedGames.length > 0} 167 + <div class="card archive-section"> 168 + <div class="archive-header"> 169 + <h2>Archive</h2> 170 + <span class="archive-count">{archivedGames.length} games</span> 171 + </div> 172 + <div class="archive-grid"> 173 + {#each paginatedArchivedGames as game} 174 + {@const resignedBy = getResignedBy(game)} 175 + {@const opponentDid = getOpponentDid(game)} 176 + <a href="/game/{game.rkey}" class="archive-card"> 177 + <img 178 + src="/api/games/{game.rkey}/board?size=70" 179 + alt="Board preview" 180 + class="archive-board-img" 181 + loading="lazy" 182 + /> 183 + <div class="archive-card-header"> 184 + <div class="game-title">{game.title}</div> 185 + {#if resignedBy} 186 + <span class="game-status game-status-cancelled"> 187 + {resignedBy === 'black' ? '⚫' : '⚪'} resigned 188 + </span> 189 + {:else} 190 + <span class="game-status game-status-completed">completed</span> 191 + {/if} 192 + </div> 193 + <div class="archive-card-meta"> 194 + <strong>{game.board_size}x{game.board_size}</strong> 195 + <span class="move-count">{moveCounts[game.id] != null ? `${moveCounts[game.id]} moves` : '...'}</span> 196 + </div> 197 + {#if opponentDid} 198 + <div class="archive-card-opponent"> 199 + vs {handles[opponentDid] || opponentDid.slice(0, 15)} 200 + </div> 201 + {/if} 202 + </a> 203 + {/each} 204 + </div> 205 + {#if archiveTotalPages > 1} 206 + <div class="pagination"> 207 + <button 208 + class="pagination-btn" 209 + disabled={archivePage <= 1} 210 + onclick={() => archivePage = archivePage - 1} 211 + > 212 + Previous 213 + </button> 214 + <span class="pagination-info"> 215 + Page {archivePage} of {archiveTotalPages} 216 + </span> 217 + <button 218 + class="pagination-btn" 219 + disabled={archivePage >= archiveTotalPages} 220 + onclick={() => archivePage = archivePage + 1} 221 + > 222 + Next 223 + </button> 224 + </div> 225 + {/if} 226 + </div> 227 + {/if} 228 + </div> 229 + 230 + <style> 231 + .container { 232 + max-width: 1200px; 233 + margin: 0 auto; 234 + padding: 2rem; 235 + } 236 + 237 + .profile-header { 238 + display: flex; 239 + flex-direction: column; 240 + gap: 1.5rem; 241 + margin-bottom: 2rem; 242 + } 243 + 244 + .profile-info { 245 + display: flex; 246 + gap: 1.5rem; 247 + align-items: flex-start; 248 + } 249 + 250 + .profile-avatar { 251 + width: 80px; 252 + height: 80px; 253 + border-radius: 50%; 254 + border: 3px solid var(--sky-blue-pale); 255 + object-fit: cover; 256 + flex-shrink: 0; 257 + } 258 + 259 + .profile-avatar-fallback { 260 + width: 80px; 261 + height: 80px; 262 + border-radius: 50%; 263 + border: 3px solid var(--sky-blue-pale); 264 + background: linear-gradient(135deg, var(--sky-apricot-light), var(--sky-rose-light)); 265 + display: flex; 266 + align-items: center; 267 + justify-content: center; 268 + font-weight: 700; 269 + color: var(--sky-slate-dark); 270 + font-size: 2rem; 271 + flex-shrink: 0; 272 + } 273 + 274 + .profile-details { 275 + flex: 1; 276 + } 277 + 278 + .profile-name { 279 + font-size: 2rem; 280 + font-weight: 700; 281 + color: var(--sky-slate-dark); 282 + margin: 0; 283 + } 284 + 285 + .profile-handle { 286 + color: var(--sky-gray); 287 + font-size: 1rem; 288 + margin: 0.25rem 0 0.5rem 0; 289 + } 290 + 291 + .profile-description { 292 + color: var(--sky-slate); 293 + font-size: 0.95rem; 294 + margin: 0.5rem 0 0 0; 295 + } 296 + 297 + .profile-stats { 298 + display: flex; 299 + gap: 2rem; 300 + padding-top: 1rem; 301 + border-top: 1px solid var(--sky-blue-pale); 302 + } 303 + 304 + .stat { 305 + text-align: center; 306 + } 307 + 308 + .stat-value { 309 + font-size: 1.75rem; 310 + font-weight: 700; 311 + color: var(--sky-slate-dark); 312 + } 313 + 314 + .stat-label { 315 + font-size: 0.85rem; 316 + color: var(--sky-gray); 317 + text-transform: uppercase; 318 + letter-spacing: 0.05em; 319 + } 320 + 321 + .card { 322 + background: linear-gradient( 323 + 135deg, 324 + rgba(255, 255, 255, 0.95) 0%, 325 + rgba(245, 248, 250, 0.9) 50%, 326 + rgba(232, 239, 244, 0.85) 100% 327 + ); 328 + border: none; 329 + border-radius: 1.8rem 2rem 1.6rem 2.1rem; 330 + padding: 1.5rem; 331 + box-shadow: 332 + 0 0 15px rgba(255, 255, 255, 0.7), 333 + 0 0 30px rgba(255, 255, 255, 0.3), 334 + 0 6px 24px rgba(90, 122, 144, 0.1), 335 + inset 0 1px 1px rgba(255, 255, 255, 0.9); 336 + backdrop-filter: blur(8px); 337 + margin-bottom: 1.5rem; 338 + position: relative; 339 + } 340 + 341 + .card::before { 342 + content: ''; 343 + position: absolute; 344 + inset: -2px; 345 + border-radius: inherit; 346 + background: linear-gradient( 347 + 135deg, 348 + rgba(255, 255, 255, 0.5) 0%, 349 + rgba(212, 229, 239, 0.2) 50%, 350 + rgba(255, 255, 255, 0.3) 100% 351 + ); 352 + filter: blur(3px); 353 + z-index: -1; 354 + } 355 + 356 + .card h2 { 357 + margin-top: 0; 358 + color: var(--sky-slate-dark); 359 + font-weight: 600; 360 + } 361 + 362 + .games-layout { 363 + display: grid; 364 + grid-template-columns: 1fr 1fr; 365 + gap: 1.5rem; 366 + margin-bottom: 1.5rem; 367 + } 368 + 369 + @media (max-width: 768px) { 370 + .games-layout { 371 + grid-template-columns: 1fr; 372 + } 373 + 374 + .profile-info { 375 + flex-direction: column; 376 + align-items: center; 377 + text-align: center; 378 + } 379 + 380 + .profile-stats { 381 + justify-content: center; 382 + } 383 + } 384 + 385 + .games-list { 386 + display: flex; 387 + flex-direction: column; 388 + gap: 0.75rem; 389 + } 390 + 391 + .game-item { 392 + display: flex; 393 + align-items: center; 394 + gap: 1rem; 395 + padding: 1rem 1.25rem; 396 + border: 1px solid var(--sky-blue-pale); 397 + border-radius: 0.75rem; 398 + background: linear-gradient(135deg, var(--sky-white) 0%, var(--sky-cloud) 100%); 399 + transition: all 0.2s; 400 + } 401 + 402 + .game-item:hover { 403 + border-color: var(--sky-apricot); 404 + box-shadow: 0 4px 12px rgba(90, 122, 144, 0.1); 405 + } 406 + 407 + .mini-board-img { 408 + flex-shrink: 0; 409 + width: 70px; 410 + height: 70px; 411 + border-radius: 6px; 412 + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); 413 + border: 1px solid var(--sky-blue-pale); 414 + } 415 + 416 + .game-info { 417 + display: flex; 418 + flex-direction: column; 419 + gap: 0.375rem; 420 + flex: 1; 421 + min-width: 0; 422 + } 423 + 424 + .game-title { 425 + font-weight: 700; 426 + font-size: 1.05rem; 427 + color: var(--sky-slate-dark); 428 + } 429 + 430 + .move-count { 431 + color: var(--sky-gray); 432 + font-size: 0.875rem; 433 + margin-left: 0.5rem; 434 + } 435 + 436 + .game-players { 437 + font-size: 0.875rem; 438 + color: var(--sky-gray); 439 + } 440 + 441 + .player-link { 442 + color: var(--sky-slate); 443 + text-decoration: none; 444 + transition: color 0.2s; 445 + } 446 + 447 + .player-link:hover { 448 + color: var(--sky-apricot-dark); 449 + } 450 + 451 + .button { 452 + padding: 0.875rem 1.75rem; 453 + border: none; 454 + border-radius: 0.5rem; 455 + font-size: 1rem; 456 + font-weight: 600; 457 + cursor: pointer; 458 + transition: all 0.2s; 459 + text-decoration: none; 460 + display: inline-block; 461 + } 462 + 463 + .button-primary { 464 + background: linear-gradient(135deg, var(--sky-apricot-dark) 0%, var(--sky-apricot) 100%); 465 + color: white; 466 + box-shadow: 0 2px 8px rgba(229, 168, 120, 0.3); 467 + } 468 + 469 + .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); 473 + } 474 + 475 + .button-secondary { 476 + background: var(--sky-cloud); 477 + color: var(--sky-slate); 478 + border: 1px solid var(--sky-blue-pale); 479 + } 480 + 481 + .button-secondary:hover { 482 + background: var(--sky-blue-pale); 483 + color: var(--sky-slate-dark); 484 + } 485 + 486 + .button-sm { 487 + padding: 0.5rem 1rem; 488 + font-size: 0.875rem; 489 + } 490 + 491 + .empty-state { 492 + text-align: center; 493 + color: var(--sky-gray); 494 + padding: 2rem; 495 + font-style: italic; 496 + } 497 + 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 + .archive-section { 537 + margin-top: 2rem; 538 + border-top: 2px solid var(--sky-blue-pale); 539 + padding-top: 1rem; 540 + } 541 + 542 + .archive-section h2 { 543 + color: var(--sky-gray); 544 + margin: 0; 545 + } 546 + 547 + .archive-header { 548 + display: flex; 549 + justify-content: space-between; 550 + align-items: center; 551 + margin-bottom: 1rem; 552 + } 553 + 554 + .archive-count { 555 + font-size: 0.875rem; 556 + color: var(--sky-gray-light); 557 + } 558 + 559 + .archive-grid { 560 + display: grid; 561 + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); 562 + gap: 1rem; 563 + } 564 + 565 + .archive-card { 566 + display: flex; 567 + flex-direction: column; 568 + gap: 0.5rem; 569 + padding: 1rem 1.25rem; 570 + border: 1px solid var(--sky-blue-pale); 571 + border-radius: 0.75rem; 572 + background: linear-gradient(135deg, var(--sky-white) 0%, var(--sky-cloud) 100%); 573 + transition: all 0.2s; 574 + text-decoration: none; 575 + color: inherit; 576 + opacity: 0.85; 577 + transform: rotate(0deg); 578 + } 579 + 580 + .archive-card:nth-child(3n+1) { 581 + transform: rotate(-0.5deg); 582 + } 583 + 584 + .archive-card:nth-child(3n+2) { 585 + transform: rotate(0.5deg); 586 + } 587 + 588 + .archive-card:nth-child(3n) { 589 + transform: rotate(-0.3deg); 590 + } 591 + 592 + .archive-card:hover { 593 + opacity: 1; 594 + border-color: var(--sky-apricot); 595 + box-shadow: 0 4px 12px rgba(90, 122, 144, 0.1); 596 + transform: translateY(-2px) rotate(0deg); 597 + } 598 + 599 + .archive-board-img { 600 + width: 70px; 601 + height: 70px; 602 + border-radius: 6px; 603 + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); 604 + border: 1px solid var(--sky-blue-pale); 605 + align-self: center; 606 + } 607 + 608 + .archive-card-header { 609 + display: flex; 610 + justify-content: space-between; 611 + align-items: flex-start; 612 + gap: 0.5rem; 613 + } 614 + 615 + .archive-card-header .game-title { 616 + flex: 1; 617 + font-size: 1rem; 618 + } 619 + 620 + .game-status { 621 + display: inline-block; 622 + padding: 0.25rem 0.75rem; 623 + border-radius: 9999px; 624 + font-size: 0.7rem; 625 + font-weight: 700; 626 + text-transform: uppercase; 627 + letter-spacing: 0.05em; 628 + } 629 + 630 + .game-status-completed { 631 + background: var(--sky-blue-pale); 632 + color: var(--sky-slate); 633 + } 634 + 635 + .game-status-cancelled { 636 + background: var(--sky-rose-light); 637 + color: var(--sky-rose-dark); 638 + } 639 + 640 + .archive-card-meta { 641 + font-size: 0.875rem; 642 + color: var(--sky-slate); 643 + } 644 + 645 + .archive-card-opponent { 646 + font-size: 0.8rem; 647 + color: var(--sky-gray); 648 + } 649 + 650 + .pagination { 651 + display: flex; 652 + justify-content: center; 653 + align-items: center; 654 + gap: 1rem; 655 + margin-top: 1.5rem; 656 + padding-top: 1rem; 657 + border-top: 1px solid var(--sky-blue-pale); 658 + } 659 + 660 + .pagination-btn { 661 + padding: 0.5rem 1rem; 662 + border: 1px solid var(--sky-blue-pale); 663 + border-radius: 0.5rem; 664 + background: var(--sky-white); 665 + color: var(--sky-slate); 666 + font-size: 0.875rem; 667 + font-weight: 500; 668 + cursor: pointer; 669 + transition: all 0.2s; 670 + } 671 + 672 + .pagination-btn:hover:not(:disabled) { 673 + background: var(--sky-cloud); 674 + border-color: var(--sky-apricot); 675 + } 676 + 677 + .pagination-btn:disabled { 678 + opacity: 0.4; 679 + cursor: not-allowed; 680 + } 681 + 682 + .pagination-info { 683 + font-size: 0.875rem; 684 + color: var(--sky-gray); 685 + } 686 + </style>