Run a giveaway from a bsky post. Choose from those who interacted with it
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>