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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
759 lines · 24.5 KB Raw History
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import { useQuery } from '@tanstack/react-query';
import {
  FileIcon,
  FolderIcon,
  FolderPlusIcon,
  HardDriveIcon,
  LockIcon,
  Trash2Icon,
  UploadIcon,
} from 'lucide-react';
import { useRef, useState } from 'react';
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, upload } from '#/lib/api.js';
import { bytes, num, timeAgo } from '#/lib/format.js';
import { queryKeys, useApiMutation } from '#/lib/query.js';

/** The drive page URL for one folder, in the public repo or in a space. */
export function folderHref(folder, space) {
  const query = new URLSearchParams();
  if (folder) query.set('folder', folder);
  if (space) query.set('space', space);
  const qs = query.toString();
  return qs ? `/account/drive?${qs}` : '/account/drive';
}

/** Where the browser fetches one file's bytes. */
function downloadHref(file, space) {
  const query = new URLSearchParams({ rkey: file.rkey });
  if (space) query.set('space', space);
  return `/account/api/drive/file?${query.toString()}`;
}

/** A read-only value with a button that puts it on the clipboard. */
function CopyField({ label, value, rows }) {
  const [copied, setCopied] = useState(false);
  return (
    <div className="space-y-1.5">
      <div className="flex items-center justify-between">
        <span className="font-medium text-[12.5px] text-foreground">
          {label}
        </span>
        <Button
          variant="ghost"
          size="sm"
          onClick={async () => {
            await navigator.clipboard.writeText(value);
            setCopied(true);
            setTimeout(() => setCopied(false), 1500);
          }}
        >
          {copied ? 'Copied' : 'Copy'}
        </Button>
      </div>
      <textarea
        readOnly
        rows={rows ?? 1}
        value={value}
        onFocus={(event) => event.target.select()}
        className="w-full resize-none rounded-lg border bg-muted/40 px-2.5 py-1.5 font-mono text-[12px] text-muted-foreground"
      />
    </div>
  );
}

/**
 * The blob behind a single-blob public file, in the two forms another app
 * takes: the public URL its bytes serve from, and the blob ref a record
 * embeds. Split files and space files have no public single blob, so the
 * dialog only offers on files with a blobCid outside a space.
 */
function LinkFileDialog({ file, did }) {
  const origin = typeof window === 'undefined' ? '' : window.location.origin;
  const url = `${origin}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(file.blobCid)}`;
  const ref = JSON.stringify(
    {
      $type: 'blob',
      ref: { $link: file.blobCid },
      mimeType: file.mimeType,
      size: file.size,
    },
    null,
    2,
  );
  return (
    <Dialog title={`Link ${file.name}`} trigger={<TextLink>Link</TextLink>}>
      <div className="space-y-4">
        <p className="text-[13px] text-muted-foreground leading-relaxed">
          This file is one blob in your account. Any record can reference it. A
          post embed, a Spark video, or a video tag can use these directly.
        </p>
        <CopyField label="Public URL" value={url} rows={2} />
        <CopyField label="Blob ref" value={ref} rows={7} />
      </div>
    </Dialog>
  );
}

/** The path back to the top level, one link per folder along the way. */
function Breadcrumb({ ancestry, space, onNavigate }) {
  if (!ancestry?.length) return null;
  return (
    <div className="flex flex-wrap items-center gap-1 text-[13px] text-muted-foreground">
      <TextLink onClick={() => onNavigate(folderHref('', space))}>
        All files
      </TextLink>
      {ancestry.map((entry, index) => {
        const last = index === ancestry.length - 1;
        return (
          <span key={entry.rkey} className="flex items-center gap-1">
            <span aria-hidden="true">/</span>
            {last ? (
              <span className="font-medium text-foreground">{entry.name}</span>
            ) : (
              <TextLink
                onClick={() => onNavigate(folderHref(entry.rkey, space))}
              >
                {entry.name}
              </TextLink>
            )}
          </span>
        );
      })}
    </div>
  );
}

/**
 * Make a drive that lives in a permissioned space. Its files are private and
 * shared with whoever joins the space, and it is a space of the drive's own
 * type: another app's space is that app's data model.
 */
function NewDriveDialog({ onCreate, busy }) {
  const [name, setName] = useState('');
  return (
    <Dialog
      title="New private drive"
      trigger={
        <Button variant="ghost" size="sm" disabled={busy}>
          <LockIcon className="size-3.5" /> New private drive
        </Button>
      }
    >
      <div className="space-y-3 text-[13px]">
        <p className="text-muted-foreground leading-relaxed">
          A private drive keeps its files out of your public repo. Whoever you
          add to it can read them.
        </p>
        <input
          type="text"
          aria-label="Drive name"
          value={name}
          onChange={(event) => setName(event.target.value)}
          placeholder="photos"
          className="w-full rounded-lg border border-border bg-background px-3 py-2 text-[13px]"
        />
        <p className="text-faint text-[12px]">
          Lowercase letters, numbers and dashes.
        </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
              size="sm"
              disabled={!name.trim()}
              onClick={() => onCreate(name.trim())}
            >
              Make drive
            </Button>
          }
        />
      </div>
    </Dialog>
  );
}

/** Make a folder here. It is a record of its own, so an empty one stays. */
function NewFolderDialog({ folder, space, onCreate, busy }) {
  const [name, setName] = useState('');
  return (
    <Dialog
      title="New folder"
      trigger={
        <Button variant="ghost" size="sm" disabled={busy}>
          <FolderPlusIcon className="size-4" /> New folder
        </Button>
      }
    >
      <div className="space-y-3 text-[13px]">
        <input
          type="text"
          aria-label="Folder name"
          value={name}
          onChange={(event) => setName(event.target.value)}
          placeholder="photos"
          className="w-full rounded-lg border border-border bg-background px-3 py-2 text-[13px]"
        />
      </div>
      <div className="mt-5 flex justify-end gap-2">
        <BaseDialog.Close
          render={
            <Button variant="ghost" size="sm">
              Cancel
            </Button>
          }
        />
        <BaseDialog.Close
          render={
            <Button
              size="sm"
              disabled={!name.trim()}
              onClick={() => onCreate(name.trim(), folder, space)}
            >
              Make folder
            </Button>
          }
        />
      </div>
    </Dialog>
  );
}

/** Rename one folder. Every file keeps its own record, so nothing moves. */
function RenameFolderDialog({ entry, space, onRename, busy }) {
  const [name, setName] = useState(entry.name);
  return (
    <Dialog
      title={`Rename ${entry.name}`}
      trigger={<TextLink disabled={busy}>Rename</TextLink>}
    >
      <div className="space-y-3 text-[13px]">
        <input
          type="text"
          aria-label="Folder name"
          value={name}
          onChange={(event) => setName(event.target.value)}
          className="w-full rounded-lg border border-border bg-background px-3 py-2 text-[13px]"
        />
      </div>
      <div className="mt-5 flex justify-end gap-2">
        <BaseDialog.Close
          render={
            <Button variant="ghost" size="sm">
              Cancel
            </Button>
          }
        />
        <BaseDialog.Close
          render={
            <Button
              size="sm"
              disabled={!name.trim()}
              onClick={() => onRename(entry.rkey, name.trim(), space)}
            >
              Rename
            </Button>
          }
        />
      </div>
    </Dialog>
  );
}

/**
 * What deleting put aside. The bytes are still stored, so this says what the
 * trash costs and offers to give it back or let it go.
 */
function Trash({ space, busy, onRestore, onPurge }) {
  const { data } = useQuery({
    queryKey: queryKeys.driveTrash(space || ''),
    queryFn: () => {
      const query = space ? `?space=${encodeURIComponent(space)}` : '';
      return get(`/drive/trash${query}`);
    },
  });
  const [open, setOpen] = useState(false);

  if (!data?.enabled || !data.items?.length) return null;
  return (
    <div className="mt-2 flex flex-col gap-2">
      <div className="flex items-center justify-between px-1">
        <TextLink onClick={() => setOpen(!open)}>
          <Trash2Icon className="size-3.5" />
          {open ? 'Hide' : 'Show'} recently deleted ({num(data.items.length)})
        </TextLink>
        <span className="text-[12px] text-muted-foreground">
          {bytes(data.totalSize)} still stored
        </span>
      </div>
      {open &&
        data.items.map((item) => (
          <ListRow
            key={item.rkey}
            variant="plain"
            media={
              <RowIcon>{item.isFolder ? <FolderIcon /> : <FileIcon />}</RowIcon>
            }
            title={<span className="text-[14px]">{item.name}</span>}
            description={
              <>
                {item.isFolder ? 'Folder' : bytes(item.size)} · deleted{' '}
                {timeAgo(item.deletedAt)}
              </>
            }
            actions={
              <div className="flex shrink-0 items-center gap-3">
                <TextLink disabled={busy} onClick={() => onRestore(item.rkey)}>
                  Put back
                </TextLink>
                <Dialog
                  title={`Delete ${item.name} for good?`}
                  trigger={
                    <TextLink tone="danger" disabled={busy}>
                      Delete for good
                    </TextLink>
                  }
                >
                  <div className="space-y-3 text-[13px] text-muted-foreground leading-relaxed">
                    <p>
                      This frees the stored bytes. Nothing here can bring them
                      back afterwards.
                    </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={() => onPurge(item.rkey)}
                        >
                          Delete for good
                        </Button>
                      }
                    />
                  </div>
                </Dialog>
              </div>
            }
          />
        ))}
    </div>
  );
}

/**
 * @param {{data: Object, folder: string, onNavigate: (href: string) => void}} props
 */
export function DrivePage({ data: account, folder, space, onNavigate }) {
  const key = { folder, space: space || '' };
  const { data, error } = useQuery({
    queryKey: queryKeys.driveFiles(key),
    queryFn: () => {
      const query = new URLSearchParams({ folder });
      if (space) query.set('space', space);
      return get(`/drive/files?${query.toString()}`);
    },
  });
  const action = useApiMutation([
    queryKeys.driveFiles(key),
    queryKeys.driveTrash(space || ''),
    queryKeys.overview,
  ]);
  const fileInput = useRef(/** @type {HTMLInputElement|null} */ (null));
  const [dragging, setDragging] = useState(false);
  const [sending, setSending] = useState(/** @type {string|null} */ (null));

  const canUpload = Boolean(account.features.driveUpload);

  // A file larger than one request body may carry goes up in parts through
  // an upload session, and still lands as one blob.
  const sendInParts = async (file, partSize) => {
    const { id } = await post('/drive/upload/start', {
      name: file.name,
      mimeType: file.type || undefined,
      folder: folder || undefined,
      space: space || undefined,
    });
    try {
      for (let at = 0; at < file.size; at += partSize) {
        setSending(`${file.name} (${Math.round((at / file.size) * 100)}%)`);
        await upload(
          `/drive/upload/part?id=${id}`,
          file.slice(at, at + partSize),
        );
      }
      setSending(file.name);
      await post('/drive/upload/finish', { id });
    } catch (e) {
      post('/drive/upload/abort', { id }).catch(() => {});
      throw e;
    }
  };

  const sendFiles = (files) =>
    action.run(async () => {
      try {
        for (const file of files) {
          setSending(file.name);
          const partSize = data?.uploadPartSize;
          if (partSize && file.size > partSize) {
            await sendInParts(file, partSize);
            continue;
          }
          const query = new URLSearchParams({ name: file.name });
          if (folder) query.set('folder', folder);
          if (space) query.set('space', space);
          await upload(`/drive/upload?${query.toString()}`, file);
        }
      } finally {
        setSending(null);
      }
    });

  if (error) return <Notice variant="error">{error.message}</Notice>;
  if (!data) return null;
  if (!data.enabled) {
    return (
      <EmptyState
        icon={<HardDriveIcon />}
        title="Drive is off"
        description="This server does not have the drive feature composed in."
      />
    );
  }

  const remove = (rkey) =>
    action.run(async () => {
      await post('/drive/delete', { rkey, space: space || undefined });
    });

  const makeFolder = (name, parent, inSpace) =>
    action.run(async () => {
      await post('/drive/folder', {
        name,
        parent: parent || undefined,
        space: inSpace || undefined,
      });
    });

  const renameFolder = (rkey, name, inSpace) =>
    action.run(async () => {
      await post('/drive/folder/rename', {
        rkey,
        name,
        space: inSpace || undefined,
      });
    });

  const removeFolder = (rkey) =>
    action.run(async () => {
      await post('/drive/folder/delete', { rkey, space: space || undefined });
    });

  const restoreItem = (rkey) =>
    action.run(async () => {
      await post('/drive/restore', { rkey, space: space || undefined });
    });

  const purgeItem = (rkey) =>
    action.run(async () => {
      await post('/drive/purge', { rkey, space: space || undefined });
    });

  const makeDrive = (name) =>
    action.run(async () => {
      const made = await post('/drive/space', { name });
      onNavigate(folderHref('', made.uri));
    });

  const empty = data.folders.length === 0 && data.files.length === 0;

  return (
    // biome-ignore lint/a11y/noStaticElementInteractions: the drop target is the file list, and the button beside it does the same job for the keyboard
    <div
      className="flex flex-col gap-3"
      onDragOver={(event) => {
        if (!canUpload) return;
        event.preventDefault();
        setDragging(true);
      }}
      onDragLeave={() => setDragging(false)}
      onDrop={(event) => {
        if (!canUpload) return;
        event.preventDefault();
        setDragging(false);
        const files = [...event.dataTransfer.files];
        if (files.length) sendFiles(files);
      }}
    >
      {(data.spaces?.length > 0 || data.canMakeSpace) && (
        <div className="flex flex-wrap items-center gap-2 text-[13px]">
          <span className="text-muted-foreground">Drive in</span>
          <Button
            variant={space ? 'ghost' : 'secondary'}
            size="sm"
            onClick={() => onNavigate(folderHref('', ''))}
          >
            Your repo
          </Button>
          {data.spaces.map((entry) => (
            <Button
              key={entry.uri}
              variant={space === entry.uri ? 'secondary' : 'ghost'}
              size="sm"
              onClick={() => onNavigate(folderHref('', entry.uri))}
            >
              <LockIcon className="size-3.5" /> {entry.name}
            </Button>
          ))}
          {data.canMakeSpace && (
            <NewDriveDialog onCreate={makeDrive} busy={action.busy} />
          )}
        </div>
      )}

      <div className="flex flex-wrap items-center justify-between gap-2">
        <Breadcrumb
          ancestry={data.ancestry}
          space={space}
          onNavigate={onNavigate}
        />
        {canUpload && (
          <div className="ml-auto flex items-center gap-2">
            <NewFolderDialog
              folder={data.folder}
              space={space}
              busy={action.busy}
              onCreate={makeFolder}
            />
            <input
              ref={fileInput}
              type="file"
              multiple
              className="hidden"
              onChange={(event) => {
                const files = [...event.target.files];
                event.target.value = '';
                if (files.length) sendFiles(files);
              }}
            />
            <Button
              size="sm"
              disabled={action.busy}
              onClick={() => fileInput.current?.click()}
            >
              <UploadIcon className="size-4" />
              {action.busy ? 'Uploading…' : 'Upload files'}
            </Button>
          </div>
        )}
      </div>

      {action.error && <Notice variant="error">{action.error}</Notice>}
      {sending && (
        <p className="px-1 text-[12.5px] text-muted-foreground">
          Uploading {sending}…
        </p>
      )}
      {dragging && (
        <div className="rounded-2xl border border-key border-dashed px-5 py-8 text-center text-[13px] text-muted-foreground">
          Drop to upload into{' '}
          <span className="font-medium text-foreground">
            {data.path || 'your drive'}
          </span>
        </div>
      )}

      {empty ? (
        <EmptyState
          icon={<HardDriveIcon />}
          title={data.folder ? 'This folder is empty' : 'No files yet'}
          description={
            canUpload
              ? 'Drop files here, or use Upload files. Each one is stored in your own repo, with its bytes in your account’s blob storage.'
              : 'Files stored as dev.pdsjs.drive.file records appear here, with their bytes kept in your account’s blob storage.'
          }
        />
      ) : (
        <>
          {data.folders.map((child) => (
            <ListRow
              key={child.name}
              className="px-5 py-4"
              media={
                <RowIcon>
                  <FolderIcon />
                </RowIcon>
              }
              title={<span className="text-[16px]">{child.name}</span>}
              description={
                <>
                  {num(child.files)} file{child.files === 1 ? '' : 's'} ·{' '}
                  {bytes(child.size)}
                </>
              }
              href={folderHref(child.rkey, space)}
              onNavigate={onNavigate}
              actions={
                <div className="flex shrink-0 items-center gap-3">
                  <RenameFolderDialog
                    entry={child}
                    space={space}
                    busy={action.busy}
                    onRename={renameFolder}
                  />
                  <Dialog
                    title={`Delete ${child.name}?`}
                    trigger={
                      <TextLink tone="danger" disabled={action.busy}>
                        Delete
                      </TextLink>
                    }
                  >
                    <div className="space-y-3 text-[13px] text-muted-foreground leading-relaxed">
                      <p>
                        {child.files > 0
                          ? `This puts the folder and the ${child.files} file${child.files === 1 ? '' : 's'} in it into recently deleted.`
                          : 'This puts the folder into recently deleted.'}
                      </p>
                      <p>You can put it back from there.</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={() => removeFolder(child.rkey)}
                          >
                            Delete folder
                          </Button>
                        }
                      />
                    </div>
                  </Dialog>
                </div>
              }
            />
          ))}

          {data.files.map((file) => (
            <ListRow
              key={file.rkey}
              className="px-5 py-4"
              media={
                <RowIcon>
                  <FileIcon />
                </RowIcon>
              }
              title={
                <span className="min-w-0 truncate text-[16px]">
                  {file.name}
                </span>
              }
              description={
                <>
                  {bytes(file.size)} · {file.mimeType}
                  {file.partCount > 1 ? ` · ${num(file.partCount)} parts` : ''}
                  {file.updatedAt || file.createdAt
                    ? ` · ${timeAgo(file.updatedAt || file.createdAt)}`
                    : ''}
                </>
              }
              actions={
                <div className="flex shrink-0 items-center gap-3">
                  {!space && file.blobCid && (
                    <LinkFileDialog file={file} did={account.did} />
                  )}
                  <TextLink external href={downloadHref(file, space)}>
                    Download
                  </TextLink>
                  <Dialog
                    title={`Delete ${file.name}?`}
                    trigger={
                      <TextLink tone="danger" disabled={action.busy}>
                        Delete
                      </TextLink>
                    }
                  >
                    <div className="space-y-3 text-[13px] text-muted-foreground leading-relaxed">
                      <p>
                        This puts the file into recently deleted. Its bytes stay
                        stored until you empty it from there.
                      </p>
                      <p>You can put it back.</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(file.rkey)}
                          >
                            Delete file
                          </Button>
                        }
                      />
                    </div>
                  </Dialog>
                </div>
              }
            />
          ))}
        </>
      )}

      <Trash
        space={space}
        busy={action.busy}
        onRestore={restoreItem}
        onPurge={purgeItem}
      />

      {data.totalFiles > 0 && (
        <p className="px-1 text-[12.5px] text-muted-foreground">
          {num(data.totalFiles)} file{data.totalFiles === 1 ? '' : 's'} in your
          drive, {bytes(data.totalSize)} in all.
        </p>
      )}
    </div>
  );
}