~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
360 lines · 14.0 KB Raw History
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import { Popover } from '@base-ui/react/popover';
import { useQuery } from '@tanstack/react-query';
import {
  AtSignIcon,
  BinaryIcon,
  CalendarIcon,
  ChevronRightIcon,
  CircleCheckIcon,
  CircleDotIcon,
  CircleHelpIcon,
  ClockIcon,
  FingerprintIcon,
  FolderIcon,
  GlobeIcon,
  HashIcon,
  LanguagesIcon,
  LayersIcon,
  LinkIcon,
  ListIcon,
  PaperclipIcon,
  ToggleLeftIcon,
  TypeIcon,
  UserIcon,
  XIcon,
} from 'lucide-react';
import { useState } from 'react';
import { get } from '#/lib/api.js';
import { queryKeys } from '#/lib/query.js';
import { cn } from '#/lib/utils.js';

/**
 * A check shown beside a collection NSID when that record type's lexicon is
 * published via the protocol. Clicking it opens a popover explaining, in plain
 * terms, what that means, with a button to view the schema itself. A popover
 * (click) rather than a hover tooltip so it works on touch, stays open to be
 * read, and can hold actions.
 *
 * Pass `published` when the caller already knows it (the app page resolves every
 * collection in one batch); pass `nsid` alone to have it resolve that one NSID.
 * Controlled callers must pass a real boolean even while their own data is
 * still loading: an undefined prop flips the component into resolving the NSID
 * itself, competing with the caller's request. Renders nothing unless the
 * lexicon is published.
 *
 * @param {object} props
 * @param {string} [props.nsid] - Collection NSID to resolve when uncontrolled.
 * @param {boolean} [props.published] - Known published state (controlled).
 * @param {string} [props.className] - Extra classes for the check icon (e.g. size).
 */
export function PublishedCheck({ nsid, published, className }) {
  const [popoverOpen, setPopoverOpen] = useState(false);
  const [schemaOpen, setSchemaOpen] = useState(false);

  const { data } = useQuery({
    queryKey: queryKeys.lexiconStatus(nsid ? [nsid] : []),
    queryFn: () =>
      get(`/lexicon-status?collections=${encodeURIComponent(nsid)}`),
    enabled: published === undefined && Boolean(nsid),
    staleTime: 5 * 60_000,
  });

  const isPublished =
    published !== undefined
      ? published
      : Boolean(nsid && data?.published?.[nsid]);
  if (!isPublished) return null;

  return (
    <>
      <Popover.Root open={popoverOpen} onOpenChange={setPopoverOpen}>
        {/* A span, not a button: this can sit inside a row that is itself a link,
            where a nested button would be invalid. preventDefault stops the link
            from navigating and stopPropagation stops its click handler, so the
            check opens the popover instead of opening the record list. */}
        <Popover.Trigger
          nativeButton={false}
          render={<span />}
          aria-label="What does this check mean?"
          onClick={(event) => {
            event.stopPropagation();
            event.preventDefault();
          }}
          className="inline-flex cursor-pointer rounded-full text-key outline-none transition-colors hover:text-key/80 focus-visible:ring-[3px] focus-visible:ring-ring/50"
        >
          <CircleCheckIcon className={cn('size-4 shrink-0', className)} />
        </Popover.Trigger>
        <Popover.Portal>
          <Popover.Positioner
            side="top"
            align="center"
            sideOffset={8}
            className="z-50"
          >
            {/* This popup portals to the body but, in React, still bubbles
                events to its ancestors — which can include a row that is a link.
                Stop clicks here so nothing inside triggers that row's navigation. */}
            <Popover.Popup
              onClick={(event) => event.stopPropagation()}
              className="max-w-[17rem] rounded-2xl border border-border bg-card p-4 text-left shadow-xl outline-none"
            >
              <Popover.Title className="mb-1.5 flex items-center gap-1.5 font-semibold text-[13px] text-foreground">
                <CircleCheckIcon className="size-3.5 text-key" />
                Published lexicon
              </Popover.Title>
              <Popover.Description className="text-[12.5px] text-muted-foreground leading-relaxed">
                These records follow an openly published definition called a
                lexicon. Any app on the AT Protocol network can look up what
                they are and how they're structured.
              </Popover.Description>
              <div className="mt-3 flex items-center gap-3">
                <button
                  type="button"
                  onClick={() => {
                    setPopoverOpen(false);
                    setSchemaOpen(true);
                  }}
                  className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-2.5 py-1.5 font-medium text-[12.5px] text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50"
                >
                  View schema
                </button>
                <a
                  href="https://atproto.com/guides/lexicon"
                  target="_blank"
                  rel="noreferrer"
                  className="font-medium text-[12.5px] text-key hover:underline"
                >
                  Learn more →
                </a>
              </div>
            </Popover.Popup>
          </Popover.Positioner>
        </Popover.Portal>
      </Popover.Root>

      {nsid && (
        <SchemaDialog
          nsid={nsid}
          open={schemaOpen}
          onOpenChange={setSchemaOpen}
        />
      )}
    </>
  );
}

/**
 * A modal that shows a published lexicon in plain terms — the record's fields,
 * each with a friendly type, whether it's required, and its description —
 * fetched when it opens. Not raw JSON: this is for people reading their own
 * data, not implementing against it. Controlled, and — like the popover — it
 * stops click propagation so it can live inside a row that is a link without
 * triggering its navigation.
 *
 * @param {object} props
 * @param {string} props.nsid
 * @param {boolean} props.open
 * @param {(open: boolean) => void} props.onOpenChange
 */
function SchemaDialog({ nsid, open, onOpenChange }) {
  const [showRaw, setShowRaw] = useState(false);
  const { data, isFetching, isError } = useQuery({
    queryKey: queryKeys.lexiconSchema(nsid),
    queryFn: () =>
      get(`/lexicon-schema?collection=${encodeURIComponent(nsid)}`),
    enabled: open,
    staleTime: 5 * 60_000,
  });
  const lexicon = data?.lexicon ?? null;
  const main = lexicon?.defs?.main;
  const description = main?.description || lexicon?.description || '';
  const record = main?.record;
  const properties = record?.properties || {};
  const required = new Set(record?.required || []);
  const fields = Object.keys(properties);

  return (
    <BaseDialog.Root open={open} onOpenChange={onOpenChange}>
      <BaseDialog.Portal>
        <BaseDialog.Backdrop
          onClick={(event) => event.stopPropagation()}
          className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
        />
        <BaseDialog.Popup
          onClick={(event) => event.stopPropagation()}
          className="fixed top-1/2 left-1/2 z-50 flex max-h-[85vh] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-3xl border border-border bg-card p-6 shadow-2xl outline-none"
        >
          <div className="mb-4 flex items-start gap-3">
            <div className="min-w-0">
              <BaseDialog.Title className="break-all font-extrabold text-[16px] text-foreground tracking-tight">
                {nsid}
              </BaseDialog.Title>
              <p className="mt-0.5 text-[12.5px] text-muted-foreground">
                What this kind of record can hold
              </p>
            </div>
            <div className="flex-1" />
            <BaseDialog.Close className="grid size-8 shrink-0 place-items-center rounded-xl text-faint outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4">
              <XIcon />
            </BaseDialog.Close>
          </div>

          <div className="overflow-y-auto">
            {isFetching && !lexicon ? (
              <p className="py-8 text-center text-[13px] text-muted-foreground">
                Loading…
              </p>
            ) : isError || !lexicon ? (
              <p className="py-8 text-center text-[13px] text-muted-foreground">
                This schema couldn't be loaded right now.
              </p>
            ) : (
              <>
                {description && (
                  <p className="mb-4 text-[13px] text-muted-foreground leading-relaxed">
                    {description}
                  </p>
                )}
                {fields.length ? (
                  <dl className="flex flex-col gap-3">
                    {fields.map((name) => (
                      <SchemaField
                        key={name}
                        name={name}
                        field={properties[name]}
                        required={required.has(name)}
                      />
                    ))}
                  </dl>
                ) : (
                  <p className="py-6 text-center text-[13px] text-muted-foreground">
                    This record type doesn't define named fields.
                  </p>
                )}

                {/* Friendly by default, raw on demand: the exact schema for
                    anyone who wants it, tucked behind a disclosure. */}
                <div className="mt-5 border-border border-t pt-4">
                  <button
                    type="button"
                    onClick={() => setShowRaw((v) => !v)}
                    className="inline-flex items-center gap-1.5 rounded-md font-medium text-[12.5px] text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
                  >
                    <ChevronRightIcon
                      className={cn(
                        'size-3.5 transition-transform',
                        showRaw && 'rotate-90',
                      )}
                    />
                    {showRaw
                      ? 'Hide technical details'
                      : 'Show technical details'}
                  </button>
                  {showRaw && (
                    <pre className="mt-3 overflow-auto rounded-2xl border border-border bg-muted p-4 font-mono text-[11px] text-foreground leading-relaxed">
                      {JSON.stringify(lexicon, null, 2)}
                    </pre>
                  )}
                </div>
              </>
            )}
          </div>
        </BaseDialog.Popup>
      </BaseDialog.Portal>
    </BaseDialog.Root>
  );
}

/**
 * One field of a record: its name, a plain-English type, a Required marker, and
 * its description if the lexicon gives one.
 *
 * @param {object} props
 * @param {string} props.name
 * @param {Record<string, unknown>} props.field
 * @param {boolean} props.required
 */
function SchemaField({ name, field, required }) {
  const { label, Icon } = describeType(field);
  return (
    <div className="rounded-2xl border border-border bg-muted/40 px-3.5 py-3">
      <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
        <dt className="font-semibold text-[13.5px] text-foreground">{name}</dt>
        <span className="inline-flex items-center gap-1 rounded-md bg-key/10 px-1.5 py-0.5 font-medium text-[11px] text-key">
          <Icon className="size-3" />
          {label}
        </span>
        {required && (
          <span className="text-[11px] text-muted-foreground">Required</span>
        )}
      </div>
      {typeof field?.description === 'string' && field.description && (
        <dd className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
          {field.description}
        </dd>
      )}
    </div>
  );
}

/**
 * A friendly, non-technical name and matching icon for a lexicon field's type —
 * so the field list reads without protocol vocabulary and scans by icon.
 * @param {Record<string, unknown>} [field]
 * @returns {{ label: string, Icon: typeof TypeIcon }}
 */
function describeType(field) {
  if (!field || typeof field !== 'object')
    return { label: 'Value', Icon: CircleHelpIcon };
  switch (field.type) {
    case 'string': {
      const byFormat = {
        datetime: { label: 'Date & time', Icon: CalendarIcon },
        uri: { label: 'Link', Icon: GlobeIcon },
        'at-uri': { label: 'Record link', Icon: LinkIcon },
        did: { label: 'Account', Icon: UserIcon },
        'at-identifier': { label: 'Account', Icon: UserIcon },
        handle: { label: 'Handle', Icon: AtSignIcon },
        language: { label: 'Language', Icon: LanguagesIcon },
        cid: { label: 'Content hash', Icon: FingerprintIcon },
        tid: { label: 'Timestamp id', Icon: ClockIcon },
      };
      if (typeof field.format === 'string' && byFormat[field.format])
        return byFormat[field.format];
      if (Array.isArray(field.knownValues) || Array.isArray(field.enum))
        return { label: 'Option', Icon: CircleDotIcon };
      return { label: 'Text', Icon: TypeIcon };
    }
    case 'integer':
    case 'number':
      return { label: 'Number', Icon: HashIcon };
    case 'boolean':
      return { label: 'Yes / no', Icon: ToggleLeftIcon };
    case 'blob':
      return { label: 'File', Icon: PaperclipIcon };
    case 'bytes':
      return { label: 'Binary data', Icon: BinaryIcon };
    case 'array':
      return {
        label: `List of ${describeType(field.items).label.toLowerCase()}`,
        Icon: ListIcon,
      };
    case 'union':
      return { label: 'One of several types', Icon: LayersIcon };
    case 'ref':
      return { label: 'Linked record', Icon: LinkIcon };
    case 'object':
      return { label: 'Group of fields', Icon: FolderIcon };
    case 'unknown':
      return { label: 'Any data', Icon: CircleHelpIcon };
    default:
      return {
        label:
          typeof field.type === 'string'
            ? field.type.charAt(0).toUpperCase() + field.type.slice(1)
            : 'Value',
        Icon: CircleHelpIcon,
      };
  }
}