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 <h2>SIGN</h2>
233 ${!isLoggedIn ? `
234 <form id="login-form">
235 <div class="form-group">
236 <label for="handle">AT Proto Handle</label>
237 <actor-typeahead>
238 <input
239 type="text"
240 id="handle"
241 name="handle"
242 placeholder="user.bsky.social"
243 autocomplete="username"
244 required
245 />
246 </actor-typeahead>
247 </div>
248 <button type="submit" id="login-btn">Sign in to Continue</button>
249 <div class="status" id="status"></div>
250 </form>
251 ` : `
252 <form id="sign-form">
253 <div class="form-group">
254 <label for="name">Name</label>
255 <input
256 type="text"
257 id="name"
258 name="name"
259 placeholder="Your name"
260 value="${this.userHandle || globalAgent?.session.info.sub || ''}"
261 readonly
262 />
263 </div>
264 <div class="form-group">
265 <label for="message">Message</label>
266 <textarea
267 id="message"
268 name="message"
269 placeholder="Share your thoughts..."
270 maxlength="100"
271 required
272 ></textarea>
273 </div>
274 <button type="submit" id="submit-btn">Sign Guestbook</button>
275 <div class="status" id="status"></div>
276 </form>
277 `}
278 </div>
279 `;
280
281 if (isLoggedIn) {
282 this.form = this.shadowRoot.querySelector('#sign-form');
283 this.messageInput = this.shadowRoot.querySelector('#message');
284 this.submitButton = this.shadowRoot.querySelector('#submit-btn');
285 } else {
286 this.form = this.shadowRoot.querySelector('#login-form');
287 this.loginInput = this.shadowRoot.querySelector('#handle');
288 this.loginButton = this.shadowRoot.querySelector('#login-btn');
289 }
290
291 this.statusDiv = this.shadowRoot.querySelector('#status');
292 }
293
294 private attachEventListeners() {
295 if (!this.form) {
296 return;
297 }
298
299 const isLoggedIn = globalAgent !== null;
300
301 if (isLoggedIn && this.messageInput) {
302 // character counter
303 this.messageInput.addEventListener('input', () => {
304 this.updateCharCount();
305 });
306
307 // form submission for signing
308 this.form.addEventListener('submit', (e) => {
309 e.preventDefault();
310 this.handleSubmit();
311 });
312 } else {
313 // form submission for login
314 this.form.addEventListener('submit', (e) => {
315 e.preventDefault();
316 this.handleLogin();
317 });
318 }
319 }
320
321 private updateCharCount() {
322 if (!this.messageInput || !this.shadowRoot) {
323 return;
324 }
325
326 const length = this.messageInput.value.length;
327 const charCountEl = this.shadowRoot.querySelector('#char-count');
328
329 if (charCountEl) {
330 charCountEl.textContent = `${length} / 100`;
331 charCountEl.classList.remove('warning', 'error');
332
333 if (length > 90) {
334 charCountEl.classList.add('error');
335 } else if (length > 80) {
336 charCountEl.classList.add('warning');
337 }
338 }
339 }
340
341 private async handleLogin() {
342 const handle = this.loginInput?.value.trim();
343
344 if (!handle) {
345 this.showStatus('Please enter your Bluesky handle.', 'error');
346 return;
347 }
348
349 try {
350 this.showStatus('Redirecting to sign in...', 'loading');
351 this.setFormDisabled(true);
352
353 const config = getConfig();
354 if (!config) {
355 throw new Error('Guestbook not configured. Call configureGuestbook() first.');
356 }
357
358 const authUrl = await createAuthorizationUrl({
359 target: { type: 'account', identifier: handle as `${string}.${string}` },
360 scope: config.oauth.scope,
361 });
362
363 // recommended to wait for the browser to persist local storage before proceeding
364 await sleep(250);
365
366 // redirect the user to sign in and authorize the app
367 window.location.assign(authUrl);
368
369 } catch (error) {
370 console.error('Login error:', error);
371 this.showStatus('Login failed. Please try again.', 'error');
372 this.setFormDisabled(false);
373 }
374 }
375
376 private async handleSubmit() {
377 if (!globalAgent) {
378 this.showStatus('Please log in first.', 'error');
379 return;
380 }
381
382 const did = this.getAttribute('did');
383 if (!did) {
384 this.showStatus('Missing guestbook DID.', 'error');
385 return;
386 }
387
388 // subject is just the DID (at-identifier format)
389 const subject = did;
390
391 const message = this.messageInput?.value.trim();
392 if (!message) {
393 this.showStatus('Please enter a message.', 'error');
394 return;
395 }
396
397 try {
398 this.showStatus('Signing guestbook...', 'loading');
399 this.setFormDisabled(true);
400
401 // get the client from the OAuth agent
402 const client = new Client({ handler: globalAgent });
403
404 // create the record
405 const record = {
406 $type: 'pet.nkp.guestbook.sign',
407 subject: subject,
408 message: message,
409 createdAt: new Date().toISOString(),
410 };
411
412 const response = await client.post('com.atproto.repo.createRecord', {
413 input: {
414 repo: globalAgent.session.info.sub, // user's DID
415 collection: 'pet.nkp.guestbook.sign',
416 record: record,
417 },
418 });
419
420 if (response.ok) {
421 this.showStatus('✓ Successfully signed the guestbook!', 'success');
422
423 // clear the form
424 if (this.messageInput) {
425 this.messageInput.value = '';
426 }
427 this.updateCharCount();
428
429 // dispatch custom event for parent to listen to
430 this.dispatchEvent(new CustomEvent('sign-created', {
431 detail: {
432 uri: response.data.uri,
433 cid: response.data.cid,
434 },
435 bubbles: true,
436 composed: true,
437 }));
438
439 // hide success message after 3 seconds
440 setTimeout(() => {
441 this.hideStatus();
442 }, 3000);
443 } else {
444 throw new Error('Failed to create record');
445 }
446 } catch (error) {
447 console.error('Error signing guestbook:', error);
448 this.showStatus(
449 `Failed to sign: ${error instanceof Error ? error.message : 'Unknown error'}`,
450 'error'
451 );
452 } finally {
453 this.setFormDisabled(false);
454 }
455 }
456
457 private showStatus(message: string, type: 'success' | 'error' | 'loading') {
458 if (!this.statusDiv) {
459 return;
460 }
461
462 this.statusDiv.textContent = message;
463 this.statusDiv.className = `status show ${type}`;
464 }
465
466 private hideStatus() {
467 if (!this.statusDiv) {
468 return;
469 }
470
471 this.statusDiv.classList.remove('show');
472 }
473
474 private setFormDisabled(disabled: boolean) {
475 if (this.messageInput) {
476 this.messageInput.disabled = disabled;
477 }
478 if (this.submitButton) {
479 this.submitButton.disabled = disabled;
480 }
481 if (this.loginInput) {
482 this.loginInput.disabled = disabled;
483 }
484 if (this.loginButton) {
485 this.loginButton.disabled = disabled;
486 }
487 }
488}
489