Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-dashboard/src/archive.tsx

Snapshot at 34e3175

Containing directory

3,228 bytes 路 textView raw
1import { WriteHint } from "./freshness.tsx";
2import { useEffect, useRef, useState } from "react";
3import { Badge, Button, Tooltip } from "@cloudflare/kumo";
4import { Warning } from "@phosphor-icons/react";
5import type { Packet, ProjectState } from "./contracts.ts";
6import { canArchive } from "./model.ts";
7import { relativeTime } from "./time.ts";
8import { ApiError } from "./transport.ts";
9
10export function ArchivedBadge({ at }: { at: string }) {
11  return (
12    <Tooltip
13      content={`Archived 路 ${relativeTime(at)}`}
14      render={<span tabIndex={0} aria-label={`Archived 路 ${relativeTime(at)}`} />}
15    >
16      <Badge variant="secondary">Archived</Badge>
17    </Tooltip>
18  );
19}
20
21/** One deliberate panel action. Reads after conflicts never retry the write. */
22export function ArchiveControl({
23  packet,
24  state,
25  owner,
26  busy,
27  disabledReason,
28  archive,
29  unarchive,
30  missing,
31}: {
32  packet: Packet;
33  state: ProjectState;
34  owner: boolean;
35  busy: boolean;
36  disabledReason?: string;
37  archive?: (id: string) => Promise<boolean>;
38  unarchive?: (id: string) => Promise<boolean>;
39  missing(message: string): void;
40}) {
41  const [error, setError] = useState<string>();
42  const button = useRef<HTMLButtonElement>(null);
43  const restoreFocus = useRef(false);
44  const pending = useRef(false);
45  useEffect(() => {
46    if (restoreFocus.current && !busy) {
47      button.current?.focus();
48      restoreFocus.current = false;
49    }
50  }, [packet.archived, busy]);
51  const kind = packet.archived ? "unarchive" : "archive";
52  const label = packet.archived ? "Unarchive" : "Archive";
53  const write = packet.archived ? unarchive : archive;
54  const offered = owner && write && (packet.archived || canArchive(state, packet.id));
55  return (
56    <>
57      {offered && (
58        <WriteHint reason={disabledReason}><Button
59          ref={button}
60          variant="secondary"
61          size="sm"
62          data-action={kind}
63          aria-label={`${label} ${packet.id}`}
64          disabled={busy}
65          onClick={async () => {
66            if (busy || pending.current) return;
67            pending.current = true;
68            setError(undefined);
69            restoreFocus.current = true;
70            try {
71              await write!(packet.id);
72            } catch (failure) {
73              if (failure instanceof ApiError && failure.status === 404) {
74                missing("This packet doesn't exist anymore.");
75              } else {
76                setError(
77                  failure instanceof ApiError && failure.status === 409 && kind === "archive"
78                    ? `${packet.id} has work in flight, so it can't be archived yet.`
79                    : `Couldn't ${kind} ${packet.id}. Try again.`,
80                );
81              }
82            } finally {
83              pending.current = false;
84              // Also restore after an idempotent response; the replacement is already drawn.
85              button.current?.focus();
86            }
87          }}
88        >
89          {label}
90        </Button></WriteHint>
91      )}
92      {error && (
93        <p className="receipt-error-line caption" role="alert">
94          <Warning size={16} aria-hidden="true" />
95          <span>{error}</span>
96        </p>
97      )}
98    </>
99  );
100}
101