highlight reel — bluesky engagement viewer

SvelteKit + Svelte 5 static SPA. enter a handle, see their top posts
ranked by engagement, rendered as bluesky embeds.

- handle typeahead via public bsky API
- progressive rendering (results appear while still scanning)
- localStorage cache (15min TTL)
- embed opt-out detection
- dark theme, amber accents, golden ratio typography

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

+1022
+4
.gitignore
··· 1 + node_modules 2 + build 3 + .svelte-kit 4 + .vite
bun.lockb

This is a binary file and will not be displayed.

+23
package.json
··· 1 + { 2 + "name": "@zzstoatzz.io/bsky-highlight-reel", 3 + "private": true, 4 + "version": "0.0.1", 5 + "type": "module", 6 + "scripts": { 7 + "dev": "vite dev", 8 + "build": "vite build", 9 + "preview": "vite preview", 10 + "prepare": "svelte-kit sync || echo ''", 11 + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 13 + }, 14 + "devDependencies": { 15 + "@sveltejs/adapter-static": "^3.0.8", 16 + "@sveltejs/kit": "^2.49.1", 17 + "@sveltejs/vite-plugin-svelte": "^6.2.1", 18 + "svelte": "^5.45.6", 19 + "svelte-check": "^4.3.4", 20 + "typescript": "^5.9.3", 21 + "vite": "^7.2.6" 22 + } 23 + }
+12
src/app.d.ts
··· 1 + // See https://svelte.dev/docs/kit/types#app.d.ts 2 + declare global { 3 + namespace App { 4 + // interface Error {} 5 + // interface Locals {} 6 + // interface PageData {} 7 + // interface PageState {} 8 + // interface Platform {} 9 + } 10 + } 11 + 12 + export {};
+11
src/app.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + %sveltekit.head% 7 + </head> 8 + <body> 9 + <div style="display: contents">%sveltekit.body%</div> 10 + </body> 11 + </html>
+120
src/lib/api.ts
··· 1 + import type { Post } from './types.js'; 2 + 3 + const API = 'https://public.api.bsky.app/xrpc'; 4 + const CACHE_TTL = 15 * 60 * 1000; // 15 minutes 5 + 6 + interface CacheEntry { 7 + posts: Post[]; 8 + timestamp: number; 9 + } 10 + 11 + function getCached(did: string): Post[] | null { 12 + try { 13 + const raw = localStorage.getItem(`bhr:${did}`); 14 + if (!raw) return null; 15 + const entry: CacheEntry = JSON.parse(raw); 16 + if (Date.now() - entry.timestamp > CACHE_TTL) { 17 + localStorage.removeItem(`bhr:${did}`); 18 + return null; 19 + } 20 + return entry.posts; 21 + } catch { 22 + return null; 23 + } 24 + } 25 + 26 + function setCache(did: string, posts: Post[]) { 27 + try { 28 + localStorage.setItem(`bhr:${did}`, JSON.stringify({ posts, timestamp: Date.now() })); 29 + } catch { 30 + // storage full, no big deal 31 + } 32 + } 33 + 34 + export async function resolveHandle(handle: string): Promise<string> { 35 + const clean = handle.replace(/^@/, ''); 36 + const res = await fetch( 37 + `${API}/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(clean)}` 38 + ); 39 + if (!res.ok) { 40 + throw new Error(`could not resolve handle "${clean}"`); 41 + } 42 + const data = await res.json(); 43 + return data.did; 44 + } 45 + 46 + export async function checkEmbedOptOut(did: string): Promise<boolean> { 47 + const res = await fetch(`${API}/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`); 48 + if (!res.ok) return false; 49 + const data = await res.json(); 50 + const labels: Array<{ val: string }> = data.labels ?? []; 51 + return labels.some((l) => l.val === '!no-unauthenticated'); 52 + } 53 + 54 + export async function fetchAllPosts( 55 + did: string, 56 + onPage?: (posts: Post[], done: boolean) => void 57 + ): Promise<Post[]> { 58 + const cached = getCached(did); 59 + if (cached) { 60 + onPage?.(cached, true); 61 + return cached; 62 + } 63 + 64 + const posts: Post[] = []; 65 + let cursor: string | undefined; 66 + 67 + while (true) { 68 + const params = new URLSearchParams({ 69 + actor: did, 70 + limit: '100', 71 + filter: 'posts_no_replies' 72 + }); 73 + if (cursor) params.set('cursor', cursor); 74 + 75 + const res = await fetch(`${API}/app.bsky.feed.getAuthorFeed?${params}`); 76 + if (!res.ok) { 77 + throw new Error(`failed to fetch posts: ${res.status}`); 78 + } 79 + 80 + const data = await res.json(); 81 + 82 + for (const item of data.feed) { 83 + // skip reposts of other people's content 84 + if (item.reason) continue; 85 + // skip posts by other authors 86 + if (item.post.author.did !== did) continue; 87 + 88 + const post = item.post; 89 + const record = post.record; 90 + const likes = post.likeCount ?? 0; 91 + const reposts = post.repostCount ?? 0; 92 + const quotes = post.quoteCount ?? 0; 93 + const replies = post.replyCount ?? 0; 94 + const rkey = post.uri.split('/').pop()!; 95 + 96 + posts.push({ 97 + text: record.text ?? '', 98 + createdAt: record.createdAt ?? '', 99 + likes, 100 + reposts, 101 + quotes, 102 + replies, 103 + uri: post.uri, 104 + rkey, 105 + handle: post.author.handle, 106 + did: post.author.did, 107 + score: likes + reposts * 2 + quotes * 3 108 + }); 109 + } 110 + 111 + const done = !data.cursor; 112 + onPage?.([...posts], done); 113 + 114 + if (done) break; 115 + cursor = data.cursor; 116 + } 117 + 118 + setCache(did, posts); 119 + return posts; 120 + }
+249
src/lib/components/HandleInput.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + value: string; 4 + onsubmit: (handle: string) => void; 5 + disabled?: boolean; 6 + } 7 + let { value = $bindable(), onsubmit, disabled = false }: Props = $props(); 8 + 9 + let results = $state<Array<{ did: string; handle: string; displayName?: string; avatar?: string }>>([]); 10 + let showResults = $state(false); 11 + let searching = $state(false); 12 + let debounceTimer: ReturnType<typeof setTimeout> | null = $state(null); 13 + let containerEl: HTMLDivElement | undefined = $state(); 14 + 15 + function handleInput() { 16 + if (debounceTimer) clearTimeout(debounceTimer); 17 + 18 + const query = value.trim(); 19 + if (query.length < 2) { 20 + results = []; 21 + showResults = false; 22 + return; 23 + } 24 + 25 + searching = true; 26 + debounceTimer = setTimeout(async () => { 27 + try { 28 + const res = await fetch( 29 + `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=8` 30 + ); 31 + if (res.ok) { 32 + const data = await res.json(); 33 + results = data.actors ?? []; 34 + showResults = results.length > 0; 35 + } 36 + } catch { 37 + results = []; 38 + showResults = false; 39 + } finally { 40 + searching = false; 41 + } 42 + }, 250); 43 + } 44 + 45 + function selectActor(actor: { did: string; handle: string; displayName?: string; avatar?: string }) { 46 + value = actor.handle; 47 + showResults = false; 48 + onsubmit(actor.handle); 49 + } 50 + 51 + function handleClickOutside(e: MouseEvent) { 52 + if (containerEl && !containerEl.contains(e.target as Node)) { 53 + showResults = false; 54 + } 55 + } 56 + </script> 57 + 58 + <svelte:window onclick={handleClickOutside} /> 59 + 60 + <div class="handle-input" role="combobox" aria-controls="handle-results" aria-expanded={showResults && results.length > 0} bind:this={containerEl}> 61 + <form onsubmit={(e) => { e.preventDefault(); showResults = false; onsubmit(value); }}> 62 + <div class="input-row"> 63 + <span class="at">@</span> 64 + <input 65 + type="text" 66 + bind:value 67 + oninput={handleInput} 68 + onfocus={() => { if (results.length > 0) showResults = true; }} 69 + onkeydown={(e) => { if (e.key === 'Escape') showResults = false; }} 70 + placeholder="who are you?" 71 + autocomplete="off" 72 + {disabled} 73 + /> 74 + <button type="submit" disabled={disabled || !value.trim()}> 75 + {#if disabled} 76 + <span class="spinner"></span> 77 + {:else} 78 + 79 + {/if} 80 + </button> 81 + </div> 82 + </form> 83 + 84 + {#if showResults && results.length > 0} 85 + <ul class="results" id="handle-results"> 86 + {#each results as actor (actor.did)} 87 + <li> 88 + <button onclick={(e) => { e.stopPropagation(); selectActor(actor); }}> 89 + {#if actor.avatar} 90 + <img src={actor.avatar} alt="" class="avatar" /> 91 + {:else} 92 + <div class="avatar placeholder"></div> 93 + {/if} 94 + <div class="info"> 95 + {#if actor.displayName} 96 + <span class="name">{actor.displayName}</span> 97 + {/if} 98 + <span class="handle">@{actor.handle}</span> 99 + </div> 100 + </button> 101 + </li> 102 + {/each} 103 + </ul> 104 + {/if} 105 + </div> 106 + 107 + <style> 108 + .handle-input { 109 + position: relative; 110 + width: 100%; 111 + max-width: 400px; 112 + } 113 + 114 + .input-row { 115 + display: flex; 116 + align-items: center; 117 + background: transparent; 118 + border-bottom: 2px solid #333; 119 + transition: border-color 0.2s; 120 + } 121 + .input-row:focus-within { 122 + border-color: #f90; 123 + } 124 + 125 + .at { 126 + color: #555; 127 + font-size: 1.1rem; 128 + font-weight: 300; 129 + padding-right: 0.25rem; 130 + user-select: none; 131 + } 132 + 133 + input { 134 + flex: 1; 135 + background: transparent; 136 + border: none; 137 + outline: none; 138 + color: #e0e0e0; 139 + font-size: 1.1rem; 140 + font-weight: 300; 141 + padding: 0.6rem 0; 142 + font-family: inherit; 143 + } 144 + input::placeholder { 145 + color: #444; 146 + font-style: italic; 147 + } 148 + 149 + .input-row button { 150 + background: none; 151 + border: none; 152 + color: #555; 153 + font-size: 1.2rem; 154 + cursor: pointer; 155 + padding: 0.4rem; 156 + transition: color 0.2s; 157 + } 158 + .input-row button:hover:not(:disabled) { 159 + color: #f90; 160 + } 161 + .input-row button:disabled { 162 + cursor: default; 163 + } 164 + 165 + .spinner { 166 + display: inline-block; 167 + width: 14px; 168 + height: 14px; 169 + border: 2px solid #333; 170 + border-top-color: #f90; 171 + border-radius: 50%; 172 + animation: spin 0.6s linear infinite; 173 + } 174 + @keyframes spin { 175 + to { transform: rotate(360deg); } 176 + } 177 + 178 + .results { 179 + position: absolute; 180 + top: 100%; 181 + left: 0; 182 + right: 0; 183 + margin-top: 0.5rem; 184 + background: #111; 185 + border: 1px solid #222; 186 + border-radius: 8px; 187 + list-style: none; 188 + padding: 0; 189 + max-height: 280px; 190 + overflow-y: auto; 191 + z-index: 100; 192 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); 193 + } 194 + 195 + .results li button { 196 + display: flex; 197 + align-items: center; 198 + gap: 0.75rem; 199 + width: 100%; 200 + padding: 0.6rem 0.75rem; 201 + background: none; 202 + border: none; 203 + color: inherit; 204 + cursor: pointer; 205 + text-align: left; 206 + font-family: inherit; 207 + } 208 + .results li button:hover { 209 + background: #1a1a1a; 210 + } 211 + .results li:first-child button { 212 + border-radius: 8px 8px 0 0; 213 + } 214 + .results li:last-child button { 215 + border-radius: 0 0 8px 8px; 216 + } 217 + 218 + .avatar { 219 + width: 32px; 220 + height: 32px; 221 + border-radius: 50%; 222 + object-fit: cover; 223 + flex-shrink: 0; 224 + } 225 + .avatar.placeholder { 226 + background: #222; 227 + } 228 + 229 + .info { 230 + display: flex; 231 + flex-direction: column; 232 + gap: 0.1rem; 233 + min-width: 0; 234 + } 235 + .name { 236 + font-size: 0.85rem; 237 + color: #ccc; 238 + white-space: nowrap; 239 + overflow: hidden; 240 + text-overflow: ellipsis; 241 + } 242 + .handle { 243 + font-size: 0.75rem; 244 + color: #666; 245 + white-space: nowrap; 246 + overflow: hidden; 247 + text-overflow: ellipsis; 248 + } 249 + </style>
+85
src/lib/state.svelte.ts
··· 1 + import type { Post, SortMode } from './types.js'; 2 + 3 + export function createAppState() { 4 + let handle = $state(''); 5 + let posts: Post[] = $state([]); 6 + let loading = $state(false); 7 + let error: string | null = $state(null); 8 + let progress = $state(0); 9 + let sortMode: SortMode = $state('score'); 10 + let topN = $state(10); 11 + 12 + let sortedPosts = $derived.by(() => { 13 + const sorted = [...posts]; 14 + 15 + switch (sortMode) { 16 + case 'score': 17 + sorted.sort((a, b) => b.score - a.score); 18 + break; 19 + case 'likes': 20 + sorted.sort((a, b) => b.likes - a.likes); 21 + break; 22 + case 'reposts': 23 + sorted.sort((a, b) => b.reposts - a.reposts); 24 + break; 25 + case 'recency': 26 + sorted.sort((a, b) => (b.createdAt > a.createdAt ? 1 : b.createdAt < a.createdAt ? -1 : 0)); 27 + break; 28 + } 29 + 30 + return sorted; 31 + }); 32 + 33 + let displayPosts = $derived(sortedPosts.slice(0, topN)); 34 + 35 + return { 36 + get handle() { 37 + return handle; 38 + }, 39 + set handle(value: string) { 40 + handle = value; 41 + }, 42 + get posts() { 43 + return posts; 44 + }, 45 + set posts(value: Post[]) { 46 + posts = value; 47 + }, 48 + get loading() { 49 + return loading; 50 + }, 51 + set loading(value: boolean) { 52 + loading = value; 53 + }, 54 + get error() { 55 + return error; 56 + }, 57 + set error(value: string | null) { 58 + error = value; 59 + }, 60 + get progress() { 61 + return progress; 62 + }, 63 + set progress(value: number) { 64 + progress = value; 65 + }, 66 + get sortMode() { 67 + return sortMode; 68 + }, 69 + set sortMode(value: SortMode) { 70 + sortMode = value; 71 + }, 72 + get topN() { 73 + return topN; 74 + }, 75 + set topN(value: number) { 76 + topN = value; 77 + }, 78 + get sortedPosts() { 79 + return sortedPosts; 80 + }, 81 + get displayPosts() { 82 + return displayPosts; 83 + } 84 + }; 85 + }
+15
src/lib/types.ts
··· 1 + export interface Post { 2 + text: string; 3 + createdAt: string; 4 + likes: number; 5 + reposts: number; 6 + quotes: number; 7 + replies: number; 8 + uri: string; 9 + rkey: string; 10 + handle: string; 11 + did: string; 12 + score: number; 13 + } 14 + 15 + export type SortMode = 'score' | 'likes' | 'reposts' | 'recency';
+42
src/routes/+layout.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte'; 3 + 4 + let { children }: { children: Snippet } = $props(); 5 + </script> 6 + 7 + <svelte:head> 8 + <title>highlight reel</title> 9 + <meta name="description" content="your best posts, surfaced. enter a bluesky handle, see what hit." /> 10 + <meta property="og:title" content="highlight reel" /> 11 + <meta property="og:description" content="your best posts, surfaced" /> 12 + <meta property="og:image" content="/og.png" /> 13 + <meta property="og:type" content="website" /> 14 + <meta name="twitter:card" content="summary_large_image" /> 15 + <meta name="twitter:title" content="highlight reel" /> 16 + <meta name="twitter:description" content="your best posts, surfaced" /> 17 + <meta name="twitter:image" content="/og.png" /> 18 + <meta name="theme-color" content="#0a0a0a" /> 19 + <link rel="icon" href="/favicon.svg" /> 20 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 21 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" /> 22 + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600&display=swap" rel="stylesheet" /> 23 + </svelte:head> 24 + 25 + {@render children()} 26 + 27 + <style> 28 + :global(*) { 29 + margin: 0; 30 + padding: 0; 31 + box-sizing: border-box; 32 + } 33 + 34 + :global(body) { 35 + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 36 + background: #0a0a0a; 37 + color: #e0e0e0; 38 + min-height: 100dvh; 39 + -webkit-font-smoothing: antialiased; 40 + -moz-osx-font-smoothing: grayscale; 41 + } 42 + </style>
+2
src/routes/+layout.ts
··· 1 + export const prerender = false; 2 + export const ssr = false;
+420
src/routes/+page.svelte
··· 1 + <script lang="ts"> 2 + import { resolveHandle, fetchAllPosts, checkEmbedOptOut } from '$lib/api.js'; 3 + import { createAppState } from '$lib/state.svelte.js'; 4 + import type { SortMode } from '$lib/types.js'; 5 + import HandleInput from '$lib/components/HandleInput.svelte'; 6 + 7 + const app = createAppState(); 8 + const sortModes: SortMode[] = ['score', 'likes', 'reposts', 'recency']; 9 + let optedOut = $state(false); 10 + let scanning = $state(false); 11 + 12 + function goHome() { 13 + app.posts = []; 14 + app.error = null; 15 + app.handle = ''; 16 + optedOut = false; 17 + scanning = false; 18 + } 19 + 20 + async function handleSubmit(handle: string) { 21 + const clean = handle.replace(/^@/, '').trim(); 22 + if (!clean) return; 23 + app.handle = clean; 24 + app.loading = true; 25 + app.error = null; 26 + app.progress = 0; 27 + app.posts = []; 28 + optedOut = false; 29 + scanning = false; 30 + 31 + try { 32 + const did = await resolveHandle(clean); 33 + if (await checkEmbedOptOut(did)) { 34 + optedOut = true; 35 + app.loading = false; 36 + return; 37 + } 38 + scanning = true; 39 + await fetchAllPosts(did, (posts, done) => { 40 + app.posts = posts; 41 + app.progress = posts.length; 42 + if (done) { 43 + scanning = false; 44 + app.loading = false; 45 + } 46 + }); 47 + } catch (e) { 48 + app.error = (e as Error).message; 49 + scanning = false; 50 + app.loading = false; 51 + } 52 + } 53 + 54 + $effect(() => { 55 + app.displayPosts; 56 + const old = document.querySelector('script[src*="embed.bsky.app"]'); 57 + if (old) old.remove(); 58 + const script = document.createElement('script'); 59 + script.src = 'https://embed.bsky.app/static/embed.js'; 60 + script.async = true; 61 + document.body.appendChild(script); 62 + }); 63 + </script> 64 + 65 + <div class="page" class:has-results={app.posts.length > 0 || optedOut}> 66 + <div class="hero"> 67 + {#if app.posts.length > 0 || optedOut} 68 + <button class="title-btn" onclick={goHome}><h1>highlight reel</h1></button> 69 + {:else} 70 + <h1>highlight reel</h1> 71 + {/if} 72 + <p class="tagline">your best posts, surfaced</p> 73 + <HandleInput bind:value={app.handle} onsubmit={handleSubmit} disabled={app.loading && !scanning} /> 74 + </div> 75 + 76 + {#if app.loading && !scanning} 77 + <div class="status"> 78 + <span class="dot"></span> 79 + resolving... 80 + </div> 81 + {/if} 82 + 83 + {#if app.error} 84 + <div class="status error">{app.error}</div> 85 + {/if} 86 + 87 + {#if optedOut} 88 + <div class="opted-out"> 89 + <p>@{app.handle} has opted out of external embeds</p> 90 + <p class="opted-out-sub">their posts can only be viewed on bluesky</p> 91 + <a href="https://bsky.app/profile/{app.handle}" target="_blank" rel="noopener noreferrer" class="bsky-link"> 92 + view on bluesky 93 + </a> 94 + </div> 95 + {/if} 96 + 97 + {#if app.posts.length > 0} 98 + <div class="results"> 99 + <div class="results-header"> 100 + <span class="results-summary"> 101 + @{app.handle} · {app.posts.length} posts{#if scanning}<span class="scanning"> · <span class="dot-inline"></span> scanning</span>{/if} 102 + </span> 103 + </div> 104 + 105 + <div class="controls"> 106 + <div class="sort-buttons"> 107 + {#each sortModes as mode (mode)} 108 + <button 109 + class:active={app.sortMode === mode} 110 + onclick={() => { app.sortMode = mode; }} 111 + > 112 + {mode} 113 + </button> 114 + {/each} 115 + </div> 116 + <label class="top-n"> 117 + showing 118 + <input type="number" min={1} max={app.posts.length} bind:value={app.topN} /> 119 + </label> 120 + </div> 121 + 122 + <div class="posts"> 123 + {#each app.displayPosts as post, i (post.rkey)} 124 + <div class="post-card"> 125 + <div class="post-meta"> 126 + <span class="rank">#{i + 1}</span> 127 + <span class="engagement">{post.likes}</span> 128 + <span class="engagement-label">likes</span> 129 + <span class="sep">·</span> 130 + <span class="engagement-secondary">{post.reposts}</span> 131 + <span class="engagement-label">reposts</span> 132 + <span class="sep">·</span> 133 + <span class="engagement-secondary">{post.quotes}</span> 134 + <span class="engagement-label">quotes</span> 135 + </div> 136 + <blockquote class="bluesky-embed" data-bluesky-uri="at://{post.did}/app.bsky.feed.post/{post.rkey}"> 137 + <a href="https://bsky.app/profile/{post.handle}/post/{post.rkey}">view on bluesky</a> 138 + </blockquote> 139 + </div> 140 + {/each} 141 + </div> 142 + </div> 143 + {/if} 144 + 145 + {#if app.posts.length === 0 && !app.loading && !optedOut && !scanning} 146 + <footer> 147 + made with <a href="https://microcosm.blue" target="_blank" rel="noopener noreferrer">microcosm</a> 148 + by <a href="https://bsky.app/profile/zzstoatzz.bsky.social" target="_blank" rel="noopener noreferrer">@zzstoatzz</a> 149 + </footer> 150 + {/if} 151 + </div> 152 + 153 + <style> 154 + .page { 155 + min-height: 100dvh; 156 + display: flex; 157 + flex-direction: column; 158 + align-items: center; 159 + justify-content: center; 160 + padding: 2rem 1rem; 161 + transition: justify-content 0.3s; 162 + } 163 + .page.has-results { 164 + justify-content: flex-start; 165 + padding-top: 3rem; 166 + } 167 + 168 + .hero { 169 + display: flex; 170 + flex-direction: column; 171 + align-items: center; 172 + gap: 0.618rem; 173 + margin-bottom: 2.618rem; 174 + } 175 + 176 + h1 { 177 + font-size: 2.618rem; 178 + font-weight: 200; 179 + letter-spacing: -0.03em; 180 + color: #fff; 181 + } 182 + .title-btn { 183 + all: unset; 184 + cursor: pointer; 185 + display: inline; 186 + } 187 + .title-btn:hover h1 { 188 + opacity: 0.6; 189 + } 190 + .title-btn h1 { 191 + transition: opacity 0.15s; 192 + } 193 + 194 + .tagline { 195 + font-size: 0.85rem; 196 + color: #444; 197 + font-weight: 300; 198 + letter-spacing: 0.08em; 199 + margin-bottom: 1.618rem; 200 + } 201 + 202 + .status { 203 + display: flex; 204 + align-items: center; 205 + gap: 0.618rem; 206 + color: #555; 207 + font-size: 0.85rem; 208 + font-weight: 300; 209 + letter-spacing: 0.02em; 210 + margin-bottom: 1.618rem; 211 + } 212 + .status.error { 213 + color: #e55; 214 + } 215 + 216 + .dot { 217 + width: 6px; 218 + height: 6px; 219 + border-radius: 50%; 220 + background: #f90; 221 + animation: pulse 1.2s ease-in-out infinite; 222 + } 223 + @keyframes pulse { 224 + 0%, 100% { opacity: 0.2; } 225 + 50% { opacity: 1; } 226 + } 227 + 228 + .results { 229 + width: 100%; 230 + max-width: 600px; 231 + } 232 + 233 + .results-header { 234 + text-align: center; 235 + margin-bottom: 1.618rem; 236 + } 237 + .results-summary { 238 + font-size: 0.8rem; 239 + color: #444; 240 + font-weight: 300; 241 + letter-spacing: 0.03em; 242 + } 243 + .scanning { 244 + color: #f90; 245 + } 246 + .dot-inline { 247 + display: inline-block; 248 + width: 5px; 249 + height: 5px; 250 + border-radius: 50%; 251 + background: #f90; 252 + animation: pulse 1.2s ease-in-out infinite; 253 + vertical-align: middle; 254 + margin-right: 0.2rem; 255 + } 256 + 257 + .controls { 258 + display: flex; 259 + justify-content: space-between; 260 + align-items: center; 261 + margin-bottom: 1.618rem; 262 + gap: 1rem; 263 + flex-wrap: wrap; 264 + } 265 + 266 + .sort-buttons { 267 + display: flex; 268 + gap: 0.25rem; 269 + } 270 + .sort-buttons button { 271 + padding: 0.35rem 0.7rem; 272 + background: transparent; 273 + border: 1px solid #1a1a1a; 274 + border-radius: 4px; 275 + color: #444; 276 + font-size: 0.75rem; 277 + font-weight: 400; 278 + cursor: pointer; 279 + transition: all 0.15s; 280 + font-family: inherit; 281 + letter-spacing: 0.02em; 282 + } 283 + .sort-buttons button:hover { 284 + border-color: #333; 285 + color: #888; 286 + } 287 + .sort-buttons button.active { 288 + border-color: #f90; 289 + color: #f90; 290 + } 291 + 292 + .top-n { 293 + display: flex; 294 + align-items: center; 295 + gap: 0.4rem; 296 + color: #444; 297 + font-size: 0.75rem; 298 + font-weight: 300; 299 + } 300 + .top-n input { 301 + width: 50px; 302 + padding: 0.3rem 0.4rem; 303 + background: transparent; 304 + border: 1px solid #1a1a1a; 305 + border-radius: 4px; 306 + color: #888; 307 + font-size: 0.75rem; 308 + text-align: center; 309 + font-family: inherit; 310 + outline: none; 311 + } 312 + .top-n input:focus { 313 + border-color: #f90; 314 + } 315 + 316 + .posts { 317 + display: flex; 318 + flex-direction: column; 319 + gap: 1rem; 320 + } 321 + 322 + .post-card { 323 + border: 1px solid #141414; 324 + border-radius: 8px; 325 + padding: 1rem; 326 + transition: border-color 0.2s; 327 + } 328 + .post-card:hover { 329 + border-color: #222; 330 + } 331 + 332 + .post-meta { 333 + display: flex; 334 + align-items: baseline; 335 + gap: 0.35rem; 336 + margin-bottom: 0.618rem; 337 + font-size: 0.75rem; 338 + font-variant-numeric: tabular-nums; 339 + } 340 + 341 + .rank { 342 + color: #555; 343 + font-weight: 500; 344 + margin-right: 0.25rem; 345 + } 346 + 347 + .engagement { 348 + color: #f90; 349 + font-weight: 600; 350 + font-size: 0.85rem; 351 + } 352 + 353 + .engagement-secondary { 354 + color: #666; 355 + font-weight: 500; 356 + } 357 + 358 + .engagement-label { 359 + color: #333; 360 + font-weight: 300; 361 + } 362 + 363 + .sep { 364 + color: #222; 365 + margin: 0 0.1rem; 366 + } 367 + 368 + .opted-out { 369 + text-align: center; 370 + max-width: 400px; 371 + } 372 + .opted-out p { 373 + font-size: 0.9rem; 374 + font-weight: 300; 375 + color: #888; 376 + } 377 + .opted-out-sub { 378 + font-size: 0.8rem !important; 379 + color: #555 !important; 380 + margin-top: 0.3rem; 381 + } 382 + .bsky-link { 383 + display: inline-block; 384 + margin-top: 1.2rem; 385 + padding: 0.5rem 1.2rem; 386 + border: 1px solid #1a1a1a; 387 + border-radius: 6px; 388 + color: #f90; 389 + text-decoration: none; 390 + font-size: 0.8rem; 391 + font-weight: 400; 392 + letter-spacing: 0.02em; 393 + transition: border-color 0.15s; 394 + } 395 + .bsky-link:hover { 396 + border-color: #f90; 397 + } 398 + 399 + footer { 400 + position: absolute; 401 + bottom: 2.618rem; 402 + font-size: 0.7rem; 403 + font-weight: 300; 404 + color: #2a2a2a; 405 + letter-spacing: 0.03em; 406 + } 407 + footer a { 408 + color: #383838; 409 + text-decoration: none; 410 + transition: color 0.15s; 411 + } 412 + footer a:hover { 413 + color: #666; 414 + } 415 + 416 + :global(.bluesky-embed) { 417 + border: none !important; 418 + margin: 0 !important; 419 + } 420 + </style>
+3
static/favicon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"> 2 + <text y="0.9em" font-size="80">🦋</text> 3 + </svg>
static/og.png

This is a binary file and will not be displayed.

+15
svelte.config.js
··· 1 + import adapter from '@sveltejs/adapter-static'; 2 + import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 + 4 + /** @type {import('@sveltejs/kit').Config} */ 5 + const config = { 6 + preprocess: vitePreprocess(), 7 + 8 + kit: { 9 + adapter: adapter({ 10 + fallback: 'index.html' 11 + }) 12 + } 13 + }; 14 + 15 + export default config;
+15
tsconfig.json
··· 1 + { 2 + "extends": "./.svelte-kit/tsconfig.json", 3 + "compilerOptions": { 4 + "rewriteRelativeImportExtensions": true, 5 + "allowJs": true, 6 + "checkJs": true, 7 + "esModuleInterop": true, 8 + "forceConsistentCasingInFileNames": true, 9 + "resolveJsonModule": true, 10 + "skipLibCheck": true, 11 + "sourceMap": true, 12 + "strict": true, 13 + "moduleResolution": "bundler" 14 + } 15 + }
+6
vite.config.ts
··· 1 + import { sveltekit } from '@sveltejs/kit/vite'; 2 + import { defineConfig } from 'vite'; 3 + 4 + export default defineConfig({ 5 + plugins: [sveltekit()] 6 + });