+ );
+ }
+}
+
+/**
+ * One thing in the library. Clicking it adds it to the scene; an upload can
+ * also be removed from the library.
+ *
+ * The click target is a button stretched over the whole card, with the play
+ * and remove controls above it, so no button sits inside another.
+ * @component
+ */
+export default function AssetCard({
+ item,
+ busy,
+ onUse,
+ onDelete,
+}: {
+ item: LibraryItem;
+ busy: boolean;
+ onUse: (item: LibraryItem) => void;
+ onDelete?: (item: UploadItem) => void;
+}) {
+ const title = item.tags.length
+ ? `${item.name} (${item.tags.join(", ")})`
+ : item.name;
+
+ return (
+
+
+ {item.name}
+
+ {subtitle(item)}
+
+
+
+ );
+}
diff --git a/frontend/src/features/authoring/library/AssetLibrary.tsx b/frontend/src/features/authoring/library/AssetLibrary.tsx
new file mode 100644
index 000000000..274b5a89e
--- /dev/null
+++ b/frontend/src/features/authoring/library/AssetLibrary.tsx
@@ -0,0 +1,315 @@
+import {
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+ type Context,
+ type DragEvent,
+} from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { useParams } from "react-router-dom";
+import type { User } from "firebase/auth";
+import toast from "react-hot-toast";
+import { SearchIcon, UploadIcon } from "lucide-react";
+import AuthenticationContext from "../../../context/AuthenticationContext";
+import SceneContext from "../../../context/SceneContext";
+import { ACCEPTED_IMAGE_MIME_TYPES, type ModifyScene } from "../images";
+import AssetCard from "./AssetCard";
+import {
+ FILTERS,
+ assetToItem,
+ filterItems,
+ groupItems,
+ type Kind,
+ type LibraryItem,
+ type UploadItem,
+} from "./items.js";
+import { placeItem } from "./place";
+import { PRESET_ITEMS } from "./presets.js";
+import {
+ failureMessage,
+ useAssets,
+ useDeleteAsset,
+ useUploadAsset,
+} from "./useAssets";
+
+const DOCUMENT_MIME_TYPES = ["application/pdf", "text/plain", "text/markdown"];
+const ACCEPT = [
+ ...ACCEPTED_IMAGE_MIME_TYPES,
+ "audio/*",
+ ...DOCUMENT_MIME_TYPES,
+].join(",");
+
+// A first check, so an obviously wrong file doesn't wait for an upload to be
+// told so. The server has the final say on what's allowed.
+function isSupported(file: File) {
+ return (
+ ACCEPTED_IMAGE_MIME_TYPES.includes(file.type) ||
+ file.type.startsWith("audio/") ||
+ DOCUMENT_MIME_TYPES.includes(file.type)
+ );
+}
+
+function carriesFiles(event: DragEvent) {
+ return Array.from(event.dataTransfer.types).includes("Files");
+}
+
+/**
+ * The editor's asset library: ready-made buttons, icons and sounds, plus the
+ * author's own uploads, which are theirs to use in any scenario. Search finds
+ * things by name or tag, and clicking one puts it in the scene.
+ * @component
+ */
+export default function AssetLibrary() {
+ const { scenarioId } = useParams<{ scenarioId: string }>();
+ const { user } = useContext(AuthenticationContext as Context<{ user: User }>);
+ const { modifyScene } = useContext(
+ SceneContext as Context<{ modifyScene: ModifyScene }>
+ );
+ const queryClient = useQueryClient();
+
+ const assets = useAssets();
+ const upload = useUploadAsset();
+ const remove = useDeleteAsset();
+
+ const [query, setQuery] = useState("");
+ const [kind, setKind] = useState<"all" | Kind>("all");
+ const [pending, setPending] = useState(null);
+ const [tags, setTags] = useState("");
+ const [busyKey, setBusyKey] = useState(null);
+ const [dragging, setDragging] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const items = useMemo(
+ () => [...(assets.data ?? []).map(assetToItem), ...PRESET_ITEMS],
+ [assets.data]
+ );
+ const visible = filterItems(items, { query, kind });
+ const sections = groupItems(visible);
+ const searching = query.trim() !== "" || kind !== "all";
+ const hasUploads = (assets.data ?? []).length > 0;
+ // the uploads section can be empty only because nothing's uploaded yet
+ const showUploadHint = !hasUploads && !searching && !assets.isError;
+
+ function chooseFile(file: File | undefined) {
+ if (!file) return;
+ if (!isSupported(file)) {
+ toast.error("That file type isn't supported");
+ return;
+ }
+ setPending(file);
+ setTags("");
+ }
+
+ function cancelUpload() {
+ setPending(null);
+ setTags("");
+ }
+
+ async function submitUpload() {
+ if (!pending || upload.isLoading) return;
+ try {
+ await upload.mutateAsync({ file: pending, tags });
+ toast.success(`Added "${pending.name}" to your library`);
+ cancelUpload();
+ } catch (e) {
+ toast.error(failureMessage(e, "Upload failed"));
+ }
+ }
+
+ async function use(item: LibraryItem) {
+ setBusyKey(item.key);
+ try {
+ await placeItem(item, { user, scenarioId, modifyScene, queryClient });
+ } catch (e) {
+ console.error(e);
+ toast.error(failureMessage(e, `Couldn't add "${item.name}"`));
+ } finally {
+ setBusyKey(null);
+ }
+ }
+
+ async function removeUpload(item: UploadItem) {
+ const sure = window.confirm(
+ `Remove "${item.name}" from your library? Scenes that already use it keep it.`
+ );
+ if (!sure) return;
+ try {
+ await remove.mutateAsync(item.asset._id);
+ } catch (e) {
+ toast.error(failureMessage(e, `Couldn't remove "${item.name}"`));
+ }
+ }
+
+ function handleDragOver(event: DragEvent) {
+ if (!carriesFiles(event)) return;
+ event.preventDefault();
+ setDragging(true);
+ }
+
+ function handleDragLeave(event: DragEvent) {
+ if (event.currentTarget.contains(event.relatedTarget as Node | null))
+ return;
+ setDragging(false);
+ }
+
+ function handleDrop(event: DragEvent) {
+ if (!carriesFiles(event)) return;
+ event.preventDefault();
+ setDragging(false);
+ chooseFile(event.dataTransfer.files[0]);
+ }
+
+ return (
+