Files
Magent/frontend/app/requests/[id]/LatestActivity.tsx
T
Assclaw f852e7c941
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped
chore: standardize security and quality foundations
2026-09-17 20:03:47 +12:00

174 lines
5.7 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import "./latest-activity.css";
import { lockBodyScroll } from "../../lib/scrollLock";
type Event = { id: string; service: string; state: string; message: string; started_at?: string; finished_at?: string };
type Operation = {
summary?: { title: string; message: string; next: string; action?: string };
id: string;
label: string;
status: string;
events: Event[];
};
export default function LatestActivity({
operation,
besideDownload,
onDismiss,
}: {
operation: Operation;
besideDownload: boolean;
onDismiss: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(true);
useEffect(() => {
void operation.id;
setOpen(true);
}, [operation.id]);
// Events are appended in order. Client result events have no timestamp.
const latest = operation.events.at(-1);
const working = operation.status === "running" || operation.status === "searching";
const message = latest?.message ?? "";
const choosing = /[1-9]\d* releases? (found|shown)/i.test(message);
const sent =
operation.status === "complete" &&
/sent|accepted.*release/i.test(message) &&
/download|release|Sonarr|Radarr/i.test(message);
const interrupted = latest?.id === "connection-error";
const status =
operation.summary?.title ??
(working
? "Working on it"
: choosing
? "Choose a download"
: operation.status === "complete"
? "Done"
: "Needs your attention");
const currentStep =
operation.summary?.message ??
(working
? /send release/i.test(operation.label)
? "Sending your download..."
: /search/i.test(operation.label)
? "Looking for a download..."
: latest?.service === "Jellyfin"
? "Checking if it is ready to watch..."
: latest?.service === "qBittorrent"
? "Checking your download..."
: "Checking your request..."
: interrupted
? "The connection was lost."
: choosing
? "The search is finished. Choose a version to download."
: sent
? "Your download has been sent."
: operation.status === "error"
? "We could not finish this step."
: "This check is finished.");
const nextStep =
operation.summary?.next ??
(working
? "Please wait. You can close this box while we work."
: interrupted
? "Close this box and check the request before trying again."
: choosing
? "Close this box to see the available downloads."
: sent
? "You can close this box. The request will update when the download starts."
: operation.status === "error"
? "Close this box to review the request and its available options."
: "Close this box to see the updated request status.");
const progress = (
<div
className={`activity-process is-${working ? "working" : operation.status}`}
role="progressbar"
aria-label={currentStep}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={working || operation.status === "error" ? undefined : 100}
>
<span />
</div>
);
useEffect(() => {
if (!open) return;
const element = dialog.current;
element?.showModal();
const unlock = lockBodyScroll();
return () => {
element?.close();
unlock();
trigger.current?.focus();
};
}, [open]);
return (
<div className={`request-overview-block latest-activity ${besideDownload ? "beside-download" : "full-row"}`}>
<button
ref={trigger}
type="button"
className="latest-activity-trigger"
onClick={() => setOpen(true)}
aria-haspopup="dialog"
aria-expanded={open}
>
<span className="latest-activity-heading">
<span className="request-overview-label">Latest activity</span>
<span className={`latest-activity-badge is-${operation.status}`}>{status}</span>
</span>
<span className="latest-activity-message" role="status">
{working && <span className="activity-spinner" aria-hidden="true" />}
{currentStep}
</span>
{progress}
<span className="latest-activity-more">View progress</span>
</button>
<dialog
ref={dialog}
className="activity-dialog"
aria-labelledby="activity-dialog-title"
onCancel={() => setOpen(false)}
onClose={() => setOpen(false)}
>
<div className="activity-dialog-content">
<header>
<div>
<span className="request-overview-label">Request progress</span>
<h2 id="activity-dialog-title">{status}</h2>
</div>
<button type="button" onClick={() => setOpen(false)}>
Close
</button>
</header>
<div className="activity-current" role="status" aria-live="polite" aria-atomic="true">
<p className="activity-current-step">
{working && <span className="activity-spinner" aria-hidden="true" />}
{currentStep}
</p>
{progress}
<p className="activity-next-step">{nextStep}</p>
</div>
{!working && (
<footer>
<button
type="button"
onClick={() => {
setOpen(false);
onDismiss();
}}
>
{operation.summary?.action ?? "Dismiss activity"}
</button>
</footer>
)}
</div>
</dialog>
</div>
);
}