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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
235 lines · 7.3 KB Raw History
import { parseSpaceUri } from '@pdsjs/spaces/uri';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowRight, Boxes, Plus } from 'lucide-react';
import { useState } from 'react';
import { AppTile } from '#/components/atoms/app-tile.jsx';
import { Badge } from '#/components/atoms/badge.jsx';
import { Button } from '#/components/atoms/button.jsx';
import { Card } from '#/components/atoms/card.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
import { ActorAvatar } from '#/components/molecules/actor-avatar.jsx';
import { AtUri } from '#/components/molecules/at-uri.jsx';
import { EmptyState } from '#/components/molecules/empty-state.jsx';
import { Identity } from '#/components/molecules/identity.jsx';
import { TextField } from '#/components/molecules/text-field.jsx';
import { spaceParts } from '#/lib/format.js';
import { href, useNavigate } from '#/lib/navigation.jsx';
import { useSession } from '#/lib/session.jsx';
import { createSpace, listSpaces } from '#/lib/spaces.js';

/** One row in the list: what the space is, and who governs it. */
function SpaceRow({ uri, isOwner, did }) {
  const { spaceDid, spaceType, skey } = spaceParts(uri);
  return (
    <Card className="flex gap-3.5 p-4">
      {/* The space type is an NSID, and the domain it reverses to is the app
          the space belongs to. Its icon says at a glance which app a space is
          for, which the type alone takes a moment to read. */}
      <AppTile nsid={spaceType} size={36} />
      <div className="min-w-0 flex-1">
        <div className="flex flex-wrap items-baseline justify-between gap-2">
          <Link
            href={href('/space', { uri })}
            className="font-medium font-mono text-[13px] no-underline hover:underline"
          >
            {spaceType}
          </Link>
          {isOwner && <Badge variant="scope">you host this</Badge>}
        </div>
        <div className="mt-1 text-muted-foreground text-xs">{skey}</div>
        <div className="mt-2 flex min-w-0 items-center gap-1.5 text-faint text-xs">
          {spaceDid === did ? (
            'Under your own authority'
          ) : (
            <>
              Under
              <ActorAvatar did={spaceDid} size={16} />
              <Identity did={spaceDid} inline />
            </>
          )}
        </div>
        <AtUri uri={uri} className="mt-2 text-[11px]" />
      </div>
    </Card>
  );
}

/**
 * The way into a space this list does not name.
 *
 * `listSpaces` reports the spaces this account has written to, which is what a
 * PDS tracks. A space somebody has admitted this account to but which it has
 * never written in appears nowhere, and its URI is the only way to reach it.
 */
function OpenByUri() {
  const navigate = useNavigate();
  const [uri, setUri] = useState('');
  const [error, setError] = useState(null);

  function open(event) {
    event.preventDefault();
    const trimmed = uri.trim();
    try {
      parseSpaceUri(trimmed);
    } catch (failure) {
      setError(failure.message);
      return;
    }
    navigate(href('/space', { uri: trimmed }));
  }

  return (
    <form onSubmit={open} className="flex flex-col gap-2">
      <div className="flex gap-2">
        <input
          value={uri}
          onChange={(event) => {
            setUri(event.target.value);
            setError(null);
          }}
          placeholder="at://did:plc:…/space/com.example.forum/general"
          spellCheck="false"
          autoCapitalize="none"
          autoCorrect="off"
          aria-label="Open a space by URI"
          className="h-9 min-w-0 flex-1 rounded-lg border border-input bg-transparent px-3 font-mono text-[12.5px] text-foreground outline-none placeholder:text-faint focus-visible:border-ring"
        />
        <Button
          size="sm"
          type="submit"
          variant="outline"
          disabled={!uri.trim()}
        >
          <ArrowRight />
          Open
        </Button>
      </div>
      {error && <Notice variant="error">{error}</Notice>}
    </form>
  );
}

/** The form for a space anchored on this account. */
function NewSpace({ onCreated }) {
  const { session } = useSession();
  const [open, setOpen] = useState(false);
  const [type, setType] = useState('');
  const [skey, setSkey] = useState('');

  const create = useMutation({
    mutationFn: () => createSpace(session, { type, skey: skey || undefined }),
    onSuccess: () => {
      setOpen(false);
      setType('');
      setSkey('');
      onCreated();
    },
  });

  if (!open) {
    return (
      <Button variant="dashed" size="surface" onClick={() => setOpen(true)}>
        <span className="flex items-center justify-center gap-2 py-3 text-sm">
          <Plus className="size-4" />
          New space
        </span>
      </Button>
    );
  }

  return (
    <Card className="flex flex-col gap-3 p-4">
      <TextField
        name="type"
        label="Space type"
        placeholder="com.example.forum"
        hint="An NSID. It names the data model, and every space of one type shares it."
        value={type}
        onChange={(event) => setType(event.target.value)}
      />
      <TextField
        name="skey"
        label="Key"
        placeholder="left blank for a timestamp"
        value={skey}
        onChange={(event) => setSkey(event.target.value)}
      />
      {create.error && <Notice variant="error">{create.error.message}</Notice>}
      <div className="flex gap-2">
        <Button
          onClick={() => create.mutate()}
          disabled={!type.includes('.') || create.isPending}
        >
          {create.isPending ? 'Creating…' : 'Create'}
        </Button>
        <Button variant="ghost" onClick={() => setOpen(false)}>
          Cancel
        </Button>
      </div>
    </Card>
  );
}

export function SpacesPage() {
  const { session, writable } = useSession();
  const client = useQueryClient();

  const { data, error, isPending } = useQuery({
    queryKey: ['spaces', session.did],
    queryFn: () => listSpaces(session),
  });

  if (isPending) {
    return (
      <div className="flex flex-col gap-3">
        {[0, 1, 2].map((row) => (
          <Skeleton key={row} className="h-24" />
        ))}
      </div>
    );
  }

  if (error) {
    return (
      <Notice variant="error">
        {error.name === 'MethodNotImplemented'
          ? 'This PDS does not serve permissioned spaces.'
          : error.message}
      </Notice>
    );
  }

  const spaces = data.spaces || [];

  return (
    <div className="flex flex-col gap-3">
      <p className="text-muted-foreground text-sm leading-relaxed">
        The spaces your repo holds data in. A PDS tracks the spaces its own user
        has written to, so a space you have been admitted to but never written
        in is not listed here.
      </p>

      <OpenByUri />

      {spaces.length === 0 ? (
        <EmptyState
          icon={<Boxes />}
          title="No spaces"
          description="This account has written nothing into a permissioned space. Open one above by its URI."
        />
      ) : (
        spaces.map((space) => (
          <SpaceRow key={space.uri} {...space} did={session.did} />
        ))
      )}

      {writable && (
        <NewSpace
          onCreated={() => client.invalidateQueries({ queryKey: ['spaces'] })}
        />
      )}
    </div>
  );
}