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.
at master 414 lines 10 kB view raw
1<script lang="ts"> 2 import { onMount } from 'svelte'; 3 import { goto } from '$app/navigation'; 4 import { cubicOut } from 'svelte/easing'; 5 import type { TransitionConfig } from 'svelte/transition'; 6 import { fetchCloudGoProfile } from '$lib/atproto-client'; 7 import { getSoundManager } from '$lib/sound-manager'; 8 9 let { avatar, handle, did }: { avatar: string | null; handle: string; did: string } = $props(); 10 11 let isOpen = $state(false); 12 let dropdownRef: HTMLDivElement | null = $state(null); 13 let currentStatus = $state<'playing' | 'watching' | 'offline'>('offline'); 14 let isUpdatingStatus = $state(false); 15 let sfxEnabled = $state(true); 16 17 function cloudMaterialize(node: Element): TransitionConfig { 18 return { 19 duration: 600, 20 easing: cubicOut, 21 css: (t: number) => { 22 const opacity = t; 23 const blur = (1 - t) * 8; 24 const translateY = (1 - t) * -10; 25 const scale = 0.95 + (t * 0.05); 26 27 return ` 28 opacity: ${opacity}; 29 filter: blur(${blur}px); 30 transform: translateY(${translateY}px) scale(${scale}); 31 `; 32 } 33 }; 34 } 35 36 function toggleDropdown(event: MouseEvent) { 37 event.stopPropagation(); 38 isOpen = !isOpen; 39 } 40 41 function handleClickOutside(event: MouseEvent) { 42 if (dropdownRef && !dropdownRef.contains(event.target as Node)) { 43 isOpen = false; 44 } 45 } 46 47 function handleProfileClick(event: MouseEvent) { 48 event.preventDefault(); 49 event.stopPropagation(); 50 isOpen = false; 51 // Use goto for navigation to avoid any link issues 52 goto(`/profile/${did}`); 53 } 54 55 async function handleLogout() { 56 await fetch('/auth/logout', { method: 'POST' }); 57 window.location.reload(); 58 } 59 60 async function updateStatus(status: 'playing' | 'watching' | 'offline') { 61 if (isUpdatingStatus) return; 62 63 isUpdatingStatus = true; 64 try { 65 const response = await fetch('/api/profile', { 66 method: 'POST', 67 headers: { 'Content-Type': 'application/json' }, 68 body: JSON.stringify({ status }) 69 }); 70 71 if (response.ok) { 72 currentStatus = status; 73 } 74 } catch (err) { 75 console.error('Failed to update status:', err); 76 } finally { 77 isUpdatingStatus = false; 78 } 79 } 80 81 function toggleSfx() { 82 const soundManager = getSoundManager(); 83 sfxEnabled = !sfxEnabled; 84 soundManager.setEnabled(sfxEnabled); 85 } 86 87 onMount(async () => { 88 document.addEventListener('click', handleClickOutside); 89 90 // Fetch current status 91 try { 92 const profile = await fetchCloudGoProfile(did); 93 if (profile?.status) { 94 currentStatus = profile.status as 'playing' | 'watching' | 'offline'; 95 } 96 } catch (err) { 97 console.error('Failed to fetch profile status:', err); 98 } 99 100 // Load SFX setting 101 const soundManager = getSoundManager(); 102 sfxEnabled = soundManager.isEnabled(); 103 104 return () => { 105 document.removeEventListener('click', handleClickOutside); 106 }; 107 }); 108</script> 109 110<div class="profile-dropdown" bind:this={dropdownRef}> 111 <button class="avatar-button" onclick={toggleDropdown} aria-label="Profile menu"> 112 <div class="avatar-wrapper status-{currentStatus}"> 113 {#if avatar} 114 <img src={avatar} alt={handle} class="avatar-img" /> 115 {:else} 116 <div class="avatar-fallback"> 117 {handle.charAt(0).toUpperCase()} 118 </div> 119 {/if} 120 </div> 121 </button> 122 123 {#if isOpen} 124 <div class="dropdown-menu" transition:cloudMaterialize> 125 <button onclick={handleProfileClick} class="dropdown-item"> 126 View Cloud Go Profile 127 </button> 128 129 <div class="status-section"> 130 <div class="status-label">Update Status</div> 131 <div class="status-circles"> 132 <button 133 class="status-circle" 134 class:active={currentStatus === 'playing'} 135 onclick={() => updateStatus('playing')} 136 disabled={isUpdatingStatus} 137 aria-label="Set status to playing" 138 > 139 <div class="circle playing"></div> 140 <span class="status-text">Playing</span> 141 </button> 142 <button 143 class="status-circle" 144 class:active={currentStatus === 'watching'} 145 onclick={() => updateStatus('watching')} 146 disabled={isUpdatingStatus} 147 aria-label="Set status to watching" 148 > 149 <div class="circle watching"></div> 150 <span class="status-text">Watching</span> 151 </button> 152 <button 153 class="status-circle" 154 class:active={currentStatus === 'offline'} 155 onclick={() => updateStatus('offline')} 156 disabled={isUpdatingStatus} 157 aria-label="Set status to offline" 158 > 159 <div class="circle offline"></div> 160 <span class="status-text">Offline</span> 161 </button> 162 </div> 163 </div> 164 165 <button onclick={toggleSfx} class="dropdown-item sfx-toggle"> 166 <span class="sfx-label">Sound Effects</span> 167 <span class="sfx-status">{sfxEnabled ? '🔊 On' : '🔇 Off'}</span> 168 </button> 169 170 <button onclick={handleLogout} class="dropdown-item logout-btn"> 171 Logout 172 </button> 173 </div> 174 {/if} 175</div> 176 177<style> 178 .profile-dropdown { 179 position: relative; 180 } 181 182 .avatar-button { 183 background: none; 184 border: none; 185 cursor: pointer; 186 padding: 0; 187 border-radius: 50%; 188 transition: all 0.6s ease; 189 } 190 191 .avatar-wrapper { 192 position: relative; 193 border-radius: 50%; 194 padding: 3px; 195 transition: all 0.6s ease; 196 } 197 198 .avatar-wrapper.status-playing { 199 background: linear-gradient(135deg, #059669, #10b981); 200 box-shadow: 201 0 0 20px rgba(5, 150, 105, 0.3), 202 0 0 40px rgba(5, 150, 105, 0.2), 203 0 0 60px rgba(5, 150, 105, 0.1); 204 filter: blur(0.5px); 205 } 206 207 .avatar-wrapper.status-watching { 208 background: linear-gradient(135deg, #ca8a04, #eab308); 209 box-shadow: 210 0 0 20px rgba(202, 138, 4, 0.3), 211 0 0 40px rgba(202, 138, 4, 0.2), 212 0 0 60px rgba(202, 138, 4, 0.1); 213 filter: blur(0.5px); 214 } 215 216 .avatar-wrapper.status-offline { 217 background: linear-gradient(135deg, #94a3b8, #cbd5e1); 218 box-shadow: 219 0 0 20px rgba(148, 163, 184, 0.25), 220 0 0 40px rgba(148, 163, 184, 0.15), 221 0 0 60px rgba(148, 163, 184, 0.1); 222 filter: blur(0.5px); 223 } 224 225 .avatar-button:hover .avatar-wrapper { 226 transform: scale(1.05); 227 } 228 229 .avatar-button:hover .avatar-wrapper.status-playing { 230 box-shadow: 231 0 0 25px rgba(5, 150, 105, 0.4), 232 0 0 50px rgba(5, 150, 105, 0.3), 233 0 0 75px rgba(5, 150, 105, 0.15); 234 } 235 236 .avatar-button:hover .avatar-wrapper.status-watching { 237 box-shadow: 238 0 0 25px rgba(202, 138, 4, 0.4), 239 0 0 50px rgba(202, 138, 4, 0.3), 240 0 0 75px rgba(202, 138, 4, 0.15); 241 } 242 243 .avatar-button:hover .avatar-wrapper.status-offline { 244 box-shadow: 245 0 0 25px rgba(148, 163, 184, 0.35), 246 0 0 50px rgba(148, 163, 184, 0.2), 247 0 0 75px rgba(148, 163, 184, 0.1); 248 } 249 250 .avatar-img { 251 width: 80px; 252 height: 80px; 253 border-radius: 50%; 254 border: 3px solid var(--sky-white); 255 object-fit: cover; 256 transition: all 0.6s ease; 257 display: block; 258 } 259 260 .avatar-fallback { 261 width: 80px; 262 height: 80px; 263 border-radius: 50%; 264 border: 3px solid var(--sky-white); 265 background: linear-gradient(135deg, var(--sky-apricot-light), var(--sky-rose-light)); 266 display: flex; 267 align-items: center; 268 justify-content: center; 269 font-weight: 600; 270 color: var(--sky-slate-dark); 271 font-size: 2rem; 272 transition: all 0.6s ease; 273 } 274 275 .dropdown-menu { 276 position: absolute; 277 top: calc(100% + 0.5rem); 278 right: 0; 279 min-width: 220px; 280 background: var(--sky-white); 281 border: 2px solid var(--sky-blue-pale); 282 border-radius: 0.75rem; 283 box-shadow: 0 8px 24px rgba(90, 122, 144, 0.15); 284 z-index: 10000; 285 overflow: hidden; 286 } 287 288 .dropdown-item { 289 display: block; 290 width: 100%; 291 padding: 0.875rem 1.25rem; 292 border: none; 293 background: var(--sky-white); 294 color: var(--sky-slate-dark); 295 text-align: left; 296 text-decoration: none; 297 font-size: 0.9rem; 298 font-weight: 500; 299 cursor: pointer; 300 transition: all 0.6s ease; 301 border-bottom: 1px solid var(--sky-cloud); 302 } 303 304 .dropdown-item:last-child { 305 border-bottom: none; 306 } 307 308 .dropdown-item:hover { 309 background: var(--sky-apricot-light); 310 box-shadow: 0 0 12px rgba(229, 168, 120, 0.4), inset 0 0 20px rgba(229, 168, 120, 0.1); 311 } 312 313 .logout-btn { 314 font-family: inherit; 315 } 316 317 .status-section { 318 padding: 1rem 1.25rem; 319 border-bottom: 1px solid var(--sky-cloud); 320 } 321 322 .status-label { 323 font-size: 0.75rem; 324 font-weight: 600; 325 text-transform: uppercase; 326 letter-spacing: 0.05em; 327 color: var(--sky-gray); 328 margin-bottom: 0.75rem; 329 text-align: center; 330 } 331 332 .status-circles { 333 display: flex; 334 justify-content: space-around; 335 gap: 0.5rem; 336 } 337 338 .status-circle { 339 display: flex; 340 flex-direction: column; 341 align-items: center; 342 gap: 0.35rem; 343 background: none; 344 border: none; 345 cursor: pointer; 346 padding: 0.25rem; 347 transition: all 0.2s; 348 border-radius: 0.5rem; 349 } 350 351 .status-circle:hover:not(:disabled) { 352 background: var(--sky-cloud); 353 } 354 355 .status-circle:disabled { 356 opacity: 0.5; 357 cursor: not-allowed; 358 } 359 360 .status-circle.active .circle { 361 box-shadow: 0 0 0 3px var(--sky-apricot-light); 362 transform: scale(1.1); 363 } 364 365 .circle { 366 width: 32px; 367 height: 32px; 368 border-radius: 50%; 369 transition: all 0.2s; 370 border: 2px solid transparent; 371 } 372 373 .circle.playing { 374 background: radial-gradient(circle at 35% 35%, #22c55e, #16a34a); 375 border-color: #15803d; 376 } 377 378 .circle.watching { 379 background: radial-gradient(circle at 35% 35%, #facc15, #eab308); 380 border-color: #ca8a04; 381 } 382 383 .circle.offline { 384 background: radial-gradient(circle at 35% 35%, #cbd5e1, #94a3b8); 385 border-color: #64748b; 386 } 387 388 .status-text { 389 font-size: 0.7rem; 390 font-weight: 500; 391 color: var(--sky-slate); 392 text-align: center; 393 } 394 395 .status-circle.active .status-text { 396 font-weight: 700; 397 color: var(--sky-slate-dark); 398 } 399 400 .sfx-toggle { 401 display: flex; 402 justify-content: space-between; 403 align-items: center; 404 } 405 406 .sfx-label { 407 font-weight: 500; 408 } 409 410 .sfx-status { 411 font-size: 0.85rem; 412 color: var(--sky-gray); 413 } 414</style>