chore: standardize security and quality foundations
This commit is contained in:
@@ -1,80 +1,173 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import './latest-activity.css'
|
||||
import { lockBodyScroll } from '../../lib/scrollLock'
|
||||
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[] }
|
||||
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
|
||||
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(() => { setOpen(true) }, [operation.id])
|
||||
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>
|
||||
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()
|
||||
if (!open) return;
|
||||
const element = dialog.current;
|
||||
element?.showModal();
|
||||
const unlock = lockBodyScroll();
|
||||
return () => {
|
||||
element?.close()
|
||||
unlock()
|
||||
trigger.current?.focus()
|
||||
}
|
||||
}, [open])
|
||||
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>
|
||||
<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)}>
|
||||
<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>
|
||||
<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>
|
||||
<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>}
|
||||
{!working && (
|
||||
<footer>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
{operation.summary?.action ?? "Dismiss activity"}
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,80 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, getApiBase } from '../../lib/auth'
|
||||
import { useEffect, useState } from "react";
|
||||
import { authFetch, getApiBase } from "../../lib/auth";
|
||||
|
||||
type AudioChoice = { language: { code: string } | null; originalEnabled?: boolean; canChange?: boolean; profileLanguage?: string }
|
||||
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>
|
||||
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)
|
||||
const [choice, setChoice] = useState<AudioChoice | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [revision, setRevision] = useState(0);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
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>
|
||||
.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>
|
||||
);
|
||||
}
|
||||
|
||||
+1118
-764
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user