forked from
slices.network/quickslice
Auto-indexing service and GraphQL API for AT Protocol Records
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://unpkg.com; style-src 'self' 'unsafe-inline'; connect-src http://localhost:8080 http://127.0.0.1:8080; 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://unpkg.com/quickslice-client-js/dist/quickslice-client.min.js"></script>
372
373 <script>
374 // =============================================================================
375 // CONFIGURATION
376 // =============================================================================
377
378 // Use the same hostname as the current page to avoid DPoP htu mismatch
379 const SERVER_URL = `http://${window.location.hostname}:8080`;
380 const CLIENT_ID = ""; // Set your OAuth client ID here
381
382 const EMOJIS = [
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
412 // Client instance
413 let client;
414
415 // =============================================================================
416 // INITIALIZATION
417 // =============================================================================
418
419 async function main() {
420 // Check if this is an OAuth callback
421 if (window.location.search.includes("code=")) {
422 if (!CLIENT_ID) {
423 showError(
424 "OAuth callback received but CLIENT_ID is not configured.",
425 );
426 renderLoginForm();
427 return;
428 }
429
430 try {
431 client = await QuicksliceClient.createQuicksliceClient({
432 server: SERVER_URL,
433 clientId: CLIENT_ID,
434 });
435 await client.handleRedirectCallback();
436 console.log("OAuth callback handled successfully");
437 } catch (error) {
438 console.error("OAuth callback error:", error);
439 showError(`Authentication failed: ${error.message}`);
440 renderLoginForm();
441 renderEmojiPicker(null, false);
442 await loadAndRenderStatuses();
443 return;
444 }
445 } else if (CLIENT_ID) {
446 // Initialize client with configured ID
447 try {
448 client = await QuicksliceClient.createQuicksliceClient({
449 server: SERVER_URL,
450 clientId: CLIENT_ID,
451 });
452 } catch (error) {
453 console.error("Failed to initialize client:", error);
454 }
455 }
456
457 // Render based on auth state
458 await renderApp();
459 }
460
461 async function renderApp() {
462 const isLoggedIn = client && (await client.isAuthenticated());
463
464 if (isLoggedIn) {
465 try {
466 const viewer = await fetchViewer();
467 renderUserCard(viewer);
468 } catch (error) {
469 console.error("Failed to fetch viewer:", error);
470 renderUserCard(null);
471 }
472 } else {
473 renderLoginForm();
474 }
475
476 // Render emoji picker (enabled only if logged in)
477 renderEmojiPicker(null, isLoggedIn);
478
479 // Load statuses
480 await loadAndRenderStatuses();
481 }
482
483 // =============================================================================
484 // DATA FETCHING
485 // =============================================================================
486
487 async function fetchStatuses() {
488 const query = `
489 query GetStatuses {
490 xyzStatusphereStatus(
491 first: 20
492 sortBy: [{ field: "createdAt", direction: DESC }]
493 ) {
494 edges {
495 node {
496 uri
497 did
498 status
499 createdAt
500 appBskyActorProfileByDid {
501 actorHandle
502 displayName
503 }
504 }
505 }
506 }
507 }
508 `;
509
510 // Use client if available, otherwise create a temporary one for public query
511 if (client) {
512 const data = await client.publicQuery(query);
513 return data.xyzStatusphereStatus?.edges?.map((e) => e.node) || [];
514 } else {
515 // For unauthenticated users, make a direct fetch
516 const response = await fetch(`${SERVER_URL}/graphql`, {
517 method: "POST",
518 headers: { "Content-Type": "application/json" },
519 body: JSON.stringify({ query }),
520 });
521 const result = await response.json();
522 return (
523 result.data?.xyzStatusphereStatus?.edges?.map((e) => e.node) || []
524 );
525 }
526 }
527
528 async function fetchViewer() {
529 const query = `
530 query {
531 viewer {
532 did
533 handle
534 appBskyActorProfileByDid {
535 displayName
536 avatar { url }
537 }
538 }
539 }
540 `;
541
542 const data = await client.query(query);
543 return data?.viewer;
544 }
545
546 async function postStatus(emoji) {
547 const mutation = `
548 mutation CreateStatus($status: String!, $createdAt: DateTime!) {
549 createXyzStatusphereStatus(
550 input: { status: $status, createdAt: $createdAt }
551 ) {
552 uri
553 status
554 createdAt
555 }
556 }
557 `;
558
559 const variables = {
560 status: emoji,
561 createdAt: new Date().toISOString(),
562 };
563
564 return await client.mutate(mutation, variables);
565 }
566
567 async function loadAndRenderStatuses() {
568 renderLoading("status-feed");
569 try {
570 const statuses = await fetchStatuses();
571 renderStatusFeed(statuses);
572 } catch (error) {
573 console.error("Failed to fetch statuses:", error);
574 document.getElementById("status-feed").innerHTML = `
575 <div class="card">
576 <p class="loading" style="color: var(--error-text);">
577 Failed to load statuses. Is the quickslice server running at ${SERVER_URL}?
578 </p>
579 </div>
580 `;
581 }
582 }
583
584 // =============================================================================
585 // EVENT HANDLERS
586 // =============================================================================
587
588 async function handleLogin(event) {
589 event.preventDefault();
590
591 const handle = document.getElementById("handle").value.trim();
592
593 if (!handle) {
594 showError("Please enter your Bluesky handle");
595 return;
596 }
597
598 try {
599 client = await QuicksliceClient.createQuicksliceClient({
600 server: SERVER_URL,
601 clientId: CLIENT_ID,
602 });
603
604 await client.loginWithRedirect({ handle });
605 } catch (error) {
606 showError(`Login failed: ${error.message}`);
607 }
608 }
609
610 async function selectStatus(emoji) {
611 if (!client || !(await client.isAuthenticated())) {
612 showError("Please login to set your status");
613 return;
614 }
615
616 try {
617 // Disable buttons while posting
618 document
619 .querySelectorAll(".emoji-btn")
620 .forEach((btn) => (btn.disabled = true));
621
622 await postStatus(emoji);
623
624 // Refresh the page to show new status
625 window.location.reload();
626 } catch (error) {
627 showError(`Failed to post status: ${error.message}`);
628 // Re-enable buttons
629 document
630 .querySelectorAll(".emoji-btn")
631 .forEach((btn) => (btn.disabled = false));
632 }
633 }
634
635 function logout() {
636 if (client) {
637 client.logout();
638 } else {
639 window.location.reload();
640 }
641 }
642
643 // =============================================================================
644 // UI RENDERING
645 // =============================================================================
646
647 function showError(message) {
648 const banner = document.getElementById("error-banner");
649 banner.innerHTML = `
650 <span>${escapeHtml(message)}</span>
651 <button onclick="hideError()">×</button>
652 `;
653 banner.classList.remove("hidden");
654 }
655
656 function hideError() {
657 document.getElementById("error-banner").classList.add("hidden");
658 }
659
660 function escapeHtml(text) {
661 const div = document.createElement("div");
662 div.textContent = text;
663 return div.innerHTML;
664 }
665
666 function formatDate(dateString) {
667 const date = new Date(dateString);
668 const now = new Date();
669 const isToday = date.toDateString() === now.toDateString();
670
671 if (isToday) {
672 return "today";
673 }
674
675 return date.toLocaleDateString("en-US", {
676 month: "short",
677 day: "numeric",
678 year:
679 date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
680 });
681 }
682
683 function renderLoginForm() {
684 const container = document.getElementById("auth-section");
685
686 // Show configuration message if CLIENT_ID is not set
687 if (!CLIENT_ID) {
688 container.innerHTML = `
689 <div class="card">
690 <p style="color: var(--error-text); text-align: center; margin-bottom: 1rem;">
691 <strong>Configuration Required</strong>
692 </p>
693 <p style="color: var(--gray-700); text-align: center;">
694 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.
695 </p>
696 </div>
697 `;
698 return;
699 }
700
701 container.innerHTML = `
702 <div class="card">
703 <form class="login-form" onsubmit="handleLogin(event)">
704 <div class="form-group">
705 <label for="handle">Bluesky Handle</label>
706 <input
707 type="text"
708 id="handle"
709 placeholder="you.bsky.social"
710 required
711 >
712 </div>
713 <button type="submit" class="btn btn-primary">Login with Bluesky</button>
714 </form>
715 <p style="margin-top: 1rem; font-size: 0.875rem; color: var(--gray-500); text-align: center;">
716 Don't have a Bluesky account? <a href="https://bsky.app" target="_blank">Sign up</a>
717 </p>
718 </div>
719 `;
720 }
721
722 function renderUserCard(viewer) {
723 const container = document.getElementById("auth-section");
724 const displayName =
725 viewer?.appBskyActorProfileByDid?.displayName || "User";
726 const handle = viewer?.handle || "unknown";
727 const avatarUrl = viewer?.appBskyActorProfileByDid?.avatar?.url;
728
729 container.innerHTML = `
730 <div class="card user-card">
731 <div class="user-info">
732 <div class="user-avatar">
733 ${avatarUrl ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` : "👤"}
734 </div>
735 <div>
736 <div class="user-name">Hi, ${escapeHtml(displayName)}!</div>
737 <div class="user-handle">@${escapeHtml(handle)}</div>
738 </div>
739 </div>
740 <button class="btn btn-secondary" onclick="logout()">Logout</button>
741 </div>
742 `;
743 }
744
745 function renderEmojiPicker(currentStatus, enabled = true) {
746 const container = document.getElementById("emoji-picker");
747
748 container.innerHTML = `
749 <div class="card">
750 <div class="emoji-grid">
751 ${EMOJIS.map(
752 (emoji) => `
753 <button
754 class="emoji-btn ${emoji === currentStatus ? "selected" : ""}"
755 onclick="selectStatus('${emoji}')"
756 ${!enabled ? "disabled" : ""}
757 title="${enabled ? "Set status" : "Login to set status"}"
758 >
759 ${emoji}
760 </button>
761 `,
762 ).join("")}
763 </div>
764 </div>
765 `;
766 }
767
768 function renderStatusFeed(statuses) {
769 const container = document.getElementById("status-feed");
770
771 if (statuses.length === 0) {
772 container.innerHTML = `
773 <div class="card">
774 <p class="loading">No statuses yet. Be the first to post!</p>
775 </div>
776 `;
777 return;
778 }
779
780 container.innerHTML = `
781 <div class="card">
782 <h2 class="feed-title">Recent Statuses</h2>
783 <ul class="status-list">
784 ${statuses
785 .map((status) => {
786 const handle =
787 status.appBskyActorProfileByDid?.actorHandle || status.did;
788 const displayHandle = handle.startsWith("did:")
789 ? handle.substring(0, 20) + "..."
790 : handle;
791 const profileUrl = handle.startsWith("did:")
792 ? `https://bsky.app/profile/${status.did}`
793 : `https://bsky.app/profile/${handle}`;
794
795 return `
796 <li class="status-item">
797 <span class="status-emoji">${status.status}</span>
798 <div class="status-content">
799 <span class="status-text">
800 <a href="${profileUrl}" target="_blank" class="status-author">@${escapeHtml(displayHandle)}</a>
801 is feeling ${status.status}
802 </span>
803 <div class="status-date">${formatDate(status.createdAt)}</div>
804 </div>
805 </li>
806 `;
807 })
808 .join("")}
809 </ul>
810 </div>
811 `;
812 }
813
814 function renderLoading(container) {
815 document.getElementById(container).innerHTML = `
816 <div class="card">
817 <p class="loading">Loading...</p>
818 </div>
819 `;
820 }
821
822 // Run on page load
823 main();
824 </script>
825 </body>
826</html>