feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { authFetch, getApiBase } from "../../lib/auth";
|
||||
|
||||
type AudioChoice = {
|
||||
language: { code: string } | null;
|
||||
originalEnabled?: boolean;
|
||||
canChange?: boolean;
|
||||
profileLanguage?: string;
|
||||
};
|
||||
|
||||
export default function RequestLanguage({
|
||||
requestId,
|
||||
disabled,
|
||||
onApply,
|
||||
}: {
|
||||
requestId: string;
|
||||
disabled: boolean;
|
||||
onApply: (code: string) => Promise<void>;
|
||||
}) {
|
||||
const [choice, setChoice] = useState<AudioChoice | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [revision, setRevision] = useState(0);
|
||||
useEffect(() => {
|
||||
void revision;
|
||||
const controller = new AbortController();
|
||||
void authFetch(`${getApiBase()}/requests/${requestId}/language`, { signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error("Could not check the audio settings. Reload the request to try again.");
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) setChoice(data);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!controller.signal.aborted) setError(e.message);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [requestId, revision]);
|
||||
if (error && !choice) return <p role="alert">{error}</p>;
|
||||
if (!choice?.language) return null;
|
||||
const code = choice.language.code;
|
||||
const name = new Intl.DisplayNames(["en"], { type: "language" }).of(code) || code;
|
||||
return (
|
||||
<section className="request-language-notice" aria-label="Audio language">
|
||||
<h2>
|
||||
{name} audio {choice.originalEnabled ? "enabled" : "may need your approval"}
|
||||
</h2>
|
||||
<p>
|
||||
This movie was originally made in {name}. An English dub may not exist.{" "}
|
||||
{choice.originalEnabled
|
||||
? "Radarr is set to accept its original audio."
|
||||
: `The current audio requirement is ${choice.profileLanguage || "set by the library"}. This can leave the request waiting even when an original-language release exists.`}
|
||||
</p>
|
||||
<p>
|
||||
Accepting original audio keeps the quality requirements. Subtitles and individual release audio tracks are not
|
||||
guaranteed by title metadata.
|
||||
</p>
|
||||
{choice.canChange && !choice.originalEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await onApply(code);
|
||||
setRevision((v) => v + 1);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "The audio choice could not be saved.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Use {name} audio & search
|
||||
</button>
|
||||
)}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.latest-activity.beside-download { grid-column: 7 / -1; grid-row: 2; }
|
||||
.latest-activity.full-row { grid-column: 1 / -1; }
|
||||
.latest-activity .latest-activity-trigger { display: grid; gap: .6rem; width: 100%; padding: 0; border: 0; background: transparent; color: inherit; text-align: left; box-shadow: none; text-transform: none; }
|
||||
.latest-activity-trigger:focus-visible { outline: 2px solid var(--ops-accent, #83d7f7); outline-offset: 6px; }
|
||||
.latest-activity-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||
.latest-activity-message { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; font-size: .875rem; line-height: 1.5; font-weight: 400; }
|
||||
.latest-activity-more { color: var(--ops-accent, #83d7f7); font-size: .75rem; }
|
||||
.latest-activity-badge { font-size: .7rem; font-weight: 500; color: var(--ops-muted, #bbb); }
|
||||
.latest-activity-badge.is-error { color: #ff9b9b; }
|
||||
.latest-activity-badge.is-complete { color: #55dec0; }
|
||||
.activity-dialog { position: fixed; inset: 0; margin: auto; width: min(720px, calc(100vw - 32px)); max-width: none; max-height: min(760px, calc(100dvh - 40px)); padding: 0; border: 1px solid var(--ops-border, #444); border-radius: 16px; color: var(--ops-text, #eee); background: var(--ops-surface, #1c1c1e); overflow: auto; box-shadow: 0 24px 80px #0008; }
|
||||
.activity-dialog::backdrop { background: #000a; backdrop-filter: blur(5px); }
|
||||
.activity-dialog-content { padding: 1.25rem; }
|
||||
.activity-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
||||
.activity-dialog h2 { font-size: 1.2rem; margin: .4rem 0; }
|
||||
.activity-dialog small { color: var(--ops-muted, #bbb); }
|
||||
.activity-dialog footer { display: flex; justify-content: flex-end; margin-top: 1rem; }
|
||||
@media (max-width: 720px) {
|
||||
.latest-activity.beside-download { grid-column: 1 / -1; grid-row: auto; }
|
||||
}
|
||||
|
||||
.activity-current { padding: 1rem 0 .25rem; }
|
||||
.activity-current-step { font-size: 1.05rem; font-weight: 600; margin: 0 0 1rem; }
|
||||
.activity-next-step { color: var(--ops-muted, #bbb); font-size: .875rem; line-height: 1.6; margin: 1rem 0 0; }
|
||||
.activity-process { height: 6px; width: 100%; overflow: hidden; border-radius: 999px; background: #ffffff14; }
|
||||
.activity-process > span { display: block; height: 100%; width: 100%; border-radius: inherit; background: #55dec0; }
|
||||
.activity-process.is-working > span { width: 35%; background: var(--ops-accent, #83d7f7); animation: activity-process-slide 1.6s ease-in-out infinite alternate; }
|
||||
.activity-process.is-error > span { background: #e6b86c; }
|
||||
@keyframes activity-process-slide { from { transform: translateX(0); } to { transform: translateX(185%); } }
|
||||
@media (prefers-reduced-motion: reduce) { .activity-process.is-working > span { animation: none; width: 100%; opacity: .65; } }
|
||||
|
||||
.activity-spinner { display: inline-block; width: 28px; height: 28px; flex: 0 0 28px; border: 3px solid #ffffff26; border-top-color: var(--ops-accent, #83d7f7); border-right-color: var(--ops-accent, #83d7f7); border-radius: 50%; animation: activity-spinner-turn .8s linear infinite; }
|
||||
.activity-current-step, .latest-activity .latest-activity-message { display: flex; align-items: center; gap: 12px; }
|
||||
@keyframes activity-spinner-turn { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .activity-spinner { animation: none; } }
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user