Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/src/archive.tsx
Snapshot at 34e3175
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