Run a giveaway from a bsky post. Choose from those who interacted with it
at main 20 kB view raw
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.0, maximum-scale=1.0, user-scalable=no"/> 6 <meta property="og:title" content="at://giveaways 🎉"> 7 <meta property="og:image" content="/images/cover.jpg"> 8 <meta property="og:description" content="Host a giveaway from a Bluesky post."> 9 10 <title>at://giveaways 🎉</title> 11 <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css"/> 12 <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> 13 <script src="https://unpkg.com/alpinejs" defer></script> 14 15 <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"> 16 <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"> 17 <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"> 18 <link rel="manifest" href="/site.webmanifest"> 19 20 <script type="module"> 21 import { 22 CompositeHandleResolver, 23 DohJsonHandleResolver, 24 WellKnownHandleResolver, 25 CompositeDidDocumentResolver, 26 PlcDidDocumentResolver, 27 WebDidDocumentResolver 28 } from 'https://esm.sh/@atcute/identity-resolver'; 29 30 const handleResolver = new CompositeHandleResolver({ 31 strategy: 'race', 32 methods: { 33 dns: new DohJsonHandleResolver({dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query'}), 34 http: new WellKnownHandleResolver(), 35 }, 36 }); 37 38 window.resolveHandle = async (handle) => await handleResolver.resolve(handle); 39 40 const docResolver = new CompositeDidDocumentResolver({ 41 methods: { 42 plc: new PlcDidDocumentResolver(), 43 web: new WebDidDocumentResolver(), 44 }, 45 }); 46 47 window.resolveDidDocument = async (did) => await docResolver.resolve(did); 48 49 </script> 50 51 <script> 52 53 const constellationEndpoint = 'https://constellation.microcosm.blue'; 54 const likesCollection = 'app.bsky.feed.like'; 55 const repostsCollection = 'app.bsky.feed.repost'; 56 57 function getQueryParams() { 58 const params = new URLSearchParams(window.location.search); 59 60 // Get post_url and validate it's a Bluesky URL or AT URI 61 let postUrl = params.get('post_url') || ''; 62 if (postUrl && !postUrl.startsWith('https://bsky.app/') && !postUrl.startsWith('at://')) { 63 console.warn('Invalid post_url parameter. Must be a Bluesky URL or AT URI.'); 64 postUrl = ''; 65 } 66 67 // Get winner_count and ensure it's a positive number 68 let winnerCount = parseInt(params.get('winner_count')) || 1; 69 if (winnerCount < 1) { 70 console.warn('Invalid winner_count parameter. Must be a positive number.'); 71 winnerCount = 1; 72 } 73 74 // Get option and ensure it's one of the valid options 75 let option = params.get('option') || 'likes'; 76 if (option && !['likes', 'reposts', 'both', 'or'].includes(option)) { 77 console.warn('Invalid option parameter. Must be "likes", "reposts", "both", or "or".'); 78 option = 'likes'; 79 } 80 81 return { 82 post_url: postUrl, 83 winner_count: winnerCount, 84 option: option 85 }; 86 } 87 88 async function callConstellationEndpoint(target, collection, path, cursor = null) { 89 try { 90 const url = new URL(`${constellationEndpoint}/links/distinct-dids`); 91 url.searchParams.append('target', target); 92 url.searchParams.append('collection', collection); 93 url.searchParams.append('path', path); 94 url.searchParams.append('limit', '100'); 95 if (cursor) { 96 url.searchParams.append('cursor', cursor); 97 } 98 const response = await fetch(url); 99 if (!response.ok) { 100 throw new Error(`HTTP error! Status: ${response.status}`); 101 } 102 103 return await response.json(); 104 } catch (error) { 105 console.error('Error calling constellation endpoint:', error); 106 throw error; 107 } 108 } 109 110 document.addEventListener('alpine:init', () => { 111 Alpine.data('giveaway', () => ({ 112 //Form input 113 post_url: '', 114 winner_count: 1, 115 likes_only: true, 116 reposts_only: false, 117 likes_and_reposts: false, 118 likes_or_reposts: false, 119 120 error: '', 121 loading: false, 122 winners: [], 123 participants: 0, 124 showResults: false, 125 statusText: '', 126 127 // Initialize component with query parameters 128 init() { 129 const params = getQueryParams(); 130 131 // Set form values from query parameters 132 if (params.post_url) { 133 this.post_url = params.post_url; 134 } 135 136 if (params.winner_count && params.winner_count > 0) { 137 this.winner_count = params.winner_count; 138 } 139 140 // Set winning options based on the option parameter 141 if (params.option) { 142 this.likes_only = params.option === 'likes'; 143 this.reposts_only = params.option === 'reposts'; 144 this.likes_and_reposts = params.option === 'both'; 145 this.likes_or_reposts = params.option === 'or'; 146 } 147 148 // Automatically run the giveaway if post_url is provided 149 if (params.post_url) { 150 // Use setTimeout to ensure the component is fully initialized 151 setTimeout(() => { 152 this.runGiveaway(); 153 }, 100); 154 } 155 }, 156 157 validateCheckBoxes(event) { 158 const targetId = event.target.id; 159 this.likes_only = targetId === 'likes'; 160 this.reposts_only = targetId === 'reposts_only'; 161 this.likes_and_reposts = targetId === 'likes_and_reposts'; 162 this.likes_or_reposts = targetId === 'likes_or_reposts'; 163 }, 164 async runGiveaway() { 165 this.error = ''; 166 this.loading = true; 167 this.winners = []; 168 this.showResults = false; 169 170 try { 171 //Form validation 172 if (this.winner_count < 1) { 173 this.error = 'SOMEBODY has to win'; 174 return; 175 } 176 177 if (!this.likes_only && !this.reposts_only && !this.likes_and_reposts && !this.likes_or_reposts) { 178 this.error = 'Well, you have to pick some way for them to win'; 179 return; 180 } 181 182 let atUri = ''; 183 if (this.post_url.startsWith('at://')) { 184 atUri = this.post_url; 185 } else { 186 //More checks to make sure it's a bsky url 187 if (!this.post_url.startsWith('https://bsky.app/')) { 188 this.error = 'Link to the Bluesky post or at uri please'; 189 return; 190 } 191 const postSplit = this.post_url.split('/'); 192 if (postSplit.length < 7) { 193 this.error = 'Invalid Bluesky post URL. Should look like https://bsky.app/profile/baileytownsend.dev/post/3lbq7o74fcc2d'; 194 return; 195 } 196 try { 197 const handle = postSplit[4]; 198 const recordKey = postSplit[6]; 199 200 let did = await window.resolveHandle(handle); 201 atUri = `at://${did}/app.bsky.feed.post/${recordKey}`; 202 203 } catch (e) { 204 console.log(e); 205 this.error = e.message; 206 return; 207 } 208 } 209 210 211 // Determine which collections to fetch based on user selection 212 const collections = []; 213 if (this.likes_only || this.likes_and_reposts || this.likes_or_reposts) { 214 collections.push(likesCollection); 215 } 216 if (this.reposts_only || this.likes_and_reposts || this.likes_or_reposts) { 217 collections.push(repostsCollection); 218 } 219 220 // Path to extract the subject URI 221 const path = '.subject.uri'; 222 223 // Fetch data for each collection 224 const results = []; 225 for (const collection of collections) { 226 console.log(`Fetching ${collection} data...`); 227 let cursor = null; 228 let pageCount = 1; 229 let displayTypeText = '' 230 let displayTotal = 0; 231 if (collection === likesCollection) { 232 displayTypeText = 'likes' 233 }else if (collection === repostsCollection) { 234 displayTypeText = 'reposts' 235 } 236 else { 237 displayTypeText = 'both' 238 } 239 do { 240 console.log(`Fetching ${collection} data, page ${pageCount}${cursor ? ' with cursor' : ''}...`); 241 const response = await callConstellationEndpoint(atUri, collection, path, cursor); 242 console.log(`${collection} response (page ${pageCount}):`, response); 243 244 if (response && response.linking_dids) { 245 displayTotal += response.linking_dids.length; 246 this.statusText = `${displayTotal}/${response.total} ${displayTypeText} fetched.`; 247 248 let dids = response.linking_dids.map(x => ({ 249 collection: collection, 250 did: x 251 })) 252 results.push(...dids); 253 cursor = response.cursor; 254 pageCount++; 255 } else { 256 cursor = null; 257 } 258 } while (cursor); 259 260 console.log(`Completed fetching ${collection} data, total pages: ${pageCount - 1}`); 261 } 262 263 264 let uniqueDids = []; 265 if (this.likes_only || this.reposts_only) { 266 uniqueDids = results.map(x => x.did); 267 } 268 if (this.likes_and_reposts) { 269 const likesDids = results.filter(x => x.collection === likesCollection).map(x => x.did); 270 const repostsDids = results.filter(x => x.collection === repostsCollection).map(x => x.did); 271 uniqueDids = likesDids.filter(did => repostsDids.includes(did)); 272 } 273 if (this.likes_or_reposts) { 274 uniqueDids = [...new Set(results.map(x => x.did))]; 275 } 276 277 this.participants = uniqueDids.length; 278 // Select winners 279 if (uniqueDids.length === 0) { 280 this.error = 'No participants found for this post'; 281 return; 282 } 283 284 const winnerCount = Math.min(this.winner_count, uniqueDids.length); 285 286 // Randomly select winners 287 for (let i = 0; i < winnerCount; i++) { 288 const randomIndex = Math.floor(Math.random() * uniqueDids.length); 289 try { 290 const didDoc = await window.resolveDidDocument(uniqueDids[randomIndex]); 291 const handle = didDoc.alsoKnownAs[0].replace("at://", "") ?? uniqueDids[randomIndex]; 292 this.winners.push(handle); 293 } catch (e) { 294 console.log(e); 295 this.winners.push(uniqueDids[randomIndex]); 296 } 297 298 // Remove the winner to avoid duplicates 299 uniqueDids.splice(randomIndex, 1); 300 } 301 302 this.showResults = true; 303 304 } catch (error) { 305 console.error('Error in runGiveaway:', error); 306 this.error = `Error fetching data: ${error.message}`; 307 } finally { 308 this.loading = false; 309 } 310 } 311 312 })) 313 }) 314 </script> 315</head> 316 317 318<body> 319<div class="hero bg-base-200 min-h-screen"> 320 <div class="hero-content flex-col "> 321 <div class="text-center"> 322 <h1 class="text-5xl font-bold">at://giveaways 🎉</h1> 323 <p class="py-6"> 324 Pick which Bluesky post you want to use for a giveaway. 325 </p> 326 <div>uses <a class="link" href="https://constellation.microcosm.blue/">constellation 327 🌌</a> 328 powered 329 by 330 <a href="https://microcosm.blue" class="link"><span 331 style="color: rgb(243, 150, 169);">m</span><span style="color: rgb(244, 156, 92);">i</span><span 332 style="color: rgb(199, 176, 76);">c</span><span style="color: rgb(146, 190, 76);">r</span><span 333 style="color: rgb(78, 198, 136);">o</span><span style="color: rgb(81, 194, 182);">c</span><span 334 style="color: rgb(84, 190, 215);">o</span><span style="color: rgb(143, 177, 241);">s</span><span 335 style="color: rgb(206, 157, 241);">m</span></a> 336 </div> 337 </div> 338 <div class="card bg-base-100 w-full max-w-sm shrink-0 shadow-2xl"> 339 <div class="card-body" x-data="giveaway"> 340 <form x-on:submit.prevent="await runGiveaway()"> 341 <fieldset class="fieldset"> 342 <label for="post_url" class="label">Post Url</label> 343 <input x-model="post_url" id="post_url" type="text" class="input" 344 placeholder="https://bsky.app/profile/baileytownsend.dev/post/3lutd557tyk2y"/> 345 <label for="winner_count" class="label">How many winners?</label> 346 <input x-model="winner_count" id="winner_count" type="number" class="input" value="1"/> 347 <fieldset class="fieldset bg-base-100 border-base-300 rounded-box w-64 border p-4"> 348 <legend class="fieldset-legend">Winning options</legend> 349 <label class="label"> 350 <input x-model="likes_only" x-on:change="validateCheckBoxes($event)" 351 id="likes" 352 type="checkbox" 353 checked="checked" 354 class="checkbox"/> 355 Likes only 356 </label> 357 <label class="label"> 358 <input x-model="reposts_only" x-on:change="validateCheckBoxes($event)" id="reposts_only" 359 type="checkbox" 360 class="checkbox"/> 361 Reposts only 362 </label> 363 <label class="label"> 364 <input x-model="likes_and_reposts" x-on:change="validateCheckBoxes($event)" 365 id="likes_and_reposts" 366 type="checkbox" 367 class="checkbox"/> 368 Both Likes & Reposts 369 </label> 370 371 <label class="label"> 372 <input x-model="likes_or_reposts" x-on:change="validateCheckBoxes($event)" 373 id="likes_or_reposts" 374 type="checkbox" 375 class="checkbox"/> 376 Likes OR Reposts 377 </label> 378 379 </fieldset> 380 <span x-show="error" x-text="error" class="text-red-500 text-lg font-bold"></span> 381 <span x-show="statusText && loading" x-text="statusText" class="text-gray-500 text-lg font-bold"></span> 382 <span x-show="statusText && loading" class="text-gray-500 text-sm font-bold">Posts with lots of likes and reposts can take a while. It will show an error if it fails.</span> 383 <button type="submit" class="btn btn-neutral mt-4" x-bind:disabled="loading"> 384 <span x-show="!loading">I choose you!</span> 385 <span x-show="loading" class="loading loading-spinner"></span> 386 </button> 387 </fieldset> 388 </form> 389 390 <!-- Results Section --> 391 <div x-show="showResults" class="mt-6 p-4 bg-base-200 rounded-lg"> 392 <h3 class="text-xl font-bold mb-2">🎉 Winners 🎉</h3> 393 <p class="mb-2">Total participants: <span x-text="participants"></span></p> 394 <ol class="list-decimal pl-5"> 395 <template x-for="winner in winners"> 396 <li class="mb-1"> 397 <a class="link" x-bind:href="`https://bsky.app/profile/${winner}`" x-text="winner"></a> 398 </li> 399 </template> 400 </ol> 401 <a x-bind:href="post_url" class="link">View the giveaway post</a> 402 </div> 403 404 <a href="https://tangled.sh/@baileytownsend.dev/at-giveaways" class="link mt-4 block">View on <span 405 class="font-semibold italic">tangled.sh</span></a> 406 407 <!-- URL Parameters Documentation --> 408 <div class="mt-6 p-4 bg-base-200 rounded-lg text-sm"> 409 <h3 class="text-lg font-bold mb-2">🔗 URL Parameters</h3> 410 <p class="mb-2">You can create links that automatically run the giveaway when the page is loaded with these GET query parameters:</p> 411 <ul class="list-disc pl-5 mb-2"> 412 <li><code>post_url</code>: URL of the Bluesky post</li> 413 <li><code>winner_count</code>: Number of winners (default: 1)</li> 414 <li><code>option</code>: 'likes', 'reposts', 'both', or 'or' (default: 'likes')</li> 415 </ul> 416 417 </div> 418 </div> 419 </div> 420 </div> 421</div> 422</body> 423</html>