828 lines
26 KiB
TypeScript
828 lines
26 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { formatBytes, triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||
|
||
import {
|
||
createBatchArchive,
|
||
createBatchReport,
|
||
createPolicyEvidence,
|
||
createSafeShareReport,
|
||
genericOutputName,
|
||
policyById,
|
||
SELECTIVE_REMOVAL_POLICIES,
|
||
sanitizeStaticImage,
|
||
scanFilesInWorker,
|
||
serializeReport,
|
||
type FindingCategory,
|
||
type ImageScanResult,
|
||
type SelectiveRemovalPolicy,
|
||
type SanitizedAsset,
|
||
} from "../privacy";
|
||
|
||
interface FileRecord {
|
||
file: File;
|
||
scan: ImageScanResult;
|
||
asset?: SanitizedAsset;
|
||
sanitizing?: boolean;
|
||
error?: string;
|
||
}
|
||
|
||
interface Progress {
|
||
completed: number;
|
||
total: number;
|
||
currentName: string;
|
||
}
|
||
|
||
const CATEGORY_LABELS: Readonly<Record<FindingCategory, string>> =
|
||
Object.freeze({
|
||
location: "Location",
|
||
identity: "People, authorship & rights",
|
||
timestamp: "Dates & times",
|
||
device: "Device, serial & lens",
|
||
software: "Software & history",
|
||
"document-id": "Document identifiers",
|
||
comment: "Comments, titles & keywords",
|
||
thumbnail: "Embedded previews & extra images",
|
||
"colour-profile": "Colour profiles",
|
||
provenance: "Provenance & signatures",
|
||
technical: "Technical metadata",
|
||
unknown: "Unclassified metadata",
|
||
});
|
||
|
||
export function Workbench() {
|
||
const [records, setRecords] = useState<FileRecord[]>([]);
|
||
const [busy, setBusy] = useState<"scan" | "sanitize" | "archive" | null>(
|
||
null,
|
||
);
|
||
const [progress, setProgress] = useState<Progress | null>(null);
|
||
const [error, setError] = useState("");
|
||
const [dragging, setDragging] = useState(false);
|
||
const [genericNames, setGenericNames] = useState(true);
|
||
const [policyId, setPolicyId] =
|
||
useState<SelectiveRemovalPolicy["id"]>("safe-share");
|
||
const abortRef = useRef<AbortController | null>(null);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const cleanable = useMemo(
|
||
() => records.filter((record) => record.scan.cleanable),
|
||
[records],
|
||
);
|
||
const assets = useMemo(
|
||
() => records.flatMap((record) => (record.asset ? [record.asset] : [])),
|
||
[records],
|
||
);
|
||
const policy = useMemo(() => policyById(policyId), [policyId]);
|
||
const policyEvidence = useMemo(
|
||
() =>
|
||
createPolicyEvidence(
|
||
records.map((record) => record.scan),
|
||
assets,
|
||
policy,
|
||
),
|
||
[assets, policy, records],
|
||
);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
abortRef.current?.abort();
|
||
},
|
||
[],
|
||
);
|
||
|
||
const importFiles = async (selection: FileList | readonly File[]) => {
|
||
const files = Array.from(selection);
|
||
if (files.length === 0) return;
|
||
abortRef.current?.abort();
|
||
const controller = new AbortController();
|
||
abortRef.current = controller;
|
||
setBusy("scan");
|
||
setProgress({
|
||
completed: 0,
|
||
total: files.length,
|
||
currentName: files[0]?.name ?? "",
|
||
});
|
||
setError("");
|
||
setRecords([]);
|
||
try {
|
||
const scans = await scanFilesInWorker(
|
||
files,
|
||
setProgress,
|
||
controller.signal,
|
||
);
|
||
if (controller.signal.aborted) return;
|
||
setRecords(scans.map((scan, index) => ({ file: files[index]!, scan })));
|
||
} catch (caught) {
|
||
if (!isAbort(caught)) setError(errorMessage(caught));
|
||
} finally {
|
||
if (abortRef.current === controller) {
|
||
abortRef.current = null;
|
||
setBusy(null);
|
||
setProgress(null);
|
||
}
|
||
if (inputRef.current) inputRef.current.value = "";
|
||
}
|
||
};
|
||
|
||
const sanitizeOne = async (id: string) => {
|
||
const record = records.find((item) => item.scan.id === id);
|
||
if (!record?.scan.cleanable) return;
|
||
const controller = new AbortController();
|
||
abortRef.current = controller;
|
||
setBusy("sanitize");
|
||
setError("");
|
||
setRecords((current) =>
|
||
current.map((item) =>
|
||
item.scan.id === id
|
||
? { ...item, sanitizing: true, error: undefined }
|
||
: item,
|
||
),
|
||
);
|
||
try {
|
||
const asset = await sanitizeStaticImage(record.file, record.scan, {
|
||
signal: controller.signal,
|
||
outputName: genericNames
|
||
? genericOutputName(
|
||
records.findIndex((item) => item.scan.id === id),
|
||
record.scan.identity.detectedKind,
|
||
)
|
||
: undefined,
|
||
});
|
||
setRecords((current) =>
|
||
current.map((item) =>
|
||
item.scan.id === id
|
||
? { ...item, asset, sanitizing: false, error: undefined }
|
||
: item,
|
||
),
|
||
);
|
||
} catch (caught) {
|
||
if (!isAbort(caught))
|
||
setRecords((current) =>
|
||
current.map((item) =>
|
||
item.scan.id === id
|
||
? {
|
||
...item,
|
||
sanitizing: false,
|
||
error: errorMessage(caught),
|
||
}
|
||
: item,
|
||
),
|
||
);
|
||
} finally {
|
||
if (abortRef.current === controller) abortRef.current = null;
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const sanitizeAll = async () => {
|
||
const pending = records.filter(
|
||
(record) => record.scan.cleanable && !record.asset,
|
||
);
|
||
if (pending.length === 0) return;
|
||
const controller = new AbortController();
|
||
abortRef.current = controller;
|
||
setBusy("sanitize");
|
||
setError("");
|
||
for (let index = 0; index < pending.length; index += 1) {
|
||
const record = pending[index];
|
||
if (!record || controller.signal.aborted) break;
|
||
setProgress({
|
||
completed: index,
|
||
total: pending.length,
|
||
currentName: record.file.name,
|
||
});
|
||
setRecords((current) =>
|
||
current.map((item) =>
|
||
item.scan.id === record.scan.id
|
||
? { ...item, sanitizing: true, error: undefined }
|
||
: item,
|
||
),
|
||
);
|
||
try {
|
||
const asset = await sanitizeStaticImage(record.file, record.scan, {
|
||
signal: controller.signal,
|
||
outputName: genericNames
|
||
? genericOutputName(
|
||
records.findIndex((item) => item.scan.id === record.scan.id),
|
||
record.scan.identity.detectedKind,
|
||
)
|
||
: undefined,
|
||
});
|
||
setRecords((current) =>
|
||
current.map((item) =>
|
||
item.scan.id === record.scan.id
|
||
? { ...item, asset, sanitizing: false }
|
||
: item,
|
||
),
|
||
);
|
||
} catch (caught) {
|
||
if (isAbort(caught)) break;
|
||
setRecords((current) =>
|
||
current.map((item) =>
|
||
item.scan.id === record.scan.id
|
||
? {
|
||
...item,
|
||
sanitizing: false,
|
||
error: errorMessage(caught),
|
||
}
|
||
: item,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
if (abortRef.current === controller) abortRef.current = null;
|
||
setRecords((current) =>
|
||
current.map((record) =>
|
||
record.sanitizing ? { ...record, sanitizing: false } : record,
|
||
),
|
||
);
|
||
setProgress(null);
|
||
setBusy(null);
|
||
};
|
||
|
||
const downloadReport = () => {
|
||
setError("");
|
||
try {
|
||
const report = createBatchReport(
|
||
records.map((record) => record.scan),
|
||
assets,
|
||
);
|
||
triggerBlobDownload(
|
||
new Blob([serializeReport(report)], { type: "application/json" }),
|
||
"privacy-tools-report.json",
|
||
);
|
||
} catch (caught) {
|
||
setError(errorMessage(caught));
|
||
}
|
||
};
|
||
|
||
const downloadSafeReport = () => {
|
||
setError("");
|
||
try {
|
||
const report = createSafeShareReport(
|
||
records.map((record) => record.scan),
|
||
assets,
|
||
);
|
||
triggerBlobDownload(
|
||
new Blob([serializeReport(report)], { type: "application/json" }),
|
||
"privacy-tools-safe-share-report.json",
|
||
);
|
||
} catch (caught) {
|
||
setError(errorMessage(caught));
|
||
}
|
||
};
|
||
|
||
const downloadPolicyEvidence = () => {
|
||
setError("");
|
||
try {
|
||
triggerBlobDownload(
|
||
new Blob([serializeReport(policyEvidence)], {
|
||
type: "application/json",
|
||
}),
|
||
`privacy-tools-${policy.id}-policy-evidence.json`,
|
||
);
|
||
} catch (caught) {
|
||
setError(errorMessage(caught));
|
||
}
|
||
};
|
||
|
||
const downloadArchive = async () => {
|
||
if (assets.length === 0) return;
|
||
setBusy("archive");
|
||
setError("");
|
||
try {
|
||
const blob = await createBatchArchive(
|
||
records.map((record) => record.scan),
|
||
assets,
|
||
undefined,
|
||
"safe-share",
|
||
);
|
||
triggerBlobDownload(blob, "privacy-tools-re-encoded-images.zip");
|
||
} catch (caught) {
|
||
setError(errorMessage(caught));
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const clear = () => {
|
||
abortRef.current?.abort();
|
||
abortRef.current = null;
|
||
setRecords([]);
|
||
setBusy(null);
|
||
setProgress(null);
|
||
setError("");
|
||
};
|
||
|
||
return (
|
||
<div className="workbench">
|
||
<header className="hero">
|
||
<div>
|
||
<p className="eyebrow">Image metadata workbench</p>
|
||
<h1>Privacy Tools</h1>
|
||
<p>
|
||
Inventory files, inspect static-image metadata, then create and
|
||
independently re-scan pixel-only sharing copies—all in this browser.
|
||
</p>
|
||
</div>
|
||
<span className="privacy-pill">Local & ephemeral</span>
|
||
</header>
|
||
|
||
<section className="panel import-panel" aria-labelledby="import-heading">
|
||
<div className="panel-heading">
|
||
<div>
|
||
<p className="eyebrow">Step 1</p>
|
||
<h2 id="import-heading">Choose files to inspect</h2>
|
||
</div>
|
||
<span className="limit-note">
|
||
100 files · 128 MiB each · 512 MiB batch
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={`drop-zone${dragging ? " is-dragging" : ""}`}
|
||
disabled={busy !== null}
|
||
onClick={() => inputRef.current?.click()}
|
||
onDragEnter={(event) => {
|
||
event.preventDefault();
|
||
setDragging(true);
|
||
}}
|
||
onDragOver={(event) => event.preventDefault()}
|
||
onDragLeave={(event) => {
|
||
if (
|
||
!event.currentTarget.contains(event.relatedTarget as Node | null)
|
||
)
|
||
setDragging(false);
|
||
}}
|
||
onDrop={(event) => {
|
||
event.preventDefault();
|
||
setDragging(false);
|
||
void importFiles(event.dataTransfer.files);
|
||
}}
|
||
>
|
||
<span className="drop-icon" aria-hidden="true">
|
||
⇧
|
||
</span>
|
||
<strong>Drop files here or choose files</strong>
|
||
<span>
|
||
Every file gets claimed-vs-detected type inventory. JPEG, PNG and
|
||
static WebP receive the deep clean-copy workflow.
|
||
</span>
|
||
</button>
|
||
<input
|
||
ref={inputRef}
|
||
className="sr-only"
|
||
type="file"
|
||
multiple
|
||
aria-label="Choose files to inspect"
|
||
onChange={(event) => {
|
||
if (event.currentTarget.files)
|
||
void importFiles(event.currentTarget.files);
|
||
}}
|
||
/>
|
||
{busy && progress ? (
|
||
<div className="progress" role="status">
|
||
<progress value={progress.completed} max={progress.total} />
|
||
<span>
|
||
{busy === "scan" ? "Inspecting" : "Re-encoding"}{" "}
|
||
{progress.currentName} · {progress.completed} of {progress.total}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
{error ? (
|
||
<p className="error-message" role="alert">
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
</section>
|
||
|
||
{records.length > 0 ? (
|
||
<>
|
||
<section className="panel" aria-labelledby="inventory-heading">
|
||
<div className="panel-heading action-heading">
|
||
<div>
|
||
<p className="eyebrow">Step 2</p>
|
||
<h2 id="inventory-heading">Batch inventory</h2>
|
||
<p className="muted">
|
||
Detection uses file bytes, not just the extension or browser
|
||
claim.
|
||
</p>
|
||
</div>
|
||
<div className="button-row">
|
||
<label className="privacy-option">
|
||
<input
|
||
type="checkbox"
|
||
checked={genericNames}
|
||
disabled={busy !== null || assets.length > 0}
|
||
onChange={(event) => setGenericNames(event.target.checked)}
|
||
/>
|
||
Use generic sequential output names
|
||
</label>
|
||
<button
|
||
type="button"
|
||
className="primary-button"
|
||
disabled={
|
||
busy !== null || cleanable.every((record) => record.asset)
|
||
}
|
||
onClick={() => void sanitizeAll()}
|
||
>
|
||
Re-encode {cleanable.filter((record) => !record.asset).length}{" "}
|
||
supported
|
||
</button>
|
||
{busy === "scan" || busy === "sanitize" ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => abortRef.current?.abort()}
|
||
>
|
||
Cancel
|
||
</button>
|
||
) : null}
|
||
<button type="button" disabled={busy !== null} onClick={clear}>
|
||
Clear batch
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="inventory-table-wrap">
|
||
<table className="inventory-table">
|
||
<thead>
|
||
<tr>
|
||
<th>File</th>
|
||
<th>Claimed</th>
|
||
<th>Detected</th>
|
||
<th>Findings</th>
|
||
<th>Clean copy</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{records.map((record) => (
|
||
<tr key={record.scan.id}>
|
||
<td>
|
||
<strong>{record.scan.name}</strong>
|
||
<span>
|
||
{formatBytes(record.scan.size)} ·{" "}
|
||
{record.scan.sha256.slice(0, 12)}…
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<code>
|
||
{record.scan.identity.claimedType || "not claimed"}
|
||
</code>
|
||
<span>.{record.scan.identity.extension || "none"}</span>
|
||
</td>
|
||
<td>
|
||
<code>{record.scan.identity.detectedType}</code>
|
||
<StatusBadge status={record.scan.identity.typeMatch} />
|
||
</td>
|
||
<td>
|
||
<strong>{record.scan.findings.length}</strong>
|
||
<span>
|
||
{record.scan.coverage.projectScanner} project scan
|
||
</span>
|
||
</td>
|
||
<td>
|
||
{record.asset ? (
|
||
<StatusBadge status={record.asset.report.status} />
|
||
) : record.scan.cleanable ? (
|
||
<button
|
||
type="button"
|
||
disabled={busy !== null}
|
||
onClick={() => void sanitizeOne(record.scan.id)}
|
||
>
|
||
{record.sanitizing ? "Working…" : "Re-encode"}
|
||
</button>
|
||
) : (
|
||
<span className="muted">Inspect only</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="file-results" aria-label="Detailed file findings">
|
||
{records.map((record) => (
|
||
<FileResult
|
||
key={record.scan.id}
|
||
record={record}
|
||
busy={busy !== null}
|
||
onSanitize={() => void sanitizeOne(record.scan.id)}
|
||
/>
|
||
))}
|
||
</section>
|
||
|
||
<section
|
||
className="panel export-panel"
|
||
aria-labelledby="export-heading"
|
||
>
|
||
<div className="panel-heading action-heading">
|
||
<div>
|
||
<p className="eyebrow">Step 3</p>
|
||
<h2 id="export-heading">Export deliberately</h2>
|
||
<p className="muted">
|
||
Safe-share reports omit names, hashes, exact sizes, values and
|
||
offsets. Detailed reports retain that evidence and may
|
||
themselves be sensitive.
|
||
</p>
|
||
</div>
|
||
<div className="button-row">
|
||
<button
|
||
type="button"
|
||
onClick={downloadSafeReport}
|
||
disabled={busy !== null}
|
||
>
|
||
Download safe-share report
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={downloadReport}
|
||
disabled={busy !== null}
|
||
>
|
||
Download detailed report
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="primary-button"
|
||
onClick={() => void downloadArchive()}
|
||
disabled={busy !== null || assets.length === 0}
|
||
>
|
||
Download safe-share ZIP with {assets.length} re-encoded{" "}
|
||
{assets.length === 1 ? "image" : "images"} + report
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section className="panel" aria-labelledby="policy-heading">
|
||
<div className="panel-heading action-heading">
|
||
<div>
|
||
<p className="eyebrow">Reusable policy evidence</p>
|
||
<h2 id="policy-heading">Selective-removal policy</h2>
|
||
<p className="muted">{policy.description}</p>
|
||
</div>
|
||
<div className="button-row">
|
||
<label className="policy-select">
|
||
<span>Policy profile</span>
|
||
<select
|
||
value={policyId}
|
||
onChange={(event) =>
|
||
setPolicyId(
|
||
event.target.value as SelectiveRemovalPolicy["id"],
|
||
)
|
||
}
|
||
>
|
||
{SELECTIVE_REMOVAL_POLICIES.map((item) => (
|
||
<option value={item.id} key={item.id}>
|
||
{item.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button type="button" onClick={downloadPolicyEvidence}>
|
||
Download policy evidence
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="inventory-table-wrap">
|
||
<table className="inventory-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Evidence ID</th>
|
||
<th>Format</th>
|
||
<th>Decision</th>
|
||
<th>Operation</th>
|
||
<th>Remove / preserve / review</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{policyEvidence.files.map((file) => (
|
||
<tr key={file.fileId}>
|
||
<td>{file.fileId}</td>
|
||
<td>{file.detectedKind}</td>
|
||
<td>
|
||
<StatusBadge status={file.decision} />
|
||
</td>
|
||
<td>{file.availableOperation}</td>
|
||
<td>
|
||
{file.findings.remove} / {file.findings.preserve} /{" "}
|
||
{file.findings.review}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p className="boundary-inline">
|
||
A policy can require preservation that the pixel re-encoder cannot
|
||
guarantee. In that case the evidence says “policy-not-executable”
|
||
instead of silently discarding metadata. Unsupported formats
|
||
remain inspect-only.
|
||
</p>
|
||
</section>
|
||
</>
|
||
) : null}
|
||
|
||
<aside className="boundary-note">
|
||
<strong>No anonymity promise.</strong> Metadata removal cannot remove
|
||
visible faces or text, steganography, invisible watermarks,
|
||
reverse-image matches, sidecar files, filesystem records, or copies
|
||
stored elsewhere.
|
||
</aside>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FileResult({
|
||
record,
|
||
busy,
|
||
onSanitize,
|
||
}: {
|
||
record: FileRecord;
|
||
busy: boolean;
|
||
onSanitize(): void;
|
||
}) {
|
||
const grouped = groupFindings(record.scan);
|
||
return (
|
||
<article className="panel file-card">
|
||
<header className="file-card-heading">
|
||
<div>
|
||
<p className="eyebrow">
|
||
{record.scan.identity.detectedKind.toUpperCase()}
|
||
</p>
|
||
<h2>{record.scan.name}</h2>
|
||
<p className="muted hash-line">
|
||
{record.scan.width && record.scan.height
|
||
? `${record.scan.width} × ${record.scan.height} pixels · `
|
||
: ""}
|
||
SHA-256 <code>{record.scan.sha256}</code>
|
||
</p>
|
||
</div>
|
||
<div className="button-row">
|
||
{record.scan.cleanable && !record.asset ? (
|
||
<button
|
||
type="button"
|
||
className="primary-button"
|
||
disabled={busy}
|
||
onClick={onSanitize}
|
||
>
|
||
Re-encode & verify
|
||
</button>
|
||
) : null}
|
||
{record.asset ? (
|
||
<button
|
||
type="button"
|
||
className="primary-button"
|
||
onClick={() =>
|
||
triggerBlobDownload(
|
||
record.asset!.blob,
|
||
record.asset!.report.outputName,
|
||
)
|
||
}
|
||
>
|
||
Download re-encoded output
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</header>
|
||
{record.error ? (
|
||
<p className="error-message" role="alert">
|
||
{record.error}
|
||
</p>
|
||
) : null}
|
||
{record.scan.warnings.length > 0 ? (
|
||
<ul className="warning-list">
|
||
{record.scan.warnings.map((warning) => (
|
||
<li key={warning}>{warning}</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
<div className="finding-summary">
|
||
{grouped.length === 0 ? (
|
||
<p>
|
||
No recognized metadata findings were reported within scanner
|
||
coverage.
|
||
</p>
|
||
) : (
|
||
grouped.map(([category, findings]) => (
|
||
<details
|
||
key={category}
|
||
open={findings.some((finding) => finding.risk === "sensitive")}
|
||
>
|
||
<summary>
|
||
<span>{CATEGORY_LABELS[category]}</span>
|
||
<span>{findings.length}</span>
|
||
</summary>
|
||
<div className="finding-list">
|
||
{findings.slice(0, 50).map((finding) => (
|
||
<div key={finding.id}>
|
||
<div>
|
||
<strong>{finding.label}</strong>
|
||
<span>{finding.source}</span>
|
||
</div>
|
||
<code>{finding.value || "(empty)"}</code>
|
||
</div>
|
||
))}
|
||
{findings.length > 50 ? (
|
||
<p className="omitted-findings">
|
||
{findings.length - 50} more findings are included in the
|
||
JSON report.
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</details>
|
||
))
|
||
)}
|
||
</div>
|
||
{record.asset ? <Verification report={record.asset.report} /> : null}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function Verification({ report }: { report: SanitizedAsset["report"] }) {
|
||
return (
|
||
<section
|
||
className={`verification is-${report.status}`}
|
||
aria-label="Output verification"
|
||
>
|
||
<div className="verification-heading">
|
||
<div>
|
||
<p className="eyebrow">Mandatory output re-scan</p>
|
||
<h3>{report.summary}</h3>
|
||
</div>
|
||
<StatusBadge status={report.status} />
|
||
</div>
|
||
<dl className="verification-grid">
|
||
<div>
|
||
<dt>Output hash</dt>
|
||
<dd>
|
||
<code>{report.outputSha256}</code>
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Metadata</dt>
|
||
<dd>
|
||
{report.removed.length} removed · {report.preserved.length}{" "}
|
||
preserved · {report.generated.length} generated
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Orientation</dt>
|
||
<dd>
|
||
{report.orientationNormalized ? "Normalized" : "Review required"}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Pixel sample</dt>
|
||
<dd>
|
||
{report.pixelComparison.identical
|
||
? "Identical"
|
||
: "Changed after encoding"}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
{report.generated.length > 0 ? (
|
||
<details>
|
||
<summary>Generated or preserved output metadata</summary>
|
||
<ul>
|
||
{report.generated.map((finding) => (
|
||
<li key={finding.id}>
|
||
{finding.label}: {finding.value}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</details>
|
||
) : null}
|
||
{[...report.unsupported, ...report.incomplete].length > 0 ? (
|
||
<ul className="warning-list">
|
||
{[...report.unsupported, ...report.incomplete].map((note) => (
|
||
<li key={note}>{note}</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
<p className="disclaimer">{report.disclaimer}</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function StatusBadge({ status }: { status: string }) {
|
||
const normalized = status.replace(/[^a-z]+/gu, "-");
|
||
return <span className={`status-badge is-${normalized}`}>{status}</span>;
|
||
}
|
||
|
||
function groupFindings(
|
||
scan: ImageScanResult,
|
||
): Array<[FindingCategory, ImageScanResult["findings"]]> {
|
||
const groups = new Map<FindingCategory, ImageScanResult["findings"]>();
|
||
for (const finding of scan.findings) {
|
||
const values = groups.get(finding.category) ?? [];
|
||
values.push(finding);
|
||
groups.set(finding.category, values);
|
||
}
|
||
return [...groups.entries()];
|
||
}
|
||
|
||
function errorMessage(error: unknown): string {
|
||
return error instanceof Error ? error.message : "The operation failed.";
|
||
}
|
||
|
||
function isAbort(error: unknown): boolean {
|
||
return error instanceof DOMException && error.name === "AbortError";
|
||
}
|