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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
178 lines · 5.7 KB Raw History
import { useQuery } from '@tanstack/react-query';
import { AppWindowIcon, ChevronRightIcon } 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 { Notice } from '#/components/atoms/notice.jsx';
import { Sparkline } from '#/components/atoms/sparkline.jsx';
import { EmptyState } from '#/components/molecules/empty-state.jsx';
import { ErrorNotice } from '#/components/molecules/error-notice.jsx';
import { ListRow } from '#/components/molecules/list-row.jsx';
import { SearchField } from '#/components/molecules/search-field.jsx';
import { get, post } from '#/lib/api.js';
import {
  appActivitySeries,
  combineOffers,
  lastActiveAt,
  mergeApps,
  num,
  timeAgo,
} from '#/lib/format.js';
import { queryKeys, useApiMutation } from '#/lib/query.js';

const SEARCH_THRESHOLD = 6;

export function AppsPage({ data, appHref, onNavigate }) {
  const { data: appsData, error } = useQuery({
    queryKey: queryKeys.apps,
    queryFn: () => get('/apps'),
  });
  const { data: spacesData } = useQuery({
    queryKey: queryKeys.spaces,
    queryFn: () => get('/spaces'),
    enabled: Boolean(data.features?.spaces),
  });
  const [query, setQuery] = useState('');
  const combine = useApiMutation([queryKeys.apps]);

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

  const apps = mergeApps(
    data.collections,
    appsData.apps,
    appsData.aliases,
    spacesData?.spaces,
  );
  const offers = appsData.aliasesAvailable ? combineOffers(apps, appsData) : [];

  if (!apps.length) {
    return (
      <EmptyState
        icon={<AppWindowIcon />}
        title="No apps yet"
        description="An app you sign in to, or that writes records to your account, appears here. Open one to see what it has stored and manage its access."
      />
    );
  }

  const needle = query.trim().toLowerCase();
  const matches = needle
    ? apps.filter(
        (app) =>
          app.domain.toLowerCase().includes(needle) ||
          (app.folded || []).some((domain) => domain.includes(needle)),
      )
    : apps;

  return (
    <div className="flex flex-col gap-3">
      <ErrorNotice error={combine.error} />
      {offers.map((offer) => (
        <Notice key={`${offer.from}>${offer.to}`}>
          <span className="font-semibold text-foreground">{offer.to}</span> and{' '}
          <span className="font-semibold text-foreground">{offer.from}</span>{' '}
          look like the same app. One name holds the sign-in and the other holds
          its records. Combine them into one entry?
          <div className="mt-2.5 flex gap-2">
            <Button
              size="sm"
              disabled={combine.busy}
              onClick={() =>
                combine.run(() =>
                  post('/apps/alias', {
                    action: 'combine',
                    from: offer.from,
                    to: offer.to,
                  }),
                )
              }
            >
              Combine
            </Button>
            <Button
              variant="outline"
              size="sm"
              disabled={combine.busy}
              onClick={() =>
                combine.run(() =>
                  post('/apps/alias', {
                    action: 'dismiss',
                    from: offer.from,
                    to: offer.to,
                  }),
                )
              }
            >
              Keep separate
            </Button>
          </div>
        </Notice>
      ))}
      {apps.length > SEARCH_THRESHOLD && (
        <SearchField
          value={query}
          onChange={setQuery}
          placeholder="Search apps"
        />
      )}
      {matches.length === 0 && (
        <p className="px-1 py-8 text-center text-[13px] text-muted-foreground">
          No apps match "{query.trim()}".
        </p>
      )}
      {matches.map((app) => {
        const active = lastActiveAt(app);
        // Lead with recency — the app's own page and History carry the record
        // detail. Fall back to a count only when nothing datable is known (an
        // app whose records all have non-TID rkeys, and no session).
        let description;
        if (active) {
          description = `Active ${timeAgo(active)}`;
        } else if (app.count > 0) {
          description = `${num(app.count)} record${app.count === 1 ? '' : 's'}`;
        } else {
          description = 'No records stored';
        }
        if (app.folded?.length) {
          description += ` · includes ${app.folded.join(', ')}`;
        }
        const series = appActivitySeries(
          data.recordActivity,
          app.domain,
          app.folded,
        );
        return (
          <ListRow
            key={app.domain}
            className="px-5 py-4"
            href={appHref(app.domain)}
            onNavigate={onNavigate}
            background={series && <Sparkline values={series} />}
            media={<AppTile domain={app.domain} label={app.domain} size={44} />}
            title={
              <div className="flex min-w-0 items-center gap-2">
                <span className="min-w-0 truncate text-[16px]">
                  {app.domain}
                </span>
                {app.sessions.length > 0 && (
                  <Badge variant="scope" className="shrink-0">
                    {app.sessions.length} session
                    {app.sessions.length === 1 ? '' : 's'}
                  </Badge>
                )}
              </div>
            }
            description={description}
            actions={
              <ChevronRightIcon className="size-4 shrink-0 text-faint" />
            }
          />
        );
      })}
    </div>
  );
}