Tools for the Atmosphere tools.slices.network
quickslice atproto html

feat: add lexicon validator tool

- Single-page HTML tool to validate AT Protocol lexicon schemas and records
- NSID dropdown auto-populates from entered lexicons with record types
- Record template auto-generates from required fields with sensible defaults
- Reset button to regenerate template
- Powered by honk library (hexdocs.pm/honk)
- Add prettier config and format.sh for HTML formatting
- Update index.html and README.md

+986 -78
+9
.prettierrc
···
··· 1 + { 2 + "printWidth": 100, 3 + "tabWidth": 2, 4 + "useTabs": false, 5 + "semi": true, 6 + "singleQuote": false, 7 + "htmlWhitespaceSensitivity": "css", 8 + "bracketSameLine": false 9 + }
+1
README.md
··· 6 7 - [Teal Plays](https://tools.slices.network/teal-plays) - Live music feed from the Atmosphere 8 - [Statusphere](https://tools.slices.network/statusphere) - Set your status on the Atmosphere
··· 6 7 - [Teal Plays](https://tools.slices.network/teal-plays) - Live music feed from the Atmosphere 8 - [Statusphere](https://tools.slices.network/statusphere) - Set your status on the Atmosphere 9 + - [Lexicon Validator](https://tools.slices.network/lexicon-validator) - Validate AT Protocol lexicon schemas and records
+2
format.sh
···
··· 1 + #!/bin/bash 2 + npx prettier --write "**/*.html"
+40 -9
index.html
··· 6 <title>Tools</title> 7 <style> 8 /* CSS Reset */ 9 - *, *::before, *::after { box-sizing: border-box; } 10 - * { margin: 0; } 11 - body { line-height: 1.5; -webkit-font-smoothing: antialiased; } 12 13 :root { 14 --bg-primary: #fafaf9; ··· 27 padding: 3rem 1rem; 28 } 29 30 - #app { max-width: 500px; margin: 0 auto; } 31 32 - header { text-align: center; margin-bottom: 2.5rem; } 33 34 .logo { 35 width: 64px; ··· 44 margin-bottom: 0.25rem; 45 } 46 47 - .tagline { color: var(--text-secondary); font-size: 0.875rem; } 48 49 .tools-list { 50 display: flex; ··· 59 padding: 1.25rem; 60 border: 1px solid var(--border); 61 text-decoration: none; 62 - transition: box-shadow 0.15s, transform 0.15s; 63 } 64 65 .tool-card:hover { ··· 120 <span class="tool-icon">🎵</span> 121 <span class="tool-name">Teal Plays</span> 122 </div> 123 - <div class="tool-description">Live music feed from the Atmosphere. See what everyone is listening to in real-time.</div> 124 </a> 125 <a href="statusphere" class="tool-card"> 126 <div class="tool-header"> 127 <span class="tool-icon">😎</span> 128 <span class="tool-name">Statusphere</span> 129 </div> 130 - <div class="tool-description">Set your status on the Atmosphere. Share how you're feeling with a single emoji.</div> 131 </a> 132 </main> 133 </div>
··· 6 <title>Tools</title> 7 <style> 8 /* CSS Reset */ 9 + *, 10 + *::before, 11 + *::after { 12 + box-sizing: border-box; 13 + } 14 + * { 15 + margin: 0; 16 + } 17 + body { 18 + line-height: 1.5; 19 + -webkit-font-smoothing: antialiased; 20 + } 21 22 :root { 23 --bg-primary: #fafaf9; ··· 36 padding: 3rem 1rem; 37 } 38 39 + #app { 40 + max-width: 500px; 41 + margin: 0 auto; 42 + } 43 44 + header { 45 + text-align: center; 46 + margin-bottom: 2.5rem; 47 + } 48 49 .logo { 50 width: 64px; ··· 59 margin-bottom: 0.25rem; 60 } 61 62 + .tagline { 63 + color: var(--text-secondary); 64 + font-size: 0.875rem; 65 + } 66 67 .tools-list { 68 display: flex; ··· 77 padding: 1.25rem; 78 border: 1px solid var(--border); 79 text-decoration: none; 80 + transition: 81 + box-shadow 0.15s, 82 + transform 0.15s; 83 } 84 85 .tool-card:hover { ··· 140 <span class="tool-icon">🎵</span> 141 <span class="tool-name">Teal Plays</span> 142 </div> 143 + <div class="tool-description"> 144 + Live music feed from the Atmosphere. See what everyone is listening to in real-time. 145 + </div> 146 </a> 147 <a href="statusphere" class="tool-card"> 148 <div class="tool-header"> 149 <span class="tool-icon">😎</span> 150 <span class="tool-name">Statusphere</span> 151 </div> 152 + <div class="tool-description"> 153 + Set your status on the Atmosphere. Share how you're feeling with a single emoji. 154 + </div> 155 + </a> 156 + <a href="lexicon-validator" class="tool-card"> 157 + <div class="tool-header"> 158 + <span class="tool-icon" style="color: #0066cc">{ }</span> 159 + <span class="tool-name">Lexicon Validator</span> 160 + </div> 161 + <div class="tool-description">Validate AT Protocol lexicon schemas and records.</div> 162 </a> 163 </main> 164 </div>
+795
lexicon-validator.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';" 9 + /> 10 + <title>{ Lexicon Validator }</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 + textarea { 28 + font: inherit; 29 + } 30 + 31 + /* Light Theme */ 32 + :root { 33 + --bg-primary: #f5f5f5; 34 + --bg-card: #ffffff; 35 + --bg-input: #fafafa; 36 + --text-primary: #1a1a1a; 37 + --text-secondary: #666666; 38 + --accent: #0066cc; 39 + --accent-hover: #0052a3; 40 + --border: #e0e0e0; 41 + --border-focus: #0066cc; 42 + --error-bg: #fef2f2; 43 + --error-border: #fca5a5; 44 + --error-text: #dc2626; 45 + --success-bg: #f0fdf4; 46 + --success-border: #86efac; 47 + --success-text: #16a34a; 48 + } 49 + 50 + body { 51 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 52 + background: var(--bg-primary); 53 + color: var(--text-primary); 54 + min-height: 100vh; 55 + padding: 2rem 1rem; 56 + } 57 + 58 + #app { 59 + max-width: 700px; 60 + margin: 0 auto; 61 + } 62 + 63 + header { 64 + text-align: center; 65 + margin-bottom: 2rem; 66 + } 67 + 68 + header h1 { 69 + font-size: 2rem; 70 + color: var(--text-primary); 71 + margin-bottom: 0.25rem; 72 + } 73 + 74 + .tagline { 75 + color: var(--text-secondary); 76 + font-size: 0.875rem; 77 + } 78 + 79 + .tagline a { 80 + color: var(--accent); 81 + text-decoration: none; 82 + } 83 + 84 + .tagline a:hover { 85 + text-decoration: underline; 86 + } 87 + 88 + /* Sections */ 89 + .section { 90 + background: var(--bg-card); 91 + border-radius: 0.5rem; 92 + padding: 1rem; 93 + margin-bottom: 1rem; 94 + border: 1px solid var(--border); 95 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); 96 + } 97 + 98 + .section-header { 99 + display: flex; 100 + justify-content: space-between; 101 + align-items: center; 102 + margin-bottom: 0.75rem; 103 + } 104 + 105 + .section-title { 106 + font-weight: 600; 107 + font-size: 0.875rem; 108 + text-transform: uppercase; 109 + letter-spacing: 0.05em; 110 + color: var(--text-secondary); 111 + } 112 + 113 + /* Buttons */ 114 + .btn { 115 + padding: 0.5rem 1rem; 116 + border: none; 117 + border-radius: 0.375rem; 118 + font-size: 0.875rem; 119 + font-weight: 500; 120 + cursor: pointer; 121 + transition: 122 + background-color 0.15s, 123 + opacity 0.15s; 124 + } 125 + 126 + .btn-primary { 127 + background: var(--accent); 128 + color: #ffffff; 129 + } 130 + 131 + .btn-primary:hover { 132 + background: var(--accent-hover); 133 + } 134 + 135 + .btn-secondary { 136 + background: var(--bg-card); 137 + color: var(--text-primary); 138 + border: 1px solid var(--border); 139 + } 140 + 141 + .btn-secondary:hover { 142 + background: var(--bg-primary); 143 + } 144 + 145 + .btn-small { 146 + padding: 0.25rem 0.5rem; 147 + font-size: 0.75rem; 148 + } 149 + 150 + .btn-danger { 151 + background: var(--error-bg); 152 + color: var(--error-text); 153 + border: 1px solid var(--error-border); 154 + } 155 + 156 + .btn-danger:hover { 157 + background: #fee2e2; 158 + } 159 + 160 + .btn:disabled { 161 + opacity: 0.5; 162 + cursor: not-allowed; 163 + } 164 + 165 + /* Editors */ 166 + .editor-card { 167 + background: var(--bg-input); 168 + border: 1px solid var(--border); 169 + border-radius: 0.375rem; 170 + margin-bottom: 0.75rem; 171 + } 172 + 173 + .editor-header { 174 + display: flex; 175 + justify-content: space-between; 176 + align-items: center; 177 + padding: 0.5rem 0.75rem; 178 + border-bottom: 1px solid var(--border); 179 + background: var(--bg-card); 180 + border-radius: 0.375rem 0.375rem 0 0; 181 + } 182 + 183 + .editor-label { 184 + font-size: 0.75rem; 185 + color: var(--text-secondary); 186 + font-weight: 500; 187 + } 188 + 189 + textarea { 190 + width: 100%; 191 + min-height: 200px; 192 + padding: 0.75rem; 193 + background: var(--bg-input); 194 + border: none; 195 + color: var(--text-primary); 196 + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; 197 + font-size: 0.8125rem; 198 + resize: vertical; 199 + border-radius: 0 0 0.375rem 0.375rem; 200 + } 201 + 202 + textarea:focus { 203 + outline: none; 204 + background: #ffffff; 205 + } 206 + 207 + textarea::placeholder { 208 + color: var(--text-secondary); 209 + opacity: 0.6; 210 + } 211 + 212 + /* NSID Input */ 213 + .nsid-row { 214 + display: flex; 215 + align-items: center; 216 + gap: 0.75rem; 217 + margin-bottom: 0.75rem; 218 + } 219 + 220 + .nsid-label { 221 + font-size: 0.875rem; 222 + color: var(--text-secondary); 223 + white-space: nowrap; 224 + } 225 + 226 + input[type="text"], 227 + select { 228 + flex: 1; 229 + padding: 0.5rem 0.75rem; 230 + background: var(--bg-input); 231 + border: 1px solid var(--border); 232 + border-radius: 0.375rem; 233 + color: var(--text-primary); 234 + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; 235 + font-size: 0.8125rem; 236 + } 237 + 238 + input[type="text"]:focus, 239 + select:focus { 240 + outline: none; 241 + border-color: var(--border-focus); 242 + background: #ffffff; 243 + } 244 + 245 + input[type="text"]::placeholder { 246 + color: var(--text-secondary); 247 + opacity: 0.6; 248 + } 249 + 250 + select:disabled { 251 + opacity: 0.6; 252 + cursor: not-allowed; 253 + } 254 + 255 + /* Results */ 256 + .result { 257 + padding: 1rem; 258 + border-radius: 0.375rem; 259 + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; 260 + font-size: 0.8125rem; 261 + white-space: pre-wrap; 262 + word-break: break-word; 263 + } 264 + 265 + .result-success { 266 + background: var(--success-bg); 267 + border: 1px solid var(--success-border); 268 + color: var(--success-text); 269 + } 270 + 271 + .result-error { 272 + background: var(--error-bg); 273 + border: 1px solid var(--error-border); 274 + color: var(--error-text); 275 + } 276 + 277 + .result:empty { 278 + display: none; 279 + } 280 + 281 + /* Button row */ 282 + .button-row { 283 + display: flex; 284 + gap: 0.5rem; 285 + margin-top: 0.75rem; 286 + } 287 + 288 + .hidden { 289 + display: none !important; 290 + } 291 + </style> 292 + </head> 293 + <body> 294 + <div id="app"> 295 + <header> 296 + <h1><span style="color: var(--accent)">{</span> Lexicon Validator <span style="color: var(--accent)">}</span></h1> 297 + <p class="tagline"> 298 + Powered by <a href="https://hexdocs.pm/honk/index.html" target="_blank">honk</a> 299 + </p> 300 + </header> 301 + <main> 302 + <div id="lexicons-section" class="section"></div> 303 + <div id="record-section" class="section"></div> 304 + <div id="results-section"></div> 305 + </main> 306 + </div> 307 + <script src="https://cdn.jsdelivr.net/gh/bigmoves/honk@deaa420/dist/honk.min.js"></script> 308 + <script> 309 + // ============================================================================= 310 + // STATE 311 + // ============================================================================= 312 + 313 + const state = { 314 + lexicons: [{ id: 1, value: "" }], 315 + nextLexiconId: 2, 316 + record: "", 317 + nsid: "", 318 + availableNsids: [], 319 + result: null, 320 + }; 321 + 322 + // ============================================================================= 323 + // PLACEHOLDERS 324 + // ============================================================================= 325 + 326 + const LEXICON_PLACEHOLDER = `{ 327 + "lexicon": 1, 328 + "id": "com.example.myRecord", 329 + "defs": { 330 + "main": { 331 + "type": "record", 332 + "record": { 333 + "type": "object", 334 + "required": ["status", "createdAt"], 335 + "properties": { 336 + "status": { 337 + "type": "string", 338 + "maxLength": 100 339 + }, 340 + "createdAt": { 341 + "type": "string", 342 + "format": "datetime" 343 + } 344 + } 345 + } 346 + } 347 + } 348 + }`; 349 + 350 + const RECORD_PLACEHOLDER = `{ 351 + "status": "Hello world", 352 + "createdAt": "2025-01-15T12:00:00Z" 353 + }`; 354 + 355 + // ============================================================================= 356 + // HELPERS 357 + // ============================================================================= 358 + 359 + function esc(str) { 360 + const d = document.createElement("div"); 361 + d.textContent = str || ""; 362 + return d.innerHTML; 363 + } 364 + 365 + function escapeAttr(str) { 366 + return (str || "") 367 + .replace(/&/g, "&amp;") 368 + .replace(/"/g, "&quot;") 369 + .replace(/</g, "&lt;") 370 + .replace(/>/g, "&gt;"); 371 + } 372 + 373 + function unwrapHonkResult(result) { 374 + if (typeof result.isOk === "function") { 375 + return { ok: result.isOk(), value: result[0] }; 376 + } 377 + return { ok: true, value: result }; 378 + } 379 + 380 + function formatHonkError(err) { 381 + if (!err) return "Unknown error"; 382 + if (typeof err === "string") return err; 383 + if (err.message) { 384 + return err.path ? `${err.message} (at ${err.path})` : err.message; 385 + } 386 + return JSON.stringify(err, null, 2); 387 + } 388 + 389 + // ============================================================================= 390 + // RENDERING 391 + // ============================================================================= 392 + 393 + function renderLexiconsSection() { 394 + const section = document.getElementById("lexicons-section"); 395 + 396 + const editorsHtml = state.lexicons 397 + .map( 398 + (lex, idx) => ` 399 + <div class="editor-card" data-lexicon-id="${lex.id}"> 400 + <div class="editor-header"> 401 + <span class="editor-label">Lexicon ${idx + 1}</span> 402 + <button 403 + class="btn btn-danger btn-small" 404 + onclick="removeLexicon(${lex.id})" 405 + ${state.lexicons.length === 1 ? "disabled" : ""} 406 + >Remove</button> 407 + </div> 408 + <textarea 409 + placeholder="${escapeAttr(LEXICON_PLACEHOLDER)}" 410 + onchange="updateLexicon(${lex.id}, this.value)" 411 + oninput="updateLexicon(${lex.id}, this.value)" 412 + >${esc(lex.value)}</textarea> 413 + </div> 414 + `, 415 + ) 416 + .join(""); 417 + 418 + section.innerHTML = ` 419 + <div class="section-header"> 420 + <span class="section-title">Lexicons</span> 421 + </div> 422 + ${editorsHtml} 423 + <div class="button-row"> 424 + <button class="btn btn-secondary" onclick="addLexicon()">+ Add Lexicon</button> 425 + <button class="btn btn-primary" onclick="validateLexicons()">Validate Lexicons</button> 426 + </div> 427 + `; 428 + } 429 + 430 + function renderRecordSection() { 431 + const section = document.getElementById("record-section"); 432 + 433 + const optionsHtml = 434 + state.availableNsids.length === 0 435 + ? '<option value="" disabled selected>Enter a lexicon with a record type</option>' 436 + : [ 437 + `<option value="" disabled ${!state.nsid ? "selected" : ""}>Select a record type</option>`, 438 + ] 439 + .concat( 440 + state.availableNsids.map( 441 + (nsid) => 442 + `<option value="${escapeAttr(nsid)}" ${state.nsid === nsid ? "selected" : ""}>${esc(nsid)}</option>`, 443 + ), 444 + ) 445 + .join(""); 446 + 447 + section.innerHTML = ` 448 + <div class="section-header"> 449 + <span class="section-title">Record</span> 450 + </div> 451 + <div class="nsid-row"> 452 + <span class="nsid-label">NSID:</span> 453 + <select 454 + id="nsid-select" 455 + onchange="updateNsid(this.value)" 456 + ${state.availableNsids.length === 0 ? "disabled" : ""} 457 + >${optionsHtml}</select> 458 + </div> 459 + <div class="editor-card"> 460 + <div class="editor-header"> 461 + <span class="editor-label">Record Data</span> 462 + <button 463 + class="btn btn-secondary btn-small" 464 + onclick="resetRecordTemplate()" 465 + ${!state.nsid ? "disabled" : ""} 466 + >Reset</button> 467 + </div> 468 + <textarea 469 + id="record-input" 470 + placeholder="${escapeAttr(RECORD_PLACEHOLDER)}" 471 + onchange="updateRecord(this.value)" 472 + oninput="updateRecord(this.value)" 473 + >${esc(state.record)}</textarea> 474 + </div> 475 + <div class="button-row"> 476 + <button class="btn btn-primary" onclick="validateRecord()">Validate Record</button> 477 + </div> 478 + `; 479 + } 480 + 481 + function renderResult() { 482 + const section = document.getElementById("results-section"); 483 + 484 + if (!state.result) { 485 + section.innerHTML = ""; 486 + return; 487 + } 488 + 489 + const cls = state.result.success ? "result-success" : "result-error"; 490 + const icon = state.result.success ? "✓" : "✗"; 491 + 492 + section.innerHTML = ` 493 + <div class="result ${cls}">${icon} ${esc(state.result.message)}</div> 494 + `; 495 + } 496 + 497 + // ============================================================================= 498 + // STATE UPDATES 499 + // ============================================================================= 500 + 501 + function addLexicon() { 502 + state.lexicons.push({ id: state.nextLexiconId++, value: "" }); 503 + renderLexiconsSection(); 504 + } 505 + 506 + function removeLexicon(id) { 507 + if (state.lexicons.length <= 1) return; 508 + state.lexicons = state.lexicons.filter((l) => l.id !== id); 509 + renderLexiconsSection(); 510 + } 511 + 512 + function updateLexicon(id, value) { 513 + const lex = state.lexicons.find((l) => l.id === id); 514 + if (lex) lex.value = value; 515 + updateAvailableNsids(); 516 + } 517 + 518 + function updateAvailableNsids() { 519 + const nsids = []; 520 + for (const lex of state.lexicons) { 521 + const trimmed = lex.value.trim(); 522 + if (!trimmed) continue; 523 + try { 524 + const obj = JSON.parse(trimmed); 525 + if (obj.id && obj.defs?.main?.type === "record") { 526 + nsids.push(obj.id); 527 + } 528 + } catch (e) { 529 + // Invalid JSON, skip 530 + } 531 + } 532 + state.availableNsids = nsids; 533 + renderRecordSection(); 534 + } 535 + 536 + function updateRecord(value) { 537 + state.record = value; 538 + } 539 + 540 + function resetRecordTemplate() { 541 + if (state.nsid) { 542 + const template = generateRecordTemplate(state.nsid); 543 + if (template) { 544 + state.record = template; 545 + renderRecordSection(); 546 + } 547 + } 548 + } 549 + 550 + function updateNsid(value) { 551 + state.nsid = value; 552 + if (value) { 553 + const template = generateRecordTemplate(value); 554 + if (template) { 555 + state.record = template; 556 + } 557 + } 558 + renderRecordSection(); 559 + } 560 + 561 + function generateRecordTemplate(nsid) { 562 + for (const lex of state.lexicons) { 563 + const trimmed = lex.value.trim(); 564 + if (!trimmed) continue; 565 + try { 566 + const obj = JSON.parse(trimmed); 567 + if (obj.id === nsid && obj.defs?.main?.type === "record") { 568 + const record = obj.defs.main.record; 569 + if (record?.type === "object" && record.properties) { 570 + const template = {}; 571 + const required = record.required || []; 572 + for (const field of required) { 573 + const prop = record.properties[field]; 574 + if (prop) { 575 + template[field] = getDefaultValue(prop); 576 + } 577 + } 578 + return JSON.stringify(template, null, 2); 579 + } 580 + } 581 + } catch (e) { 582 + // Invalid JSON, skip 583 + } 584 + } 585 + return null; 586 + } 587 + 588 + function getDefaultValue(prop) { 589 + switch (prop.type) { 590 + case "string": 591 + if (prop.format === "datetime") return new Date().toISOString(); 592 + if (prop.format === "uri") return "https://example.com"; 593 + if (prop.format === "at-uri") return "at://did:plc:example/app.bsky.feed.post/abc123"; 594 + if (prop.format === "did") return "did:plc:example"; 595 + if (prop.format === "handle") return "user.example.com"; 596 + if (prop.format === "cid") return "bafyreib..."; 597 + if (prop.const) return prop.const; 598 + if (prop.enum) return prop.enum[0]; 599 + return ""; 600 + case "integer": 601 + return prop.minimum ?? prop.default ?? 0; 602 + case "boolean": 603 + return prop.default ?? false; 604 + case "array": 605 + return []; 606 + case "object": 607 + return {}; 608 + case "blob": 609 + return { $type: "blob", ref: { $link: "" }, mimeType: "", size: 0 }; 610 + default: 611 + return null; 612 + } 613 + } 614 + 615 + // ============================================================================= 616 + // VALIDATION 617 + // ============================================================================= 618 + 619 + function parseLexicons() { 620 + const parsed = []; 621 + for (let i = 0; i < state.lexicons.length; i++) { 622 + const lex = state.lexicons[i]; 623 + const trimmed = lex.value.trim(); 624 + 625 + if (!trimmed) { 626 + return { 627 + error: `Lexicon ${i + 1}: Empty - please enter a lexicon schema`, 628 + }; 629 + } 630 + 631 + // Validate JSON syntax first 632 + try { 633 + const obj = JSON.parse(trimmed); 634 + if (Array.isArray(obj)) { 635 + return { 636 + error: `Lexicon ${i + 1}: Expected a single lexicon object, not an array. Use "+ Add Lexicon" for multiple.`, 637 + }; 638 + } 639 + } catch (e) { 640 + return { error: `Lexicon ${i + 1}: Invalid JSON - ${e.message}` }; 641 + } 642 + 643 + // Parse to Gleam Json type 644 + const parseResult = honk.parse_json_string(trimmed); 645 + const unwrapped = unwrapHonkResult(parseResult); 646 + if (!unwrapped.ok) { 647 + return { error: `Lexicon ${i + 1}: ${formatHonkError(unwrapped.value)}` }; 648 + } 649 + parsed.push(unwrapped.value); 650 + } 651 + return { lexicons: parsed }; 652 + } 653 + 654 + function validateLexicons() { 655 + state.result = null; 656 + 657 + const parseResult = parseLexicons(); 658 + if (parseResult.error) { 659 + state.result = { success: false, message: parseResult.error }; 660 + renderResult(); 661 + return; 662 + } 663 + 664 + try { 665 + // Convert JS array to Gleam List 666 + const lexiconList = honk.toList(parseResult.lexicons); 667 + const result = honk.validate(lexiconList); 668 + const unwrapped = unwrapHonkResult(result); 669 + 670 + if (unwrapped.ok) { 671 + const count = parseResult.lexicons.length; 672 + const noun = count === 1 ? "lexicon" : "lexicons"; 673 + state.result = { success: true, message: `${count} ${noun} valid` }; 674 + } else { 675 + state.result = { 676 + success: false, 677 + message: `Validation failed: ${formatHonkError(unwrapped.value)}`, 678 + }; 679 + } 680 + } catch (e) { 681 + state.result = { 682 + success: false, 683 + message: `Validation error: ${e.message}`, 684 + }; 685 + } 686 + 687 + renderResult(); 688 + } 689 + 690 + function validateRecord() { 691 + state.result = null; 692 + 693 + const nsid = state.nsid.trim(); 694 + if (!nsid) { 695 + state.result = { 696 + success: false, 697 + message: "NSID is required for record validation", 698 + }; 699 + renderResult(); 700 + return; 701 + } 702 + 703 + if (!honk.is_valid_nsid(nsid)) { 704 + state.result = { 705 + success: false, 706 + message: `Invalid NSID format: "${nsid}"`, 707 + }; 708 + renderResult(); 709 + return; 710 + } 711 + 712 + const parseResult = parseLexicons(); 713 + if (parseResult.error) { 714 + state.result = { success: false, message: parseResult.error }; 715 + renderResult(); 716 + return; 717 + } 718 + 719 + const recordTrimmed = state.record.trim(); 720 + if (!recordTrimmed) { 721 + state.result = { success: false, message: "Record data is required" }; 722 + renderResult(); 723 + return; 724 + } 725 + 726 + // Validate JSON syntax first 727 + try { 728 + JSON.parse(recordTrimmed); 729 + } catch (e) { 730 + state.result = { 731 + success: false, 732 + message: `Record: Invalid JSON - ${e.message}`, 733 + }; 734 + renderResult(); 735 + return; 736 + } 737 + 738 + // Parse record to Gleam Json type 739 + const recordParseResult = honk.parse_json_string(recordTrimmed); 740 + const recordUnwrapped = unwrapHonkResult(recordParseResult); 741 + if (!recordUnwrapped.ok) { 742 + state.result = { 743 + success: false, 744 + message: `Record: ${formatHonkError(recordUnwrapped.value)}`, 745 + }; 746 + renderResult(); 747 + return; 748 + } 749 + 750 + try { 751 + // Convert JS array to Gleam List 752 + const lexiconList = honk.toList(parseResult.lexicons); 753 + const result = honk.validate_record(lexiconList, nsid, recordUnwrapped.value); 754 + const unwrapped = unwrapHonkResult(result); 755 + 756 + if (unwrapped.ok) { 757 + state.result = { 758 + success: true, 759 + message: `Record valid against ${nsid}`, 760 + }; 761 + } else { 762 + state.result = { 763 + success: false, 764 + message: `Record invalid: ${formatHonkError(unwrapped.value)}`, 765 + }; 766 + } 767 + } catch (e) { 768 + state.result = { 769 + success: false, 770 + message: `Validation error: ${e.message}`, 771 + }; 772 + } 773 + 774 + renderResult(); 775 + } 776 + 777 + // ============================================================================= 778 + // INIT 779 + // ============================================================================= 780 + 781 + function init() { 782 + if (typeof honk === "undefined") { 783 + document.getElementById("results-section").innerHTML = ` 784 + <div class="result result-error">✗ Failed to load honk library from CDN. Check your internet connection.</div> 785 + `; 786 + return; 787 + } 788 + renderLexiconsSection(); 789 + renderRecordSection(); 790 + } 791 + 792 + window.addEventListener("DOMContentLoaded", init); 793 + </script> 794 + </body> 795 + </html>
+10 -28
statusphere.html
··· 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; ··· 419 // Check if this is an OAuth callback 420 if (window.location.search.includes("code=")) { 421 if (!CLIENT_ID) { 422 - showError( 423 - "OAuth callback received but CLIENT_ID is not configured.", 424 - ); 425 renderLoginForm(); 426 return; 427 } ··· 518 body: JSON.stringify({ query }), 519 }); 520 const result = await response.json(); 521 - return ( 522 - result.data?.xyzStatusphereStatus?.edges?.map((e) => e.node) || [] 523 - ); 524 } 525 } 526 ··· 614 615 try { 616 // Disable buttons while posting 617 - document 618 - .querySelectorAll(".emoji-btn") 619 - .forEach((btn) => (btn.disabled = true)); 620 621 await postStatus(emoji); 622 ··· 625 } catch (error) { 626 showError(`Failed to post status: ${error.message}`); 627 // Re-enable buttons 628 - document 629 - .querySelectorAll(".emoji-btn") 630 - .forEach((btn) => (btn.disabled = false)); 631 } 632 } 633 ··· 674 return date.toLocaleDateString("en-US", { 675 month: "short", 676 day: "numeric", 677 - year: 678 - date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, 679 }); 680 } 681 ··· 720 721 function renderUserCard(viewer) { 722 const container = document.getElementById("auth-section"); 723 - const displayName = 724 - viewer?.appBskyActorProfileByDid?.displayName || "User"; 725 const handle = viewer?.handle || "unknown"; 726 const avatarUrl = viewer?.appBskyActorProfileByDid?.avatar?.url; 727 ··· 729 <div class="card user-card"> 730 <div class="user-info"> 731 <div class="user-avatar"> 732 - ${ 733 - avatarUrl 734 - ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` 735 - : "👤" 736 - } 737 </div> 738 <div> 739 <div class="user-name">Hi, ${escapeHtml(displayName)}!</div> ··· 786 <ul class="status-list"> 787 ${statuses 788 .map((status) => { 789 - const handle = 790 - status.appBskyActorProfileByDid?.actorHandle || status.did; 791 const displayHandle = handle.startsWith("did:") 792 ? handle.substring(0, 20) + "..." 793 : handle; ··· 805 )}</a> 806 is feeling ${status.status} 807 </span> 808 - <div class="status-date">${formatDate( 809 - status.createdAt, 810 - )}</div> 811 </div> 812 </li> 813 `;
··· 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; ··· 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 } ··· 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 ··· 609 610 try { 611 // Disable buttons while posting 612 + document.querySelectorAll(".emoji-btn").forEach((btn) => (btn.disabled = true)); 613 614 await postStatus(emoji); 615 ··· 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 ··· 665 return date.toLocaleDateString("en-US", { 666 month: "short", 667 day: "numeric", 668 + year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, 669 }); 670 } 671 ··· 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 ··· 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> ··· 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; ··· 789 )}</a> 790 is feeling ${status.status} 791 </span> 792 + <div class="status-date">${formatDate(status.createdAt)}</div> 793 </div> 794 </li> 795 `;
+129 -41
teal-plays.html
··· 10 <title>Teal Plays</title> 11 <style> 12 /* CSS Reset */ 13 - *, *::before, *::after { box-sizing: border-box; } 14 - * { margin: 0; } 15 - body { line-height: 1.5; -webkit-font-smoothing: antialiased; } 16 - input, button { font: inherit; } 17 18 /* Dark Music Theme */ 19 :root { ··· 38 padding: 2rem 1rem; 39 } 40 41 - #app { max-width: 600px; margin: 0 auto; } 42 43 /* Header */ 44 - header { text-align: center; margin-bottom: 1.5rem; } 45 46 header h1 { 47 font-size: 2rem; ··· 67 } 68 69 @keyframes pulse { 70 - 0%, 100% { opacity: 1; transform: scale(1); } 71 - 50% { opacity: 0.5; transform: scale(1.2); } 72 } 73 74 - .tagline { color: var(--text-secondary); font-size: 0.875rem; } 75 76 /* Buttons */ 77 .btn { ··· 81 font-size: 0.875rem; 82 font-weight: 500; 83 cursor: pointer; 84 - transition: background-color 0.15s, opacity 0.15s; 85 } 86 87 - .btn-primary { background: var(--accent); color: var(--bg-primary); } 88 - .btn-primary:hover { background: var(--accent-hover); } 89 - .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } 90 91 .btn-secondary { 92 background: var(--bg-hover); 93 color: var(--text-primary); 94 border: 1px solid var(--border); 95 } 96 - .btn-secondary:hover { background: var(--border); } 97 98 /* Cards */ 99 .card { ··· 109 } 110 111 @keyframes highlight-fade { 112 - 0% { border-color: var(--accent); box-shadow: 0 0 10px rgba(29, 185, 84, 0.3); } 113 - 100% { border-color: var(--border); box-shadow: none; } 114 } 115 116 /* Play Card */ ··· 130 flex-shrink: 0; 131 } 132 133 - .play-avatar img { width: 100%; height: 100%; object-fit: cover; } 134 135 - .play-meta { flex: 1; min-width: 0; } 136 137 .play-user { 138 color: var(--accent); ··· 140 font-weight: 500; 141 font-size: 0.875rem; 142 } 143 - .play-user:hover { text-decoration: underline; } 144 145 - .play-time { color: var(--text-secondary); font-size: 0.75rem; } 146 147 .play-track { 148 font-size: 1.125rem; ··· 168 flex-shrink: 0; 169 } 170 171 - .play-artist { color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.125rem; } 172 - .play-album { color: var(--text-secondary); font-size: 0.75rem; font-style: italic; } 173 174 - .play-content { display: flex; gap: 0.75rem; } 175 176 .play-art { 177 width: 64px; ··· 197 color: var(--text-secondary); 198 } 199 200 - .play-art img.hidden { display: none; } 201 - .play-art:has(img:not(.hidden)) .play-art-fallback { display: none; } 202 203 - .play-info { flex: 1; min-width: 0; } 204 205 .play-links { 206 display: flex; ··· 215 text-decoration: none; 216 font-size: 0.75rem; 217 } 218 - .play-link:hover { color: var(--accent); } 219 220 /* Status */ 221 .status-msg { ··· 224 padding: 2rem; 225 } 226 227 - .load-more { text-align: center; padding: 1rem; } 228 229 /* Error Banner */ 230 #error-banner { ··· 244 z-index: 100; 245 } 246 247 - #error-banner.hidden { display: none; } 248 #error-banner button { 249 background: none; 250 border: none; ··· 254 line-height: 1; 255 } 256 257 - .hidden { display: none !important; } 258 259 /* Spinner */ 260 .spinner { ··· 268 } 269 270 @keyframes spin { 271 - to { transform: rotate(360deg); } 272 } 273 274 .loading-container { ··· 317 cursor: null, 318 hasMore: true, 319 isLoading: false, 320 - liveConnected: false 321 }; 322 323 // ============================================================================= ··· 389 const res = await fetch(`${SERVER_URL}/graphql`, { 390 method: "POST", 391 headers: { "Content-Type": "application/json" }, 392 - body: JSON.stringify({ query: PLAYS_QUERY, variables }) 393 }); 394 395 if (!res.ok) throw new Error(`HTTP ${res.status}`); ··· 441 } 442 443 function getArtists(play) { 444 - if (play.artists?.length) return play.artists.map(a => a.artistName).join(", "); 445 if (play.artistNames?.length) return play.artistNames.join(", "); 446 return "Unknown Artist"; 447 } ··· 455 "tidal.com": "Tidal", 456 "deezer.com": "Deezer", 457 "soundcloud.com": "SoundCloud", 458 - "music.youtube.com": "YouTube Music" 459 }; 460 return map[domain] || domain; 461 } ··· 467 } 468 469 function getAlbumArtUrl(play) { 470 - if (play.releaseMbId) return `https://coverartarchive.org/release/${play.releaseMbId}/front-250`; 471 return ""; 472 } 473 ··· 588 589 function handleNewPlay(play) { 590 // Skip duplicates 591 - if (state.plays.some(p => p.uri === play.uri)) return; 592 593 // Insert in correct position by playedTime (descending) 594 const playTime = new Date(play.playedTime).getTime(); 595 - const insertIdx = state.plays.findIndex(p => new Date(p.playedTime).getTime() < playTime); 596 if (insertIdx === -1) { 597 state.plays.push(play); 598 } else { ··· 604 605 function renderFeedWithHighlight(highlightUri) { 606 const el = document.getElementById("play-feed"); 607 - el.innerHTML = state.plays.map(p => renderPlayCard(p, p.uri === highlightUri)).join(""); 608 } 609 610 function renderLoadMore() { ··· 646 return; 647 } 648 649 - el.innerHTML = state.plays.map(p => renderPlayCard(p)).join(""); 650 } 651 652 async function loadPlays(append = false) { ··· 657 658 try { 659 const data = await fetchPlays(append ? state.cursor : null); 660 - const newPlays = data.edges.map(e => e.node); 661 662 state.plays = append ? [...state.plays, ...newPlays] : newPlays; 663 state.cursor = data.pageInfo.endCursor;
··· 10 <title>Teal Plays</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 /* Dark Music Theme */ 31 :root { ··· 50 padding: 2rem 1rem; 51 } 52 53 + #app { 54 + max-width: 600px; 55 + margin: 0 auto; 56 + } 57 58 /* Header */ 59 + header { 60 + text-align: center; 61 + margin-bottom: 1.5rem; 62 + } 63 64 header h1 { 65 font-size: 2rem; ··· 85 } 86 87 @keyframes pulse { 88 + 0%, 89 + 100% { 90 + opacity: 1; 91 + transform: scale(1); 92 + } 93 + 50% { 94 + opacity: 0.5; 95 + transform: scale(1.2); 96 + } 97 } 98 99 + .tagline { 100 + color: var(--text-secondary); 101 + font-size: 0.875rem; 102 + } 103 104 /* Buttons */ 105 .btn { ··· 109 font-size: 0.875rem; 110 font-weight: 500; 111 cursor: pointer; 112 + transition: 113 + background-color 0.15s, 114 + opacity 0.15s; 115 } 116 117 + .btn-primary { 118 + background: var(--accent); 119 + color: var(--bg-primary); 120 + } 121 + .btn-primary:hover { 122 + background: var(--accent-hover); 123 + } 124 + .btn-primary:disabled { 125 + opacity: 0.5; 126 + cursor: not-allowed; 127 + } 128 129 .btn-secondary { 130 background: var(--bg-hover); 131 color: var(--text-primary); 132 border: 1px solid var(--border); 133 } 134 + .btn-secondary:hover { 135 + background: var(--border); 136 + } 137 138 /* Cards */ 139 .card { ··· 149 } 150 151 @keyframes highlight-fade { 152 + 0% { 153 + border-color: var(--accent); 154 + box-shadow: 0 0 10px rgba(29, 185, 84, 0.3); 155 + } 156 + 100% { 157 + border-color: var(--border); 158 + box-shadow: none; 159 + } 160 } 161 162 /* Play Card */ ··· 176 flex-shrink: 0; 177 } 178 179 + .play-avatar img { 180 + width: 100%; 181 + height: 100%; 182 + object-fit: cover; 183 + } 184 185 + .play-meta { 186 + flex: 1; 187 + min-width: 0; 188 + } 189 190 .play-user { 191 color: var(--accent); ··· 193 font-weight: 500; 194 font-size: 0.875rem; 195 } 196 + .play-user:hover { 197 + text-decoration: underline; 198 + } 199 200 + .play-time { 201 + color: var(--text-secondary); 202 + font-size: 0.75rem; 203 + } 204 205 .play-track { 206 font-size: 1.125rem; ··· 226 flex-shrink: 0; 227 } 228 229 + .play-artist { 230 + color: var(--text-secondary); 231 + font-size: 0.875rem; 232 + margin-bottom: 0.125rem; 233 + } 234 + .play-album { 235 + color: var(--text-secondary); 236 + font-size: 0.75rem; 237 + font-style: italic; 238 + } 239 240 + .play-content { 241 + display: flex; 242 + gap: 0.75rem; 243 + } 244 245 .play-art { 246 width: 64px; ··· 266 color: var(--text-secondary); 267 } 268 269 + .play-art img.hidden { 270 + display: none; 271 + } 272 + .play-art:has(img:not(.hidden)) .play-art-fallback { 273 + display: none; 274 + } 275 276 + .play-info { 277 + flex: 1; 278 + min-width: 0; 279 + } 280 281 .play-links { 282 display: flex; ··· 291 text-decoration: none; 292 font-size: 0.75rem; 293 } 294 + .play-link:hover { 295 + color: var(--accent); 296 + } 297 298 /* Status */ 299 .status-msg { ··· 302 padding: 2rem; 303 } 304 305 + .load-more { 306 + text-align: center; 307 + padding: 1rem; 308 + } 309 310 /* Error Banner */ 311 #error-banner { ··· 325 z-index: 100; 326 } 327 328 + #error-banner.hidden { 329 + display: none; 330 + } 331 #error-banner button { 332 background: none; 333 border: none; ··· 337 line-height: 1; 338 } 339 340 + .hidden { 341 + display: none !important; 342 + } 343 344 /* Spinner */ 345 .spinner { ··· 353 } 354 355 @keyframes spin { 356 + to { 357 + transform: rotate(360deg); 358 + } 359 } 360 361 .loading-container { ··· 404 cursor: null, 405 hasMore: true, 406 isLoading: false, 407 + liveConnected: false, 408 }; 409 410 // ============================================================================= ··· 476 const res = await fetch(`${SERVER_URL}/graphql`, { 477 method: "POST", 478 headers: { "Content-Type": "application/json" }, 479 + body: JSON.stringify({ query: PLAYS_QUERY, variables }), 480 }); 481 482 if (!res.ok) throw new Error(`HTTP ${res.status}`); ··· 528 } 529 530 function getArtists(play) { 531 + if (play.artists?.length) return play.artists.map((a) => a.artistName).join(", "); 532 if (play.artistNames?.length) return play.artistNames.join(", "); 533 return "Unknown Artist"; 534 } ··· 542 "tidal.com": "Tidal", 543 "deezer.com": "Deezer", 544 "soundcloud.com": "SoundCloud", 545 + "music.youtube.com": "YouTube Music", 546 }; 547 return map[domain] || domain; 548 } ··· 554 } 555 556 function getAlbumArtUrl(play) { 557 + if (play.releaseMbId) 558 + return `https://coverartarchive.org/release/${play.releaseMbId}/front-250`; 559 return ""; 560 } 561 ··· 676 677 function handleNewPlay(play) { 678 // Skip duplicates 679 + if (state.plays.some((p) => p.uri === play.uri)) return; 680 681 // Insert in correct position by playedTime (descending) 682 const playTime = new Date(play.playedTime).getTime(); 683 + const insertIdx = state.plays.findIndex((p) => new Date(p.playedTime).getTime() < playTime); 684 if (insertIdx === -1) { 685 state.plays.push(play); 686 } else { ··· 692 693 function renderFeedWithHighlight(highlightUri) { 694 const el = document.getElementById("play-feed"); 695 + el.innerHTML = state.plays.map((p) => renderPlayCard(p, p.uri === highlightUri)).join(""); 696 } 697 698 function renderLoadMore() { ··· 734 return; 735 } 736 737 + el.innerHTML = state.plays.map((p) => renderPlayCard(p)).join(""); 738 } 739 740 async function loadPlays(append = false) { ··· 745 746 try { 747 const data = await fetchPlays(append ? state.cursor : null); 748 + const newPlays = data.edges.map((e) => e.node); 749 750 state.plays = append ? [...state.plays, ...newPlays] : newPlays; 751 state.cursor = data.pageInfo.endCursor;