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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
628 lines · 22.2 KB Raw History
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import { useQuery } from '@tanstack/react-query';
import { ExternalLinkIcon, GlobeIcon } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '#/components/atoms/badge.jsx';
import { Button } from '#/components/atoms/button.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { RowIcon } from '#/components/atoms/row-icon.jsx';
import { Dialog } from '#/components/molecules/dialog.jsx';
import { EmptyState } from '#/components/molecules/empty-state.jsx';
import { ListRow } from '#/components/molecules/list-row.jsx';
import { TextLink } from '#/components/molecules/text-link.jsx';
import { get, post } from '#/lib/api.js';
import { bytes, num, timeAgo } from '#/lib/format.js';
import { queryKeys, useApiMutation } from '#/lib/query.js';

/** The expanded site's manifest: every served path with its size and type. */
function SiteFiles({ site }) {
  const { data, error } = useQuery({
    queryKey: queryKeys.siteFiles(site.name),
    queryFn: () => get(`/sites/files?name=${encodeURIComponent(site.name)}`),
  });

  if (error) {
    return (
      <div className="px-5 py-3">
        <Notice variant="error">{error.message}</Notice>
      </div>
    );
  }
  if (!data) {
    return (
      <div className="px-5 py-3 text-[12px] text-muted-foreground">
        Loading files…
      </div>
    );
  }
  return (
    <div className="max-h-72 overflow-y-auto rounded-2xl border border-border bg-card px-5 py-2">
      {data.files.map((file) => (
        <div
          key={file.path}
          className="flex items-center gap-3 border-border border-b py-2 text-[12.5px] last:border-b-0"
        >
          <TextLink
            external
            href={`${site.url}/${file.path}`}
            className="min-w-0 flex-1 truncate font-mono font-normal"
          >
            /{file.path}
          </TextLink>
          <span className="shrink-0 text-muted-foreground">
            {file.contentType}
          </span>
          <span className="w-16 shrink-0 text-right text-muted-foreground">
            {bytes(file.size)}
          </span>
        </div>
      ))}
    </div>
  );
}

/** A form generated from an install's settings schema snapshot. */
function InstallSettings({ install }) {
  const [draft, setDraft] = useState(install.config);
  const [site, setSite] = useState(install.site || '');
  const action = useApiMutation([queryKeys.installs, queryKeys.sites]);
  const dirty =
    JSON.stringify(draft) !== JSON.stringify(install.config) ||
    site !== install.site;

  const save = () =>
    action.run(async () => {
      if (site !== install.site) {
        await post('/installs/rename', { uri: install.uri, site });
      }
      if (JSON.stringify(draft) !== JSON.stringify(install.config)) {
        await post('/installs/config', { uri: install.uri, config: draft });
      }
    });

  return (
    <div className="rounded-2xl border border-border bg-card px-5 py-4">
      {action.error && <Notice variant="error">{action.error}</Notice>}
      <div className="flex flex-col gap-3">
        <div className="flex items-center justify-between gap-4 text-[13px]">
          <span className="text-muted-foreground">Subdomain</span>
          <input
            type="text"
            aria-label="Subdomain"
            value={site}
            onChange={(event) => setSite(event.target.value)}
            className="w-32 rounded-lg border border-border bg-background px-2 py-1 text-right font-mono text-[12.5px]"
          />
        </div>
        {install.settings.map((field) => (
          <div
            key={field.key}
            className="flex items-center justify-between gap-4 text-[13px]"
          >
            <span className="text-muted-foreground">{field.label}</span>
            {field.type === 'boolean' && (
              <input
                type="checkbox"
                aria-label={field.label}
                checked={Boolean(draft[field.key])}
                onChange={(event) =>
                  setDraft({ ...draft, [field.key]: event.target.checked })
                }
              />
            )}
            {field.type === 'string' && (
              <input
                type="text"
                aria-label={field.label}
                value={String(draft[field.key] ?? '')}
                onChange={(event) =>
                  setDraft({ ...draft, [field.key]: event.target.value })
                }
                className="rounded-lg border border-border bg-background px-2 py-1 text-[13px]"
              />
            )}
            {field.type === 'select' && (
              <select
                aria-label={field.label}
                value={String(draft[field.key] ?? '')}
                onChange={(event) =>
                  setDraft({ ...draft, [field.key]: event.target.value })
                }
                className="rounded-lg border border-border bg-background px-2 py-1 text-[13px]"
              >
                {(field.options || []).map((option) => (
                  <option key={option} value={option}>
                    {option}
                  </option>
                ))}
              </select>
            )}
          </div>
        ))}
      </div>
      <div className="mt-4 flex justify-end">
        <Button size="sm" disabled={!dirty || action.busy} onClick={save}>
          Save settings
        </Button>
      </div>
    </div>
  );
}

/** Paste an at-uri, see what it is, install it. */
function InstallDialog() {
  const [uri, setUri] = useState('');
  const [preview, setPreview] = useState(/** @type {any} */ (null));
  const [config, setConfig] = useState(/** @type {Record<string, any>} */ ({}));
  const [siteName, setSiteName] = useState('');
  const action = useApiMutation([queryKeys.installs, queryKeys.sites]);
  const [done, setDone] = useState(/** @type {string|null} */ (null));

  const loadPreview = () =>
    action.run(async () => {
      const sheet = await post('/installs/preview', { uri: uri.trim() });
      const defaults = {};
      for (const field of sheet.settings) {
        if (field.default !== undefined) defaults[field.key] = field.default;
      }
      setConfig(defaults);
      setSiteName(sheet.defaultSite || '');
      setPreview(sheet);
    });

  const install = () =>
    action.run(async () => {
      const result = await post('/installs/install', {
        uri: uri.trim(),
        site: siteName.trim() || undefined,
        config,
      });
      setDone(result.site);
    });

  return (
    <Dialog
      title="Install an app"
      trigger={<TextLink>Install an app</TextLink>}
    >
      <div className="space-y-3 text-[13px]">
        {action.error && <Notice variant="error">{action.error}</Notice>}
        {done ? (
          <p className="text-muted-foreground">
            Installed. It serves as <span className="font-mono">{done}</span> on
            your site domain, and appears under Installed apps.
          </p>
        ) : !preview ? (
          <>
            <p className="text-muted-foreground">
              Paste the app's manifest address, an{' '}
              <span className="font-mono">at://</span> URI naming a{' '}
              <span className="font-mono">dev.pdsjs.app.manifest</span> record.
            </p>
            <input
              type="text"
              value={uri}
              onChange={(event) => setUri(event.target.value)}
              placeholder="at://handle/dev.pdsjs.app.manifest/name"
              className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-[12.5px]"
            />
            <div className="flex justify-end">
              <Button
                size="sm"
                disabled={!uri.trim() || action.busy}
                onClick={loadPreview}
              >
                Preview
              </Button>
            </div>
          </>
        ) : (
          <>
            <div className="flex items-center gap-3">
              {preview.iconUrl ? (
                <img
                  src={preview.iconUrl}
                  alt=""
                  className="size-11 rounded-xl bg-muted object-cover"
                />
              ) : (
                <RowIcon>
                  <GlobeIcon />
                </RowIcon>
              )}
              <div className="min-w-0">
                <div className="font-extrabold text-[15px] text-foreground">
                  {preview.name}
                </div>
                <div className="text-[12px] text-muted-foreground">
                  by {preview.author.handle} · {num(preview.files.count)} file
                  {preview.files.count === 1 ? '' : 's'} ·{' '}
                  {bytes(preview.files.totalSize)}
                </div>
              </div>
            </div>
            {preview.description && (
              <p className="text-muted-foreground leading-relaxed">
                {preview.description}
              </p>
            )}
            {preview.external && (
              <Notice variant="error">
                This app talks to services beyond your own PDS.
              </Notice>
            )}
            {preview.pinDrifted && (
              <Notice variant="error">
                The developer changed this app after publishing the manifest. It
                cannot be installed until they publish an update.
              </Notice>
            )}
            <div className="flex items-center justify-between gap-4">
              <span className="text-muted-foreground">Serves at</span>
              <span className="flex items-center gap-1 font-mono text-[12.5px]">
                <input
                  type="text"
                  aria-label="Subdomain"
                  value={siteName}
                  onChange={(event) => setSiteName(event.target.value)}
                  className="w-28 rounded-lg border border-border bg-background px-2 py-1 text-right"
                />
                .{'{'}your domain{'}'}
              </span>
            </div>
            {preview.settings.length > 0 && (
              <div className="flex flex-col gap-2 rounded-xl border border-border p-3">
                {preview.settings.map((field) => (
                  <div
                    key={field.key}
                    className="flex items-center justify-between gap-4"
                  >
                    <span className="text-muted-foreground">{field.label}</span>
                    {field.type === 'boolean' && (
                      <input
                        type="checkbox"
                        aria-label={field.label}
                        checked={Boolean(config[field.key])}
                        onChange={(event) =>
                          setConfig({
                            ...config,
                            [field.key]: event.target.checked,
                          })
                        }
                      />
                    )}
                    {field.type === 'string' && (
                      <input
                        type="text"
                        aria-label={field.label}
                        value={String(config[field.key] ?? '')}
                        onChange={(event) =>
                          setConfig({
                            ...config,
                            [field.key]: event.target.value,
                          })
                        }
                        className="rounded-lg border border-border bg-background px-2 py-1"
                      />
                    )}
                    {field.type === 'select' && (
                      <select
                        aria-label={field.label}
                        value={String(config[field.key] ?? '')}
                        onChange={(event) =>
                          setConfig({
                            ...config,
                            [field.key]: event.target.value,
                          })
                        }
                        className="rounded-lg border border-border bg-background px-2 py-1"
                      >
                        {(field.options || []).map((option) => (
                          <option key={option} value={option}>
                            {option}
                          </option>
                        ))}
                      </select>
                    )}
                  </div>
                ))}
              </div>
            )}
            <div className="flex justify-end gap-2">
              <Button
                variant="ghost"
                size="sm"
                onClick={() => setPreview(null)}
              >
                Back
              </Button>
              <Button
                size="sm"
                disabled={action.busy || preview.pinDrifted}
                onClick={install}
              >
                {action.busy ? 'Installing\u2026' : 'Install'}
              </Button>
            </div>
          </>
        )}
      </div>
    </Dialog>
  );
}

/** Installed apps: each install record with its settings form. */
function InstalledApps({ domain }) {
  const { data, error } = useQuery({
    queryKey: queryKeys.installs,
    queryFn: () => get('/installs'),
  });
  const updates = useQuery({
    queryKey: queryKeys.installUpdates,
    queryFn: () => get('/installs/updates'),
    staleTime: 5 * 60 * 1000,
  });
  const action = useApiMutation([queryKeys.installs, queryKeys.installUpdates]);
  const [open, setOpen] = useState(/** @type {string|null} */ (null));

  const update = (manifestUri) =>
    action.run(async () => {
      await post('/installs/install', { uri: manifestUri });
    });

  if (error || !data || !data.installs.length) return null;
  return (
    <div className="mt-6 flex flex-col gap-3">
      <div className="flex items-center justify-between">
        <h2 className="font-extrabold text-[15px] text-foreground">
          Installed apps
        </h2>
        <InstallDialog />
      </div>
      {action.error && <Notice variant="error">{action.error}</Notice>}
      {data.installs.map((install) => (
        <div key={install.uri} className="flex flex-col gap-2">
          <ListRow
            className="px-5 py-4"
            media={
              install.iconUrl ? (
                <img
                  src={install.iconUrl}
                  alt=""
                  className="size-9 rounded-xl bg-muted object-cover"
                />
              ) : (
                <RowIcon>
                  <GlobeIcon />
                </RowIcon>
              )
            }
            title={
              <span className="text-[16px]">
                {install.name || install.site}
              </span>
            }
            description={
              <>
                Installed from{' '}
                <span className="font-mono">
                  {install.manifest?.uri?.replace('at://', '')}
                </span>
              </>
            }
            actions={
              <div className="flex shrink-0 items-center gap-3">
                {updates.data?.updates?.[install.uri]?.changed && (
                  <TextLink
                    disabled={action.busy}
                    onClick={() => update(install.manifest?.uri)}
                  >
                    Update available
                  </TextLink>
                )}
                <TextLink
                  onClick={() =>
                    setOpen(open === install.uri ? null : install.uri)
                  }
                >
                  {open === install.uri ? 'Hide settings' : 'Settings'}
                </TextLink>
                <TextLink
                  external
                  href={`https://${install.site}.${domain}`}
                  className="gap-1"
                >
                  {install.site}.{domain}
                  <ExternalLinkIcon className="size-3.5" />
                </TextLink>
              </div>
            }
          />
          {open === install.uri && <InstallSettings install={install} />}
        </div>
      ))}
    </div>
  );
}

/** The address an installer pastes: the manifest at-uri, one click away. */
function CopyInstallLink({ manifestUri }) {
  const [copied, setCopied] = useState(false);
  return (
    <TextLink
      onClick={async () => {
        await navigator.clipboard.writeText(manifestUri);
        setCopied(true);
        setTimeout(() => setCopied(false), 2000);
      }}
    >
      {copied ? 'Copied' : 'Copy install link'}
    </TextLink>
  );
}

export function SitesPage() {
  const { data, error } = useQuery({
    queryKey: queryKeys.sites,
    queryFn: () => get('/sites'),
  });
  const action = useApiMutation([queryKeys.sites, queryKeys.overview]);
  const [openFiles, setOpenFiles] = useState(/** @type {string|null} */ (null));

  if (error) {
    return <Notice variant="error">{error.message}</Notice>;
  }
  if (!data) return null;

  const remove = (name) =>
    action.run(async () => {
      await post('/sites/delete', { name });
    });

  if (!data.sites.filter((site) => !site.installed).length) {
    return (
      <div className="flex flex-col gap-3">
        <EmptyState
          icon={<GlobeIcon />}
          title="No sites deployed"
          description={
            <>
              A site is a folder of files stored in your repo and served at{' '}
              <span className="font-mono">name.{data.domain}</span> (the "
              {data.apexName}" site serves at {data.domain} itself). Deploy one
              with{' '}
              <span className="font-mono text-[12px]">
                pdsjs-site deploy ./dist --site {data.apexName} --pds
                https://your-pds --handle your.handle
              </span>
            </>
          }
        />
        <InstalledApps domain={data.domain} />
      </div>
    );
  }

  return (
    <div className="flex flex-col gap-3">
      {data.apexShadowed && (
        <Notice variant="error">
          This PDS answers at {data.domain} itself, so the "{data.apexName}"
          site is unreachable at the apex. Move the PDS to its own subdomain
          (pds.{data.domain}, say) to serve the apex site.
        </Notice>
      )}
      {action.error && <Notice variant="error">{action.error}</Notice>}
      {data.sites
        .filter((site) => !site.installed)
        .map((site) => (
          <div key={site.name} className="flex flex-col gap-2">
            <ListRow
              className="px-5 py-4"
              media={
                <RowIcon>
                  <GlobeIcon />
                </RowIcon>
              }
              title={
                <div className="flex min-w-0 items-center gap-2">
                  <span className="min-w-0 truncate text-[16px]">
                    {site.name}
                  </span>
                  {site.name === data.apexName && (
                    <Badge variant="scope" className="shrink-0">
                      apex
                    </Badge>
                  )}
                  {site.publishedApp && (
                    <Badge variant="scope" className="shrink-0">
                      published app
                    </Badge>
                  )}
                  {!site.servable && (
                    <Badge variant="scope" className="shrink-0">
                      not a valid subdomain
                    </Badge>
                  )}
                </div>
              }
              description={
                <>
                  {num(site.fileCount)} file{site.fileCount === 1 ? '' : 's'} ·{' '}
                  {bytes(site.totalSize)}
                  {site.deployedAt
                    ? ` · deployed ${timeAgo(site.deployedAt)}`
                    : ''}
                </>
              }
              actions={
                <div className="flex shrink-0 items-center gap-3">
                  <TextLink
                    onClick={() =>
                      setOpenFiles(openFiles === site.name ? null : site.name)
                    }
                  >
                    {openFiles === site.name ? 'Hide files' : 'Files'}
                  </TextLink>
                  {site.manifestUri && (
                    <CopyInstallLink manifestUri={site.manifestUri} />
                  )}
                  <TextLink external href={site.url} className="gap-1">
                    {site.publishedApp
                      ? 'preview'
                      : site.url.replace(/^https:\/\//, '')}
                    <ExternalLinkIcon className="size-3.5" />
                  </TextLink>
                  <Dialog
                    title={`Delete ${site.name}?`}
                    trigger={
                      <TextLink tone="danger" disabled={action.busy}>
                        Delete
                      </TextLink>
                    }
                  >
                    <div className="space-y-3 text-[13px] text-muted-foreground leading-relaxed">
                      <p>
                        This removes the site record from your repo and takes{' '}
                        <span className="font-mono">
                          {site.url.replace(/^https:\/\//, '')}
                        </span>{' '}
                        offline. Its files become unreferenced and this server
                        cleans them up.
                      </p>
                      <p>
                        A deploy with the same name brings the site back;
                        unchanged files will upload again.
                      </p>
                    </div>
                    <div className="mt-5 flex justify-end gap-2">
                      <BaseDialog.Close
                        render={
                          <Button variant="ghost" size="sm">
                            Cancel
                          </Button>
                        }
                      />
                      <BaseDialog.Close
                        render={
                          <Button
                            variant="destructive"
                            size="sm"
                            onClick={() => remove(site.name)}
                          >
                            Delete site
                          </Button>
                        }
                      />
                    </div>
                  </Dialog>
                </div>
              }
            />
            {openFiles === site.name && <SiteFiles site={site} />}
          </div>
        ))}
      <InstalledApps domain={data.domain} />
    </div>
  );
}