slack status without the slack status.zzstoatzz.io
hatk statusphere
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

improve login page with typeahead and FAQ accordions

- add handle typeahead using Bluesky's searchActorsTypeahead API
- show avatar, display name, and handle in suggestions dropdown
- add FAQ accordions explaining internet handles (matching plyr.fm)
- fix input/button width mismatch by removing conflicting CSS rules
- update styling to match slides/plyr.fm patterns

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

zzstoatzz 22a8262f fa0921ce

+428 -24
+202 -8
site/app.js
··· 705 705 }); 706 706 } 707 707 708 + // Handle typeahead state 709 + let handleSuggestions = []; 710 + let selectedSuggestionIndex = -1; 711 + let typeaheadDebounceTimer = null; 712 + let typeaheadAbortController = null; 713 + 714 + // Fetch handle suggestions from Bluesky 715 + async function fetchHandleSuggestions(query) { 716 + if (typeaheadAbortController) typeaheadAbortController.abort(); 717 + typeaheadAbortController = new AbortController(); 718 + 719 + try { 720 + const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=5`; 721 + const res = await fetch(url, { signal: typeaheadAbortController.signal }); 722 + if (!res.ok) throw new Error(`HTTP ${res.status}`); 723 + const data = await res.json(); 724 + return data.actors || []; 725 + } catch (e) { 726 + if (e.name === 'AbortError') return []; 727 + console.error('Typeahead error:', e); 728 + return []; 729 + } 730 + } 731 + 732 + // Render suggestions dropdown 733 + function renderSuggestions(suggestions, dropdown, input) { 734 + handleSuggestions = suggestions; 735 + selectedSuggestionIndex = -1; 736 + 737 + if (suggestions.length === 0) { 738 + dropdown.classList.add('hidden'); 739 + dropdown.innerHTML = ''; 740 + return; 741 + } 742 + 743 + dropdown.innerHTML = suggestions.map((s, i) => ` 744 + <button type="button" class="suggestion-item" data-handle="${escapeHtml(s.handle)}" data-index="${i}"> 745 + ${s.avatar ? `<img src="${escapeHtml(s.avatar)}" class="suggestion-avatar" alt="" />` : '<div class="suggestion-avatar-placeholder"></div>'} 746 + <div class="suggestion-info"> 747 + <span class="suggestion-name">${escapeHtml(s.displayName || s.handle)}</span> 748 + <span class="suggestion-handle">@${escapeHtml(s.handle)}</span> 749 + </div> 750 + </button> 751 + `).join(''); 752 + 753 + dropdown.classList.remove('hidden'); 754 + 755 + // Attach click handlers 756 + dropdown.querySelectorAll('.suggestion-item').forEach(btn => { 757 + btn.addEventListener('click', () => { 758 + input.value = btn.dataset.handle; 759 + dropdown.classList.add('hidden'); 760 + handleSuggestions = []; 761 + }); 762 + }); 763 + } 764 + 765 + // Handle keyboard navigation in suggestions 766 + function handleSuggestionKeydown(e, dropdown, input) { 767 + if (handleSuggestions.length === 0) return false; 768 + 769 + const items = dropdown.querySelectorAll('.suggestion-item'); 770 + 771 + switch (e.key) { 772 + case 'ArrowDown': 773 + e.preventDefault(); 774 + selectedSuggestionIndex = Math.min(selectedSuggestionIndex + 1, handleSuggestions.length - 1); 775 + items.forEach((item, i) => item.classList.toggle('selected', i === selectedSuggestionIndex)); 776 + return true; 777 + 778 + case 'ArrowUp': 779 + e.preventDefault(); 780 + selectedSuggestionIndex = Math.max(selectedSuggestionIndex - 1, -1); 781 + items.forEach((item, i) => item.classList.toggle('selected', i === selectedSuggestionIndex)); 782 + return true; 783 + 784 + case 'Enter': 785 + if (selectedSuggestionIndex >= 0) { 786 + e.preventDefault(); 787 + input.value = handleSuggestions[selectedSuggestionIndex].handle; 788 + dropdown.classList.add('hidden'); 789 + handleSuggestions = []; 790 + return true; 791 + } 792 + return false; 793 + 794 + case 'Escape': 795 + dropdown.classList.add('hidden'); 796 + handleSuggestions = []; 797 + return true; 798 + } 799 + return false; 800 + } 801 + 802 + // Handle input for typeahead 803 + function handleTypeaheadInput(input, dropdown) { 804 + const query = input.value.trim(); 805 + 806 + if (typeaheadDebounceTimer) clearTimeout(typeaheadDebounceTimer); 807 + 808 + if (query.length < 3) { 809 + dropdown.classList.add('hidden'); 810 + handleSuggestions = []; 811 + return; 812 + } 813 + 814 + typeaheadDebounceTimer = setTimeout(async () => { 815 + const suggestions = await fetchHandleSuggestions(query); 816 + renderSuggestions(suggestions, dropdown, input); 817 + }, 300); 818 + } 819 + 708 820 // Resolve handle to DID 709 821 async function resolveHandle(handle) { 710 822 const res = await fetch(`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`); ··· 779 891 780 892 if (!isAuthed) { 781 893 main.innerHTML = ` 782 - <div class="center"> 783 - <p>what's happening?</p> 784 - <form id="login-form"> 785 - <input type="text" id="handle-input" placeholder="your.handle" required> 786 - <button type="submit">log in</button> 787 - </form> 894 + <div class="login-container"> 895 + <div class="login-card"> 896 + <h2 class="login-title">what's happening?</h2> 897 + <p class="login-tagline">share what you're up to</p> 898 + <form id="login-form"> 899 + <div class="input-group"> 900 + <label for="handle-input">internet handle</label> 901 + <div class="handle-input-wrapper"> 902 + <input type="text" id="handle-input" placeholder="you.bsky.social" autocomplete="off" spellcheck="false" required> 903 + <div id="suggestions-dropdown" class="suggestions-dropdown hidden"></div> 904 + </div> 905 + </div> 906 + <button type="submit">sign in</button> 907 + </form> 908 + <div class="login-faq"> 909 + <button type="button" class="faq-toggle" data-faq="handle"> 910 + <span>what is an internet handle?</span> 911 + <svg class="chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 912 + <polyline points="6 9 12 15 18 9"></polyline> 913 + </svg> 914 + </button> 915 + <div id="faq-handle" class="faq-content hidden"> 916 + <p> 917 + your internet handle is a domain that identifies you across apps built on 918 + <a href="https://atproto.com" target="_blank" rel="noopener">AT Protocol</a>. 919 + if you signed up for Bluesky or another ATProto service, you already have one 920 + (like <code>yourname.bsky.social</code>). 921 + </p> 922 + <p> 923 + read more at <a href="https://internethandle.org" target="_blank" rel="noopener">internethandle.org</a>. 924 + </p> 925 + </div> 926 + <button type="button" class="faq-toggle" data-faq="signup"> 927 + <span>don't have one?</span> 928 + <svg class="chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 929 + <polyline points="6 9 12 15 18 9"></polyline> 930 + </svg> 931 + </button> 932 + <div id="faq-signup" class="faq-content hidden"> 933 + <p> 934 + the easiest way to get one is to sign up for <a href="https://bsky.app" target="_blank" rel="noopener">Bluesky</a>. 935 + once you have an account, you can use that handle here. 936 + </p> 937 + </div> 938 + </div> 939 + </div> 788 940 </div> 789 941 `; 790 - document.getElementById('login-form').addEventListener('submit', async (e) => { 942 + 943 + const loginForm = document.getElementById('login-form'); 944 + const handleInput = document.getElementById('handle-input'); 945 + const suggestionsDropdown = document.getElementById('suggestions-dropdown'); 946 + 947 + // Typeahead input handler 948 + handleInput.addEventListener('input', () => { 949 + handleTypeaheadInput(handleInput, suggestionsDropdown); 950 + }); 951 + 952 + // Keyboard navigation 953 + handleInput.addEventListener('keydown', (e) => { 954 + handleSuggestionKeydown(e, suggestionsDropdown, handleInput); 955 + }); 956 + 957 + // Close dropdown on blur (with delay for click events) 958 + handleInput.addEventListener('blur', () => { 959 + setTimeout(() => { 960 + suggestionsDropdown.classList.add('hidden'); 961 + }, 200); 962 + }); 963 + 964 + // Reopen on focus if there's content 965 + handleInput.addEventListener('focus', () => { 966 + if (handleInput.value.trim().length >= 3 && handleSuggestions.length > 0) { 967 + suggestionsDropdown.classList.remove('hidden'); 968 + } 969 + }); 970 + 971 + loginForm.addEventListener('submit', async (e) => { 791 972 e.preventDefault(); 792 - const handle = document.getElementById('handle-input').value.trim(); 973 + const handle = handleInput.value.trim(); 793 974 if (handle && client) { 794 975 await client.loginWithRedirect({ handle }); 795 976 } 977 + }); 978 + 979 + // FAQ toggle handlers 980 + document.querySelectorAll('.faq-toggle').forEach(btn => { 981 + btn.addEventListener('click', () => { 982 + const faqId = btn.dataset.faq; 983 + const content = document.getElementById(`faq-${faqId}`); 984 + const chevron = btn.querySelector('.chevron'); 985 + if (content) { 986 + content.classList.toggle('hidden'); 987 + chevron?.classList.toggle('open'); 988 + } 989 + }); 796 990 }); 797 991 } else { 798 992 const user = client.getUser();
+226 -16
site/styles.css
··· 134 134 .hidden { display: none !important; } 135 135 .center { text-align: center; padding: 2rem; } 136 136 137 - /* Login form */ 138 - #login-form { 137 + /* Login form - base button styles */ 138 + button[type="submit"] { 139 + padding: 0.75rem 1.5rem; 140 + background: var(--accent); 141 + color: white; 142 + border: none; 143 + border-radius: var(--radius); 144 + cursor: pointer; 145 + font-family: inherit; 146 + font-size: 1rem; 147 + } 148 + 149 + button[type="submit"]:hover { 150 + opacity: 0.9; 151 + } 152 + 153 + /* Login container - centered layout */ 154 + .login-container { 155 + display: flex; 156 + flex-direction: column; 157 + align-items: center; 158 + justify-content: center; 159 + min-height: 50vh; 160 + padding: 2rem 1rem; 161 + } 162 + 163 + .login-card { 164 + background: var(--bg-card); 165 + border: 1px solid var(--border); 166 + border-radius: var(--radius); 167 + padding: 2.5rem 2rem; 168 + width: 100%; 169 + max-width: 380px; 170 + text-align: center; 171 + } 172 + 173 + .login-title { 174 + font-size: 1.75rem; 175 + font-weight: 600; 176 + margin-bottom: 0.5rem; 177 + } 178 + 179 + .login-tagline { 180 + color: var(--text-secondary); 181 + font-size: 1rem; 182 + margin-bottom: 1.5rem; 183 + } 184 + 185 + .login-card #login-form { 139 186 display: flex; 187 + flex-direction: column; 188 + gap: 1.25rem; 189 + } 190 + 191 + .login-card .input-group { 192 + display: flex; 193 + flex-direction: column; 140 194 gap: 0.5rem; 141 - margin-top: 1rem; 142 - justify-content: center; 195 + } 196 + 197 + .login-card .input-group label { 198 + color: var(--text-secondary); 199 + font-size: 0.9rem; 200 + } 201 + 202 + .handle-input-wrapper { 203 + position: relative; 204 + width: 100%; 143 205 } 144 206 145 - #login-form input { 146 - padding: 0.75rem 1rem; 207 + .handle-input-wrapper input { 208 + width: 100%; 209 + padding: 0.875rem 1rem; 147 210 border: 1px solid var(--border); 148 211 border-radius: var(--radius); 149 - background: var(--bg-card); 212 + background: var(--bg); 150 213 color: var(--text); 151 214 font-family: inherit; 152 215 font-size: 1rem; 153 - width: 200px; 216 + transition: border-color 0.15s; 217 + box-sizing: border-box; 218 + } 219 + 220 + .handle-input-wrapper input:focus { 221 + outline: none; 222 + border-color: var(--accent); 154 223 } 155 224 156 - #login-form button, button[type="submit"] { 157 - padding: 0.75rem 1.5rem; 158 - background: var(--accent); 159 - color: white; 225 + .login-card button[type="submit"] { 226 + width: 100%; 227 + padding: 0.875rem 1rem; 228 + box-sizing: border-box; 229 + } 230 + 231 + /* Suggestions dropdown */ 232 + .suggestions-dropdown { 233 + position: absolute; 234 + top: 100%; 235 + left: 0; 236 + right: 0; 237 + margin-top: 4px; 238 + background: var(--bg-card); 239 + border: 1px solid var(--border); 240 + border-radius: 8px; 241 + overflow: hidden; 242 + z-index: 100; 243 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); 244 + } 245 + 246 + .suggestion-item { 247 + display: flex; 248 + align-items: center; 249 + gap: 10px; 250 + width: 100%; 251 + padding: 10px 12px; 252 + background: transparent; 160 253 border: none; 161 - border-radius: var(--radius); 254 + border-bottom: 1px solid var(--border); 255 + color: var(--text); 162 256 cursor: pointer; 257 + text-align: left; 163 258 font-family: inherit; 164 - font-size: 1rem; 259 + transition: background 0.15s; 260 + } 261 + 262 + .suggestion-item:last-child { 263 + border-bottom: none; 264 + } 265 + 266 + .suggestion-item:hover, 267 + .suggestion-item.selected { 268 + background: var(--bg); 269 + } 270 + 271 + .suggestion-avatar { 272 + width: 32px; 273 + height: 32px; 274 + border-radius: 50%; 275 + object-fit: cover; 276 + flex-shrink: 0; 277 + } 278 + 279 + .suggestion-avatar-placeholder { 280 + width: 32px; 281 + height: 32px; 282 + border-radius: 50%; 283 + background: var(--border); 284 + display: flex; 285 + align-items: center; 286 + justify-content: center; 287 + font-size: 12px; 288 + color: var(--text-secondary); 289 + flex-shrink: 0; 290 + } 291 + 292 + .suggestion-info { 293 + display: flex; 294 + flex-direction: column; 295 + min-width: 0; 296 + } 297 + 298 + .suggestion-name { 299 + font-size: 14px; 300 + font-weight: 500; 301 + white-space: nowrap; 302 + overflow: hidden; 303 + text-overflow: ellipsis; 304 + } 305 + 306 + .suggestion-handle { 307 + font-size: 12px; 308 + color: var(--text-secondary); 309 + white-space: nowrap; 310 + overflow: hidden; 311 + text-overflow: ellipsis; 312 + } 313 + 314 + /* FAQ accordions */ 315 + .login-faq { 316 + margin-top: 1.5rem; 317 + border-top: 1px solid var(--border); 318 + padding-top: 1rem; 319 + } 320 + 321 + .faq-toggle { 322 + width: 100%; 323 + display: flex; 324 + justify-content: space-between; 325 + align-items: center; 326 + padding: 0.75rem 0; 327 + background: none; 328 + border: none; 329 + color: var(--text-secondary); 330 + font-family: inherit; 331 + font-size: 0.9rem; 332 + cursor: pointer; 333 + text-align: left; 165 334 } 166 335 167 - #login-form button:hover, button[type="submit"]:hover { 168 - opacity: 0.9; 336 + .faq-toggle:hover { 337 + color: var(--text); 338 + } 339 + 340 + .faq-toggle .chevron { 341 + transition: transform 0.2s; 342 + flex-shrink: 0; 343 + } 344 + 345 + .faq-toggle .chevron.open { 346 + transform: rotate(180deg); 347 + } 348 + 349 + .faq-content { 350 + padding: 0 0 1rem 0; 351 + color: var(--text-secondary); 352 + font-size: 0.875rem; 353 + line-height: 1.6; 354 + } 355 + 356 + .faq-content p { 357 + margin: 0 0 0.75rem 0; 358 + text-align: left; 359 + } 360 + 361 + .faq-content p:last-child { 362 + margin-bottom: 0; 363 + } 364 + 365 + .faq-content a { 366 + color: var(--accent); 367 + text-decoration: none; 368 + } 369 + 370 + .faq-content a:hover { 371 + text-decoration: underline; 372 + } 373 + 374 + .faq-content code { 375 + background: var(--bg); 376 + padding: 0.15rem 0.4rem; 377 + border-radius: 4px; 378 + font-size: 0.85em; 169 379 } 170 380 171 381 /* Profile card */