atmosphere explorer
at main 746 lines 26 kB view raw
1import { Nsid } from "@atcute/lexicons"; 2import { AtprotoDid } from "@atcute/lexicons/syntax"; 3import { A, useLocation, useNavigate } from "@solidjs/router"; 4import { createEffect, For, Show } from "solid-js"; 5import { resolveLexiconAuthority } from "../utils/api.js"; 6import Tooltip from "./tooltip.jsx"; 7 8// Style constants 9const CONTAINER_CLASS = 10 "divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-neutral-50/50 px-3 dark:divide-neutral-700 dark:border-neutral-700 dark:bg-neutral-800/30"; 11 12const CARD_CLASS = 13 "flex flex-col gap-2 rounded-lg border border-neutral-200 bg-neutral-50/50 p-3 dark:border-neutral-700 dark:bg-neutral-800/30"; 14 15const RESOURCE_COLORS = { 16 repo: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300", 17 rpc: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300", 18 default: "bg-neutral-200 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300", 19} as const; 20 21const DEF_TYPE_COLORS = { 22 record: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300", 23 query: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300", 24 procedure: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300", 25 subscription: "bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-300", 26 object: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300", 27 token: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300", 28 "permission-set": "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300", 29 default: "bg-neutral-200 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300", 30} as const; 31 32// Utility functions 33const hasConstraints = (property: LexiconProperty | LexiconDef) => 34 property.minLength !== undefined || 35 property.maxLength !== undefined || 36 property.maxGraphemes !== undefined || 37 property.minGraphemes !== undefined || 38 property.minimum !== undefined || 39 property.maximum !== undefined || 40 property.maxSize !== undefined || 41 property.accept || 42 property.enum || 43 property.const || 44 property.default !== undefined || 45 property.knownValues || 46 property.closed; 47 48interface LexiconSchema { 49 lexicon: number; 50 id: string; 51 description?: string; 52 defs: { 53 [key: string]: LexiconDef; 54 }; 55} 56 57interface LexiconPermission { 58 type: "permission"; 59 // NOTE: blob, account, and identity are not supported in lexicon schema context 60 resource: "repo" | "rpc" | "blob" | "account" | "identity"; 61 collection?: string[]; 62 action?: string[]; 63 lxm?: string[]; 64 aud?: string; 65 inheritAud?: boolean; 66} 67 68interface LexiconDef { 69 type: string; 70 description?: string; 71 key?: string; 72 record?: LexiconObject; 73 parameters?: LexiconObject; 74 input?: { encoding: string; schema?: LexiconObject }; 75 output?: { encoding: string; schema?: LexiconObject }; 76 errors?: Array<{ name: string; description?: string }>; 77 properties?: { [key: string]: LexiconProperty }; 78 required?: string[]; 79 nullable?: string[]; 80 maxLength?: number; 81 minLength?: number; 82 maxGraphemes?: number; 83 minGraphemes?: number; 84 items?: LexiconProperty; 85 refs?: string[]; 86 closed?: boolean; 87 enum?: string[]; 88 const?: string; 89 default?: string | number | boolean; 90 minimum?: number; 91 maximum?: number; 92 accept?: string[]; 93 maxSize?: number; 94 knownValues?: string[]; 95 format?: string; 96 // Permission-set fields 97 title?: string; 98 "title:lang"?: { [lang: string]: string }; 99 detail?: string; 100 "detail:lang"?: { [lang: string]: string }; 101 permissions?: LexiconPermission[]; 102} 103 104interface LexiconObject { 105 type: string; 106 description?: string; 107 ref?: string; 108 refs?: string[]; 109 closed?: boolean; 110 properties?: { [key: string]: LexiconProperty }; 111 required?: string[]; 112 nullable?: string[]; 113} 114 115interface LexiconProperty { 116 type: string; 117 description?: string; 118 ref?: string; 119 refs?: string[]; 120 closed?: boolean; 121 format?: string; 122 items?: LexiconProperty; 123 minLength?: number; 124 maxLength?: number; 125 maxGraphemes?: number; 126 minGraphemes?: number; 127 minimum?: number; 128 maximum?: number; 129 enum?: string[]; 130 const?: string | boolean | number; 131 default?: string | number | boolean; 132 knownValues?: string[]; 133 accept?: string[]; 134 maxSize?: number; 135} 136 137const TypeBadge = (props: { type: string; format?: string; refType?: string }) => { 138 const navigate = useNavigate(); 139 const displayType = 140 props.refType ? props.refType.replace(/^#/, "") 141 : props.format ? `${props.type}:${props.format}` 142 : props.type; 143 144 const isLocalRef = () => props.refType?.startsWith("#"); 145 const isExternalRef = () => props.refType && !props.refType.startsWith("#"); 146 147 const handleClick = async () => { 148 if (isLocalRef()) { 149 const defName = props.refType!.slice(1); 150 window.history.replaceState(null, "", `#schema:${defName}`); 151 const element = document.getElementById(`def-${defName}`); 152 if (element) { 153 element.scrollIntoView({ behavior: "instant", block: "start" }); 154 } 155 } else if (isExternalRef()) { 156 try { 157 const [nsid, anchor] = props.refType!.split("#"); 158 const authority = await resolveLexiconAuthority(nsid as Nsid); 159 160 const hash = anchor ? `#schema:${anchor}` : "#schema"; 161 navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`); 162 } catch (err) { 163 console.error("Failed to resolve lexicon authority:", err); 164 } 165 } 166 }; 167 168 return ( 169 <Show 170 when={props.refType} 171 fallback={ 172 <span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">{displayType}</span> 173 } 174 > 175 <button 176 type="button" 177 onClick={handleClick} 178 class="inline-block cursor-pointer truncate font-mono text-xs text-blue-500 hover:underline dark:text-blue-400" 179 > 180 {displayType} 181 </button> 182 </Show> 183 ); 184}; 185 186const UnionBadges = (props: { refs: string[] }) => ( 187 <div class="flex flex-col items-start gap-1"> 188 <For each={props.refs}>{(refType) => <TypeBadge type="union" refType={refType} />}</For> 189 </div> 190); 191 192const ConstraintsList = (props: { property: LexiconProperty }) => { 193 const valueClass = "text-neutral-600 dark:text-neutral-400"; 194 return ( 195 <div class="flex flex-wrap gap-x-4 gap-y-1 text-xs"> 196 <Show when={props.property.minLength !== undefined}> 197 <span> 198 minLength: <span class={valueClass}>{props.property.minLength}</span> 199 </span> 200 </Show> 201 <Show when={props.property.maxLength !== undefined}> 202 <span> 203 maxLength: <span class={valueClass}>{props.property.maxLength}</span> 204 </span> 205 </Show> 206 <Show when={props.property.maxGraphemes !== undefined}> 207 <span> 208 maxGraphemes: <span class={valueClass}>{props.property.maxGraphemes}</span> 209 </span> 210 </Show> 211 <Show when={props.property.minGraphemes !== undefined}> 212 <span> 213 minGraphemes: <span class={valueClass}>{props.property.minGraphemes}</span> 214 </span> 215 </Show> 216 <Show when={props.property.minimum !== undefined}> 217 <span> 218 min: <span class={valueClass}>{props.property.minimum}</span> 219 </span> 220 </Show> 221 <Show when={props.property.maximum !== undefined}> 222 <span> 223 max: <span class={valueClass}>{props.property.maximum}</span> 224 </span> 225 </Show> 226 <Show when={props.property.maxSize !== undefined}> 227 <span> 228 maxSize: <span class={valueClass}>{props.property.maxSize}</span> 229 </span> 230 </Show> 231 <Show when={props.property.accept}> 232 <span> 233 accept: <span class={valueClass}>[{props.property.accept!.join(", ")}]</span> 234 </span> 235 </Show> 236 <Show when={props.property.enum}> 237 <span> 238 enum: <span class={valueClass}>[{props.property.enum!.join(", ")}]</span> 239 </span> 240 </Show> 241 <Show when={props.property.const}> 242 <span> 243 const: <span class={valueClass}>{props.property.const?.toString()}</span> 244 </span> 245 </Show> 246 <Show when={props.property.default !== undefined}> 247 <span> 248 default: <span class={valueClass}>{JSON.stringify(props.property.default)}</span> 249 </span> 250 </Show> 251 <Show when={props.property.knownValues}> 252 <span> 253 knownValues: <span class={valueClass}>[{props.property.knownValues!.join(", ")}]</span> 254 </span> 255 </Show> 256 <Show when={props.property.closed}> 257 <span> 258 closed: <span class={valueClass}>true</span> 259 </span> 260 </Show> 261 </div> 262 ); 263}; 264 265const PropertyRow = (props: { 266 name: string; 267 property: LexiconProperty; 268 required?: boolean; 269 hideNameType?: boolean; 270}) => { 271 return ( 272 <div class="flex flex-col gap-2 py-3"> 273 <Show when={!props.hideNameType}> 274 <div class="flex flex-wrap items-baseline gap-2"> 275 <span class="font-semibold">{props.name}</span> 276 <Show when={!props.property.refs}> 277 <TypeBadge 278 type={props.property.type} 279 format={props.property.format} 280 refType={props.property.ref} 281 /> 282 </Show> 283 <Show when={props.property.refs}> 284 <span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">union</span> 285 </Show> 286 <Show when={props.required}> 287 <span class="text-xs font-semibold text-red-500 dark:text-red-400">required</span> 288 </Show> 289 </div> 290 </Show> 291 <Show when={props.property.refs}> 292 <UnionBadges refs={props.property.refs!} /> 293 </Show> 294 <Show when={hasConstraints(props.property)}> 295 <ConstraintsList property={props.property} /> 296 </Show> 297 <Show when={props.property.items}> 298 <div class="flex flex-col gap-2"> 299 <div class="flex items-baseline gap-2 text-xs"> 300 <span class="font-medium">items:</span> 301 <Show when={!props.property.items!.refs}> 302 <TypeBadge 303 type={props.property.items!.type} 304 format={props.property.items!.format} 305 refType={props.property.items!.ref} 306 /> 307 </Show> 308 <Show when={props.property.items!.refs}> 309 <span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">union</span> 310 </Show> 311 </div> 312 <Show when={props.property.items!.refs}> 313 <UnionBadges refs={props.property.items!.refs!} /> 314 </Show> 315 </div> 316 </Show> 317 <Show when={props.property.items && hasConstraints(props.property.items)}> 318 <ConstraintsList property={props.property.items!} /> 319 </Show> 320 <Show when={props.property.description && !props.hideNameType}> 321 <p class="text-sm wrap-break-word text-neutral-700 dark:text-neutral-300"> 322 {props.property.description} 323 </p> 324 </Show> 325 </div> 326 ); 327}; 328 329const NsidLink = (props: { nsid: string }) => { 330 const navigate = useNavigate(); 331 332 const handleClick = async () => { 333 try { 334 const authority = await resolveLexiconAuthority(props.nsid as Nsid); 335 navigate(`/at://${authority}/com.atproto.lexicon.schema/${props.nsid}#schema`); 336 } catch (err) { 337 console.error("Failed to resolve lexicon authority:", err); 338 } 339 }; 340 341 return ( 342 <button 343 type="button" 344 onClick={handleClick} 345 class="cursor-pointer font-mono text-xs text-blue-500 hover:underline dark:text-blue-400" 346 > 347 {props.nsid} 348 </button> 349 ); 350}; 351 352const resourceColor = (resource: string) => 353 RESOURCE_COLORS[resource as keyof typeof RESOURCE_COLORS] || RESOURCE_COLORS.default; 354 355const SchemaSection = (props: { title: string; encoding: string; schema?: LexiconObject }) => { 356 return ( 357 <div class="flex flex-col gap-2"> 358 <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400"> 359 {props.title} 360 </h4> 361 <div class={CARD_CLASS}> 362 <div class="text-sm"> 363 <span class="font-semibold">Encoding: </span> 364 <span class="font-mono">{props.encoding}</span> 365 </div> 366 <Show when={props.schema?.ref}> 367 <div class="flex items-center gap-2"> 368 <span class="text-sm font-semibold">Schema:</span> 369 <TypeBadge type="ref" refType={props.schema!.ref} /> 370 </div> 371 </Show> 372 <Show when={props.schema?.refs}> 373 <div class="flex flex-col gap-2"> 374 <div class="flex items-center gap-2"> 375 <span class="text-sm font-semibold">Schema (union):</span> 376 </div> 377 <UnionBadges refs={props.schema!.refs!} /> 378 </div> 379 </Show> 380 <Show when={props.schema?.properties && Object.keys(props.schema.properties).length > 0}> 381 <div class={CONTAINER_CLASS}> 382 <For each={Object.entries(props.schema!.properties!)}> 383 {([name, property]) => ( 384 <PropertyRow 385 name={name} 386 property={property} 387 required={(props.schema?.required || []).includes(name)} 388 /> 389 )} 390 </For> 391 </div> 392 </Show> 393 </div> 394 </div> 395 ); 396}; 397 398const PermissionRow = (props: { permission: LexiconPermission; index: number }) => { 399 return ( 400 <div class="flex flex-col gap-2 py-3"> 401 <div class="flex flex-wrap items-center gap-2"> 402 <span class="font-semibold">#{props.index + 1}</span> 403 <span 404 class={`rounded px-1.5 py-0.5 font-mono text-xs font-semibold ${resourceColor(props.permission.resource)}`} 405 > 406 {props.permission.resource} 407 </span> 408 </div> 409 410 {/* Collections (for repo resource) */} 411 <Show when={props.permission.collection && props.permission.collection.length > 0}> 412 <div class="flex flex-col gap-1"> 413 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400"> 414 Collections: 415 </span> 416 <div class="flex flex-col items-start gap-1"> 417 <For each={props.permission.collection}>{(col) => <NsidLink nsid={col} />}</For> 418 </div> 419 </div> 420 </Show> 421 422 {/* Actions */} 423 <Show when={props.permission.action && props.permission.action.length > 0}> 424 <div class="flex flex-col gap-1"> 425 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400">Actions:</span> 426 <div class="flex flex-wrap gap-1"> 427 <For each={props.permission.action}> 428 {(action) => ( 429 <span class="dark:bg-dark-200 rounded bg-neutral-200/50 px-1.5 py-0.5 font-mono text-xs"> 430 {action} 431 </span> 432 )} 433 </For> 434 </div> 435 </div> 436 </Show> 437 438 {/* LXM (for rpc resource) */} 439 <Show when={props.permission.lxm && props.permission.lxm.length > 0}> 440 <div class="flex flex-col gap-1"> 441 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400"> 442 Lexicon Methods: 443 </span> 444 <div class="flex flex-col items-start gap-1"> 445 <For each={props.permission.lxm}>{(method) => <NsidLink nsid={method} />}</For> 446 </div> 447 </div> 448 </Show> 449 450 {/* Audience */} 451 <Show when={props.permission.aud}> 452 <div class="flex items-center gap-2 text-xs"> 453 <span class="font-semibold text-neutral-500 dark:text-neutral-400">Audience:</span> 454 <span class="font-mono">{props.permission.aud}</span> 455 </div> 456 </Show> 457 458 {/* Inherit Audience */} 459 <Show when={props.permission.inheritAud}> 460 <div class="flex items-center gap-1 text-xs"> 461 <span class="font-semibold text-neutral-500 dark:text-neutral-400"> 462 Inherit Audience: 463 </span> 464 <span>true</span> 465 </div> 466 </Show> 467 </div> 468 ); 469}; 470 471const DefSection = (props: { name: string; def: LexiconDef }) => { 472 const defTypeColor = () => 473 DEF_TYPE_COLORS[props.def.type as keyof typeof DEF_TYPE_COLORS] || DEF_TYPE_COLORS.default; 474 475 const hasDefContent = () => props.def.refs || props.def.items || hasConstraints(props.def); 476 477 return ( 478 <div class="flex flex-col gap-3" id={`def-${props.name}`}> 479 <div class="group flex items-center gap-2"> 480 <a href={`#schema:${props.name}`} class="relative text-lg font-semibold hover:underline"> 481 <span class="iconify lucide--link absolute top-1/2 -left-6 -translate-y-1/2 text-base opacity-0 transition-opacity group-hover:opacity-100" /> 482 {props.name === "main" ? "Main Definition" : props.name} 483 </a> 484 <span class={`rounded px-2 py-0.5 text-xs font-semibold uppercase ${defTypeColor()}`}> 485 {props.def.type.replace("-", " ")} 486 </span> 487 </div> 488 489 <Show when={props.def.description}> 490 <p class="text-sm text-neutral-700 dark:text-neutral-300">{props.def.description}</p> 491 </Show> 492 493 {/* Record key */} 494 <Show when={props.def.key}> 495 <div> 496 <span class="text-sm font-semibold">Record Key: </span> 497 <span class="font-mono text-sm">{props.def.key}</span> 498 </div> 499 </Show> 500 501 {/* Permission-set: Title and Detail */} 502 <Show when={props.def.type === "permission-set" && (props.def.title || props.def.detail)}> 503 <div class={CARD_CLASS}> 504 <Show when={props.def.title}> 505 <div class="flex flex-col gap-1"> 506 <span class="text-xs font-semibold text-neutral-500 uppercase dark:text-neutral-400"> 507 Title 508 </span> 509 <span class="text-sm font-medium">{props.def.title}</span> 510 </div> 511 </Show> 512 <Show when={props.def["title:lang"]}> 513 <div class="flex flex-col gap-1"> 514 <span class="text-xs font-semibold text-neutral-500 uppercase dark:text-neutral-400"> 515 Localized Titles 516 </span> 517 <div class="flex flex-col gap-1"> 518 <For each={Object.entries(props.def["title:lang"]!)}> 519 {([lang, text]) => ( 520 <div class="flex items-center gap-2 text-sm"> 521 <span class="dark:bg-dark-200 rounded bg-neutral-200/50 px-1.5 py-0.5 font-mono text-xs"> 522 {lang} 523 </span> 524 <span>{text}</span> 525 </div> 526 )} 527 </For> 528 </div> 529 </div> 530 </Show> 531 <Show when={props.def.detail}> 532 <div class="flex flex-col gap-1"> 533 <span class="text-xs font-semibold text-neutral-500 uppercase dark:text-neutral-400"> 534 Detail 535 </span> 536 <p class="text-sm text-neutral-700 dark:text-neutral-300">{props.def.detail}</p> 537 </div> 538 </Show> 539 <Show when={props.def["detail:lang"]}> 540 <div class="flex flex-col gap-1"> 541 <span class="text-xs font-semibold text-neutral-500 uppercase dark:text-neutral-400"> 542 Localized Details 543 </span> 544 <div class="flex flex-col gap-1"> 545 <For each={Object.entries(props.def["detail:lang"]!)}> 546 {([lang, text]) => ( 547 <div class="flex flex-col gap-1 text-sm"> 548 <span class="dark:bg-dark-200 w-fit rounded bg-neutral-200/50 px-1.5 py-0.5 font-mono text-xs"> 549 {lang} 550 </span> 551 <p class="text-neutral-700 dark:text-neutral-300">{text}</p> 552 </div> 553 )} 554 </For> 555 </div> 556 </div> 557 </Show> 558 </div> 559 </Show> 560 561 {/* Permission-set: Permissions list */} 562 <Show 563 when={ 564 props.def.permissions && 565 props.def.permissions.filter((p) => p.resource === "repo" || p.resource === "rpc") 566 .length > 0 567 } 568 > 569 <div class="flex flex-col gap-2"> 570 <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400"> 571 Permissions 572 </h4> 573 <div class={CONTAINER_CLASS}> 574 <For 575 each={props.def.permissions!.filter( 576 (p) => p.resource === "repo" || p.resource === "rpc", 577 )} 578 > 579 {(permission, index) => <PermissionRow permission={permission} index={index()} />} 580 </For> 581 </div> 582 </div> 583 </Show> 584 585 {/* Properties (for record/object types) */} 586 <Show 587 when={Object.keys(props.def.properties || props.def.record?.properties || {}).length > 0} 588 > 589 <div class="flex flex-col gap-2"> 590 <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400"> 591 Properties 592 </h4> 593 <div class={CONTAINER_CLASS}> 594 <For each={Object.entries(props.def.properties || props.def.record?.properties || {})}> 595 {([name, property]) => ( 596 <PropertyRow 597 name={name} 598 property={property} 599 required={(props.def.required || props.def.record?.required || []).includes(name)} 600 /> 601 )} 602 </For> 603 </div> 604 </div> 605 </Show> 606 607 {/* Parameters (for query/procedure) */} 608 <Show 609 when={ 610 props.def.parameters?.properties && 611 Object.keys(props.def.parameters.properties).length > 0 612 } 613 > 614 <div class="flex flex-col gap-2"> 615 <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400"> 616 Parameters 617 </h4> 618 <div class={CONTAINER_CLASS}> 619 <For each={Object.entries(props.def.parameters!.properties!)}> 620 {([name, property]) => ( 621 <PropertyRow 622 name={name} 623 property={property} 624 required={(props.def.parameters?.required || []).includes(name)} 625 /> 626 )} 627 </For> 628 </div> 629 </div> 630 </Show> 631 632 {/* Input */} 633 <Show when={props.def.input}> 634 <SchemaSection 635 title="Input" 636 encoding={props.def.input!.encoding} 637 schema={props.def.input!.schema} 638 /> 639 </Show> 640 641 {/* Output */} 642 <Show when={props.def.output}> 643 <SchemaSection 644 title="Output" 645 encoding={props.def.output!.encoding} 646 schema={props.def.output!.schema} 647 /> 648 </Show> 649 650 {/* Errors */} 651 <Show when={props.def.errors && props.def.errors.length > 0}> 652 <div class="flex flex-col gap-2"> 653 <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400"> 654 Errors 655 </h4> 656 <div class={CONTAINER_CLASS}> 657 <For each={props.def.errors}> 658 {(error) => ( 659 <div class="flex flex-col gap-1 py-2"> 660 <div class="font-semibold">{error.name}</div> 661 <Show when={error.description}> 662 <p class="text-sm text-neutral-700 dark:text-neutral-300"> 663 {error.description} 664 </p> 665 </Show> 666 </div> 667 )} 668 </For> 669 </div> 670 </div> 671 </Show> 672 673 {/* Other Definitions */} 674 <Show 675 when={ 676 !( 677 props.def.properties || 678 props.def.parameters || 679 props.def.input || 680 props.def.output || 681 props.def.errors || 682 props.def.record 683 ) && hasDefContent() 684 } 685 > 686 <div class={CONTAINER_CLASS}> 687 <PropertyRow name={props.name} property={props.def} hideNameType /> 688 </div> 689 </Show> 690 </div> 691 ); 692}; 693 694export const LexiconSchemaView = (props: { schema: LexiconSchema; authority?: AtprotoDid }) => { 695 const location = useLocation(); 696 697 // Handle scrolling to a definition when hash is like #schema:definitionName 698 createEffect(() => { 699 const hash = location.hash; 700 if (hash.startsWith("#schema:")) { 701 const defName = hash.slice(8); 702 requestAnimationFrame(() => { 703 const element = document.getElementById(`def-${defName}`); 704 if (element) element.scrollIntoView({ behavior: "instant", block: "start" }); 705 }); 706 } 707 }); 708 709 return ( 710 <div class="w-full max-w-4xl px-2"> 711 {/* Header */} 712 <div class="flex flex-col gap-2 border-b border-neutral-300 pb-3 dark:border-neutral-700"> 713 <div class="flex items-center gap-0.5"> 714 <h2 class="text-lg font-semibold">{props.schema.id}</h2> 715 <Show when={props.authority}> 716 <Tooltip text="View record"> 717 <A 718 href={`/at://${props.authority}/com.atproto.lexicon.schema/${props.schema.id}`} 719 class="flex items-center p-1.5 text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-200" 720 target="_blank" 721 > 722 <span class="iconify lucide--external-link text-sm"></span> 723 </A> 724 </Tooltip> 725 </Show> 726 </div> 727 <div class="flex gap-4 text-sm text-neutral-600 dark:text-neutral-400"> 728 <span> 729 <span class="font-medium">Lexicon version: </span> 730 <span>{props.schema.lexicon}</span> 731 </span> 732 </div> 733 <Show when={props.schema.description}> 734 <p class="text-sm text-neutral-700 dark:text-neutral-300">{props.schema.description}</p> 735 </Show> 736 </div> 737 738 {/* Definitions */} 739 <div class="flex flex-col gap-6 pt-3"> 740 <For each={Object.entries(props.schema.defs)}> 741 {([name, def]) => <DefSection name={name} def={def} />} 742 </For> 743 </div> 744 </div> 745 ); 746};