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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
183 lines · 6.8 KB Raw History
import { InfoIcon } from 'lucide-react';
import { AppTile } from '#/components/atoms/app-tile.jsx';
import { Card } from '#/components/atoms/card.jsx';
import { Chip } from '#/components/atoms/chip.jsx';
import { Dialog } from '#/components/molecules/dialog.jsx';
import { ErrorNotice } from '#/components/molecules/error-notice.jsx';
import { StorageBar } from '#/components/molecules/storage-bar.jsx';
import { TextLink } from '#/components/molecules/text-link.jsx';
import { post } from '#/lib/api.js';
import { KIND_META, KIND_ORDER } from '#/lib/file-kinds.js';
import { bytes, num } from '#/lib/format.js';
import { useApiMutation } from '#/lib/query.js';
import { cn } from '#/lib/utils.js';

/** The Newest/Largest toggle with its how-it-works explainer. */
function SortControls({ sort, filters, hrefFor, onNavigate }) {
  return (
    <>
      {['newest', 'largest'].map((order) => (
        <Chip
          key={order}
          active={sort === order}
          href={hrefFor({
            ...filters,
            sort: order === 'largest' ? 'largest' : undefined,
          })}
          onNavigate={onNavigate}
        >
          {order === 'newest' ? 'Newest' : 'Largest'}
        </Chip>
      ))}
      <Dialog
        title="How files are ordered"
        trigger={
          <button
            type="button"
            aria-label="How files are ordered"
            className="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full text-faint outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
          >
            <InfoIcon className="size-4" />
          </button>
        }
      >
        <div className="space-y-3 text-[13px] text-muted-foreground leading-relaxed">
          <p>
            <span className="font-semibold text-foreground">Newest</span> lists
            files by when their content was made. The date comes from the record
            that uses the file, so it stays accurate even if your account moves
            between hosts.
          </p>
          <p>
            Some records carry no date of their own. Those files are listed by
            when they reached this server instead, and their dates read
            &ldquo;Stored&rdquo;.
          </p>
          <p>
            <span className="font-semibold text-foreground">Largest</span> lists
            files by size, biggest first.
          </p>
        </div>
      </Dialog>
    </>
  );
}

/** The bar plus every filter control; the chip rows double as the legend. */
export function FilesHeader({ facets, filters, hrefFor, onNavigate }) {
  const sort = filters.sort === 'largest' ? 'largest' : 'newest';
  const kinds = KIND_ORDER.map((kind) =>
    facets.kinds.find((facet) => facet.kind === kind),
  ).filter(Boolean);
  // Rebuilds blob usage from record content, for repos that arrived by
  // import, where usage undercounts until the links are rebuilt.
  const relink = useApiMutation([['blobs']]);

  return (
    <Card className="mb-4 p-5">
      <div className="flex items-baseline justify-between gap-3">
        <p className="text-[12.5px] text-muted-foreground">
          {num(facets.total.count)}{' '}
          {facets.total.count === 1 ? 'file' : 'files'}, using{' '}
          {bytes(facets.total.bytes)} of storage.
        </p>
        <TextLink
          className="text-[12px]"
          onClick={() => relink.run(() => post('/blobs/relink'))}
          disabled={relink.busy}
          title="Re-read every record to rebuild which files are used. Useful after moving to this server."
        >
          {relink.busy ? 'Rechecking…' : 'Recheck usage'}
        </TextLink>
      </div>
      <ErrorNotice error={relink.error} />
      <StorageBar
        facets={facets}
        filters={filters}
        hrefFor={hrefFor}
        onNavigate={onNavigate}
      />

      <div className="-mx-5 -mb-1 mt-3.5 flex items-center gap-1.5 overflow-x-auto px-5 pb-1 [-ms-overflow-style:none] [scrollbar-width:none] sm:mx-0 sm:mb-0 sm:flex-wrap sm:overflow-visible sm:px-0 sm:pb-0 [&::-webkit-scrollbar]:hidden">
        {/* On a phone this row scrolls sideways, so sort leads at the left
            edge, always in view; the kinds trail off, hinting at the scroll.
            On wider screens sort moves to its usual place at the right. */}
        <div className="flex shrink-0 items-center gap-1.5 sm:hidden">
          <SortControls
            sort={sort}
            filters={filters}
            hrefFor={hrefFor}
            onNavigate={onNavigate}
          />
          <span aria-hidden className="mx-0.5 h-4 w-px shrink-0 bg-border" />
        </div>
        {kinds.map((facet) => (
          <Chip
            key={facet.kind}
            active={filters.kind === facet.kind}
            href={hrefFor({
              ...filters,
              kind: filters.kind === facet.kind ? undefined : facet.kind,
            })}
            onNavigate={onNavigate}
            title={bytes(facet.bytes)}
          >
            <span
              className={cn('size-2 rounded-full', KIND_META[facet.kind].dot)}
            />
            {KIND_META[facet.kind].label}
            <span className="font-normal text-faint">{num(facet.count)}</span>
          </Chip>
        ))}

        {facets.unused.count > 0 && (
          <Chip
            active={Boolean(filters.unused)}
            href={hrefFor({ ...filters, unused: !filters.unused })}
            onNavigate={onNavigate}
            title={bytes(facets.unused.bytes)}
          >
            Not used
            <span className="font-normal text-faint">
              {num(facets.unused.count)}
            </span>
          </Chip>
        )}

        <div className="ml-auto hidden items-center gap-1.5 sm:flex">
          <SortControls
            sort={sort}
            filters={filters}
            hrefFor={hrefFor}
            onNavigate={onNavigate}
          />
        </div>
      </div>

      {facets.apps.length > 0 && (
        <div className="-mx-5 -mb-1 mt-2 flex items-center gap-1.5 overflow-x-auto px-5 pb-1 [-ms-overflow-style:none] [scrollbar-width:none] sm:mx-0 sm:mb-0 sm:flex-wrap sm:overflow-visible sm:px-0 sm:pb-0 [&::-webkit-scrollbar]:hidden">
          {facets.apps.map((app) => (
            <Chip
              key={app.domain}
              active={filters.app === app.domain}
              href={hrefFor({
                ...filters,
                app: filters.app === app.domain ? undefined : app.domain,
              })}
              onNavigate={onNavigate}
              title={`${num(app.count)} ${app.count === 1 ? 'file' : 'files'}`}
            >
              <AppTile
                domain={app.domain}
                size={16}
                className="rounded-[5px]"
              />
              {app.domain}
              <span className="font-normal text-faint">{bytes(app.bytes)}</span>
            </Chip>
          ))}
        </div>
      )}
    </Card>
  );
}