Add approved asset removal controls
This commit is contained in:
parent
7150c67e33
commit
08c9cc4efb
@ -33,6 +33,7 @@ import {
|
|||||||
createAdminUpload,
|
createAdminUpload,
|
||||||
activateSafeCue,
|
activateSafeCue,
|
||||||
createCue,
|
createCue,
|
||||||
|
deleteAsset,
|
||||||
deleteCue,
|
deleteCue,
|
||||||
fireCue,
|
fireCue,
|
||||||
generateCue,
|
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) => {
|
const setBlackout = (blackout: boolean) => {
|
||||||
setCueState((current) => ({
|
setCueState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@ -1872,6 +1907,16 @@ export const App = () => {
|
|||||||
<button type="button" onClick={handleResetMetadataDraft} disabled={metadataSaving || !metadataDirty}>
|
<button type="button" onClick={handleResetMetadataDraft} disabled={metadataSaving || !metadataDirty}>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
|
{metadataAsset?.moderationStatus === "approved" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={() => void handleDeleteApprovedAsset(metadataAsset)}
|
||||||
|
disabled={metadataSaving}
|
||||||
|
>
|
||||||
|
Delete from bank
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -1889,9 +1934,13 @@ export const App = () => {
|
|||||||
const isSelected = selectedAssetIds.includes(asset.id);
|
const isSelected = selectedAssetIds.includes(asset.id);
|
||||||
const isAnchor = selectedAssetIds[0] === asset.id;
|
const isAnchor = selectedAssetIds[0] === asset.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<article
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
className={`bank-item ${isSelected ? "bank-item--selected" : ""} ${isAnchor ? "bank-item--anchor" : ""} ${metadataAssetId === asset.id ? "bank-item--editing" : ""}`}
|
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)}
|
onClick={() => toggleAssetSelection(asset.id)}
|
||||||
onFocus={() => setMetadataAssetId(asset.id)}
|
onFocus={() => setMetadataAssetId(asset.id)}
|
||||||
title={`${assetLabel}\n${submission?.caption ?? submission?.promptAnswer ?? ""}`}
|
title={`${assetLabel}\n${submission?.caption ?? submission?.promptAnswer ?? ""}`}
|
||||||
@ -1912,6 +1961,18 @@ export const App = () => {
|
|||||||
{assetDetail ? <small className="asset-text-line">{assetDetail}</small> : null}
|
{assetDetail ? <small className="asset-text-line">{assetDetail}</small> : null}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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}
|
{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;
|
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 {
|
.bank-item--selected {
|
||||||
border-color: rgba(136, 180, 215, 0.28);
|
border-color: rgba(136, 180, 215, 0.28);
|
||||||
}
|
}
|
||||||
@ -891,6 +903,26 @@ select:focus-visible {
|
|||||||
height: 100%;
|
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 {
|
.bank-item__overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: auto 0 0 0;
|
inset: auto 0 0 0;
|
||||||
|
|||||||
@ -122,3 +122,22 @@ export const deleteCue = async (cueId: string) => {
|
|||||||
throw new Error(`Request failed for /api/cues/${cueId}.`);
|
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 { 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 path from "node:path";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import multipart from "@fastify/multipart";
|
import multipart from "@fastify/multipart";
|
||||||
@ -133,6 +133,20 @@ const storeUploadedFile = async (filePart: ParsedMultipartFile, assetId: string)
|
|||||||
return `/uploads/${relativePath}`;
|
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 (
|
const createSubmissionFromMultipart = async (
|
||||||
store: StateStore,
|
store: StateStore,
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
@ -371,6 +385,29 @@ export const buildServer = async () => {
|
|||||||
return reply.status(204).send();
|
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) => {
|
app.post<{ Params: { cueId: string } }>("/api/cues/:cueId/fire", async (request, reply) => {
|
||||||
await store.logCueEvent("cue_fired", { cueId: request.params.cueId });
|
await store.logCueEvent("cue_fired", { cueId: request.params.cueId });
|
||||||
return reply.status(204).send();
|
return reply.status(204).send();
|
||||||
|
|||||||
@ -580,6 +580,8 @@ export class StateStore {
|
|||||||
submission.status =
|
submission.status =
|
||||||
payload.decision === "approved"
|
payload.decision === "approved"
|
||||||
? "approved_all"
|
? "approved_all"
|
||||||
|
: payload.decision === "archive_only"
|
||||||
|
? "archived"
|
||||||
: payload.decision === "rejected"
|
: payload.decision === "rejected"
|
||||||
? "rejected"
|
? "rejected"
|
||||||
: "pending_moderation";
|
: "pending_moderation";
|
||||||
@ -711,4 +713,50 @@ export class StateStore {
|
|||||||
return state;
|
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