Tools for the Atmosphere
tools.slices.network
quickslice
atproto
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: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
49 background: var(--gray-100);
50 color: var(--gray-900);
51 min-height: 100vh;
52 padding: 2rem 1rem;
53 }
54
55 #app {
56 max-width: 600px;
57 margin: 0 auto;
58 }
59
60 /* Header */
61 header {
62 text-align: center;
63 margin-bottom: 2rem;
64 }
65
66 header h1 {
67 font-size: 2.5rem;
68 color: var(--primary-500);
69 margin-bottom: 0.25rem;
70 }
71
72 .tagline {
73 color: var(--gray-500);
74 font-size: 1rem;
75 }
76
77 /* Cards */
78 .card {
79 background: white;
80 border-radius: 0.5rem;
81 padding: 1.5rem;
82 margin-bottom: 1rem;
83 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
84 }
85
86 /* Auth Section */
87 .login-form {
88 display: flex;
89 flex-direction: column;
90 gap: 1rem;
91 }
92
93 .form-group {
94 display: flex;
95 flex-direction: column;
96 gap: 0.25rem;
97 }
98
99 .form-group label {
100 font-size: 0.875rem;
101 font-weight: 500;
102 color: var(--gray-700);
103 }
104
105 .form-group input {
106 padding: 0.75rem;
107 border: 1px solid var(--border-color);
108 border-radius: 0.375rem;
109 font-size: 1rem;
110 }
111
112 .form-group input:focus {
113 outline: none;
114 border-color: var(--primary-500);
115 box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.1);
116 }
117
118 .btn {
119 padding: 0.75rem 1.5rem;
120 border: none;
121 border-radius: 0.375rem;
122 font-size: 1rem;
123 font-weight: 500;
124 cursor: pointer;
125 transition: background-color 0.15s;
126 }
127
128 .btn-primary {
129 background: var(--primary-500);
130 color: white;
131 }
132
133 .btn-primary:hover {
134 background: var(--primary-600);
135 }
136
137 .btn-primary:disabled {
138 background: var(--gray-200);
139 color: var(--gray-500);
140 cursor: not-allowed;
141 }
142
143 .btn-secondary {
144 background: var(--gray-200);
145 color: var(--gray-700);
146 }
147
148 .btn-secondary:hover {
149 background: var(--border-color);
150 }
151
152 /* User Card */
153 .user-card {
154 display: flex;
155 align-items: center;
156 justify-content: space-between;
157 }
158
159 .user-info {
160 display: flex;
161 align-items: center;
162 gap: 0.75rem;
163 }
164
165 .user-avatar {
166 width: 48px;
167 height: 48px;
168 border-radius: 50%;
169 background: var(--gray-200);
170 display: flex;
171 align-items: center;
172 justify-content: center;
173 font-size: 1.5rem;
174 }
175
176 .user-avatar img {
177 width: 100%;
178 height: 100%;
179 border-radius: 50%;
180 object-fit: cover;
181 }
182
183 .user-name {
184 font-weight: 600;
185 }
186
187 .user-handle {
188 font-size: 0.875rem;
189 color: var(--gray-500);
190 }
191
192 /* Emoji Picker */
193 .emoji-grid {
194 display: grid;
195 grid-template-columns: repeat(9, 1fr);
196 gap: 0.5rem;
197 }
198
199 .emoji-btn {
200 width: 100%;
201 aspect-ratio: 1;
202 font-size: 1.5rem;
203 border: 2px solid var(--border-color);
204 border-radius: 50%;
205 background: white;
206 cursor: pointer;
207 transition: all 0.15s;
208 display: flex;
209 align-items: center;
210 justify-content: center;
211 }
212
213 .emoji-btn:hover {
214 background: rgba(0, 120, 255, 0.1);
215 border-color: var(--primary-400);
216 }
217
218 .emoji-btn.selected {
219 border-color: var(--primary-500);
220 box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.2);
221 }
222
223 .emoji-btn:disabled {
224 opacity: 0.5;
225 cursor: not-allowed;
226 }
227
228 .emoji-btn:disabled:hover {
229 background: white;
230 border-color: var(--border-color);
231 }
232
233 /* Status Feed */
234 .feed-title {
235 font-size: 1.125rem;
236 font-weight: 600;
237 margin-bottom: 1rem;
238 color: var(--gray-700);
239 }
240
241 .status-list {
242 list-style: none;
243 padding: 0;
244 }
245
246 .status-item {
247 position: relative;
248 padding-left: 2rem;
249 padding-bottom: 1.5rem;
250 }
251
252 .status-item::before {
253 content: "";
254 position: absolute;
255 left: 0.75rem;
256 top: 1.5rem;
257 bottom: 0;
258 width: 2px;
259 background: var(--border-color);
260 }
261
262 .status-item:last-child::before {
263 display: none;
264 }
265
266 .status-item:last-child {
267 padding-bottom: 0;
268 }
269
270 .status-emoji {
271 position: absolute;
272 left: 0;
273 top: 0;
274 font-size: 1.5rem;
275 }
276
277 .status-content {
278 padding-top: 0.25rem;
279 }
280
281 .status-author {
282 color: var(--primary-500);
283 text-decoration: none;
284 font-weight: 500;
285 }
286
287 .status-author:hover {
288 text-decoration: underline;
289 }
290
291 .status-text {
292 color: var(--gray-700);
293 }
294
295 .status-date {
296 font-size: 0.875rem;
297 color: var(--gray-500);
298 }
299
300 /* Error Banner */
301 #error-banner {
302 position: fixed;
303 top: 1rem;
304 left: 50%;
305 transform: translateX(-50%);
306 background: var(--error-bg);
307 border: 1px solid var(--error-border);
308 color: var(--error-text);
309 padding: 0.75rem 1rem;
310 border-radius: 0.375rem;
311 display: flex;
312 align-items: center;
313 gap: 0.75rem;
314 max-width: 90%;
315 z-index: 100;
316 }
317
318 #error-banner.hidden {
319 display: none;
320 }
321
322 #error-banner button {
323 background: none;
324 border: none;
325 color: var(--error-text);
326 cursor: pointer;
327 font-size: 1.25rem;
328 line-height: 1;
329 }
330
331 /* Loading State */
332 .loading {
333 text-align: center;
334 color: var(--gray-500);
335 padding: 2rem;
336 }
337
338 /* Responsive */
339 @media (max-width: 480px) {
340 .emoji-grid {
341 grid-template-columns: repeat(6, 1fr);
342 }
343
344 .emoji-btn {
345 font-size: 1.25rem;
346 }
347 }
348
349 /* Hidden utility */
350 .hidden {
351 display: none !important;
352 }
353 </style>
354 </head>
355 <body>
356 <div id="app">
357 <header>
358 <h1>Statusphere</h1>
359 <p class="tagline">Set your status on the Atmosphere</p>
360 </header>
361 <main>
362 <div id="auth-section"></div>
363 <div id="emoji-picker"></div>
364 <div id="status-feed"></div>
365 </main>
366 <div id="error-banner" class="hidden"></div>
367 </div>
368
369 <!-- Quickslice Client SDK -->
370 <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script>
371
372 <script>
373 // =============================================================================
374 // CONFIGURATION
375 // =============================================================================
376
377 const SERVER_URL = "https://xyzstatusphere.slices.network";
378 const CLIENT_ID = "client_vPEnCW98y5BNr5PrYOHPXg"; // Set your OAuth client ID here
379
380 const EMOJIS = [
381 "👍",
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 // Client instance
411 let client;
412
413 // =============================================================================
414 // INITIALIZATION
415 // =============================================================================
416
417 async function main() {
418 // Check if this is an OAuth callback
419 if (window.location.search.includes("code=")) {
420 if (!CLIENT_ID) {
421 showError("OAuth callback received but CLIENT_ID is not configured.");
422 renderLoginForm();
423 return;
424 }
425
426 try {
427 client = await QuicksliceClient.createQuicksliceClient({
428 server: SERVER_URL,
429 clientId: CLIENT_ID,
430 });
431 await client.handleRedirectCallback();
432 console.log("OAuth callback handled successfully");
433 } catch (error) {
434 console.error("OAuth callback error:", error);
435 showError(`Authentication failed: ${error.message}`);
436 renderLoginForm();
437 renderEmojiPicker(null, false);
438 await loadAndRenderStatuses();
439 return;
440 }
441 } else if (CLIENT_ID) {
442 // Initialize client with configured ID
443 try {
444 client = await QuicksliceClient.createQuicksliceClient({
445 server: SERVER_URL,
446 clientId: CLIENT_ID,
447 });
448 } catch (error) {
449 console.error("Failed to initialize client:", error);
450 }
451 }
452
453 // Render based on auth state
454 await renderApp();
455 }
456
457 async function renderApp() {
458 const isLoggedIn = client && (await client.isAuthenticated());
459
460 if (isLoggedIn) {
461 try {
462 const viewer = await fetchViewer();
463 renderUserCard(viewer);
464 } catch (error) {
465 console.error("Failed to fetch viewer:", error);
466 renderUserCard(null);
467 }
468 } else {
469 renderLoginForm();
470 }
471
472 // Render emoji picker (enabled only if logged in)
473 renderEmojiPicker(null, isLoggedIn);
474
475 // Load statuses
476 await loadAndRenderStatuses();
477 }
478
479 // =============================================================================
480 // DATA FETCHING
481 // =============================================================================
482
483 async function fetchStatuses() {
484 const query = `
485 query GetStatuses {
486 xyzStatusphereStatus(
487 first: 20
488 sortBy: [{ field: "createdAt", direction: DESC }]
489 ) {
490 edges {
491 node {
492 uri
493 did
494 status
495 createdAt
496 appBskyActorProfileByDid {
497 actorHandle
498 displayName
499 }
500 }
501 }
502 }
503 }
504 `;
505
506 // Use client if available, otherwise create a temporary one for public query
507 if (client) {
508 const data = await client.publicQuery(query);
509 return data.xyzStatusphereStatus?.edges?.map((e) => e.node) || [];
510 } else {
511 // For unauthenticated users, make a direct fetch
512 const response = await fetch(`${SERVER_URL}/graphql`, {
513 method: "POST",
514 headers: { "Content-Type": "application/json" },
515 body: JSON.stringify({ query }),
516 });
517 const result = await response.json();
518 return result.data?.xyzStatusphereStatus?.edges?.map((e) => e.node) || [];
519 }
520 }
521
522 async function fetchViewer() {
523 const query = `
524 query {
525 viewer {
526 did
527 handle
528 appBskyActorProfileByDid {
529 displayName
530 avatar { url }
531 }
532 }
533 }
534 `;
535
536 const data = await client.query(query);
537 return data?.viewer;
538 }
539
540 async function postStatus(emoji) {
541 const mutation = `
542 mutation CreateStatus($status: String!, $createdAt: DateTime!) {
543 createXyzStatusphereStatus(
544 input: { status: $status, createdAt: $createdAt }
545 ) {
546 uri
547 status
548 createdAt
549 }
550 }
551 `;
552
553 const variables = {
554 status: emoji,
555 createdAt: new Date().toISOString(),
556 };
557
558 return await client.mutate(mutation, variables);
559 }
560
561 async function loadAndRenderStatuses() {
562 renderLoading("status-feed");
563 try {
564 const statuses = await fetchStatuses();
565 renderStatusFeed(statuses);
566 } catch (error) {
567 console.error("Failed to fetch statuses:", error);
568 document.getElementById("status-feed").innerHTML = `
569 <div class="card">
570 <p class="loading" style="color: var(--error-text);">
571 Failed to load statuses. Is the quickslice server running at ${SERVER_URL}?
572 </p>
573 </div>
574 `;
575 }
576 }
577
578 // =============================================================================
579 // EVENT HANDLERS
580 // =============================================================================
581
582 async function handleLogin(event) {
583 event.preventDefault();
584
585 const handle = document.getElementById("handle").value.trim();
586
587 if (!handle) {
588 showError("Please enter your Bluesky handle");
589 return;
590 }
591
592 try {
593 client = await QuicksliceClient.createQuicksliceClient({
594 server: SERVER_URL,
595 clientId: CLIENT_ID,
596 });
597
598 await client.loginWithRedirect({ handle });
599 } catch (error) {
600 showError(`Login failed: ${error.message}`);
601 }
602 }
603
604 async function selectStatus(emoji) {
605 if (!client || !(await client.isAuthenticated())) {
606 showError("Please login to set your status");
607 return;
608 }
609
610 try {
611 // Disable buttons while posting
612 document.querySelectorAll(".emoji-btn").forEach((btn) => (btn.disabled = true));
613
614 await postStatus(emoji);
615
616 // Refresh the page to show new status
617 window.location.reload();
618 } catch (error) {
619 showError(`Failed to post status: ${error.message}`);
620 // Re-enable buttons
621 document.querySelectorAll(".emoji-btn").forEach((btn) => (btn.disabled = false));
622 }
623 }
624
625 function logout() {
626 if (client) {
627 client.logout();
628 } else {
629 window.location.reload();
630 }
631 }
632
633 // =============================================================================
634 // UI RENDERING
635 // =============================================================================
636
637 function showError(message) {
638 const banner = document.getElementById("error-banner");
639 banner.innerHTML = `
640 <span>${escapeHtml(message)}</span>
641 <button onclick="hideError()">×</button>
642 `;
643 banner.classList.remove("hidden");
644 }
645
646 function hideError() {
647 document.getElementById("error-banner").classList.add("hidden");
648 }
649
650 function escapeHtml(text) {
651 const div = document.createElement("div");
652 div.textContent = text;
653 return div.innerHTML;
654 }
655
656 function formatDate(dateString) {
657 const date = new Date(dateString);
658 const now = new Date();
659 const isToday = date.toDateString() === now.toDateString();
660
661 if (isToday) {
662 return "today";
663 }
664
665 return date.toLocaleDateString("en-US", {
666 month: "short",
667 day: "numeric",
668 year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
669 });
670 }
671
672 function renderLoginForm() {
673 const container = document.getElementById("auth-section");
674
675 // Show configuration message if CLIENT_ID is not set
676 if (!CLIENT_ID) {
677 container.innerHTML = `
678 <div class="card">
679 <p style="color: var(--error-text); text-align: center; margin-bottom: 1rem;">
680 <strong>Configuration Required</strong>
681 </p>
682 <p style="color: var(--gray-700); text-align: center;">
683 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.
684 </p>
685 </div>
686 `;
687 return;
688 }
689
690 container.innerHTML = `
691 <div class="card">
692 <form class="login-form" onsubmit="handleLogin(event)">
693 <div class="form-group">
694 <label for="handle">Bluesky Handle</label>
695 <input
696 type="text"
697 id="handle"
698 placeholder="you.bsky.social"
699 required
700 >
701 </div>
702 <button type="submit" class="btn btn-primary">Login with Bluesky</button>
703 </form>
704 <p style="margin-top: 1rem; font-size: 0.875rem; color: var(--gray-500); text-align: center;">
705 Don't have a Bluesky account? <a href="https://bsky.app" target="_blank">Sign up</a>
706 </p>
707 </div>
708 `;
709 }
710
711 function renderUserCard(viewer) {
712 const container = document.getElementById("auth-section");
713 const displayName = viewer?.appBskyActorProfileByDid?.displayName || "User";
714 const handle = viewer?.handle || "unknown";
715 const avatarUrl = viewer?.appBskyActorProfileByDid?.avatar?.url;
716
717 container.innerHTML = `
718 <div class="card user-card">
719 <div class="user-info">
720 <div class="user-avatar">
721 ${avatarUrl ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` : "👤"}
722 </div>
723 <div>
724 <div class="user-name">Hi, ${escapeHtml(displayName)}!</div>
725 <div class="user-handle">@${escapeHtml(handle)}</div>
726 </div>
727 </div>
728 <button class="btn btn-secondary" onclick="logout()">Logout</button>
729 </div>
730 `;
731 }
732
733 function renderEmojiPicker(currentStatus, enabled = true) {
734 const container = document.getElementById("emoji-picker");
735
736 container.innerHTML = `
737 <div class="card">
738 <div class="emoji-grid">
739 ${EMOJIS.map(
740 (emoji) => `
741 <button
742 class="emoji-btn ${emoji === currentStatus ? "selected" : ""}"
743 onclick="selectStatus('${emoji}')"
744 ${!enabled ? "disabled" : ""}
745 title="${enabled ? "Set status" : "Login to set status"}"
746 >
747 ${emoji}
748 </button>
749 `,
750 ).join("")}
751 </div>
752 </div>
753 `;
754 }
755
756 function renderStatusFeed(statuses) {
757 const container = document.getElementById("status-feed");
758
759 if (statuses.length === 0) {
760 container.innerHTML = `
761 <div class="card">
762 <p class="loading">No statuses yet. Be the first to post!</p>
763 </div>
764 `;
765 return;
766 }
767
768 container.innerHTML = `
769 <div class="card">
770 <h2 class="feed-title">Recent Statuses</h2>
771 <ul class="status-list">
772 ${statuses
773 .map((status) => {
774 const handle = status.appBskyActorProfileByDid?.actorHandle || status.did;
775 const displayHandle = handle.startsWith("did:")
776 ? handle.substring(0, 20) + "..."
777 : handle;
778 const profileUrl = handle.startsWith("did:")
779 ? `https://bsky.app/profile/${status.did}`
780 : `https://bsky.app/profile/${handle}`;
781
782 return `
783 <li class="status-item">
784 <span class="status-emoji">${status.status}</span>
785 <div class="status-content">
786 <span class="status-text">
787 <a href="${profileUrl}" target="_blank" class="status-author">@${escapeHtml(
788 displayHandle,
789 )}</a>
790 is feeling ${status.status}
791 </span>
792 <div class="status-date">${formatDate(status.createdAt)}</div>
793 </div>
794 </li>
795 `;
796 })
797 .join("")}
798 </ul>
799 </div>
800 `;
801 }
802
803 function renderLoading(container) {
804 document.getElementById(container).innerHTML = `
805 <div class="card">
806 <p class="loading">Loading...</p>
807 </div>
808 `;
809 }
810
811 // Run on page load
812 main();
813 </script>
814 </body>
815</html>