vod jam and earl vod.atverkackt.de
4
fork

Configure Feed

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

Cache completion deets and prompt to submit them

+241 -5
+29 -4
src/lib/atproto-records.ts
··· 2 2 3 3 const COLLECTED_COLLECTION = 'de.atverkackt.vod-jam.collected'; 4 4 const COMPLETED_COLLECTION = 'de.atverkackt.vod-jam.completed'; 5 + const PENDING_COMPLETION_KEY = 'vodjam-pending-completion'; 6 + 7 + export interface PendingCompletion { 8 + timeMs: number; 9 + completedAt: string; 10 + } 11 + 12 + /** Store a pending completion so it survives an OAuth redirect */ 13 + export function savePendingCompletion(timeMs: number): void { 14 + const pending: PendingCompletion = { timeMs, completedAt: new Date().toISOString() }; 15 + localStorage.setItem(PENDING_COMPLETION_KEY, JSON.stringify(pending)); 16 + } 17 + 18 + /** Get and clear any pending completion */ 19 + export function consumePendingCompletion(): PendingCompletion | null { 20 + try { 21 + const raw = localStorage.getItem(PENDING_COMPLETION_KEY); 22 + if (!raw) return null; 23 + return JSON.parse(raw) as PendingCompletion; 24 + } catch { 25 + return null; 26 + } 27 + } 28 + 29 + export function clearPendingCompletion(): void { 30 + localStorage.removeItem(PENDING_COMPLETION_KEY); 31 + } 5 32 6 33 /** 7 34 * Write a "collected" record for a VOD. ··· 29 56 * Write/update a "completed" record when the user collects all VODs. 30 57 * Uses putRecord with rkey "self" — one per user, overwritten on replay. 31 58 */ 32 - export function writeCompletedRecord(agent: Agent, timeMs: number): void { 33 - agent.com.atproto.repo.putRecord({ 59 + export async function writeCompletedRecord(agent: Agent, timeMs: number): Promise<void> { 60 + await agent.com.atproto.repo.putRecord({ 34 61 repo: agent.did!, 35 62 collection: COMPLETED_COLLECTION, 36 63 rkey: 'self', ··· 39 66 timeMs, 40 67 completedAt: new Date().toISOString() 41 68 } 42 - }).catch((err) => { 43 - console.warn('[vodjam] Failed to write completed record:', err); 44 69 }); 45 70 }
+209
src/lib/game/CompletionScreen.svelte
··· 1 1 <script lang="ts"> 2 2 import SpriteTime from '$lib/SpriteTime.svelte'; 3 + import { 4 + initOAuth, 5 + login, 6 + getAgent, 7 + getHandle, 8 + isLoggedIn, 9 + onAuthChange 10 + } from '$lib/atproto-oauth'; 11 + import { 12 + writeCompletedRecord, 13 + savePendingCompletion, 14 + consumePendingCompletion, 15 + clearPendingCompletion 16 + } from '$lib/atproto-records'; 3 17 4 18 let { 5 19 elapsedMs, ··· 13 27 onexplore?: () => void; 14 28 } = $props(); 15 29 30 + let authReady = $state(false); 31 + let loggedIn = $state(false); 32 + let handle = $state<string | undefined>(undefined); 33 + let showLoginInput = $state(false); 34 + let loginHandle = $state(''); 35 + let loginError = $state(''); 36 + let loginLoading = $state(false); 37 + let submitted = $state(false); 38 + let submitting = $state(false); 39 + let submitError = $state(''); 40 + 41 + function syncAuth() { 42 + loggedIn = isLoggedIn(); 43 + handle = getHandle(); 44 + } 45 + 46 + async function submitScore() { 47 + const agent = getAgent(); 48 + if (!agent) return; 49 + submitting = true; 50 + submitError = ''; 51 + try { 52 + await writeCompletedRecord(agent, elapsedMs); 53 + clearPendingCompletion(); 54 + submitted = true; 55 + } catch (err: any) { 56 + submitError = err?.message ?? 'Failed to submit'; 57 + } finally { 58 + submitting = false; 59 + } 60 + } 61 + 62 + $effect(() => { 63 + initOAuth() 64 + .then(() => { 65 + syncAuth(); 66 + authReady = true; 67 + 68 + // If we just came back from an OAuth redirect with a pending completion, submit it 69 + if (isLoggedIn()) { 70 + const pending = consumePendingCompletion(); 71 + if (pending) { 72 + submitScore(); 73 + } 74 + } 75 + }) 76 + .catch((err) => { 77 + console.warn('[vodjam] OAuth init failed:', err); 78 + authReady = true; 79 + }); 80 + 81 + return onAuthChange(syncAuth); 82 + }); 83 + 84 + async function handleLogin() { 85 + if (!loginHandle.trim()) return; 86 + loginLoading = true; 87 + loginError = ''; 88 + try { 89 + // Save completion so it survives the OAuth redirect 90 + savePendingCompletion(elapsedMs); 91 + await login(loginHandle.trim()); 92 + } catch (err: any) { 93 + loginError = err?.message ?? 'Login failed'; 94 + loginLoading = false; 95 + } 96 + } 97 + 16 98 let formattedTime = $derived.by(() => { 17 99 const totalSeconds = Math.floor(elapsedMs / 1000); 18 100 const h = Math.floor(totalSeconds / 3600); ··· 47 129 </div> 48 130 </div> 49 131 132 + <div class="submit-area"> 133 + {#if !authReady} 134 + <span class="submit-loading">...</span> 135 + {:else if submitted} 136 + <p class="submit-success">Score submitted! ✓</p> 137 + {:else if loggedIn} 138 + <p class="submit-handle">@{handle ?? '...'}</p> 139 + <button 140 + class="action-btn submit-btn" 141 + onclick={submitScore} 142 + disabled={submitting} 143 + > 144 + <span class="btn-text">{submitting ? 'Submitting...' : 'Submit Score'}</span> 145 + </button> 146 + {:else if showLoginInput} 147 + <p class="submit-hint">Login with Bluesky to save your time to the leaderboard</p> 148 + <form class="login-form" onsubmit={(e) => { e.preventDefault(); handleLogin(); }}> 149 + <input 150 + class="login-input" 151 + type="text" 152 + placeholder="handle.bsky.social" 153 + bind:value={loginHandle} 154 + disabled={loginLoading} 155 + /> 156 + <button class="action-btn submit-btn" type="submit" disabled={loginLoading}> 157 + <span class="btn-text">{loginLoading ? '...' : 'Go'}</span> 158 + </button> 159 + <button class="action-btn explore-btn cancel-btn" type="button" onclick={() => { showLoginInput = false; loginError = ''; }}> 160 + <span class="btn-text">✕</span> 161 + </button> 162 + </form> 163 + {#if loginError} 164 + <p class="submit-error">{loginError}</p> 165 + {/if} 166 + {:else} 167 + <button class="action-btn submit-btn" onclick={() => (showLoginInput = true)}> 168 + <span class="btn-text">Submit Score</span> 169 + </button> 170 + {/if} 171 + {#if submitError} 172 + <p class="submit-error">{submitError}</p> 173 + {/if} 174 + </div> 175 + 50 176 <div class="completion-buttons"> 51 177 <button class="action-btn collection-btn" onclick={() => onviewcollection?.()}> 52 178 <span class="btn-text">View Collection</span> ··· 144 270 gap: 0.75rem; 145 271 width: 100%; 146 272 max-width: 360px; 273 + } 274 + 275 + .submit-area { 276 + display: flex; 277 + flex-direction: column; 278 + align-items: center; 279 + gap: 0.5rem; 280 + margin-bottom: 1.5rem; 281 + width: 100%; 282 + max-width: 360px; 283 + } 284 + 285 + .submit-hint { 286 + font-family: 'Courier New', Courier, monospace; 287 + color: #9990bb; 288 + font-size: 0.75rem; 289 + margin: 0; 290 + text-align: center; 291 + } 292 + 293 + .submit-handle { 294 + font-family: 'Courier New', Courier, monospace; 295 + color: #FF6B2C; 296 + font-size: 0.85rem; 297 + margin: 0; 298 + } 299 + 300 + .submit-success { 301 + font-family: 'Courier New', Courier, monospace; 302 + color: #FFD93D; 303 + font-size: 0.95rem; 304 + margin: 0; 305 + } 306 + 307 + .submit-error { 308 + font-family: 'Courier New', Courier, monospace; 309 + color: #ff4444; 310 + font-size: 0.75rem; 311 + margin: 0; 312 + } 313 + 314 + .submit-loading { 315 + font-family: 'Courier New', Courier, monospace; 316 + color: #9990bb; 317 + font-size: 0.75rem; 318 + } 319 + 320 + .submit-btn { 321 + background: #FFD93D; 322 + border-color: #ffe46a; 323 + color: #0B0E17; 324 + } 325 + 326 + .submit-btn:hover { 327 + background: #ffe46a; 328 + } 329 + 330 + .login-form { 331 + display: flex; 332 + align-items: center; 333 + gap: 0.5rem; 334 + width: 100%; 335 + } 336 + 337 + .login-input { 338 + flex: 1; 339 + font-family: 'Courier New', Courier, monospace; 340 + font-size: 0.85rem; 341 + background: #0B0E17; 342 + border: 2px solid #1e2450; 343 + border-radius: 2px; 344 + color: #E8E0FF; 345 + padding: 0.6rem 0.75rem; 346 + } 347 + 348 + .login-input:focus { 349 + border-color: #FF6B2C; 350 + outline: none; 351 + } 352 + 353 + .cancel-btn { 354 + flex: 0; 355 + padding: 0.75rem; 147 356 } 148 357 149 358 .action-btn {
+3 -1
src/routes/+page.svelte
··· 12 12 import CompletionScreen from '$lib/game/CompletionScreen.svelte'; 13 13 import VideoPlayer from '$lib/VideoPlayer.svelte'; 14 14 import VodjamHeader from '$lib/VodjamHeader.svelte'; 15 + import { consumePendingCompletion } from '$lib/atproto-records'; 15 16 16 17 const STORAGE_KEY = 'vodjam-state'; 17 18 ··· 73 74 let error = $state(''); 74 75 75 76 let hasProgress = restored?.collectedIds?.length ? true : false; 76 - let gameState: GameState = $state('intro'); 77 + let hasPendingCompletion = consumePendingCompletion() !== null; 78 + let gameState: GameState = $state(hasPendingCompletion && restored?.timerEnded ? 'all-collected' : 'intro'); 77 79 let previousPlayState: GameState = $state('playing'); 78 80 let selectedPresent: WorldPresent | null = $state(null); 79 81 let focusVideoId: string | undefined = $state(undefined);