add track sharing and deployment config

- implement ShareButton component with copy-to-clipboard
- add /track/[id] route for shareable track links
- hide portal link when already on portal page
- configure wrangler.toml for cloudflare pages with nodejs_compat flag
- add deployment commands to justfile (deploy-frontend, deploy-backend)
- improve auth to support Bearer token for cross-domain requests
- fix accessibility warnings in TrackItem component

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

-3
.dockerignore
··· 15 15 .env 16 16 .env.local 17 17 data/ 18 - frontend/ 19 18 sandbox/ 20 19 tests/ 21 20 docs/ 22 - *.md 23 - !README.md
+1
.env.example
··· 2 2 3 3 # app 4 4 DEBUG=false 5 + FRONTEND_URL=http://localhost:5173 5 6 6 7 # database 7 8 DATABASE_URL=postgresql+asyncpg://localhost/relay
+1
CLAUDE.md
··· 11 11 - **frontend**: SvelteKit with **bun** (not npm/pnpm) 12 12 - **backend**: FastAPI deployed on Fly.io 13 13 - **deployment**: `flyctl deploy` (runs in background per user prefs) 14 + - **logs**: `flyctl logs` is BLOCKING - must run in background with `run_in_background=true` then check output with BashOutput
+2 -2
Dockerfile
··· 6 6 WORKDIR /app 7 7 8 8 # install dependencies 9 - COPY pyproject.toml uv.lock ./ 9 + COPY pyproject.toml uv.lock README.md ./ 10 10 RUN uv sync --frozen --no-dev --no-install-project 11 11 12 12 # copy application code 13 - COPY . . 13 + COPY src ./src 14 14 15 15 # install the project itself 16 16 RUN uv sync --frozen --no-dev
frontend/bun.lockb

This is a binary file and will not be displayed.

+2 -1
frontend/package.json
··· 13 13 }, 14 14 "devDependencies": { 15 15 "@sveltejs/adapter-auto": "^7.0.0", 16 - "@sveltejs/adapter-vercel": "^6.1.1", 16 + "@sveltejs/adapter-cloudflare": "^7.2.4", 17 + "@sveltejs/adapter-static": "^3.0.10", 17 18 "@sveltejs/kit": "^2.43.2", 18 19 "@sveltejs/vite-plugin-svelte": "^6.2.0", 19 20 "svelte": "^5.39.5",
+4 -1
frontend/src/lib/components/Header.svelte
··· 1 1 <script lang="ts"> 2 + import { page } from '$app/stores'; 2 3 import type { User } from '$lib/types'; 3 4 4 5 interface Props { ··· 20 21 21 22 <nav> 22 23 {#if isAuthenticated} 23 - <a href="/portal" class="nav-link">portal</a> 24 + {#if $page.url.pathname !== '/portal'} 25 + <a href="/portal" class="nav-link">portal</a> 26 + {/if} 24 27 <span class="user-info">@{user.handle}</span> 25 28 <button onclick={onLogout} class="btn-secondary">logout</button> 26 29 {:else}
+82
frontend/src/lib/components/ShareButton.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + url: string; 4 + } 5 + 6 + let { url }: Props = $props(); 7 + 8 + let showCopied = $state(false); 9 + 10 + async function copyLink() { 11 + try { 12 + await navigator.clipboard.writeText(url); 13 + showCopied = true; 14 + setTimeout(() => { 15 + showCopied = false; 16 + }, 2000); 17 + } catch (err) { 18 + console.error('failed to copy:', err); 19 + } 20 + } 21 + </script> 22 + 23 + <button class="share-btn" onclick={copyLink} title="share track"> 24 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 25 + <circle cx="18" cy="5" r="3"></circle> 26 + <circle cx="6" cy="12" r="3"></circle> 27 + <circle cx="18" cy="19" r="3"></circle> 28 + <line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line> 29 + <line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line> 30 + </svg> 31 + {#if showCopied} 32 + <span class="copied">copied!</span> 33 + {/if} 34 + </button> 35 + 36 + <style> 37 + .share-btn { 38 + background: transparent; 39 + border: 1px solid #333; 40 + border-radius: 4px; 41 + padding: 0.5rem; 42 + color: #888; 43 + cursor: pointer; 44 + display: flex; 45 + align-items: center; 46 + gap: 0.5rem; 47 + transition: all 0.2s; 48 + position: relative; 49 + } 50 + 51 + .share-btn:hover { 52 + border-color: #3a7dff; 53 + color: #3a7dff; 54 + } 55 + 56 + .copied { 57 + position: absolute; 58 + top: -2rem; 59 + left: 50%; 60 + transform: translateX(-50%); 61 + background: #1a1a1a; 62 + border: 1px solid #3a7dff; 63 + color: #3a7dff; 64 + padding: 0.25rem 0.75rem; 65 + border-radius: 4px; 66 + font-size: 0.75rem; 67 + white-space: nowrap; 68 + pointer-events: none; 69 + animation: fadeIn 0.2s ease-in; 70 + } 71 + 72 + @keyframes fadeIn { 73 + from { 74 + opacity: 0; 75 + transform: translateX(-50%) translateY(-0.25rem); 76 + } 77 + to { 78 + opacity: 1; 79 + transform: translateX(-50%) translateY(0); 80 + } 81 + } 82 + </style>
+64 -38
frontend/src/lib/components/TrackItem.svelte
··· 1 1 <script lang="ts"> 2 + import ShareButton from './ShareButton.svelte'; 2 3 import type { Track } from '$lib/types'; 3 4 4 5 interface Props { ··· 8 9 } 9 10 10 11 let { track, isPlaying = false, onPlay }: Props = $props(); 12 + 13 + // construct shareable URL 14 + const shareUrl = typeof window !== 'undefined' 15 + ? `${window.location.origin}/track/${track.id}` 16 + : ''; 11 17 </script> 12 18 13 - <button 14 - class="track" 15 - class:playing={isPlaying} 16 - onclick={() => onPlay(track)} 17 - > 18 - <div class="track-info"> 19 - <div class="track-title">{track.title}</div> 20 - <div class="track-artist"> 21 - {track.artist} 22 - {#if track.album} 23 - <span class="album">- {track.album}</span> 24 - {/if} 25 - </div> 26 - <div class="track-meta"> 27 - <span>@{track.artist_handle}</span> 28 - {#if track.atproto_record_uri} 29 - {@const parts = track.atproto_record_uri.split('/')} 30 - {@const did = parts[2]} 31 - {@const collection = parts[3]} 32 - {@const rkey = parts[4]} 33 - <span class="separator">•</span> 34 - <a 35 - href={`https://pds.zzstoatzz.io/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${collection}&rkey=${rkey}`} 36 - target="_blank" 37 - rel="noopener" 38 - class="atproto-link" 39 - onclick={(e) => e.stopPropagation()} 40 - > 41 - view record 42 - </a> 43 - {/if} 19 + <div class="track-container" class:playing={isPlaying}> 20 + <button 21 + class="track" 22 + onclick={() => onPlay(track)} 23 + > 24 + <div class="track-info"> 25 + <div class="track-title">{track.title}</div> 26 + <div class="track-artist"> 27 + {track.artist} 28 + {#if track.album} 29 + <span class="album">- {track.album}</span> 30 + {/if} 31 + </div> 32 + <div class="track-meta"> 33 + <span>@{track.artist_handle}</span> 34 + {#if track.atproto_record_uri} 35 + {@const parts = track.atproto_record_uri.split('/')} 36 + {@const did = parts[2]} 37 + {@const collection = parts[3]} 38 + {@const rkey = parts[4]} 39 + <span class="separator">•</span> 40 + <a 41 + href={`https://pds.zzstoatzz.io/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${collection}&rkey=${rkey}`} 42 + target="_blank" 43 + rel="noopener" 44 + class="atproto-link" 45 + onclick={(e) => e.stopPropagation()} 46 + > 47 + view record 48 + </a> 49 + {/if} 50 + </div> 44 51 </div> 52 + </button> 53 + <div class="track-actions" role="presentation" onclick={(e) => e.stopPropagation()}> 54 + <ShareButton url={shareUrl} /> 45 55 </div> 46 - </button> 56 + </div> 47 57 48 58 <style> 49 - .track { 59 + .track-container { 60 + display: flex; 61 + align-items: center; 62 + gap: 0.75rem; 50 63 background: #141414; 51 64 border: 1px solid #282828; 52 65 border-left: 3px solid transparent; 53 66 padding: 1rem; 54 - cursor: pointer; 55 - text-align: left; 56 67 transition: all 0.15s ease-in-out; 57 - width: 100%; 58 68 } 59 69 60 - .track:hover { 70 + .track-container:hover { 61 71 background: #1a1a1a; 62 72 border-left-color: #6a9fff; 63 73 border-color: #333; 64 74 transform: translateX(2px); 65 75 } 66 76 67 - .track.playing { 77 + .track-container.playing { 68 78 background: #1a2330; 69 79 border-left-color: #6a9fff; 70 80 border-color: #2a3a4a; 81 + } 82 + 83 + .track { 84 + background: transparent; 85 + border: none; 86 + cursor: pointer; 87 + text-align: left; 88 + padding: 0; 89 + flex: 1; 90 + min-width: 0; 91 + } 92 + 93 + .track-actions { 94 + display: flex; 95 + gap: 0.5rem; 96 + flex-shrink: 0; 71 97 } 72 98 73 99 .track-title {
+1 -7
frontend/src/lib/config.ts
··· 1 - import { browser } from '$app/environment'; 2 - 3 - export const API_URL = browser 4 - ? (window.location.hostname === 'localhost' 5 - ? 'http://localhost:8001' 6 - : 'https://relay-api.fly.dev') 7 - : 'https://relay-api.fly.dev'; 1 + export const API_URL = 'https://relay-api.fly.dev';
+11
frontend/src/routes/+layout.svelte
··· 9 9 </svelte:head> 10 10 11 11 {@render children?.()} 12 + 13 + <style> 14 + :global(body) { 15 + margin: 0; 16 + padding: 0; 17 + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas', monospace; 18 + background: #0a0a0a; 19 + color: #e0e0e0; 20 + -webkit-font-smoothing: antialiased; 21 + } 22 + </style>
+44 -23
frontend/src/routes/+page.svelte
··· 9 9 let currentTrack = $state<Track | null>(null); 10 10 let audioElement = $state<HTMLAudioElement | undefined>(undefined); 11 11 let user = $state<User | null>(null); 12 + let loading = $state(true); 12 13 13 14 // player state - using Svelte's built-in bindings 14 15 let paused = $state(true); ··· 24 25 25 26 onMount(async () => { 26 27 // check authentication 27 - try { 28 - const authResponse = await fetch('`${API_URL}`/auth/me', { 29 - credentials: 'include' 30 - }); 31 - if (authResponse.ok) { 32 - user = await authResponse.json(); 28 + const sessionId = localStorage.getItem('session_id'); 29 + if (sessionId) { 30 + try { 31 + const authResponse = await fetch(`${API_URL}/auth/me`, { 32 + headers: { 33 + 'Authorization': `Bearer ${sessionId}` 34 + } 35 + }); 36 + if (authResponse.ok) { 37 + user = await authResponse.json(); 38 + } else { 39 + // invalid session, clear it 40 + localStorage.removeItem('session_id'); 41 + } 42 + } catch (e) { 43 + // not authenticated, that's fine 44 + localStorage.removeItem('session_id'); 33 45 } 34 - } catch (e) { 35 - // not authenticated, that's fine 36 46 } 37 47 38 48 // load tracks 39 - const response = await fetch('`${API_URL}`/tracks/'); 49 + const response = await fetch(`${API_URL}/tracks/`); 40 50 const data = await response.json(); 41 51 tracks = data.tracks; 52 + 53 + loading = false; 42 54 }); 43 55 44 56 // Use $effect to reactively handle track changes only ··· 80 92 } 81 93 82 94 async function logout() { 83 - await fetch('`${API_URL}`/auth/logout', { 84 - method: 'POST', 85 - credentials: 'include' 86 - }); 95 + const sessionId = localStorage.getItem('session_id'); 96 + if (sessionId) { 97 + await fetch(`${API_URL}/auth/logout`, { 98 + method: 'POST', 99 + headers: { 100 + 'Authorization': `Bearer ${sessionId}` 101 + } 102 + }); 103 + } 104 + localStorage.removeItem('session_id'); 87 105 user = null; 88 106 } 89 107 </script> 90 108 91 - <Header {user} onLogout={logout} /> 109 + {#if loading} 110 + <div class="loading">loading...</div> 111 + {:else} 112 + <Header {user} onLogout={logout} /> 92 113 93 - <main> 114 + <main> 94 115 95 116 <section class="tracks"> 96 117 <h2>latest tracks</h2> ··· 173 194 </div> 174 195 </div> 175 196 {/if} 176 - </main> 197 + </main> 198 + {/if} 177 199 178 200 <style> 179 - :global(body) { 180 - margin: 0; 181 - padding: 0; 182 - font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas', monospace; 183 - background: #0a0a0a; 184 - color: #e0e0e0; 185 - -webkit-font-smoothing: antialiased; 201 + .loading { 202 + display: flex; 203 + align-items: center; 204 + justify-content: center; 205 + min-height: 100vh; 206 + color: #888; 186 207 } 187 208 188 209 main {
-8
frontend/src/routes/login/+page.svelte
··· 39 39 </div> 40 40 41 41 <style> 42 - :global(body) { 43 - margin: 0; 44 - padding: 0; 45 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 46 - background: #0a0a0a; 47 - color: #fff; 48 - } 49 - 50 42 .container { 51 43 min-height: 100vh; 52 44 display: flex;
+46 -10
frontend/src/routes/portal/+page.svelte
··· 21 21 let file: File | null = null; 22 22 23 23 onMount(async () => { 24 + // check if session_id is in URL (from OAuth callback) 25 + const params = new URLSearchParams(window.location.search); 26 + const sessionId = params.get('session_id'); 27 + 28 + if (sessionId) { 29 + // store session_id in localStorage 30 + localStorage.setItem('session_id', sessionId); 31 + // remove from URL 32 + window.history.replaceState({}, '', '/portal'); 33 + } 34 + 35 + // get session_id from localStorage 36 + const storedSessionId = localStorage.getItem('session_id'); 37 + 38 + if (!storedSessionId) { 39 + window.location.href = '/login'; 40 + return; 41 + } 42 + 24 43 try { 25 - const response = await fetch('`${API_URL}`/auth/me', { 26 - credentials: 'include' 44 + const response = await fetch(`${API_URL}/auth/me`, { 45 + headers: { 46 + 'Authorization': `Bearer ${storedSessionId}` 47 + } 27 48 }); 28 49 if (response.ok) { 29 50 user = await response.json(); 30 51 await loadMyTracks(); 31 52 } else { 32 - // not authenticated, redirect to login 53 + // session invalid, clear and redirect 54 + localStorage.removeItem('session_id'); 33 55 window.location.href = '/login'; 34 56 } 35 57 } catch (e) { 58 + localStorage.removeItem('session_id'); 36 59 window.location.href = '/login'; 37 60 } finally { 38 61 loading = false; ··· 41 64 42 65 async function loadMyTracks() { 43 66 loadingTracks = true; 67 + const sessionId = localStorage.getItem('session_id'); 44 68 try { 45 - const response = await fetch('`${API_URL}`/tracks/me', { 46 - credentials: 'include' 69 + const response = await fetch(`${API_URL}/tracks/me`, { 70 + headers: { 71 + 'Authorization': `Bearer ${sessionId}` 72 + } 47 73 }); 48 74 if (response.ok) { 49 75 const data = await response.json(); ··· 64 90 uploadError = ''; 65 91 uploadSuccess = ''; 66 92 93 + const sessionId = localStorage.getItem('session_id'); 67 94 const formData = new FormData(); 68 95 formData.append('file', file); 69 96 formData.append('title', title); ··· 71 98 if (album) formData.append('album', album); 72 99 73 100 try { 74 - const response = await fetch('`${API_URL}`/tracks/', { 101 + const response = await fetch(`${API_URL}/tracks/`, { 75 102 method: 'POST', 76 103 body: formData, 77 - credentials: 'include' 104 + headers: { 105 + 'Authorization': `Bearer ${sessionId}` 106 + } 78 107 }); 79 108 80 109 if (response.ok) { ··· 102 131 async function deleteTrack(trackId: number, trackTitle: string) { 103 132 if (!confirm(`delete "${trackTitle}"?`)) return; 104 133 134 + const sessionId = localStorage.getItem('session_id'); 105 135 try { 106 136 const response = await fetch(`${API_URL}/tracks/${trackId}`, { 107 137 method: 'DELETE', 108 - credentials: 'include' 138 + headers: { 139 + 'Authorization': `Bearer ${sessionId}` 140 + } 109 141 }); 110 142 111 143 if (response.ok) { ··· 127 159 } 128 160 129 161 async function logout() { 130 - await fetch('`${API_URL}`/auth/logout', { 162 + const sessionId = localStorage.getItem('session_id'); 163 + await fetch(`${API_URL}/auth/logout`, { 131 164 method: 'POST', 132 - credentials: 'include' 165 + headers: { 166 + 'Authorization': `Bearer ${sessionId}` 167 + } 133 168 }); 169 + localStorage.removeItem('session_id'); 134 170 window.location.href = '/'; 135 171 } 136 172 </script>
+453
frontend/src/routes/track/[id]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { page } from '$app/stores'; 4 + import TrackItem from '$lib/components/TrackItem.svelte'; 5 + import Header from '$lib/components/Header.svelte'; 6 + import type { Track, User } from '$lib/types'; 7 + import { API_URL } from '$lib/config'; 8 + 9 + let tracks = $state<Track[]>([]); 10 + let currentTrack = $state<Track | null>(null); 11 + let audioElement = $state<HTMLAudioElement | undefined>(undefined); 12 + let user = $state<User | null>(null); 13 + let loading = $state(true); 14 + let trackNotFound = $state(false); 15 + 16 + // player state 17 + let paused = $state(true); 18 + let currentTime = $state(0); 19 + let duration = $state(0); 20 + let volume = $state(0.7); 21 + 22 + // derived values 23 + let hasTracks = $derived(tracks.length > 0); 24 + let isAuthenticated = $derived(user !== null); 25 + let formattedCurrentTime = $derived(formatTime(currentTime)); 26 + let formattedDuration = $derived(formatTime(duration)); 27 + 28 + onMount(async () => { 29 + // check authentication 30 + const sessionId = localStorage.getItem('session_id'); 31 + if (sessionId) { 32 + try { 33 + const authResponse = await fetch(`${API_URL}/auth/me`, { 34 + headers: { 35 + 'Authorization': `Bearer ${sessionId}` 36 + } 37 + }); 38 + if (authResponse.ok) { 39 + user = await authResponse.json(); 40 + } else { 41 + localStorage.removeItem('session_id'); 42 + } 43 + } catch (e) { 44 + localStorage.removeItem('session_id'); 45 + } 46 + } 47 + 48 + // load tracks 49 + const response = await fetch(`${API_URL}/tracks/`); 50 + const data = await response.json(); 51 + tracks = data.tracks; 52 + 53 + // get track id from URL and auto-play 54 + const trackId = parseInt($page.params.id); 55 + const track = tracks.find(t => t.id === trackId); 56 + 57 + if (track) { 58 + currentTrack = track; 59 + paused = false; // auto-play 60 + } else { 61 + trackNotFound = true; 62 + } 63 + 64 + loading = false; 65 + }); 66 + 67 + // Use $effect to reactively handle track changes 68 + let previousTrackId: number | null = null; 69 + $effect(() => { 70 + if (!currentTrack || !audioElement) return; 71 + 72 + // Only load new track if it actually changed 73 + if (currentTrack.id !== previousTrackId) { 74 + previousTrackId = currentTrack.id; 75 + audioElement.src = `${API_URL}/audio/${currentTrack.file_id}`; 76 + audioElement.load(); 77 + 78 + if (!paused) { 79 + audioElement.play().catch(err => { 80 + console.error('playback failed:', err); 81 + paused = true; 82 + }); 83 + } 84 + } 85 + }); 86 + 87 + function playTrack(track: Track) { 88 + if (currentTrack?.id === track.id) { 89 + // toggle play/pause on same track 90 + paused = !paused; 91 + } else { 92 + // switch tracks 93 + currentTrack = track; 94 + paused = false; 95 + } 96 + } 97 + 98 + function formatTime(seconds: number): string { 99 + if (!seconds || isNaN(seconds)) return '0:00'; 100 + const mins = Math.floor(seconds / 60); 101 + const secs = Math.floor(seconds % 60); 102 + return `${mins}:${secs.toString().padStart(2, '0')}`; 103 + } 104 + 105 + async function logout() { 106 + const sessionId = localStorage.getItem('session_id'); 107 + if (sessionId) { 108 + await fetch(`${API_URL}/auth/logout`, { 109 + method: 'POST', 110 + headers: { 111 + 'Authorization': `Bearer ${sessionId}` 112 + } 113 + }); 114 + } 115 + localStorage.removeItem('session_id'); 116 + user = null; 117 + } 118 + </script> 119 + 120 + {#if loading} 121 + <div class="loading">loading...</div> 122 + {:else} 123 + <Header {user} onLogout={logout} /> 124 + 125 + <main> 126 + {#if trackNotFound} 127 + <div class="not-found"> 128 + <h2>track not found</h2> 129 + <p>the requested track doesn't exist or has been removed.</p> 130 + <a href="/" class="back-link">← back to all tracks</a> 131 + </div> 132 + {:else} 133 + <section class="tracks"> 134 + <h2>latest tracks</h2> 135 + {#if !hasTracks} 136 + <p class="empty">no tracks yet</p> 137 + {:else} 138 + <div class="track-list"> 139 + {#each tracks as track} 140 + <TrackItem 141 + {track} 142 + isPlaying={currentTrack?.id === track.id} 143 + onPlay={playTrack} 144 + /> 145 + {/each} 146 + </div> 147 + {/if} 148 + </section> 149 + {/if} 150 + 151 + {#if currentTrack} 152 + <div class="player"> 153 + <audio 154 + bind:this={audioElement} 155 + bind:paused 156 + bind:currentTime 157 + bind:duration 158 + bind:volume 159 + onended={() => { 160 + currentTime = 0; 161 + paused = true; 162 + }} 163 + ></audio> 164 + 165 + <div class="player-content"> 166 + <div class="player-info"> 167 + <div class="player-title">{currentTrack.title}</div> 168 + <div class="player-artist">{currentTrack.artist}</div> 169 + </div> 170 + 171 + <div class="player-controls"> 172 + <button class="control-btn" onclick={() => paused = !paused} title={paused ? 'Play' : 'Pause'}> 173 + {#if !paused} 174 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"> 175 + <rect x="6" y="4" width="4" height="16" rx="1"></rect> 176 + <rect x="14" y="4" width="4" height="16" rx="1"></rect> 177 + </svg> 178 + {:else} 179 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 180 + <path d="M8 5v14l11-7z"></path> 181 + </svg> 182 + {/if} 183 + </button> 184 + 185 + <div class="time-control"> 186 + <span class="time">{formattedCurrentTime}</span> 187 + <input 188 + type="range" 189 + class="seek-bar" 190 + min="0" 191 + max={duration || 0} 192 + bind:value={currentTime} 193 + /> 194 + <span class="time">{formattedDuration}</span> 195 + </div> 196 + 197 + <div class="volume-control"> 198 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"> 199 + <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon> 200 + <path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path> 201 + </svg> 202 + <input 203 + type="range" 204 + class="volume-bar" 205 + min="0" 206 + max="1" 207 + step="0.01" 208 + bind:value={volume} 209 + /> 210 + </div> 211 + </div> 212 + </div> 213 + </div> 214 + {/if} 215 + </main> 216 + {/if} 217 + 218 + <style> 219 + .loading { 220 + display: flex; 221 + align-items: center; 222 + justify-content: center; 223 + min-height: 100vh; 224 + color: #888; 225 + } 226 + 227 + main { 228 + max-width: 800px; 229 + margin: 0 auto; 230 + padding: 0 1rem 120px; 231 + } 232 + 233 + .not-found { 234 + text-align: center; 235 + padding: 3rem 1rem; 236 + } 237 + 238 + .not-found h2 { 239 + font-size: 1.5rem; 240 + margin-bottom: 1rem; 241 + color: #e8e8e8; 242 + } 243 + 244 + .not-found p { 245 + color: #808080; 246 + margin-bottom: 2rem; 247 + } 248 + 249 + .back-link { 250 + color: #3a7dff; 251 + text-decoration: none; 252 + transition: color 0.2s; 253 + } 254 + 255 + .back-link:hover { 256 + color: #6a9fff; 257 + } 258 + 259 + .tracks h2 { 260 + font-size: 1.5rem; 261 + margin-bottom: 1.5rem; 262 + color: #e8e8e8; 263 + } 264 + 265 + .empty { 266 + color: #808080; 267 + padding: 2rem; 268 + text-align: center; 269 + } 270 + 271 + .track-list { 272 + display: flex; 273 + flex-direction: column; 274 + gap: 0.5rem; 275 + } 276 + 277 + .player { 278 + position: fixed; 279 + bottom: 0; 280 + left: 0; 281 + right: 0; 282 + background: #1a1a1a; 283 + border-top: 1px solid #2a2a2a; 284 + padding: 1rem; 285 + z-index: 100; 286 + } 287 + 288 + .player-content { 289 + max-width: 1200px; 290 + margin: 0 auto; 291 + display: flex; 292 + align-items: center; 293 + gap: 2rem; 294 + } 295 + 296 + .player-info { 297 + flex: 0 0 200px; 298 + min-width: 0; 299 + } 300 + 301 + .player-title { 302 + font-weight: 600; 303 + font-size: 0.95rem; 304 + margin-bottom: 0.25rem; 305 + color: #e8e8e8; 306 + white-space: nowrap; 307 + overflow: hidden; 308 + text-overflow: ellipsis; 309 + } 310 + 311 + .player-artist { 312 + color: #b0b0b0; 313 + font-size: 0.85rem; 314 + white-space: nowrap; 315 + overflow: hidden; 316 + text-overflow: ellipsis; 317 + } 318 + 319 + .player-controls { 320 + flex: 1; 321 + display: flex; 322 + align-items: center; 323 + gap: 1.5rem; 324 + } 325 + 326 + .control-btn { 327 + background: transparent; 328 + border: none; 329 + color: #fff; 330 + cursor: pointer; 331 + padding: 0.5rem; 332 + display: flex; 333 + align-items: center; 334 + justify-content: center; 335 + transition: all 0.2s; 336 + border-radius: 50%; 337 + } 338 + 339 + .control-btn:hover { 340 + background: rgba(58, 125, 255, 0.1); 341 + color: #3a7dff; 342 + } 343 + 344 + .time-control { 345 + flex: 1; 346 + display: flex; 347 + align-items: center; 348 + gap: 0.75rem; 349 + } 350 + 351 + .time { 352 + font-size: 0.8rem; 353 + color: #888; 354 + font-variant-numeric: tabular-nums; 355 + min-width: 40px; 356 + } 357 + 358 + .seek-bar { 359 + flex: 1; 360 + height: 4px; 361 + -webkit-appearance: none; 362 + appearance: none; 363 + background: #2a2a2a; 364 + border-radius: 2px; 365 + outline: none; 366 + cursor: pointer; 367 + } 368 + 369 + .seek-bar::-webkit-slider-thumb { 370 + -webkit-appearance: none; 371 + appearance: none; 372 + width: 12px; 373 + height: 12px; 374 + background: #3a7dff; 375 + border-radius: 50%; 376 + cursor: pointer; 377 + transition: all 0.2s; 378 + } 379 + 380 + .seek-bar::-webkit-slider-thumb:hover { 381 + background: #5a8fff; 382 + transform: scale(1.2); 383 + } 384 + 385 + .seek-bar::-moz-range-thumb { 386 + width: 12px; 387 + height: 12px; 388 + background: #3a7dff; 389 + border-radius: 50%; 390 + border: none; 391 + cursor: pointer; 392 + transition: all 0.2s; 393 + } 394 + 395 + .seek-bar::-moz-range-thumb:hover { 396 + background: #5a8fff; 397 + transform: scale(1.2); 398 + } 399 + 400 + .volume-control { 401 + display: flex; 402 + align-items: center; 403 + gap: 0.5rem; 404 + flex: 0 0 120px; 405 + } 406 + 407 + .volume-control svg { 408 + flex-shrink: 0; 409 + color: #888; 410 + } 411 + 412 + .volume-bar { 413 + flex: 1; 414 + height: 4px; 415 + -webkit-appearance: none; 416 + appearance: none; 417 + background: #2a2a2a; 418 + border-radius: 2px; 419 + outline: none; 420 + cursor: pointer; 421 + } 422 + 423 + .volume-bar::-webkit-slider-thumb { 424 + -webkit-appearance: none; 425 + appearance: none; 426 + width: 10px; 427 + height: 10px; 428 + background: #888; 429 + border-radius: 50%; 430 + cursor: pointer; 431 + transition: all 0.2s; 432 + } 433 + 434 + .volume-bar::-webkit-slider-thumb:hover { 435 + background: #aaa; 436 + transform: scale(1.2); 437 + } 438 + 439 + .volume-bar::-moz-range-thumb { 440 + width: 10px; 441 + height: 10px; 442 + background: #888; 443 + border-radius: 50%; 444 + border: none; 445 + cursor: pointer; 446 + transition: all 0.2s; 447 + } 448 + 449 + .volume-bar::-moz-range-thumb:hover { 450 + background: #aaa; 451 + transform: scale(1.2); 452 + } 453 + </style>
+8 -2
frontend/svelte.config.js
··· 1 - import adapter from '@sveltejs/adapter-vercel'; 1 + import adapter from '@sveltejs/adapter-cloudflare'; 2 2 import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 3 4 4 /** @type {import('@sveltejs/kit').Config} */ ··· 6 6 preprocess: vitePreprocess(), 7 7 8 8 kit: { 9 - adapter: adapter() 9 + adapter: adapter({ 10 + platformProxy: { 11 + configPath: 'wrangler.toml', 12 + experimentalJsonConfig: false, 13 + persist: false 14 + } 15 + }) 10 16 } 11 17 }; 12 18
+4
frontend/wrangler.toml
··· 1 + name = "relay" 2 + compatibility_date = "2024-01-01" 3 + compatibility_flags = ["nodejs_compat"] 4 + pages_build_output_dir = ".svelte-kit/cloudflare"
+8
justfile
··· 11 11 # run frontend dev server 12 12 dev: 13 13 cd frontend && bun run dev 14 + 15 + # deploy backend to fly.io 16 + deploy-backend: 17 + flyctl deploy 18 + 19 + # deploy frontend to cloudflare pages 20 + deploy-frontend: 21 + cd frontend && bun run build && bun x wrangler pages deploy .svelte-kit/cloudflare
+4 -17
src/relay/api/auth.py
··· 1 1 """authentication api endpoints.""" 2 2 3 3 from typing import Annotated 4 + from urllib.parse import urlparse 4 5 5 6 from fastapi import APIRouter, Depends, Query 6 7 from fastapi.responses import JSONResponse, RedirectResponse ··· 13 14 require_auth, 14 15 start_oauth_flow, 15 16 ) 17 + from relay.config import settings 16 18 17 19 router = APIRouter(prefix="/auth", tags=["auth"]) 18 20 ··· 34 36 did, handle, oauth_session = await handle_oauth_callback(code, state, iss) 35 37 session_id = create_session(did, handle, oauth_session) 36 38 37 - # redirect to localhost endpoint to set cookie properly for localhost domain 39 + # pass session_id as URL parameter for cross-domain auth 38 40 response = RedirectResponse( 39 - url=f"http://localhost:8001/auth/session?session_id={session_id}", 41 + url=f"{settings.frontend_url}/portal?session_id={session_id}", 40 42 status_code=303 41 - ) 42 - return response 43 - 44 - 45 - @router.get("/session") 46 - async def set_session_cookie(session_id: str) -> RedirectResponse: 47 - """intermediate endpoint to set session cookie for localhost domain.""" 48 - response = RedirectResponse(url="http://localhost:5173", status_code=303) 49 - response.set_cookie( 50 - key="session_id", 51 - value=session_id, 52 - httponly=True, 53 - secure=False, 54 - samesite="lax", 55 - domain="localhost", 56 43 ) 57 44 return response 58 45
+19 -2
src/relay/auth.py
··· 146 146 ) from e 147 147 148 148 149 + from fastapi import Cookie, Header, HTTPException 150 + 149 151 def require_auth( 150 - session_id: Annotated[str | None, Cookie()] = None, 152 + session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, 153 + authorization: Annotated[str | None, Header()] = None, 151 154 ) -> Session: 152 - """fastapi dependency to require authentication.""" 155 + """fastapi dependency to require authentication. 156 + 157 + Accepts session_id from either: 158 + - Cookie (for same-domain requests) 159 + - Authorization header as Bearer token (for cross-domain requests) 160 + """ 161 + session_id = None 162 + 163 + # try cookie first (for localhost/same-domain) 164 + if session_id_cookie: 165 + session_id = session_id_cookie 166 + # try authorization header (for cross-domain) 167 + elif authorization and authorization.startswith("Bearer "): 168 + session_id = authorization.removeprefix("Bearer ") 169 + 153 170 if not session_id: 154 171 raise HTTPException( 155 172 status_code=401,
+6
src/relay/config.py
··· 41 41 r2_endpoint_url: str = Field(default="", description="R2 endpoint URL") 42 42 r2_public_bucket_url: str = Field(default="", description="R2 public bucket URL") 43 43 44 + # frontend 45 + frontend_url: str = Field( 46 + default="http://localhost:5173", 47 + description="Frontend URL for redirects", 48 + ) 49 + 44 50 # atproto 45 51 atproto_pds_url: str = Field( 46 52 default="https://bsky.social",
+4 -1
src/relay/main.py
··· 29 29 # configure CORS 30 30 app.add_middleware( 31 31 CORSMiddleware, 32 - allow_origins=["*"], # configure appropriately for production 32 + allow_origins=[ 33 + "http://localhost:5173", 34 + "https://relay-4i6.pages.dev", 35 + ], 33 36 allow_credentials=True, 34 37 allow_methods=["*"], 35 38 allow_headers=["*"],