web components for a integrable atproto based guestbook
1import { Client } from '@atcute/client';
2import type {} from '@atcute/atproto';
3import {
4 createAuthorizationUrl,
5 finalizeAuthorization,
6 OAuthUserAgent,
7} from '@atcute/oauth-browser-client';
8import { getConfig } from './config';
9
10// Actor typeahead web component
11import 'actor-typeahead';
12
13// Global agent instance
14let globalAgent: OAuthUserAgent | null = null;
15
16// Helper function to wait
17const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
18
19/**
20 * Web component for signing the guestbook.
21 *
22 * Usage:
23 * <guestbook-sign did="did:web:nekomimi.pet"></guestbook-sign>
24 */
25export class GuestbookSignElement extends HTMLElement {
26 private form: HTMLFormElement | null = null;
27 private messageInput: HTMLTextAreaElement | null = null;
28 private submitButton: HTMLButtonElement | null = null;
29 private statusDiv: HTMLDivElement | null = null;
30 private loginInput: HTMLInputElement | null = null;
31 private loginButton: HTMLButtonElement | null = null;
32 private userHandle: string | null = null;
33
34 static get observedAttributes() {
35 return ['did'];
36 }
37
38 constructor() {
39 super();
40 this.attachShadow({ mode: 'open' });
41 }
42
43 async connectedCallback() {
44 await this.initializeAuth();
45 if (globalAgent) {
46 await this.fetchUserHandle();
47 }
48 this.render();
49 this.attachEventListeners();
50 }
51
52 private async fetchUserHandle() {
53 if (!globalAgent) return;
54
55 try {
56 const response = await fetch(
57 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${globalAgent.session.info.sub}`
58 );
59 if (response.ok) {
60 const data = await response.json();
61 this.userHandle = data.handle || null;
62 }
63 } catch (error) {
64 console.error('Failed to fetch user handle:', error);
65 }
66 }
67
68 private async initializeAuth() {
69 // Check if we have OAuth parameters in the hash (returning from OAuth)
70 if (location.hash.length > 1) {
71 const params = new URLSearchParams(location.hash.slice(1));
72
73 if (params.has('state') && (params.has('code') || params.has('error'))) {
74 try {
75 // Scrub the parameters from history to prevent replay
76 history.replaceState(null, '', location.pathname + location.search);
77
78 // Finalize the authorization and get the result
79 const result = await finalizeAuthorization(params);
80
81 // Create the OAuth user agent with the session
82 globalAgent = new OAuthUserAgent(result.session);
83
84 console.log('Authorization successful! Logged in as:', result.session.info.sub);
85
86 } catch (error) {
87 console.error('Failed to finalize authorization:', error);
88 alert('Login failed. Please try again.');
89 }
90 }
91 }
92 }
93
94 private render() {
95 if (!this.shadowRoot) {
96 return;
97 }
98
99 const isLoggedIn = globalAgent !== null;
100
101 this.shadowRoot.innerHTML = `
102 <style>
103 * {
104 box-sizing: border-box;
105 margin: 0;
106 padding: 0;
107 }
108
109 :host {
110 display: block;
111 font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
112 -webkit-font-smoothing: antialiased;
113 -moz-osx-font-smoothing: grayscale;
114 }
115
116 .guestbook-sign {
117 background: #f9fafb;
118 padding: 0;
119 width: 100%;
120 }
121
122 h2 {
123 margin: 0 0 24px 0;
124 font-size: 11px;
125 font-weight: 700;
126 color: #000;
127 text-transform: uppercase;
128 letter-spacing: 0.05em;
129 }
130
131 .form-group {
132 margin-bottom: 24px;
133 }
134
135 label {
136 display: block;
137 margin-bottom: 8px;
138 font-size: 14px;
139 font-weight: 600;
140 color: #000;
141 }
142
143 textarea, input[type="text"] {
144 width: 100%;
145 padding: 12px 16px;
146 border: 1px solid #e5e7eb;
147 border-radius: 6px;
148 font-family: inherit;
149 font-size: 15px;
150 background: white;
151 box-sizing: border-box;
152 transition: border-color 0.2s, box-shadow 0.2s;
153 }
154
155 textarea {
156 min-height: 120px;
157 resize: vertical;
158 }
159
160 textarea:focus, input[type="text"]:focus {
161 outline: none;
162 border-color: #a1a1aa;
163 box-shadow: 0 0 0 2px rgba(161, 161, 170, 0.2);
164 }
165
166 textarea::placeholder, input[type="text"]::placeholder {
167 color: #9ca3af;
168 }
169
170 .char-count {
171 display: none;
172 }
173
174 button {
175 width: 100%;
176 background: #18181b;
177 color: white;
178 border: none;
179 border-radius: 6px;
180 padding: 12px 24px;
181 font-size: 15px;
182 font-weight: 500;
183 cursor: pointer;
184 transition: background-color 0.2s;
185 }
186
187 button:hover:not(:disabled) {
188 background: #27272a;
189 }
190
191 button:disabled {
192 background: #d1d5db;
193 cursor: not-allowed;
194 }
195
196 .status {
197 margin-top: 16px;
198 padding: 12px;
199 border-radius: 8px;
200 font-size: 14px;
201 display: none;
202 }
203
204 .status.show {
205 display: block;
206 }
207
208 .status.success {
209 background: #ecfdf5;
210 color: #065f46;
211 border: 1px solid #d1fae5;
212 }
213
214 .status.error {
215 background: #fef2f2;
216 color: #991b1b;
217 border: 1px solid #fecaca;
218 }
219
220 .status.loading {
221 background: #eff6ff;
222 color: #1e40af;
223 border: 1px solid #dbeafe;
224 }
225
226 .login-prompt {
227 display: none;
228 }
229 </style>
230
231 <div class="guestbook-sign">
232 ${!isLoggedIn ? `
233 <form id="login-form">
234 <div class="form-group">
235 <label for="handle">AT Proto Handle</label>
236 <actor-typeahead>
237 <input
238 type="text"
239 id="handle"
240 name="handle"
241 placeholder="user.bsky.social"
242 autocomplete="off"
243 data-1p-ignore
244 data-lpignore="true"
245 required
246 />
247 </actor-typeahead>
248 </div>
249 <button type="submit" id="login-btn">Sign in to Continue</button>
250 <div class="status" id="status"></div>
251 </form>
252 ` : `
253 <form id="sign-form">
254 <div class="form-group">
255 <label for="name">Name</label>
256 <input
257 type="text"
258 id="name"
259 name="name"
260 placeholder="Your name"
261 value="${this.userHandle || globalAgent?.session.info.sub || ''}"
262 readonly
263 />
264 </div>
265 <div class="form-group">
266 <label for="message">Message</label>
267 <textarea
268 id="message"
269 name="message"
270 placeholder="Share your thoughts..."
271 maxlength="100"
272 required
273 ></textarea>
274 </div>
275 <button type="submit" id="submit-btn">Sign Guestbook</button>
276 <div class="status" id="status"></div>
277 </form>
278 `}
279 </div>
280 `;
281
282 if (isLoggedIn) {
283 this.form = this.shadowRoot.querySelector('#sign-form');
284 this.messageInput = this.shadowRoot.querySelector('#message');
285 this.submitButton = this.shadowRoot.querySelector('#submit-btn');
286 } else {
287 this.form = this.shadowRoot.querySelector('#login-form');
288 this.loginInput = this.shadowRoot.querySelector('#handle');
289 this.loginButton = this.shadowRoot.querySelector('#login-btn');
290 }
291
292 this.statusDiv = this.shadowRoot.querySelector('#status');
293 }
294
295 private attachEventListeners() {
296 if (!this.form) {
297 return;
298 }
299
300 const isLoggedIn = globalAgent !== null;
301
302 if (isLoggedIn && this.messageInput) {
303 // character counter
304 this.messageInput.addEventListener('input', () => {
305 this.updateCharCount();
306 });
307
308 // form submission for signing
309 this.form.addEventListener('submit', (e) => {
310 e.preventDefault();
311 this.handleSubmit();
312 });
313 } else {
314 // form submission for login
315 this.form.addEventListener('submit', (e) => {
316 e.preventDefault();
317 this.handleLogin();
318 });
319 }
320 }
321
322 private updateCharCount() {
323 if (!this.messageInput || !this.shadowRoot) {
324 return;
325 }
326
327 const length = this.messageInput.value.length;
328 const charCountEl = this.shadowRoot.querySelector('#char-count');
329
330 if (charCountEl) {
331 charCountEl.textContent = `${length} / 100`;
332 charCountEl.classList.remove('warning', 'error');
333
334 if (length > 90) {
335 charCountEl.classList.add('error');
336 } else if (length > 80) {
337 charCountEl.classList.add('warning');
338 }
339 }
340 }
341
342 private async handleLogin() {
343 const handle = this.loginInput?.value.trim();
344
345 if (!handle) {
346 this.showStatus('Please enter your Bluesky handle.', 'error');
347 return;
348 }
349
350 try {
351 this.showStatus('Redirecting to sign in...', 'loading');
352 this.setFormDisabled(true);
353
354 const config = getConfig();
355 if (!config) {
356 throw new Error('Guestbook not configured. Call configureGuestbook() first.');
357 }
358
359 const authUrl = await createAuthorizationUrl({
360 target: { type: 'account', identifier: handle as `${string}.${string}` },
361 scope: config.oauth.scope,
362 });
363
364 // recommended to wait for the browser to persist local storage before proceeding
365 await sleep(250);
366
367 // redirect the user to sign in and authorize the app
368 window.location.assign(authUrl);
369
370 } catch (error) {
371 console.error('Login error:', error);
372 this.showStatus('Login failed. Please try again.', 'error');
373 this.setFormDisabled(false);
374 }
375 }
376
377 private async handleSubmit() {
378 if (!globalAgent) {
379 this.showStatus('Please log in first.', 'error');
380 return;
381 }
382
383 const did = this.getAttribute('did');
384 if (!did) {
385 this.showStatus('Missing guestbook DID.', 'error');
386 return;
387 }
388
389 // subject is just the DID (at-identifier format)
390 const subject = did;
391
392 const message = this.messageInput?.value.trim();
393 if (!message) {
394 this.showStatus('Please enter a message.', 'error');
395 return;
396 }
397
398 try {
399 this.showStatus('Signing guestbook...', 'loading');
400 this.setFormDisabled(true);
401
402 // get the client from the OAuth agent
403 const client = new Client({ handler: globalAgent });
404
405 // create the record
406 const record = {
407 $type: 'pet.nkp.guestbook.sign',
408 subject: subject,
409 message: message,
410 createdAt: new Date().toISOString(),
411 };
412
413 const response = await client.post('com.atproto.repo.createRecord', {
414 input: {
415 repo: globalAgent.session.info.sub, // user's DID
416 collection: 'pet.nkp.guestbook.sign',
417 record: record,
418 },
419 });
420
421 if (response.ok) {
422 this.showStatus('✓ Successfully signed the guestbook!', 'success');
423
424 // clear the form
425 if (this.messageInput) {
426 this.messageInput.value = '';
427 }
428 this.updateCharCount();
429
430 // dispatch custom event for parent to listen to
431 this.dispatchEvent(new CustomEvent('sign-created', {
432 detail: {
433 uri: response.data.uri,
434 cid: response.data.cid,
435 },
436 bubbles: true,
437 composed: true,
438 }));
439
440 // hide success message after 3 seconds
441 setTimeout(() => {
442 this.hideStatus();
443 }, 3000);
444 } else {
445 throw new Error('Failed to create record');
446 }
447 } catch (error) {
448 console.error('Error signing guestbook:', error);
449 this.showStatus(
450 `Failed to sign: ${error instanceof Error ? error.message : 'Unknown error'}`,
451 'error'
452 );
453 } finally {
454 this.setFormDisabled(false);
455 }
456 }
457
458 private showStatus(message: string, type: 'success' | 'error' | 'loading') {
459 if (!this.statusDiv) {
460 return;
461 }
462
463 this.statusDiv.textContent = message;
464 this.statusDiv.className = `status show ${type}`;
465 }
466
467 private hideStatus() {
468 if (!this.statusDiv) {
469 return;
470 }
471
472 this.statusDiv.classList.remove('show');
473 }
474
475 private setFormDisabled(disabled: boolean) {
476 if (this.messageInput) {
477 this.messageInput.disabled = disabled;
478 }
479 if (this.submitButton) {
480 this.submitButton.disabled = disabled;
481 }
482 if (this.loginInput) {
483 this.loginInput.disabled = disabled;
484 }
485 if (this.loginButton) {
486 this.loginButton.disabled = disabled;
487 }
488 }
489}
490