atmosphere explorer pds.ls
tool typescript atproto
434
fork

Configure Feed

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

add schema tab to collection page

Juliet f5302432 d72d87c3

+202 -103
+1 -1
src/components/lexicon-schema.tsx
··· 713 713 }); 714 714 715 715 return ( 716 - <div class="w-full max-w-4xl px-2"> 716 + <div class="w-full px-2"> 717 717 {/* Header */} 718 718 <div class="flex flex-col gap-2 border-b border-neutral-300 pb-3 dark:border-neutral-700"> 719 719 <div class="flex items-center gap-0.5">
+91
src/lib/lexicon-schema.tsx
··· 1 + import { ResolvedSchema } from "@atcute/lexicon-resolver"; 2 + import { Nsid } from "@atcute/lexicons"; 3 + import { AtprotoDid } from "@atcute/lexicons/syntax"; 4 + import { useLocation } from "@solidjs/router"; 5 + import { createEffect, createSignal, ErrorBoundary, Show } from "solid-js"; 6 + import { LexiconSchemaView } from "../components/lexicon-schema.jsx"; 7 + import { resolveLexiconAuthority, resolveLexiconSchema } from "./api.js"; 8 + 9 + interface CachedLexicon { 10 + authority: AtprotoDid; 11 + schema: ResolvedSchema; 12 + } 13 + 14 + const cache = new Map<string, Promise<CachedLexicon>>(); 15 + 16 + const resolve = (nsid: Nsid): Promise<CachedLexicon> => { 17 + let cached = cache.get(nsid); 18 + if (cached) return cached; 19 + 20 + const promise = (async () => { 21 + const authority = await resolveLexiconAuthority(nsid); 22 + const schema = await resolveLexiconSchema(authority, nsid); 23 + return { authority, schema }; 24 + })(); 25 + 26 + cache.set(nsid, promise); 27 + promise.catch(() => cache.delete(nsid)); 28 + return promise; 29 + }; 30 + 31 + export const useLexiconSchema = (collection: () => string | undefined) => { 32 + const location = useLocation(); 33 + const [schema, setSchema] = createSignal<ResolvedSchema>(); 34 + const [authority, setAuthority] = createSignal<AtprotoDid>(); 35 + const [error, setError] = createSignal(false); 36 + const [loading, setLoading] = createSignal(false); 37 + 38 + const showSchema = () => location.hash === "#schema" || location.hash.startsWith("#schema:"); 39 + 40 + createEffect(() => { 41 + const col = collection(); 42 + if ( 43 + showSchema() && 44 + !schema() && 45 + !loading() && 46 + !error() && 47 + col && 48 + col !== "com.atproto.lexicon.schema" 49 + ) { 50 + setLoading(true); 51 + resolve(col as Nsid).then( 52 + (result) => { 53 + setAuthority(result.authority); 54 + setSchema(result.schema); 55 + setLoading(false); 56 + }, 57 + () => { 58 + setError(true); 59 + setLoading(false); 60 + }, 61 + ); 62 + } 63 + }); 64 + 65 + return { schema, authority, error, loading, showSchema }; 66 + }; 67 + 68 + export const SchemaTabContent = (props: { 69 + schema?: ResolvedSchema; 70 + authority?: AtprotoDid; 71 + loading: boolean; 72 + error: boolean; 73 + fallbackSchema?: any; 74 + }) => ( 75 + <> 76 + <Show when={props.error}> 77 + <span class="mt-2">Lexicon schema could not be resolved.</span> 78 + </Show> 79 + <Show when={props.loading && !props.fallbackSchema}> 80 + <span class="mt-2 text-neutral-700 dark:text-neutral-300">Resolving lexicon schema...</span> 81 + </Show> 82 + <Show when={props.schema || props.fallbackSchema}> 83 + <ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}> 84 + <LexiconSchemaView 85 + schema={props.schema?.rawSchema ?? props.fallbackSchema} 86 + authority={props.authority} 87 + /> 88 + </ErrorBoundary> 89 + </Show> 90 + </> 91 + );
+93 -60
src/views/collection.tsx
··· 16 16 import { Spinner } from "../components/spinner.jsx"; 17 17 import Tooltip from "../components/tooltip.jsx"; 18 18 import { canHover } from "../layout.jsx"; 19 + import { createLatch } from "../lib/create-latch.js"; 20 + import { useFilterShortcut } from "../lib/keyboard.js"; 21 + import { SchemaTabContent, useLexiconSchema } from "../lib/lexicon-schema.jsx"; 19 22 import { useRepo } from "../lib/repo-context.jsx"; 20 - import { createLatch } from "../lib/create-latch.js"; 21 23 import { localDateFromTimestamp } from "../utils/date.js"; 22 - import { useFilterShortcut } from "../lib/keyboard.js"; 23 24 24 25 interface AtprotoRecord { 25 26 rkey: string; ··· 96 97 const [openDelete, setOpenDelete] = createSignal(false); 97 98 const [isLoadingMore, setIsLoadingMore] = createSignal(false); 98 99 const did = repo.did(); 100 + const lexicon = useLexiconSchema(() => (hidden() ? undefined : params.collection)); 101 + 99 102 let filterInputRef: HTMLInputElement | undefined; 100 103 101 104 onMount(() => { ··· 217 220 218 221 return ( 219 222 <> 220 - <Show when={!hidden() && !records.length && (response.state === "unresolved" || response.loading)}> 223 + <Show 224 + when={!hidden() && !records.length && (response.state === "unresolved" || response.loading)} 225 + > 221 226 <Spinner /> 222 227 </Show> 223 228 <Show when={!hidden() && (records.length || response.state === "ready")}> 224 229 <div class="flex w-full flex-col items-center"> 225 230 {/* Tab bar */} 226 231 <div class="mb-2 flex min-h-7 w-full items-center justify-between px-2 text-sm sm:text-base"> 227 - <div class="flex gap-4"> 228 - <span class="border-b-2 font-medium">Records</span> 232 + <div class="flex gap-3 sm:gap-4"> 233 + <A 234 + href={`/at://${did}/${params.collection}`} 235 + classList={{ 236 + "border-b-2 font-medium transition-colors": true, 237 + "border-transparent not-hover:text-neutral-600 not-hover:dark:text-neutral-300/80": 238 + lexicon.showSchema(), 239 + }} 240 + > 241 + Records 242 + </A> 243 + <A 244 + href={`/at://${did}/${params.collection}#schema`} 245 + classList={{ 246 + "border-b-2 font-medium transition-colors": true, 247 + "border-transparent not-hover:text-neutral-600 not-hover:dark:text-neutral-300/80": 248 + !lexicon.showSchema(), 249 + }} 250 + > 251 + Schema 252 + </A> 229 253 <A 230 254 href={`/jetstream?collections=${params.collection}&dids=${params.repo}`} 231 255 class="border-b-2 border-transparent font-medium transition-colors not-hover:text-neutral-600 not-hover:dark:text-neutral-300/80" ··· 233 257 Jetstream 234 258 </A> 235 259 </div> 236 - <Show when={agent() && agent()?.sub === did}> 237 - <div class="flex items-center text-sm"> 260 + <Show when={!lexicon.showSchema() && agent() && agent()?.sub === did}> 261 + <div class="flex items-center text-sm sm:gap-1"> 238 262 <Show when={batchDelete()}> 239 263 <Tooltip text="Select all"> 240 264 <button ··· 270 294 </Show> 271 295 <PermissionButton 272 296 scope="delete" 273 - class="flex items-center gap-1 rounded-md px-2 py-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 297 + class="flex items-center gap-1 rounded-md border border-neutral-300 px-2 py-0.75 transition-colors hover:bg-neutral-200/50 active:bg-neutral-200 dark:border-neutral-700 dark:hover:bg-neutral-800 dark:active:bg-neutral-700" 274 298 disabledClass="flex items-center gap-1 rounded-md px-2 py-1 opacity-40" 275 299 onClick={() => { 276 300 setRecords({ from: 0, to: records.length - 1 }, "toDelete", false); ··· 278 302 setBatchDelete(!batchDelete()); 279 303 }} 280 304 > 281 - <span 282 - class={`iconify ${batchDelete() ? "lucide--x" : "lucide--square-check-big"}`} 283 - ></span> 284 305 {batchDelete() ? "Cancel" : "Manage"} 285 306 </PermissionButton> 286 307 </div> 287 308 </Show> 288 309 </div> 289 310 311 + {/* Schema view */} 312 + <Show when={lexicon.showSchema()}> 313 + <SchemaTabContent 314 + schema={lexicon.schema()} 315 + authority={lexicon.authority()} 316 + loading={lexicon.loading()} 317 + error={lexicon.error()} 318 + /> 319 + </Show> 320 + 290 321 {/* Record list */} 291 - <div class="flex max-w-full flex-col px-2 pb-20 font-mono"> 292 - <Show 293 - when={filteredRecords().length > 0} 294 - fallback={ 295 - <span class="font-sans text-neutral-500 dark:text-neutral-400"> 296 - {filter() ? "No records match filter" : "No records"} 297 - </span> 298 - } 299 - > 300 - <For each={filteredRecords()}> 301 - {(record, index) => { 302 - const rounding = () => { 303 - const recs = filteredRecords(); 304 - const prevSelected = recs[index() - 1]?.toDelete; 305 - const nextSelected = recs[index() + 1]?.toDelete; 306 - return `${!prevSelected ? "rounded-t" : ""} ${!nextSelected ? "rounded-b" : ""}`; 307 - }; 308 - return ( 309 - <> 310 - <Show when={batchDelete()}> 311 - <div 312 - class={`select-none ${ 313 - record.toDelete ? 314 - `bg-blue-200 hover:bg-blue-300/80 active:bg-blue-300 dark:bg-blue-700/30 dark:hover:bg-blue-700/50 dark:active:bg-blue-700/70 ${rounding()}` 315 - : "rounded hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 316 - }`} 317 - onclick={(e) => { 318 - handleSelectionClick(e, index()); 319 - setRecords(index(), "toDelete", !record.toDelete); 320 - }} 321 - > 322 - <RecordLink record={record} /> 323 - </div> 324 - </Show> 325 - <Show when={!batchDelete()}> 326 - <A 327 - href={`/at://${did}/${params.collection}/${record.rkey}`} 328 - class="rounded select-none hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 329 - > 330 - <RecordLink record={record} /> 331 - </A> 332 - </Show> 333 - </> 334 - ); 335 - }} 336 - </For> 337 - </Show> 338 - </div> 322 + <Show when={!lexicon.showSchema()}> 323 + <div class="flex max-w-full flex-col px-2 pb-20 font-mono"> 324 + <Show 325 + when={filteredRecords().length > 0} 326 + fallback={ 327 + <span class="font-sans text-neutral-500 dark:text-neutral-400"> 328 + {filter() ? "No records match filter" : "No records"} 329 + </span> 330 + } 331 + > 332 + <For each={filteredRecords()}> 333 + {(record, index) => { 334 + const rounding = () => { 335 + const recs = filteredRecords(); 336 + const prevSelected = recs[index() - 1]?.toDelete; 337 + const nextSelected = recs[index() + 1]?.toDelete; 338 + return `${!prevSelected ? "rounded-t" : ""} ${!nextSelected ? "rounded-b" : ""}`; 339 + }; 340 + return ( 341 + <> 342 + <Show when={batchDelete()}> 343 + <div 344 + class={`select-none ${ 345 + record.toDelete ? 346 + `bg-blue-200 hover:bg-blue-300/80 active:bg-blue-300 dark:bg-blue-700/30 dark:hover:bg-blue-700/50 dark:active:bg-blue-700/70 ${rounding()}` 347 + : "rounded hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 348 + }`} 349 + onclick={(e) => { 350 + handleSelectionClick(e, index()); 351 + setRecords(index(), "toDelete", !record.toDelete); 352 + }} 353 + > 354 + <RecordLink record={record} /> 355 + </div> 356 + </Show> 357 + <Show when={!batchDelete()}> 358 + <A 359 + href={`/at://${did}/${params.collection}/${record.rkey}`} 360 + class="rounded select-none hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 361 + > 362 + <RecordLink record={record} /> 363 + </A> 364 + </Show> 365 + </> 366 + ); 367 + }} 368 + </For> 369 + </Show> 370 + </div> 371 + </Show> 339 372 </div> 340 373 341 374 {/* Confirm delete/recreate modal */} ··· 364 397 </Modal> 365 398 366 399 {/* Fixed bottom panel */} 367 - <Show when={records.length > 1}> 400 + <Show when={!lexicon.showSchema() && records.length > 1}> 368 401 <div class="dark:bg-dark-500 fixed bottom-0 z-10 flex w-full flex-col items-center gap-2 border-t border-neutral-200 bg-neutral-100 px-3 pt-3 pb-6 dark:border-neutral-700"> 369 402 {/* Filter */} 370 403 <div
+17 -42
src/views/record.tsx
··· 2 2 import { DidDocument, getPdsEndpoint } from "@atcute/identity"; 3 3 import { lexiconDoc } from "@atcute/lexicon-doc"; 4 4 import { RecordValidator } from "@atcute/lexicon-doc/validations"; 5 - import { FailedLexiconResolutionError, ResolvedSchema } from "@atcute/lexicon-resolver"; 5 + import { FailedLexiconResolutionError } from "@atcute/lexicon-resolver"; 6 6 import { ActorIdentifier, is, Nsid } from "@atcute/lexicons"; 7 7 import { AtprotoDid, Did, isNsid } from "@atcute/lexicons/syntax"; 8 8 import { verifyRecord } from "@atcute/repo"; ··· 15 15 import { CopyMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 16 16 import { Favicon } from "../components/favicon.jsx"; 17 17 import { JSONValue } from "../components/json.jsx"; 18 - import { LexiconSchemaView } from "../components/lexicon-schema.jsx"; 19 18 import { Modal } from "../components/modal.jsx"; 20 19 import { addNotification, removeNotification } from "../components/notification.jsx"; 21 20 import { PermissionButton } from "../components/permission-button.jsx"; 22 21 import { canHover } from "../layout.jsx"; 22 + import { didDocumentResolver, resolveLexiconAuthority } from "../lib/api.js"; 23 + import { SchemaTabContent, useLexiconSchema } from "../lib/lexicon-schema.jsx"; 23 24 import { useRepo } from "../lib/repo-context.jsx"; 24 - import { 25 - didDocumentResolver, 26 - resolveLexiconAuthority, 27 - resolveLexiconSchema, 28 - } from "../lib/api.js"; 29 - import { addToClipboard } from "../utils/copy.js"; 30 25 import { AtUri, uriTemplates } from "../lib/templates.js"; 31 26 import { lexicons } from "../lib/types/lexicons.js"; 27 + import { addToClipboard } from "../utils/copy.js"; 32 28 33 29 const faviconWrapper = (children: any) => ( 34 30 <div class="flex size-4 items-center justify-center">{children}</div> ··· 223 219 const [externalLink, setExternalLink] = createSignal< 224 220 { label: string; link: string; icon?: string } | undefined 225 221 >(); 226 - const [lexiconAuthority, setLexiconAuthority] = createSignal<AtprotoDid>(); 227 222 const [validRecord, setValidRecord] = createSignal<boolean | undefined>(undefined); 228 223 const [validSchema, setValidSchema] = createSignal<boolean | undefined>(undefined); 229 - const [schema, setSchema] = createSignal<ResolvedSchema>(); 230 - const [lexiconNotFound, setLexiconNotFound] = createSignal<boolean>(); 231 224 const [remoteValidation, setRemoteValidation] = createSignal<boolean>(); 225 + const lexicon = useLexiconSchema(() => params.collection); 232 226 const did = repo.did(); 233 227 234 228 const fetchRecord = async () => { ··· 251 245 } 252 246 setPlaceholder(res.data.value); 253 247 setExternalLink(checkUri(res.data.uri, res.data.value)); 254 - resolveLexicon(collection as Nsid, collection); 255 248 verifyRecordIntegrity(rpc, collection, rkey); 256 249 validateLocalSchema(collection, res.data.value); 257 250 ··· 266 259 const validateLocalSchema = async (collection: string, record: Record<string, unknown>) => { 267 260 try { 268 261 if (collection === "com.atproto.lexicon.schema") { 269 - setLexiconNotFound(false); 270 262 lexiconDoc.parse(record, { mode: "passthrough" }); 271 263 setValidSchema(true); 272 264 } else if (collection in lexicons) { ··· 338 330 } 339 331 }; 340 332 341 - const resolveLexicon = async (nsid: Nsid, collection: string) => { 342 - try { 343 - const authority = await resolveLexiconAuthority(nsid); 344 - setLexiconAuthority(authority); 345 - if (collection !== "com.atproto.lexicon.schema") { 346 - const schema = await resolveLexiconSchema(authority, nsid); 347 - setSchema(schema); 348 - setLexiconNotFound(false); 349 - } 350 - } catch { 351 - setLexiconNotFound(true); 352 - } 353 - }; 354 - 355 333 const deleteRecord = async () => { 356 334 const collection = params.collection!; 357 335 const rkey = params.rkey!; ··· 551 529 /> 552 530 </div> 553 531 </Show> 554 - <Show when={location.hash === "#schema" || location.hash.startsWith("#schema:")}> 555 - <Show when={lexiconNotFound() === true}> 556 - <span class="w-full px-2 text-sm">Lexicon schema could not be resolved.</span> 557 - </Show> 558 - <Show when={lexiconNotFound() === undefined}> 559 - <span class="w-full px-2 text-sm">Resolving lexicon schema...</span> 560 - </Show> 561 - <Show when={schema() || params.collection === "com.atproto.lexicon.schema"}> 562 - <ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}> 563 - <LexiconSchemaView 564 - schema={schema()?.rawSchema ?? (record()?.value as any)} 565 - authority={lexiconAuthority()} 566 - /> 567 - </ErrorBoundary> 568 - </Show> 532 + <Show when={lexicon.showSchema()}> 533 + <SchemaTabContent 534 + schema={lexicon.schema()} 535 + authority={lexicon.authority()} 536 + loading={lexicon.loading()} 537 + error={lexicon.error()} 538 + fallbackSchema={ 539 + params.collection === "com.atproto.lexicon.schema" ? 540 + (record()?.value as any) 541 + : undefined 542 + } 543 + /> 569 544 </Show> 570 545 <Show when={location.hash === "#backlinks" || location.hash.startsWith("#backlinks:")}> 571 546 <ErrorBoundary