-
- {isAnchor ?
Anchor : isSelected ?
Selected :
}
-
{formatSubmissionSource(submission?.source)}
+
-
+
+
+ {isAnchor ? Anchor : isSelected ? Selected : }
+ {formatSubmissionSource(submission?.source)}
+
+
{assetLabel}
+
+ {asset.orientation ?? "pending orientation"}
+ {asset.qualityFlags?.tooSmall ? " / low-res" : ""}
+
+ {assetDetail ?
{assetDetail} : null}
+
+
+
+
);
})}
{filteredApprovedAssets.length === 0 ?
Approved images will appear here after import or moderation.
: null}
diff --git a/apps/admin/src/app/app.css b/apps/admin/src/app/app.css
index 67598c8..362f805 100644
--- a/apps/admin/src/app/app.css
+++ b/apps/admin/src/app/app.css
@@ -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;
diff --git a/apps/admin/src/features/live/api.ts b/apps/admin/src/features/live/api.ts
index 6f0ae10..8d56aa5 100644
--- a/apps/admin/src/features/live/api.ts
+++ b/apps/admin/src/features/live/api.ts
@@ -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);
+ }
+};
diff --git a/services/api/src/server.ts b/services/api/src/server.ts
index 643607e..8f64d4b 100644
--- a/services/api/src/server.ts
+++ b/services/api/src/server.ts
@@ -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();
diff --git a/services/api/src/state-store.ts b/services/api/src/state-store.ts
index 2c22e7e..7ff56bf 100644
--- a/services/api/src/state-store.ts
+++ b/services/api/src/state-store.ts
@@ -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;
+ });
+ }
}