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.
Something went wrong. Try again.
10 kB · 414 lines
Svelte
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415<script lang="ts"> import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { cubicOut } from 'svelte/easing'; import type { TransitionConfig } from 'svelte/transition'; import { fetchCloudGoProfile } from '$lib/atproto-client'; import { getSoundManager } from '$lib/sound-manager';
let { avatar, handle, did }: { avatar: string | null; handle: string; did: string } = $props();
let isOpen = $state(false); let dropdownRef: HTMLDivElement | null = $state(null); let currentStatus = $state<'playing' | 'watching' | 'offline'>('offline'); let isUpdatingStatus = $state(false); let sfxEnabled = $state(true);
function cloudMaterialize(node: Element): TransitionConfig { return { duration: 600, easing: cubicOut, css: (t: number) => { const opacity = t; const blur = (1 - t) * 8; const translateY = (1 - t) * -10; const scale = 0.95 + (t * 0.05);
return ` opacity: ${opacity}; filter: blur(${blur}px); transform: translateY(${translateY}px) scale(${scale}); `; } }; }
function toggleDropdown(event: MouseEvent) { event.stopPropagation(); isOpen = !isOpen; }
function handleClickOutside(event: MouseEvent) { if (dropdownRef && !dropdownRef.contains(event.target as Node)) { isOpen = false; } }
function handleProfileClick(event: MouseEvent) { event.preventDefault(); event.stopPropagation(); isOpen = false; // Use goto for navigation to avoid any link issues goto(`/profile/${did}`); }
async function handleLogout() { await fetch('/auth/logout', { method: 'POST' }); window.location.reload(); }
async function updateStatus(status: 'playing' | 'watching' | 'offline') { if (isUpdatingStatus) return;
isUpdatingStatus = true; try { const response = await fetch('/api/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) });
if (response.ok) { currentStatus = status; } } catch (err) { console.error('Failed to update status:', err); } finally { isUpdatingStatus = false; } }
function toggleSfx() { const soundManager = getSoundManager(); sfxEnabled = !sfxEnabled; soundManager.setEnabled(sfxEnabled); }
onMount(async () => { document.addEventListener('click', handleClickOutside);
// Fetch current status try { const profile = await fetchCloudGoProfile(did); if (profile?.status) { currentStatus = profile.status as 'playing' | 'watching' | 'offline'; } } catch (err) { console.error('Failed to fetch profile status:', err); }
// Load SFX setting const soundManager = getSoundManager(); sfxEnabled = soundManager.isEnabled();
return () => { document.removeEventListener('click', handleClickOutside); }; });</script>
<div class="profile-dropdown" bind:this={dropdownRef}> <button class="avatar-button" onclick={toggleDropdown} aria-label="Profile menu"> <div class="avatar-wrapper status-{currentStatus}"> {#if avatar} <img src={avatar} alt={handle} class="avatar-img" /> {:else} <div class="avatar-fallback"> {handle.charAt(0).toUpperCase()} </div> {/if} </div> </button>
{#if isOpen} <div class="dropdown-menu" transition:cloudMaterialize> <button onclick={handleProfileClick} class="dropdown-item"> View Cloud Go Profile </button>
<div class="status-section"> <div class="status-label">Update Status</div> <div class="status-circles"> <button class="status-circle" class:active={currentStatus === 'playing'} onclick={() => updateStatus('playing')} disabled={isUpdatingStatus} aria-label="Set status to playing" > <div class="circle playing"></div> <span class="status-text">Playing</span> </button> <button class="status-circle" class:active={currentStatus === 'watching'} onclick={() => updateStatus('watching')} disabled={isUpdatingStatus} aria-label="Set status to watching" > <div class="circle watching"></div> <span class="status-text">Watching</span> </button> <button class="status-circle" class:active={currentStatus === 'offline'} onclick={() => updateStatus('offline')} disabled={isUpdatingStatus} aria-label="Set status to offline" > <div class="circle offline"></div> <span class="status-text">Offline</span> </button> </div> </div>
<button onclick={toggleSfx} class="dropdown-item sfx-toggle"> <span class="sfx-label">Sound Effects</span> <span class="sfx-status">{sfxEnabled ? '🔊 On' : '🔇 Off'}</span> </button>
<button onclick={handleLogout} class="dropdown-item logout-btn"> Logout </button> </div> {/if}</div>
<style> .profile-dropdown { position: relative; }
.avatar-button { background: none; border: none; cursor: pointer; padding: 0; border-radius: 50%; transition: all 0.6s ease; }
.avatar-wrapper { position: relative; border-radius: 50%; padding: 3px; transition: all 0.6s ease; }
.avatar-wrapper.status-playing { background: linear-gradient(135deg, #059669, #10b981); box-shadow: 0 0 20px rgba(5, 150, 105, 0.3), 0 0 40px rgba(5, 150, 105, 0.2), 0 0 60px rgba(5, 150, 105, 0.1); filter: blur(0.5px); }
.avatar-wrapper.status-watching { background: linear-gradient(135deg, #ca8a04, #eab308); box-shadow: 0 0 20px rgba(202, 138, 4, 0.3), 0 0 40px rgba(202, 138, 4, 0.2), 0 0 60px rgba(202, 138, 4, 0.1); filter: blur(0.5px); }
.avatar-wrapper.status-offline { background: linear-gradient(135deg, #94a3b8, #cbd5e1); box-shadow: 0 0 20px rgba(148, 163, 184, 0.25), 0 0 40px rgba(148, 163, 184, 0.15), 0 0 60px rgba(148, 163, 184, 0.1); filter: blur(0.5px); }
.avatar-button:hover .avatar-wrapper { transform: scale(1.05); }
.avatar-button:hover .avatar-wrapper.status-playing { box-shadow: 0 0 25px rgba(5, 150, 105, 0.4), 0 0 50px rgba(5, 150, 105, 0.3), 0 0 75px rgba(5, 150, 105, 0.15); }
.avatar-button:hover .avatar-wrapper.status-watching { box-shadow: 0 0 25px rgba(202, 138, 4, 0.4), 0 0 50px rgba(202, 138, 4, 0.3), 0 0 75px rgba(202, 138, 4, 0.15); }
.avatar-button:hover .avatar-wrapper.status-offline { box-shadow: 0 0 25px rgba(148, 163, 184, 0.35), 0 0 50px rgba(148, 163, 184, 0.2), 0 0 75px rgba(148, 163, 184, 0.1); }
.avatar-img { width: 80px; height: 80px; border-radius: 50%; border: 3px solid var(--sky-white); object-fit: cover; transition: all 0.6s ease; display: block; }
.avatar-fallback { width: 80px; height: 80px; border-radius: 50%; border: 3px solid var(--sky-white); background: linear-gradient(135deg, var(--sky-apricot-light), var(--sky-rose-light)); display: flex; align-items: center; justify-content: center; font-weight: 600; color: var(--sky-slate-dark); font-size: 2rem; transition: all 0.6s ease; }
.dropdown-menu { position: absolute; top: calc(100% + 0.5rem); right: 0; min-width: 220px; background: var(--sky-white); border: 2px solid var(--sky-blue-pale); border-radius: 0.75rem; box-shadow: 0 8px 24px rgba(90, 122, 144, 0.15); z-index: 10000; overflow: hidden; }
.dropdown-item { display: block; width: 100%; padding: 0.875rem 1.25rem; border: none; background: var(--sky-white); color: var(--sky-slate-dark); text-align: left; text-decoration: none; font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: all 0.6s ease; border-bottom: 1px solid var(--sky-cloud); }
.dropdown-item:last-child { border-bottom: none; }
.dropdown-item:hover { background: var(--sky-apricot-light); box-shadow: 0 0 12px rgba(229, 168, 120, 0.4), inset 0 0 20px rgba(229, 168, 120, 0.1); }
.logout-btn { font-family: inherit; }
.status-section { padding: 1rem 1.25rem; border-bottom: 1px solid var(--sky-cloud); }
.status-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--sky-gray); margin-bottom: 0.75rem; text-align: center; }
.status-circles { display: flex; justify-content: space-around; gap: 0.5rem; }
.status-circle { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; background: none; border: none; cursor: pointer; padding: 0.25rem; transition: all 0.2s; border-radius: 0.5rem; }
.status-circle:hover:not(:disabled) { background: var(--sky-cloud); }
.status-circle:disabled { opacity: 0.5; cursor: not-allowed; }
.status-circle.active .circle { box-shadow: 0 0 0 3px var(--sky-apricot-light); transform: scale(1.1); }
.circle { width: 32px; height: 32px; border-radius: 50%; transition: all 0.2s; border: 2px solid transparent; }
.circle.playing { background: radial-gradient(circle at 35% 35%, #22c55e, #16a34a); border-color: #15803d; }
.circle.watching { background: radial-gradient(circle at 35% 35%, #facc15, #eab308); border-color: #ca8a04; }
.circle.offline { background: radial-gradient(circle at 35% 35%, #cbd5e1, #94a3b8); border-color: #64748b; }
.status-text { font-size: 0.7rem; font-weight: 500; color: var(--sky-slate); text-align: center; }
.status-circle.active .status-text { font-weight: 700; color: var(--sky-slate-dark); }
.sfx-toggle { display: flex; justify-content: space-between; align-items: center; }
.sfx-label { font-weight: 500; }
.sfx-status { font-size: 0.85rem; color: var(--sky-gray); }</style>