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