Tools for the Atmosphere tools.slices.network
quickslice atproto html

init

+1068
+9
.editorconfig
··· 1 + root = true 2 + 3 + [*] 4 + indent_style = space 5 + indent_size = 2 6 + end_of_line = lf 7 + charset = utf-8 8 + trim_trailing_whitespace = true 9 + insert_final_newline = true
+1
.gitignore
··· 1 + .env
+7
README.md
··· 1 + # tools.slices.network 2 + 3 + Tools for the Atmosphere, inspired by [Simon Willison's HTML tools](https://simonwillison.net/2025/Dec/10/html-tools/) 4 + 5 + ## Tools 6 + 7 + - [statusphere](https://tools.slices.network/statusphere) - Statusphere client
+218
deploy.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + # Bunny CDN Deploy Script for tools.slices.network 5 + # Syncs .html files to Bunny Storage with clean URLs (no .html extension) 6 + 7 + # Colors for output 8 + RED='\033[0;31m' 9 + GREEN='\033[0;32m' 10 + YELLOW='\033[1;33m' 11 + NC='\033[0m' # No Color 12 + 13 + # Counters 14 + UPLOADED=0 15 + DELETED=0 16 + 17 + # Parse arguments 18 + DRY_RUN=false 19 + VERBOSE=false 20 + 21 + while [[ $# -gt 0 ]]; do 22 + case $1 in 23 + --dry-run) 24 + DRY_RUN=true 25 + shift 26 + ;; 27 + --verbose|-v) 28 + VERBOSE=true 29 + shift 30 + ;; 31 + *) 32 + echo -e "${RED}Unknown option: $1${NC}" 33 + exit 1 34 + ;; 35 + esac 36 + done 37 + 38 + # Load .env file if it exists 39 + if [ -f .env ]; then 40 + set -a 41 + source .env 42 + set +a 43 + fi 44 + 45 + # Required environment variables 46 + : "${BUNNY_API_KEY:?BUNNY_API_KEY environment variable is required}" 47 + : "${BUNNY_STORAGE_PASSWORD:?BUNNY_STORAGE_PASSWORD environment variable is required}" 48 + : "${BUNNY_STORAGE_ZONE:?BUNNY_STORAGE_ZONE environment variable is required}" 49 + : "${BUNNY_STORAGE_HOST:?BUNNY_STORAGE_HOST environment variable is required}" 50 + : "${BUNNY_PULLZONE_ID:?BUNNY_PULLZONE_ID environment variable is required}" 51 + 52 + # Configuration 53 + STORAGE_URL="https://${BUNNY_STORAGE_HOST}/${BUNNY_STORAGE_ZONE}" 54 + 55 + echo "Bunny CDN Deploy - tools.slices.network" 56 + echo "========================================" 57 + echo "Storage Zone: ${BUNNY_STORAGE_ZONE}" 58 + if [ "$DRY_RUN" = true ]; then 59 + echo -e "${YELLOW}DRY RUN MODE - No changes will be made${NC}" 60 + fi 61 + echo "" 62 + 63 + # Upload a single file (strips .html extension for clean URLs) 64 + upload_file() { 65 + local local_path="$1" 66 + local filename 67 + filename=$(basename "$local_path") 68 + # Strip .html extension for clean URLs 69 + local remote_name="${filename%.html}" 70 + 71 + if [ "$VERBOSE" = true ]; then 72 + echo " Uploading: ${filename} -> ${remote_name}" 73 + fi 74 + 75 + if [ "$DRY_RUN" = true ]; then 76 + ((UPLOADED++)) 77 + return 0 78 + fi 79 + 80 + local response 81 + local http_code 82 + 83 + response=$(curl -s -w "\n%{http_code}" -X PUT \ 84 + "${STORAGE_URL}/${remote_name}" \ 85 + -H "AccessKey: ${BUNNY_STORAGE_PASSWORD}" \ 86 + -H "Content-Type: text/html" \ 87 + --data-binary "@${local_path}") 88 + 89 + http_code=$(echo "$response" | tail -n1) 90 + 91 + if [[ "$http_code" =~ ^2 ]]; then 92 + ((UPLOADED++)) 93 + return 0 94 + else 95 + echo -e "${RED}Failed to upload ${filename}: HTTP ${http_code}${NC}" 96 + echo "$response" | head -n -1 97 + return 1 98 + fi 99 + } 100 + 101 + # List all files in remote storage 102 + list_remote_files() { 103 + local response 104 + response=$(curl -s -X GET "${STORAGE_URL}/" \ 105 + -H "AccessKey: ${BUNNY_STORAGE_PASSWORD}" \ 106 + -H "Accept: application/json") 107 + 108 + echo "$response" | jq -r '.[] | select(.IsDirectory == false) | .ObjectName' 2>/dev/null 109 + } 110 + 111 + # Delete a single file from remote 112 + delete_file() { 113 + local remote_name="$1" 114 + 115 + if [ "$VERBOSE" = true ]; then 116 + echo " Deleting: ${remote_name}" 117 + fi 118 + 119 + if [ "$DRY_RUN" = true ]; then 120 + ((DELETED++)) 121 + return 0 122 + fi 123 + 124 + local http_code 125 + http_code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ 126 + "${STORAGE_URL}/${remote_name}" \ 127 + -H "AccessKey: ${BUNNY_STORAGE_PASSWORD}") 128 + 129 + if [[ "$http_code" =~ ^2 ]]; then 130 + ((DELETED++)) 131 + return 0 132 + else 133 + echo -e "${RED}Failed to delete ${remote_name}: HTTP ${http_code}${NC}" 134 + return 1 135 + fi 136 + } 137 + 138 + # Purge pull zone cache 139 + purge_cache() { 140 + echo "Purging CDN cache..." 141 + 142 + if [ "$DRY_RUN" = true ]; then 143 + echo -e "${YELLOW} Would purge pull zone ${BUNNY_PULLZONE_ID}${NC}" 144 + return 0 145 + fi 146 + 147 + local http_code 148 + http_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ 149 + "https://api.bunny.net/pullzone/${BUNNY_PULLZONE_ID}/purgeCache" \ 150 + -H "AccessKey: ${BUNNY_API_KEY}" \ 151 + -H "Content-Type: application/json") 152 + 153 + if [[ "$http_code" =~ ^2 ]]; then 154 + echo -e "${GREEN} Cache purged successfully${NC}" 155 + return 0 156 + else 157 + echo -e "${RED} Failed to purge cache: HTTP ${http_code}${NC}" 158 + return 1 159 + fi 160 + } 161 + 162 + # ============================================ 163 + # MAIN EXECUTION 164 + # ============================================ 165 + 166 + # Find all .html files in root directory 167 + HTML_FILES=$(find . -maxdepth 1 -name "*.html" -type f 2>/dev/null) 168 + 169 + if [ -z "$HTML_FILES" ]; then 170 + echo -e "${YELLOW}No .html files found in current directory${NC}" 171 + exit 0 172 + fi 173 + 174 + # Build list of expected remote names (without .html extension) 175 + LOCAL_NAMES_LIST=$(mktemp) 176 + trap "rm -f $LOCAL_NAMES_LIST" EXIT 177 + 178 + # Step 1: Upload all local .html files 179 + echo "Uploading files..." 180 + echo "$HTML_FILES" | while read -r file; do 181 + filename=$(basename "$file") 182 + remote_name="${filename%.html}" 183 + echo "$remote_name" >> "$LOCAL_NAMES_LIST" 184 + upload_file "$file" 185 + done 186 + 187 + echo "" 188 + 189 + # Step 2: Delete orphaned remote files 190 + echo "Checking for orphaned files..." 191 + REMOTE_FILES=$(list_remote_files) 192 + 193 + if [ -n "$REMOTE_FILES" ]; then 194 + while IFS= read -r remote_file; do 195 + if [ -z "$remote_file" ]; then 196 + continue 197 + fi 198 + if ! grep -qxF "$remote_file" "$LOCAL_NAMES_LIST"; then 199 + delete_file "$remote_file" 200 + fi 201 + done <<< "$REMOTE_FILES" 202 + fi 203 + 204 + echo "" 205 + 206 + # Step 3: Purge CDN cache 207 + purge_cache 208 + 209 + # Summary 210 + echo "" 211 + echo "========================================" 212 + echo -e "${GREEN}Deploy complete!${NC}" 213 + echo " Uploaded: ${UPLOADED} files" 214 + echo " Deleted: ${DELETED} files" 215 + if [ "$DRY_RUN" = true ]; then 216 + echo -e "${YELLOW} (DRY RUN - no actual changes made)${NC}" 217 + fi 218 + echo "========================================"
+833
statusphere.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.0" /> 6 + <meta 7 + http-equiv="Content-Security-Policy" 8 + content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; connect-src 'self' https://xyzstatusphere.slices.network https://*.webcontainer.io; img-src 'self' https: data:;" 9 + /> 10 + <title>Statusphere</title> 11 + <style> 12 + /* CSS Reset */ 13 + *, 14 + *::before, 15 + *::after { 16 + box-sizing: border-box; 17 + } 18 + * { 19 + margin: 0; 20 + } 21 + body { 22 + line-height: 1.5; 23 + -webkit-font-smoothing: antialiased; 24 + } 25 + input, 26 + button { 27 + font: inherit; 28 + } 29 + 30 + /* CSS Variables */ 31 + :root { 32 + --primary-500: #0078ff; 33 + --primary-400: #339dff; 34 + --primary-600: #0060cc; 35 + --gray-100: #f5f5f5; 36 + --gray-200: #e5e5e5; 37 + --gray-500: #737373; 38 + --gray-700: #404040; 39 + --gray-900: #171717; 40 + --border-color: #e5e5e5; 41 + --error-bg: #fef2f2; 42 + --error-border: #fecaca; 43 + --error-text: #dc2626; 44 + } 45 + 46 + /* Layout */ 47 + body { 48 + font-family: 49 + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 50 + background: var(--gray-100); 51 + color: var(--gray-900); 52 + min-height: 100vh; 53 + padding: 2rem 1rem; 54 + } 55 + 56 + #app { 57 + max-width: 600px; 58 + margin: 0 auto; 59 + } 60 + 61 + /* Header */ 62 + header { 63 + text-align: center; 64 + margin-bottom: 2rem; 65 + } 66 + 67 + header h1 { 68 + font-size: 2.5rem; 69 + color: var(--primary-500); 70 + margin-bottom: 0.25rem; 71 + } 72 + 73 + .tagline { 74 + color: var(--gray-500); 75 + font-size: 1rem; 76 + } 77 + 78 + /* Cards */ 79 + .card { 80 + background: white; 81 + border-radius: 0.5rem; 82 + padding: 1.5rem; 83 + margin-bottom: 1rem; 84 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 85 + } 86 + 87 + /* Auth Section */ 88 + .login-form { 89 + display: flex; 90 + flex-direction: column; 91 + gap: 1rem; 92 + } 93 + 94 + .form-group { 95 + display: flex; 96 + flex-direction: column; 97 + gap: 0.25rem; 98 + } 99 + 100 + .form-group label { 101 + font-size: 0.875rem; 102 + font-weight: 500; 103 + color: var(--gray-700); 104 + } 105 + 106 + .form-group input { 107 + padding: 0.75rem; 108 + border: 1px solid var(--border-color); 109 + border-radius: 0.375rem; 110 + font-size: 1rem; 111 + } 112 + 113 + .form-group input:focus { 114 + outline: none; 115 + border-color: var(--primary-500); 116 + box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.1); 117 + } 118 + 119 + .btn { 120 + padding: 0.75rem 1.5rem; 121 + border: none; 122 + border-radius: 0.375rem; 123 + font-size: 1rem; 124 + font-weight: 500; 125 + cursor: pointer; 126 + transition: background-color 0.15s; 127 + } 128 + 129 + .btn-primary { 130 + background: var(--primary-500); 131 + color: white; 132 + } 133 + 134 + .btn-primary:hover { 135 + background: var(--primary-600); 136 + } 137 + 138 + .btn-primary:disabled { 139 + background: var(--gray-200); 140 + color: var(--gray-500); 141 + cursor: not-allowed; 142 + } 143 + 144 + .btn-secondary { 145 + background: var(--gray-200); 146 + color: var(--gray-700); 147 + } 148 + 149 + .btn-secondary:hover { 150 + background: var(--border-color); 151 + } 152 + 153 + /* User Card */ 154 + .user-card { 155 + display: flex; 156 + align-items: center; 157 + justify-content: space-between; 158 + } 159 + 160 + .user-info { 161 + display: flex; 162 + align-items: center; 163 + gap: 0.75rem; 164 + } 165 + 166 + .user-avatar { 167 + width: 48px; 168 + height: 48px; 169 + border-radius: 50%; 170 + background: var(--gray-200); 171 + display: flex; 172 + align-items: center; 173 + justify-content: center; 174 + font-size: 1.5rem; 175 + } 176 + 177 + .user-avatar img { 178 + width: 100%; 179 + height: 100%; 180 + border-radius: 50%; 181 + object-fit: cover; 182 + } 183 + 184 + .user-name { 185 + font-weight: 600; 186 + } 187 + 188 + .user-handle { 189 + font-size: 0.875rem; 190 + color: var(--gray-500); 191 + } 192 + 193 + /* Emoji Picker */ 194 + .emoji-grid { 195 + display: grid; 196 + grid-template-columns: repeat(9, 1fr); 197 + gap: 0.5rem; 198 + } 199 + 200 + .emoji-btn { 201 + width: 100%; 202 + aspect-ratio: 1; 203 + font-size: 1.5rem; 204 + border: 2px solid var(--border-color); 205 + border-radius: 50%; 206 + background: white; 207 + cursor: pointer; 208 + transition: all 0.15s; 209 + display: flex; 210 + align-items: center; 211 + justify-content: center; 212 + } 213 + 214 + .emoji-btn:hover { 215 + background: rgba(0, 120, 255, 0.1); 216 + border-color: var(--primary-400); 217 + } 218 + 219 + .emoji-btn.selected { 220 + border-color: var(--primary-500); 221 + box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.2); 222 + } 223 + 224 + .emoji-btn:disabled { 225 + opacity: 0.5; 226 + cursor: not-allowed; 227 + } 228 + 229 + .emoji-btn:disabled:hover { 230 + background: white; 231 + border-color: var(--border-color); 232 + } 233 + 234 + /* Status Feed */ 235 + .feed-title { 236 + font-size: 1.125rem; 237 + font-weight: 600; 238 + margin-bottom: 1rem; 239 + color: var(--gray-700); 240 + } 241 + 242 + .status-list { 243 + list-style: none; 244 + padding: 0; 245 + } 246 + 247 + .status-item { 248 + position: relative; 249 + padding-left: 2rem; 250 + padding-bottom: 1.5rem; 251 + } 252 + 253 + .status-item::before { 254 + content: ""; 255 + position: absolute; 256 + left: 0.75rem; 257 + top: 1.5rem; 258 + bottom: 0; 259 + width: 2px; 260 + background: var(--border-color); 261 + } 262 + 263 + .status-item:last-child::before { 264 + display: none; 265 + } 266 + 267 + .status-item:last-child { 268 + padding-bottom: 0; 269 + } 270 + 271 + .status-emoji { 272 + position: absolute; 273 + left: 0; 274 + top: 0; 275 + font-size: 1.5rem; 276 + } 277 + 278 + .status-content { 279 + padding-top: 0.25rem; 280 + } 281 + 282 + .status-author { 283 + color: var(--primary-500); 284 + text-decoration: none; 285 + font-weight: 500; 286 + } 287 + 288 + .status-author:hover { 289 + text-decoration: underline; 290 + } 291 + 292 + .status-text { 293 + color: var(--gray-700); 294 + } 295 + 296 + .status-date { 297 + font-size: 0.875rem; 298 + color: var(--gray-500); 299 + } 300 + 301 + /* Error Banner */ 302 + #error-banner { 303 + position: fixed; 304 + top: 1rem; 305 + left: 50%; 306 + transform: translateX(-50%); 307 + background: var(--error-bg); 308 + border: 1px solid var(--error-border); 309 + color: var(--error-text); 310 + padding: 0.75rem 1rem; 311 + border-radius: 0.375rem; 312 + display: flex; 313 + align-items: center; 314 + gap: 0.75rem; 315 + max-width: 90%; 316 + z-index: 100; 317 + } 318 + 319 + #error-banner.hidden { 320 + display: none; 321 + } 322 + 323 + #error-banner button { 324 + background: none; 325 + border: none; 326 + color: var(--error-text); 327 + cursor: pointer; 328 + font-size: 1.25rem; 329 + line-height: 1; 330 + } 331 + 332 + /* Loading State */ 333 + .loading { 334 + text-align: center; 335 + color: var(--gray-500); 336 + padding: 2rem; 337 + } 338 + 339 + /* Responsive */ 340 + @media (max-width: 480px) { 341 + .emoji-grid { 342 + grid-template-columns: repeat(6, 1fr); 343 + } 344 + 345 + .emoji-btn { 346 + font-size: 1.25rem; 347 + } 348 + } 349 + 350 + /* Hidden utility */ 351 + .hidden { 352 + display: none !important; 353 + } 354 + </style> 355 + </head> 356 + <body> 357 + <div id="app"> 358 + <header> 359 + <h1>Statusphere</h1> 360 + <p class="tagline">Set your status on the Atmosphere</p> 361 + </header> 362 + <main> 363 + <div id="auth-section"></div> 364 + <div id="emoji-picker"></div> 365 + <div id="status-feed"></div> 366 + </main> 367 + <div id="error-banner" class="hidden"></div> 368 + </div> 369 + 370 + <!-- Quickslice Client SDK --> 371 + <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script> 372 + 373 + <script> 374 + // ============================================================================= 375 + // CONFIGURATION 376 + // ============================================================================= 377 + 378 + const SERVER_URL = "https://xyzstatusphere.slices.network"; 379 + const CLIENT_ID = "client_vPEnCW98y5BNr5PrYOHPXg"; // Set your OAuth client ID here 380 + 381 + const EMOJIS = [ 382 + "👍", 383 + "👎", 384 + "💙", 385 + "😧", 386 + "😤", 387 + "🙃", 388 + "😉", 389 + "😎", 390 + "🤩", 391 + "🥳", 392 + "😭", 393 + "😱", 394 + "🥺", 395 + "😡", 396 + "💀", 397 + "🤖", 398 + "👻", 399 + "👽", 400 + "🎃", 401 + "🤡", 402 + "💩", 403 + "🔥", 404 + "⭐", 405 + "🌈", 406 + "🍕", 407 + "🎉", 408 + "💯", 409 + ]; 410 + 411 + // Client instance 412 + let client; 413 + 414 + // ============================================================================= 415 + // INITIALIZATION 416 + // ============================================================================= 417 + 418 + async function main() { 419 + // Check if this is an OAuth callback 420 + if (window.location.search.includes("code=")) { 421 + if (!CLIENT_ID) { 422 + showError( 423 + "OAuth callback received but CLIENT_ID is not configured.", 424 + ); 425 + renderLoginForm(); 426 + return; 427 + } 428 + 429 + try { 430 + client = await QuicksliceClient.createQuicksliceClient({ 431 + server: SERVER_URL, 432 + clientId: CLIENT_ID, 433 + }); 434 + await client.handleRedirectCallback(); 435 + console.log("OAuth callback handled successfully"); 436 + } catch (error) { 437 + console.error("OAuth callback error:", error); 438 + showError(`Authentication failed: ${error.message}`); 439 + renderLoginForm(); 440 + renderEmojiPicker(null, false); 441 + await loadAndRenderStatuses(); 442 + return; 443 + } 444 + } else if (CLIENT_ID) { 445 + // Initialize client with configured ID 446 + try { 447 + client = await QuicksliceClient.createQuicksliceClient({ 448 + server: SERVER_URL, 449 + clientId: CLIENT_ID, 450 + }); 451 + } catch (error) { 452 + console.error("Failed to initialize client:", error); 453 + } 454 + } 455 + 456 + // Render based on auth state 457 + await renderApp(); 458 + } 459 + 460 + async function renderApp() { 461 + const isLoggedIn = client && (await client.isAuthenticated()); 462 + 463 + if (isLoggedIn) { 464 + try { 465 + const viewer = await fetchViewer(); 466 + renderUserCard(viewer); 467 + } catch (error) { 468 + console.error("Failed to fetch viewer:", error); 469 + renderUserCard(null); 470 + } 471 + } else { 472 + renderLoginForm(); 473 + } 474 + 475 + // Render emoji picker (enabled only if logged in) 476 + renderEmojiPicker(null, isLoggedIn); 477 + 478 + // Load statuses 479 + await loadAndRenderStatuses(); 480 + } 481 + 482 + // ============================================================================= 483 + // DATA FETCHING 484 + // ============================================================================= 485 + 486 + async function fetchStatuses() { 487 + const query = ` 488 + query GetStatuses { 489 + xyzStatusphereStatus( 490 + first: 20 491 + sortBy: [{ field: "createdAt", direction: DESC }] 492 + ) { 493 + edges { 494 + node { 495 + uri 496 + did 497 + status 498 + createdAt 499 + appBskyActorProfileByDid { 500 + actorHandle 501 + displayName 502 + } 503 + } 504 + } 505 + } 506 + } 507 + `; 508 + 509 + // Use client if available, otherwise create a temporary one for public query 510 + if (client) { 511 + const data = await client.publicQuery(query); 512 + return data.xyzStatusphereStatus?.edges?.map((e) => e.node) || []; 513 + } else { 514 + // For unauthenticated users, make a direct fetch 515 + const response = await fetch(`${SERVER_URL}/graphql`, { 516 + method: "POST", 517 + headers: { "Content-Type": "application/json" }, 518 + body: JSON.stringify({ query }), 519 + }); 520 + const result = await response.json(); 521 + return ( 522 + result.data?.xyzStatusphereStatus?.edges?.map((e) => e.node) || [] 523 + ); 524 + } 525 + } 526 + 527 + async function fetchViewer() { 528 + const query = ` 529 + query { 530 + viewer { 531 + did 532 + handle 533 + appBskyActorProfileByDid { 534 + displayName 535 + avatar { url } 536 + } 537 + } 538 + } 539 + `; 540 + 541 + const data = await client.query(query); 542 + return data?.viewer; 543 + } 544 + 545 + async function postStatus(emoji) { 546 + const mutation = ` 547 + mutation CreateStatus($status: String!, $createdAt: DateTime!) { 548 + createXyzStatusphereStatus( 549 + input: { status: $status, createdAt: $createdAt } 550 + ) { 551 + uri 552 + status 553 + createdAt 554 + } 555 + } 556 + `; 557 + 558 + const variables = { 559 + status: emoji, 560 + createdAt: new Date().toISOString(), 561 + }; 562 + 563 + return await client.mutate(mutation, variables); 564 + } 565 + 566 + async function loadAndRenderStatuses() { 567 + renderLoading("status-feed"); 568 + try { 569 + const statuses = await fetchStatuses(); 570 + renderStatusFeed(statuses); 571 + } catch (error) { 572 + console.error("Failed to fetch statuses:", error); 573 + document.getElementById("status-feed").innerHTML = ` 574 + <div class="card"> 575 + <p class="loading" style="color: var(--error-text);"> 576 + Failed to load statuses. Is the quickslice server running at ${SERVER_URL}? 577 + </p> 578 + </div> 579 + `; 580 + } 581 + } 582 + 583 + // ============================================================================= 584 + // EVENT HANDLERS 585 + // ============================================================================= 586 + 587 + async function handleLogin(event) { 588 + event.preventDefault(); 589 + 590 + const handle = document.getElementById("handle").value.trim(); 591 + 592 + if (!handle) { 593 + showError("Please enter your Bluesky handle"); 594 + return; 595 + } 596 + 597 + try { 598 + client = await QuicksliceClient.createQuicksliceClient({ 599 + server: SERVER_URL, 600 + clientId: CLIENT_ID, 601 + }); 602 + 603 + await client.loginWithRedirect({ handle }); 604 + } catch (error) { 605 + showError(`Login failed: ${error.message}`); 606 + } 607 + } 608 + 609 + async function selectStatus(emoji) { 610 + if (!client || !(await client.isAuthenticated())) { 611 + showError("Please login to set your status"); 612 + return; 613 + } 614 + 615 + try { 616 + // Disable buttons while posting 617 + document 618 + .querySelectorAll(".emoji-btn") 619 + .forEach((btn) => (btn.disabled = true)); 620 + 621 + await postStatus(emoji); 622 + 623 + // Refresh the page to show new status 624 + window.location.reload(); 625 + } catch (error) { 626 + showError(`Failed to post status: ${error.message}`); 627 + // Re-enable buttons 628 + document 629 + .querySelectorAll(".emoji-btn") 630 + .forEach((btn) => (btn.disabled = false)); 631 + } 632 + } 633 + 634 + function logout() { 635 + if (client) { 636 + client.logout(); 637 + } else { 638 + window.location.reload(); 639 + } 640 + } 641 + 642 + // ============================================================================= 643 + // UI RENDERING 644 + // ============================================================================= 645 + 646 + function showError(message) { 647 + const banner = document.getElementById("error-banner"); 648 + banner.innerHTML = ` 649 + <span>${escapeHtml(message)}</span> 650 + <button onclick="hideError()">&times;</button> 651 + `; 652 + banner.classList.remove("hidden"); 653 + } 654 + 655 + function hideError() { 656 + document.getElementById("error-banner").classList.add("hidden"); 657 + } 658 + 659 + function escapeHtml(text) { 660 + const div = document.createElement("div"); 661 + div.textContent = text; 662 + return div.innerHTML; 663 + } 664 + 665 + function formatDate(dateString) { 666 + const date = new Date(dateString); 667 + const now = new Date(); 668 + const isToday = date.toDateString() === now.toDateString(); 669 + 670 + if (isToday) { 671 + return "today"; 672 + } 673 + 674 + return date.toLocaleDateString("en-US", { 675 + month: "short", 676 + day: "numeric", 677 + year: 678 + date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, 679 + }); 680 + } 681 + 682 + function renderLoginForm() { 683 + const container = document.getElementById("auth-section"); 684 + 685 + // Show configuration message if CLIENT_ID is not set 686 + if (!CLIENT_ID) { 687 + container.innerHTML = ` 688 + <div class="card"> 689 + <p style="color: var(--error-text); text-align: center; margin-bottom: 1rem;"> 690 + <strong>Configuration Required</strong> 691 + </p> 692 + <p style="color: var(--gray-700); text-align: center;"> 693 + Please set the <code style="background: var(--gray-100); padding: 0.125rem 0.375rem; border-radius: 0.25rem;">CLIENT_ID</code> constant in this file to your OAuth client ID. 694 + </p> 695 + </div> 696 + `; 697 + return; 698 + } 699 + 700 + container.innerHTML = ` 701 + <div class="card"> 702 + <form class="login-form" onsubmit="handleLogin(event)"> 703 + <div class="form-group"> 704 + <label for="handle">Bluesky Handle</label> 705 + <input 706 + type="text" 707 + id="handle" 708 + placeholder="you.bsky.social" 709 + required 710 + > 711 + </div> 712 + <button type="submit" class="btn btn-primary">Login with Bluesky</button> 713 + </form> 714 + <p style="margin-top: 1rem; font-size: 0.875rem; color: var(--gray-500); text-align: center;"> 715 + Don't have a Bluesky account? <a href="https://bsky.app" target="_blank">Sign up</a> 716 + </p> 717 + </div> 718 + `; 719 + } 720 + 721 + function renderUserCard(viewer) { 722 + const container = document.getElementById("auth-section"); 723 + const displayName = 724 + viewer?.appBskyActorProfileByDid?.displayName || "User"; 725 + const handle = viewer?.handle || "unknown"; 726 + const avatarUrl = viewer?.appBskyActorProfileByDid?.avatar?.url; 727 + 728 + container.innerHTML = ` 729 + <div class="card user-card"> 730 + <div class="user-info"> 731 + <div class="user-avatar"> 732 + ${ 733 + avatarUrl 734 + ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` 735 + : "👤" 736 + } 737 + </div> 738 + <div> 739 + <div class="user-name">Hi, ${escapeHtml(displayName)}!</div> 740 + <div class="user-handle">@${escapeHtml(handle)}</div> 741 + </div> 742 + </div> 743 + <button class="btn btn-secondary" onclick="logout()">Logout</button> 744 + </div> 745 + `; 746 + } 747 + 748 + function renderEmojiPicker(currentStatus, enabled = true) { 749 + const container = document.getElementById("emoji-picker"); 750 + 751 + container.innerHTML = ` 752 + <div class="card"> 753 + <div class="emoji-grid"> 754 + ${EMOJIS.map( 755 + (emoji) => ` 756 + <button 757 + class="emoji-btn ${emoji === currentStatus ? "selected" : ""}" 758 + onclick="selectStatus('${emoji}')" 759 + ${!enabled ? "disabled" : ""} 760 + title="${enabled ? "Set status" : "Login to set status"}" 761 + > 762 + ${emoji} 763 + </button> 764 + `, 765 + ).join("")} 766 + </div> 767 + </div> 768 + `; 769 + } 770 + 771 + function renderStatusFeed(statuses) { 772 + const container = document.getElementById("status-feed"); 773 + 774 + if (statuses.length === 0) { 775 + container.innerHTML = ` 776 + <div class="card"> 777 + <p class="loading">No statuses yet. Be the first to post!</p> 778 + </div> 779 + `; 780 + return; 781 + } 782 + 783 + container.innerHTML = ` 784 + <div class="card"> 785 + <h2 class="feed-title">Recent Statuses</h2> 786 + <ul class="status-list"> 787 + ${statuses 788 + .map((status) => { 789 + const handle = 790 + status.appBskyActorProfileByDid?.actorHandle || status.did; 791 + const displayHandle = handle.startsWith("did:") 792 + ? handle.substring(0, 20) + "..." 793 + : handle; 794 + const profileUrl = handle.startsWith("did:") 795 + ? `https://bsky.app/profile/${status.did}` 796 + : `https://bsky.app/profile/${handle}`; 797 + 798 + return ` 799 + <li class="status-item"> 800 + <span class="status-emoji">${status.status}</span> 801 + <div class="status-content"> 802 + <span class="status-text"> 803 + <a href="${profileUrl}" target="_blank" class="status-author">@${escapeHtml( 804 + displayHandle, 805 + )}</a> 806 + is feeling ${status.status} 807 + </span> 808 + <div class="status-date">${formatDate( 809 + status.createdAt, 810 + )}</div> 811 + </div> 812 + </li> 813 + `; 814 + }) 815 + .join("")} 816 + </ul> 817 + </div> 818 + `; 819 + } 820 + 821 + function renderLoading(container) { 822 + document.getElementById(container).innerHTML = ` 823 + <div class="card"> 824 + <p class="loading">Loading...</p> 825 + </div> 826 + `; 827 + } 828 + 829 + // Run on page load 830 + main(); 831 + </script> 832 + </body> 833 + </html>