Add approved asset removal controls

This commit is contained in:
2026-04-09 22:48:09 -07:00
parent 7150c67e33
commit 08c9cc4efb
5 changed files with 217 additions and 20 deletions
+80 -19
View File
@@ -33,6 +33,7 @@ import {
createAdminUpload,
activateSafeCue,
createCue,
deleteAsset,
deleteCue,
fireCue,
generateCue,
@@ -1009,6 +1010,40 @@ export const App = () => {
}
};
const handleDeleteApprovedAsset = async (asset: PhotoAsset) => {
const submission = submissionMap.get(asset.submissionId);
const assetLabel = getAssetPrimaryLabel(asset, submission);
const confirmed = window.confirm(
submission?.source === "library_import"
? `Remove "${assetLabel}" from the approved bank? The library file will stay on disk and can be re-imported later if needed.`
: `Delete "${assetLabel}" from the system? This removes it from the approved bank and deletes its stored media files.`
);
if (!confirmed) {
return;
}
try {
if (submission?.source === "library_import") {
await moderateAsset(asset.id, {
decision: "archive_only",
note: "Removed from approved bank by operator."
});
} else {
await deleteAsset(asset.id);
}
setSelectedAssetIds((current) => current.filter((assetId) => assetId !== asset.id));
setMetadataAssetId((current) => (current === asset.id ? null : current));
await refresh(false);
setStatus(
submission?.source === "library_import"
? `Removed ${assetLabel} from the approved bank.`
: `Deleted ${assetLabel} from the approved bank and storage.`
);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Could not remove image from approved bank.");
}
};
const setBlackout = (blackout: boolean) => {
setCueState((current) => ({
...current,
@@ -1872,6 +1907,16 @@ export const App = () => {
<button type="button" onClick={handleResetMetadataDraft} disabled={metadataSaving || !metadataDirty}>
Reset
</button>
{metadataAsset?.moderationStatus === "approved" ? (
<button
type="button"
className="danger"
onClick={() => void handleDeleteApprovedAsset(metadataAsset)}
disabled={metadataSaving}
>
Delete from bank
</button>
) : null}
</div>
</>
) : (
@@ -1889,29 +1934,45 @@ export const App = () => {
const isSelected = selectedAssetIds.includes(asset.id);
const isAnchor = selectedAssetIds[0] === asset.id;
return (
<button
<article
key={asset.id}
className={`bank-item ${isSelected ? "bank-item--selected" : ""} ${isAnchor ? "bank-item--anchor" : ""} ${metadataAssetId === asset.id ? "bank-item--editing" : ""}`}
onClick={() => toggleAssetSelection(asset.id)}
onFocus={() => setMetadataAssetId(asset.id)}
title={`${assetLabel}\n${submission?.caption ?? submission?.promptAnswer ?? ""}`}
>
<div className="bank-item__thumb">
{asset.thumbKey ? <img src={asset.thumbKey} alt="" /> : <div className="asset-card__placeholder" />}
</div>
<div className="bank-item__overlay">
<div className="bank-item__flags">
{isAnchor ? <span className="bank-item__chip">Anchor</span> : isSelected ? <span className="bank-item__chip">Selected</span> : <span />}
<span className="source-badge">{formatSubmissionSource(submission?.source)}</span>
<button
type="button"
className="bank-item__select"
onClick={() => toggleAssetSelection(asset.id)}
onFocus={() => setMetadataAssetId(asset.id)}
title={`${assetLabel}\n${submission?.caption ?? submission?.promptAnswer ?? ""}`}
>
<div className="bank-item__thumb">
{asset.thumbKey ? <img src={asset.thumbKey} alt="" /> : <div className="asset-card__placeholder" />}
</div>
<strong>{assetLabel}</strong>
<p>
{asset.orientation ?? "pending orientation"}
{asset.qualityFlags?.tooSmall ? " / low-res" : ""}
</p>
{assetDetail ? <small className="asset-text-line">{assetDetail}</small> : null}
</div>
</button>
<div className="bank-item__overlay">
<div className="bank-item__flags">
{isAnchor ? <span className="bank-item__chip">Anchor</span> : isSelected ? <span className="bank-item__chip">Selected</span> : <span />}
<span className="source-badge">{formatSubmissionSource(submission?.source)}</span>
</div>
<strong>{assetLabel}</strong>
<p>
{asset.orientation ?? "pending orientation"}
{asset.qualityFlags?.tooSmall ? " / low-res" : ""}
</p>
{assetDetail ? <small className="asset-text-line">{assetDetail}</small> : null}
</div>
</button>
<button
type="button"
className="bank-item__delete danger"
onClick={(event) => {
event.stopPropagation();
void handleDeleteApprovedAsset(asset);
}}
title="Delete from approved bank"
>
Delete
</button>
</article>
);
})}
{filteredApprovedAssets.length === 0 ? <p className="empty-state">Approved images will appear here after import or moderation.</p> : null}
+32
View File
@@ -878,6 +878,18 @@ select:focus-visible {
text-align: left;
}
.bank-item__select {
display: block;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.bank-item--selected {
border-color: rgba(136, 180, 215, 0.28);
}
@@ -891,6 +903,26 @@ select:focus-visible {
height: 100%;
}
.bank-item__delete {
position: absolute;
top: 6px;
right: 6px;
z-index: 2;
padding: 4px 6px;
font-size: var(--type-3xs);
line-height: 1;
opacity: 0;
pointer-events: none;
transition: opacity 140ms ease;
}
.bank-item:hover .bank-item__delete,
.bank-item:focus-within .bank-item__delete,
.bank-item--editing .bank-item__delete {
opacity: 1;
pointer-events: auto;
}
.bank-item__overlay {
position: absolute;
inset: auto 0 0 0;
+19
View File
@@ -122,3 +122,22 @@ export const deleteCue = async (cueId: string) => {
throw new Error(`Request failed for /api/cues/${cueId}.`);
}
};
export const deleteAsset = async (assetId: string) => {
const response = await fetch(`/api/assets/${assetId}`, {
method: "DELETE"
});
if (!response.ok) {
let message = `Request failed for /api/assets/${assetId}.`;
try {
const payload = (await response.json()) as { message?: string };
if (payload.message) {
message = payload.message;
}
} catch {
// ignore non-json errors
}
throw new Error(message);
}
};