A starter kit for building apps with quickslice #getsliced
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()">×</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>