Add approved asset removal controls
This commit is contained in:
parent
7150c67e33
commit
08c9cc4efb
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user