A starter kit for building apps with quickslice #getsliced
at main 13 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" /> 6 <title>Slice Kit</title> 7 <link rel="icon" type="image/svg+xml" href="favicon.svg" /> 8 <style> 9 *, 10 *::before, 11 *::after { 12 box-sizing: border-box; 13 } 14 * { 15 margin: 0; 16 } 17 body { 18 line-height: 1.5; 19 -webkit-font-smoothing: antialiased; 20 } 21 input, 22 button { 23 font: inherit; 24 } 25 26 :root { 27 --primary-500: #0078ff; 28 --primary-600: #0060cc; 29 --gray-100: #f5f5f5; 30 --gray-200: #e5e5e5; 31 --gray-500: #737373; 32 --gray-700: #404040; 33 --gray-900: #171717; 34 --border-color: #e5e5e5; 35 --error-bg: #fef2f2; 36 --error-border: #fecaca; 37 --error-text: #dc2626; 38 } 39 40 body { 41 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 42 background: var(--gray-100); 43 color: var(--gray-900); 44 min-height: 100vh; 45 padding: 2rem 1rem; 46 } 47 48 #app { 49 max-width: 500px; 50 margin: 0 auto; 51 } 52 53 header { 54 text-align: center; 55 margin-bottom: 2rem; 56 } 57 58 .logo { 59 width: 64px; 60 height: 64px; 61 margin-bottom: 0.5rem; 62 } 63 64 header h1 { 65 font-size: 2rem; 66 color: var(--primary-500); 67 margin-bottom: 0.25rem; 68 } 69 70 .tagline { 71 color: var(--gray-500); 72 font-size: 1rem; 73 } 74 75 .card { 76 background: white; 77 border-radius: 0.5rem; 78 padding: 1.5rem; 79 margin-bottom: 1rem; 80 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 81 } 82 83 .login-form { 84 display: flex; 85 flex-direction: column; 86 gap: 1rem; 87 } 88 89 .form-group { 90 display: flex; 91 flex-direction: column; 92 gap: 0.25rem; 93 } 94 95 .form-group label { 96 font-size: 0.875rem; 97 font-weight: 500; 98 color: var(--gray-700); 99 } 100 101 .form-group input { 102 padding: 0.75rem; 103 border: 1px solid var(--border-color); 104 border-radius: 0.375rem; 105 font-size: 1rem; 106 } 107 108 .form-group input:focus { 109 outline: none; 110 border-color: var(--primary-500); 111 box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.1); 112 } 113 114 qs-actor-autocomplete { 115 --qs-input-border: var(--border-color); 116 --qs-input-border-focus: var(--primary-500); 117 --qs-input-padding: 0.75rem; 118 --qs-radius: 0.375rem; 119 } 120 121 .btn { 122 padding: 0.75rem 1.5rem; 123 border: none; 124 border-radius: 0.375rem; 125 font-size: 1rem; 126 font-weight: 500; 127 cursor: pointer; 128 transition: background-color 0.15s; 129 } 130 131 .btn-primary { 132 background: var(--primary-500); 133 color: white; 134 } 135 136 .btn-primary:hover { 137 background: var(--primary-600); 138 } 139 140 .btn-secondary { 141 background: var(--gray-200); 142 color: var(--gray-700); 143 } 144 145 .btn-secondary:hover { 146 background: var(--border-color); 147 } 148 149 .user-card { 150 display: flex; 151 align-items: center; 152 justify-content: space-between; 153 } 154 155 .user-info { 156 display: flex; 157 align-items: center; 158 gap: 0.75rem; 159 } 160 161 .user-avatar { 162 width: 48px; 163 height: 48px; 164 border-radius: 50%; 165 background: var(--gray-200); 166 display: flex; 167 align-items: center; 168 justify-content: center; 169 font-size: 1.5rem; 170 } 171 172 .user-avatar img { 173 width: 100%; 174 height: 100%; 175 border-radius: 50%; 176 object-fit: cover; 177 } 178 179 .user-name { 180 font-weight: 600; 181 } 182 183 .user-handle { 184 font-size: 0.875rem; 185 color: var(--gray-500); 186 } 187 188 #error-banner { 189 position: fixed; 190 top: 1rem; 191 left: 50%; 192 transform: translateX(-50%); 193 background: var(--error-bg); 194 border: 1px solid var(--error-border); 195 color: var(--error-text); 196 padding: 0.75rem 1rem; 197 border-radius: 0.375rem; 198 display: flex; 199 align-items: center; 200 gap: 0.75rem; 201 max-width: 90%; 202 z-index: 100; 203 } 204 205 #error-banner.hidden { 206 display: none; 207 } 208 209 #error-banner button { 210 background: none; 211 border: none; 212 color: var(--error-text); 213 cursor: pointer; 214 font-size: 1.25rem; 215 line-height: 1; 216 } 217 218 .hidden { 219 display: none !important; 220 } 221 </style> 222 </head> 223 <body> 224 <div id="app"> 225 <header> 226 <svg class="logo" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"> 227 <g transform="translate(64, 64)"> 228 <ellipse cx="0" cy="-28" rx="50" ry="20" fill="#FF5722" /> 229 <ellipse cx="0" cy="0" rx="60" ry="20" fill="#00ACC1" /> 230 <ellipse cx="0" cy="28" rx="40" ry="20" fill="#32CD32" /> 231 </g> 232 </svg> 233 <h1>Slice Kit</h1> 234 <p class="tagline">Build your slice of Atmosphere</p> 235 </header> 236 <main> 237 <div id="auth-section"></div> 238 <div id="content"></div> 239 </main> 240 <div id="error-banner" class="hidden"></div> 241 </div> 242 243 <!-- Quickslice Client SDK --> 244 <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script> 245 <!-- Web Components --> 246 <script src="https://cdn.jsdelivr.net/gh/bigmoves/elements@v0.1.0/dist/elements.min.js"></script> 247 248 <script> 249 // ============================================================================= 250 // CONFIGURATION 251 // ============================================================================= 252 253 const SERVER_URL = "http://127.0.0.1:8080"; 254 const CLIENT_ID = ""; // Set your OAuth client ID here after registering 255 256 let client; 257 258 // ============================================================================= 259 // INITIALIZATION 260 // ============================================================================= 261 262 async function main() { 263 // Check for OAuth errors in URL 264 const params = new URLSearchParams(window.location.search); 265 if (params.has("error")) { 266 const error = params.get("error"); 267 const description = params.get("error_description") || error; 268 showError(description); 269 // Clean up URL 270 window.history.replaceState({}, "", window.location.pathname); 271 } 272 273 if (window.location.search.includes("code=")) { 274 if (!CLIENT_ID) { 275 showError("OAuth callback received but CLIENT_ID is not configured."); 276 renderLoginForm(); 277 return; 278 } 279 280 try { 281 client = await QuicksliceClient.createQuicksliceClient({ 282 server: SERVER_URL, 283 clientId: CLIENT_ID, 284 }); 285 await client.handleRedirectCallback(); 286 } catch (error) { 287 console.error("OAuth callback error:", error); 288 showError(`Authentication failed: ${error.message}`); 289 renderLoginForm(); 290 return; 291 } 292 } else if (CLIENT_ID) { 293 try { 294 client = await QuicksliceClient.createQuicksliceClient({ 295 server: SERVER_URL, 296 clientId: CLIENT_ID, 297 }); 298 } catch (error) { 299 console.error("Failed to initialize client:", error); 300 } 301 } 302 303 await renderApp(); 304 } 305 306 async function renderApp() { 307 const isLoggedIn = client && (await client.isAuthenticated()); 308 309 if (isLoggedIn) { 310 try { 311 const viewer = await fetchViewer(); 312 renderUserCard(viewer); 313 renderContent(viewer); 314 } catch (error) { 315 console.error("Failed to fetch viewer:", error); 316 renderUserCard(null); 317 } 318 } else { 319 renderLoginForm(); 320 } 321 } 322 323 // ============================================================================= 324 // DATA FETCHING 325 // ============================================================================= 326 327 async function fetchViewer() { 328 const query = ` 329 query { 330 viewer { 331 did 332 handle 333 appBskyActorProfileByDid { 334 displayName 335 avatar { url } 336 } 337 } 338 } 339 `; 340 341 const data = await client.query(query); 342 return data?.viewer; 343 } 344 345 // ============================================================================= 346 // EVENT HANDLERS 347 // ============================================================================= 348 349 async function handleLogin(event) { 350 event.preventDefault(); 351 352 const handle = document.getElementById("handle").value.trim(); 353 354 if (!handle) { 355 showError("Please enter your handle"); 356 return; 357 } 358 359 try { 360 client = await QuicksliceClient.createQuicksliceClient({ 361 server: SERVER_URL, 362 clientId: CLIENT_ID, 363 }); 364 365 await client.loginWithRedirect({ handle }); 366 } catch (error) { 367 showError(`Login failed: ${error.message}`); 368 } 369 } 370 371 function logout() { 372 if (client) { 373 client.logout(); 374 } else { 375 window.location.reload(); 376 } 377 } 378 379 // ============================================================================= 380 // UI RENDERING 381 // ============================================================================= 382 383 function showError(message) { 384 const banner = document.getElementById("error-banner"); 385 banner.innerHTML = ` 386 <span>${escapeHtml(message)}</span> 387 <button onclick="hideError()">&times;</button> 388 `; 389 banner.classList.remove("hidden"); 390 } 391 392 function hideError() { 393 document.getElementById("error-banner").classList.add("hidden"); 394 } 395 396 function escapeHtml(text) { 397 const div = document.createElement("div"); 398 div.textContent = text; 399 return div.innerHTML; 400 } 401 402 function renderLoginForm() { 403 const container = document.getElementById("auth-section"); 404 405 if (!CLIENT_ID) { 406 container.innerHTML = ` 407 <div class="card"> 408 <p style="color: var(--error-text); text-align: center; margin-bottom: 1rem;"> 409 <strong>Configuration Required</strong> 410 </p> 411 <p style="color: var(--gray-700); text-align: center;"> 412 Set the <code style="background: var(--gray-100); padding: 0.125rem 0.375rem; border-radius: 0.25rem;">CLIENT_ID</code> constant after registering an OAuth client. 413 </p> 414 </div> 415 `; 416 return; 417 } 418 419 container.innerHTML = ` 420 <div class="card"> 421 <form class="login-form" onsubmit="handleLogin(event)"> 422 <div class="form-group"> 423 <label for="handle">AT Protocol Handle</label> 424 <qs-actor-autocomplete 425 id="handle" 426 name="handle" 427 placeholder="you.bsky.social" 428 required 429 ></qs-actor-autocomplete> 430 </div> 431 <button type="submit" class="btn btn-primary">Login</button> 432 </form> 433 </div> 434 `; 435 } 436 437 function renderUserCard(viewer) { 438 const container = document.getElementById("auth-section"); 439 const displayName = viewer?.appBskyActorProfileByDid?.displayName || "User"; 440 const handle = viewer?.handle || "unknown"; 441 const avatarUrl = viewer?.appBskyActorProfileByDid?.avatar?.url; 442 443 container.innerHTML = ` 444 <div class="card user-card"> 445 <div class="user-info"> 446 <div class="user-avatar"> 447 ${avatarUrl ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` : "👤"} 448 </div> 449 <div> 450 <div class="user-name">Hi, ${escapeHtml(displayName)}!</div> 451 <div class="user-handle">@${escapeHtml(handle)}</div> 452 </div> 453 </div> 454 <button class="btn btn-secondary" onclick="logout()">Logout</button> 455 </div> 456 `; 457 } 458 459 function renderContent(viewer) { 460 const container = document.getElementById("content"); 461 container.innerHTML = ` 462 <div class="card"> 463 <p style="color: var(--gray-700);">You're logged in! #getsliced</p> 464 </div> 465 `; 466 } 467 468 main(); 469 </script> 470 </body> 471</html>