Add approved asset removal controls

This commit is contained in:
vance 2026-04-09 22:48:09 -07:00
parent 7150c67e33
commit 08c9cc4efb
5 changed files with 217 additions and 20 deletions

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,9 +1934,13 @@ 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" : ""}`}
>
<button
type="button"
className="bank-item__select"
onClick={() => toggleAssetSelection(asset.id)}
onFocus={() => setMetadataAssetId(asset.id)}
title={`${assetLabel}\n${submission?.caption ?? submission?.promptAnswer ?? ""}`}
@ -1912,6 +1961,18 @@ export const App = () => {
{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}

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;

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);
}
};

View File

@ -1,5 +1,5 @@
import { watch } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import cors from "@fastify/cors";
import multipart from "@fastify/multipart";
@ -133,6 +133,20 @@ const storeUploadedFile = async (filePart: ParsedMultipartFile, assetId: string)
return `/uploads/${relativePath}`;
};
const resolveUploadKeyPath = (uploadKey: string | undefined) => {
if (!uploadKey || !uploadKey.startsWith("/uploads/")) {
return null;
}
const relativePath = uploadKey.slice("/uploads/".length);
const absolutePath = path.resolve(config.storageDir, relativePath);
if (!absolutePath.startsWith(path.resolve(config.storageDir))) {
return null;
}
return absolutePath;
};
const createSubmissionFromMultipart = async (
store: StateStore,
request: FastifyRequest,
@ -371,6 +385,29 @@ export const buildServer = async () => {
return reply.status(204).send();
});
app.delete<{ Params: { assetId: string } }>("/api/assets/:assetId", async (request, reply) => {
const currentState = await store.read();
const asset = currentState.photoAssets.find((entry) => entry.id === request.params.assetId);
if (!asset) {
return reply.status(404).send({ message: "Asset not found." });
}
const submission = currentState.submissions.find((entry) => entry.id === asset.submissionId);
if (submission?.source === "library_import") {
return reply.status(400).send({ message: "Library assets should be removed from the approved bank without deleting the source file." });
}
await store.deleteAsset(request.params.assetId);
const uploadPaths = [asset.originalKey, asset.thumbKey, asset.previewKey, asset.renderKey]
.map((key) => resolveUploadKeyPath(key))
.filter((entry): entry is string => Boolean(entry));
await Promise.all(uploadPaths.map((filePath) => rm(filePath, { force: true })));
return reply.status(204).send();
});
app.post<{ Params: { cueId: string } }>("/api/cues/:cueId/fire", async (request, reply) => {
await store.logCueEvent("cue_fired", { cueId: request.params.cueId });
return reply.status(204).send();

View File

@ -580,6 +580,8 @@ export class StateStore {
submission.status =
payload.decision === "approved"
? "approved_all"
: payload.decision === "archive_only"
? "archived"
: payload.decision === "rejected"
? "rejected"
: "pending_moderation";
@ -711,4 +713,50 @@ export class StateStore {
return state;
});
}
async deleteAsset(assetId: string) {
return this.update((state) => {
const asset = state.photoAssets.find((entry) => entry.id === assetId);
if (!asset) {
throw new Error("Asset not found.");
}
const submission = state.submissions.find((entry) => entry.id === asset.submissionId);
if (submission?.source === "library_import") {
throw new Error("Library assets must be removed from the approved bank without deleting the source file.");
}
state.photoAssets = state.photoAssets.filter((entry) => entry.id !== assetId);
state.collections = state.collections.map((collection) => ({
...collection,
assetIds: collection.assetIds.filter((entry) => entry !== assetId),
coverAssetId: collection.coverAssetId === assetId ? undefined : collection.coverAssetId
}));
state.cues = normalizeCueOrder(
state.cues.map((cue) => ({
...cue,
assetIds: cue.assetIds?.filter((entry) => entry !== assetId)
}))
);
state.moderationDecisions = state.moderationDecisions.filter((decision) => decision.assetId !== assetId);
const remainingSubmissionAssets = state.photoAssets.filter((entry) => entry.submissionId === asset.submissionId);
if (remainingSubmissionAssets.length === 0 && submission) {
state.submissions = state.submissions.filter((entry) => entry.id !== submission.id);
state.consents = state.consents.filter((entry) => entry.id !== submission.consentId);
state.sessionEvents = state.sessionEvents.filter((event) => {
const eventAssetId = typeof event.payload.assetId === "string" ? event.payload.assetId : null;
const eventSubmissionId = typeof event.payload.submissionId === "string" ? event.payload.submissionId : null;
return eventAssetId !== assetId && eventSubmissionId !== submission.id;
});
} else {
state.sessionEvents = state.sessionEvents.filter((event) => {
const eventAssetId = typeof event.payload.assetId === "string" ? event.payload.assetId : null;
return eventAssetId !== assetId;
});
}
return state;
});
}
}