diff --git a/backend/src/db/daos/assetDao.js b/backend/src/db/daos/assetDao.js new file mode 100644 index 000000000..6dc98fd08 --- /dev/null +++ b/backend/src/db/daos/assetDao.js @@ -0,0 +1,95 @@ +import Asset from "../models/asset.js"; +import UploadedFile from "../models/uploadedFile.js"; + +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 500; + +function escapeRegex(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Lists a user's assets, newest first. + * + * @param {string} ownerUid + * @param {object} [filters] + * @param {"image" | "audio" | "document"} [filters.type] + * @param {string} [filters.tag] - an exact (normalised) tag + * @param {string} [filters.q] - search words; each must match the name or a tag + * @param {number} [filters.limit] + */ +export function listAssets(ownerUid, { type, tag, q, limit } = {}) { + const filter = { ownerUid }; + if (type) filter.type = type; + if (tag) filter.tags = tag; + + const words = (q ?? "").trim().split(/\s+/).filter(Boolean); + if (words.length) { + filter.$and = words.map((word) => { + const pattern = new RegExp(escapeRegex(word), "i"); + return { $or: [{ name: pattern }, { tags: pattern }] }; + }); + } + + const take = Math.min( + Math.max(parseInt(limit, 10) || DEFAULT_LIMIT, 1), + MAX_LIMIT + ); + return Asset.find(filter).sort({ createdAt: -1, _id: -1 }).limit(take).lean(); +} + +/** An asset, but only if it belongs to this user. */ +export function retrieveOwnedAsset(assetId, ownerUid) { + return Asset.findOne({ _id: assetId, ownerUid }); +} + +/** + * The scenario's copy of an asset, made the first time it's used there and + * reused after that. It points at the asset's stored object, and starts out + * orphaned until a scene references it, like any freshly uploaded file. + * + * @returns {Promise<{ file: object, created: boolean }>} + */ +export async function useAssetInScenario(asset, scenarioId) { + const existing = await UploadedFile.findOne({ + scenarioId, + assetId: asset._id, + }); + if (existing) return { file: existing, created: false }; + + const file = await UploadedFile.create({ + name: asset.name, + type: asset.type, + path: asset.path, + url: asset.url, + contentType: asset.contentType, + size: asset.size, + uploaderUid: asset.ownerUid, + scenarioId, + assetId: asset._id, + orphanedAt: Date.now(), + }); + return { file, created: true }; +} + +/** + * Removes an asset from its owner's library. Scenarios that already use it keep + * working: their files stay, just no longer tied to a library entry. + * + * @returns {Promise<{ removed: object | null, storageStillUsed: boolean }>} + * `storageStillUsed` is whether any scenario file still points at the stored + * object, in which case the caller must not delete it. + */ +export async function removeAsset(assetId, ownerUid) { + const removed = await Asset.findOneAndDelete({ _id: assetId, ownerUid }); + if (!removed) return { removed: null, storageStillUsed: false }; + + await UploadedFile.updateMany( + { assetId: removed._id }, + { $set: { assetId: null } } + ); + const storageStillUsed = Boolean( + await UploadedFile.exists({ path: removed.path }) + ); + return { removed, storageStillUsed }; +} diff --git a/backend/src/db/models/asset.js b/backend/src/db/models/asset.js new file mode 100644 index 000000000..3a821c05e --- /dev/null +++ b/backend/src/db/models/asset.js @@ -0,0 +1,36 @@ +import { model, Schema } from "mongoose"; + +export const ASSET_NAME_MAX_LENGTH = 255; + +/** + * An entry in a user's asset library: a file they uploaded once and can use in + * any of their scenarios. Distinct from UploadedFile, which belongs to a single + * scenario. Using an asset in a scenario creates an UploadedFile that points at + * the same stored object, so scenes keep working as they do for any upload. + */ +const assetSchema = new Schema( + { + name: { + type: String, + required: true, + trim: true, + maxlength: ASSET_NAME_MAX_LENGTH, + }, + type: { + type: String, + enum: ["image", "audio", "document"], + required: true, + }, + tags: { type: [String], default: [] }, + path: { type: String, required: true }, // firebase storage path + url: { type: String, required: true }, // public url + contentType: { type: String, required: true }, + size: { type: Number, required: true }, + ownerUid: { type: String, required: true, index: true }, + }, + { timestamps: true } +); + +assetSchema.index({ ownerUid: 1, createdAt: -1 }); + +export default model("Asset", assetSchema); diff --git a/backend/src/db/models/uploadedFile.js b/backend/src/db/models/uploadedFile.js index f9254379d..ad0b68512 100644 --- a/backend/src/db/models/uploadedFile.js +++ b/backend/src/db/models/uploadedFile.js @@ -19,6 +19,11 @@ const uploadedFileSchema = new Schema( required: true, index: true, }, + // Set when the file is a scenario's use of a library asset. Several files + // (and the asset) then share one stored object at `path`, so whatever + // eventually cleans up orphaned files must not delete `path` while any of + // them still points at it. + assetId: { type: Schema.Types.ObjectId, ref: "Asset", default: null }, refCount: { type: Number, default: 0, min: 0 }, orphanedAt: { type: Date, default: null }, }, diff --git a/backend/src/routes/api/__tests__/assetsApi.test.js b/backend/src/routes/api/__tests__/assetsApi.test.js new file mode 100644 index 000000000..575be1638 --- /dev/null +++ b/backend/src/routes/api/__tests__/assetsApi.test.js @@ -0,0 +1,433 @@ +import { jest, describe, beforeEach, it, expect } from "@jest/globals"; +import express from "express"; +import mongoose from "mongoose"; +import axios from "axios"; +import FormData from "form-data"; + +import assetsRouter from "../assets.js"; +import Asset from "../../../db/models/asset.js"; +import UploadedFile from "../../../db/models/uploadedFile.js"; +import auth from "../../../middleware/firebaseAuth.js"; +import { isAuthor } from "../../../middleware/scenarioAuth.js"; +import errorHandler from "../../../middleware/errorHandler.js"; +import { uploadFile, deleteFile } from "../../../firebase/storage.js"; + +import { authHeaders } from "./testHelpers.js"; +import { + useMongoMemoryServer, + useExpressServer, +} from "../../../test/testSetup.js"; + +jest.mock("../../../firebase/storage.js"); +jest.mock("../../../middleware/firebaseAuth"); +jest.mock("../../../middleware/scenarioAuth"); +jest.mock("firebase-admin"); + +// sets both req.uid (read by the routes) and req.body.uid, like the real one +auth.mockImplementation(async (req, res, next) => { + const uid = req.headers.authorization?.split(" ")[1]; + req.uid = uid; + req.body.uid = uid; + next(); +}); + +// "author-1" and "author-2" can edit the scenarios used below +isAuthor.mockImplementation(async (scenarioId, uid) => + ["author-1", "author-2"].includes(uid) +); + +let storedCount = 0; +uploadFile.mockImplementation(async (buffer, mimetype) => ({ + path: `files/stored-${++storedCount}`, + url: `https://storage.example/${storedCount}?type=${mimetype}`, +})); + +describe("Assets API tests", () => { + useMongoMemoryServer(); + const ctx = useExpressServer(() => { + const app = express(); + app.use(express.json()); + app.use("/api/assets", assetsRouter); + app.use(errorHandler); + return app; + }); + + const url = (path = "") => `http://localhost:${ctx.port}/api/assets${path}`; + const scenarioId = new mongoose.mongo.ObjectId("ccc000000000000000000001"); + const otherScenarioId = new mongoose.mongo.ObjectId( + "ccc000000000000000000002" + ); + + const makeAsset = (overrides = {}) => + Asset.create({ + name: "heart.png", + type: "image", + tags: ["heart", "medical"], + path: "files/heart", + url: "https://storage.example/heart", + contentType: "image/png", + size: 1024, + ownerUid: "author-1", + ...overrides, + }); + + function uploadForm( + { filename = "photo.png", contentType = "image/png", ...fields } = {}, + data = "fake-bytes" + ) { + const form = new FormData(); + form.append("file", Buffer.from(data), { filename, contentType }); + for (const [key, value] of Object.entries(fields)) form.append(key, value); + return form; + } + + const post = (form, uid = "author-1") => + axios.post(url(), form, { + headers: { ...form.getHeaders(), Authorization: `Bearer ${uid}` }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + storedCount = 0; + }); + + // --- upload --- + + it("POST /assets stores the file and records the asset for the caller", async () => { + const response = await post( + uploadForm({ tags: "Heart, #Medical , heart, first aid " }) + ); + + expect(response.status).toBe(201); + expect(response.data).toMatchObject({ + name: "photo.png", + type: "image", + contentType: "image/png", + ownerUid: "author-1", + tags: ["heart", "medical", "first aid"], + }); + expect(uploadFile).toHaveBeenCalledTimes(1); + + const stored = await Asset.findById(response.data._id); + expect(stored.path).toBe("files/stored-1"); + }); + + it("POST /assets works out the type from the mime type", async () => { + const audio = await post( + uploadForm({ filename: "beep.wav", contentType: "audio/wav" }) + ); + const document = await post( + uploadForm({ filename: "guide.pdf", contentType: "application/pdf" }) + ); + expect(audio.data.type).toBe("audio"); + expect(document.data.type).toBe("document"); + }); + + it("POST /assets uses a given name, else the file name", async () => { + const named = await post(uploadForm({ name: " My heart " })); + const unnamed = await post(uploadForm({ filename: "other.png" })); + expect(named.data.name).toBe("My heart"); + expect(unnamed.data.name).toBe("other.png"); + }); + + it("POST /assets keeps at most 10 tags", async () => { + const tags = Array.from({ length: 15 }, (_, i) => `tag${i}`).join(","); + const response = await post(uploadForm({ tags })); + expect(response.data.tags).toHaveLength(10); + }); + + it("POST /assets cuts overlong tags to 30 characters", async () => { + const response = await post(uploadForm({ tags: "a".repeat(50) })); + expect(response.data.tags).toEqual(["a".repeat(30)]); + }); + + it("POST /assets rejects a file type that isn't allowed", async () => { + await expect( + post( + uploadForm({ + filename: "run.exe", + contentType: "application/x-msdownload", + }) + ) + ).rejects.toMatchObject({ response: { status: 415 } }); + expect(uploadFile).not.toHaveBeenCalled(); + }); + + it("POST /assets returns 400 when no file is sent", async () => { + const form = new FormData(); + form.append("tags", "heart"); + await expect(post(form)).rejects.toMatchObject({ + response: { status: 400 }, + }); + }); + + it("POST /assets removes the stored file if saving the asset fails", async () => { + const original = Asset.create; + Asset.create = jest.fn().mockRejectedValue(new Error("db down")); + try { + await expect(post(uploadForm())).rejects.toMatchObject({ + response: { status: 500 }, + }); + expect(deleteFile).toHaveBeenCalledWith("files/stored-1"); + } finally { + Asset.create = original; + } + }); + + // --- list --- + + describe("GET /assets", () => { + beforeEach(async () => { + await makeAsset({ name: "heart.png", tags: ["heart", "medical"] }); + await makeAsset({ + name: "Heart monitor", + type: "audio", + tags: ["beep", "monitor"], + path: "files/monitor", + }); + await makeAsset({ + name: "ward-guide.pdf", + type: "document", + tags: ["guide"], + path: "files/guide", + }); + await makeAsset({ name: "someone-elses.png", ownerUid: "author-2" }); + }); + + const list = async (query = "", uid = "author-1") => + (await axios.get(url(query), authHeaders(uid))).data; + + it("returns only the caller's assets, newest first", async () => { + const assets = await list(); + expect(assets.map((a) => a.name)).toEqual([ + "ward-guide.pdf", + "Heart monitor", + "heart.png", + ]); + }); + + it("filters by type", async () => { + const assets = await list("?type=audio"); + expect(assets.map((a) => a.name)).toEqual(["Heart monitor"]); + }); + + it("rejects an unknown type", async () => { + await expect(list("?type=video")).rejects.toMatchObject({ + response: { status: 400 }, + }); + }); + + it("filters by exact tag", async () => { + const assets = await list("?tag=Medical"); + expect(assets.map((a) => a.name)).toEqual(["heart.png"]); + }); + + it("searches names and tags, ignoring case", async () => { + const heart = await list("?q=HEART"); + expect(heart.map((a) => a.name)).toEqual(["Heart monitor", "heart.png"]); + + const byTag = await list("?q=guide"); + expect(byTag.map((a) => a.name)).toEqual(["ward-guide.pdf"]); + }); + + it("needs every search word to match", async () => { + const assets = await list("?q=heart monitor"); + expect(assets.map((a) => a.name)).toEqual(["Heart monitor"]); + }); + + it("treats the search text literally, not as a pattern", async () => { + expect(await list("?q=.*")).toEqual([]); + expect(await list("?q=(")).toEqual([]); + }); + + it("limits the number of results", async () => { + expect(await list("?limit=2")).toHaveLength(2); + }); + }); + + // --- update --- + + describe("PATCH /assets/:assetId", () => { + const patch = (id, body, uid = "author-1") => + axios.patch(url(`/${id}`), body, authHeaders(uid)); + + it("renames and retags", async () => { + const asset = await makeAsset(); + const response = await patch(asset._id, { + name: " Red heart ", + tags: ["Cardiac", "cardiac", "#red"], + }); + expect(response.data.name).toBe("Red heart"); + expect(response.data.tags).toEqual(["cardiac", "red"]); + + const stored = await Asset.findById(asset._id); + expect(stored.name).toBe("Red heart"); + }); + + it("can clear the tags", async () => { + const asset = await makeAsset(); + const response = await patch(asset._id, { tags: [] }); + expect(response.data.tags).toEqual([]); + }); + + it("rejects an empty name, bad tags, and an empty update", async () => { + const asset = await makeAsset(); + for (const body of [{ name: " " }, { tags: 5 }, {}]) { + await expect(patch(asset._id, body)).rejects.toMatchObject({ + response: { status: 400 }, + }); + } + }); + + it("404s for an asset that isn't the caller's", async () => { + const asset = await makeAsset({ ownerUid: "author-2" }); + await expect( + patch(asset._id, { name: "mine now" }) + ).rejects.toMatchObject({ response: { status: 404 } }); + expect((await Asset.findById(asset._id)).name).toBe("heart.png"); + }); + + it("400s for a malformed id", async () => { + await expect(patch("nope", { name: "x" })).rejects.toMatchObject({ + response: { status: 400 }, + }); + }); + }); + + // --- delete --- + + describe("DELETE /assets/:assetId", () => { + const del = (id, uid = "author-1") => + axios.delete(url(`/${id}`), authHeaders(uid)); + + it("removes the asset and its stored file when nothing uses it", async () => { + const asset = await makeAsset(); + const response = await del(asset._id); + + expect(response.status).toBe(204); + expect(await Asset.findById(asset._id)).toBeNull(); + expect(deleteFile).toHaveBeenCalledWith("files/heart"); + }); + + it("keeps the stored file while a scenario still uses it", async () => { + const asset = await makeAsset(); + const used = await UploadedFile.create({ + name: "heart.png", + type: "image", + path: asset.path, + url: asset.url, + contentType: "image/png", + size: 1024, + uploaderUid: "author-1", + scenarioId, + assetId: asset._id, + refCount: 1, + }); + + const response = await del(asset._id); + expect(response.status).toBe(204); + expect(await Asset.findById(asset._id)).toBeNull(); + expect(deleteFile).not.toHaveBeenCalled(); + + // the scenario's file lives on, no longer tied to a library entry + const stillThere = await UploadedFile.findById(used._id); + expect(stillThere).not.toBeNull(); + expect(stillThere.assetId).toBeNull(); + }); + + it("still succeeds if the stored file can't be deleted", async () => { + deleteFile.mockRejectedValueOnce(new Error("storage down")); + const errorLog = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + const asset = await makeAsset(); + + const response = await del(asset._id); + expect(response.status).toBe(204); + expect(await Asset.findById(asset._id)).toBeNull(); + errorLog.mockRestore(); + }); + + it("404s for an asset that isn't the caller's, and leaves it alone", async () => { + const asset = await makeAsset({ ownerUid: "author-2" }); + await expect(del(asset._id)).rejects.toMatchObject({ + response: { status: 404 }, + }); + expect(await Asset.findById(asset._id)).not.toBeNull(); + expect(deleteFile).not.toHaveBeenCalled(); + }); + }); + + // --- use in a scenario --- + + describe("POST /assets/:assetId/use", () => { + const use = (id, body, uid = "author-1") => + axios.post(url(`/${id}/use`), body, authHeaders(uid)); + + it("gives the scenario its own file pointing at the same stored object", async () => { + const asset = await makeAsset(); + const response = await use(asset._id, { scenarioId }); + + expect(response.status).toBe(201); + expect(response.data).toMatchObject({ + name: "heart.png", + type: "image", + path: asset.path, + url: asset.url, + contentType: "image/png", + scenarioId: scenarioId.toString(), + assetId: asset._id.toString(), + uploaderUid: "author-1", + }); + + const stored = await UploadedFile.findById(response.data._id); + // unreferenced until a scene uses it, like any fresh upload + expect(stored.orphanedAt).toBeInstanceOf(Date); + expect(stored.refCount).toBe(0); + expect(uploadFile).not.toHaveBeenCalled(); + }); + + it("gives the same file back when asked again for the same scenario", async () => { + const asset = await makeAsset(); + const first = await use(asset._id, { scenarioId }); + const second = await use(asset._id, { scenarioId }); + + expect(second.status).toBe(200); + expect(second.data._id).toBe(first.data._id); + expect(await UploadedFile.countDocuments({ assetId: asset._id })).toBe(1); + }); + + it("gives a separate file for each scenario", async () => { + const asset = await makeAsset(); + const first = await use(asset._id, { scenarioId }); + const second = await use(asset._id, { scenarioId: otherScenarioId }); + + expect(second.data._id).not.toBe(first.data._id); + expect(second.data.path).toBe(first.data.path); + }); + + it("refuses when the caller can't edit the scenario", async () => { + const asset = await makeAsset({ ownerUid: "reader" }); + await expect( + use(asset._id, { scenarioId }, "reader") + ).rejects.toMatchObject({ response: { status: 401 } }); + expect(await UploadedFile.countDocuments()).toBe(0); + }); + + it("400s for a missing or malformed scenario id", async () => { + const asset = await makeAsset(); + for (const body of [{}, { scenarioId: "nope" }]) { + await expect(use(asset._id, body)).rejects.toMatchObject({ + response: { status: 400 }, + }); + } + }); + + it("404s for an asset that isn't the caller's", async () => { + const asset = await makeAsset({ ownerUid: "author-2" }); + await expect(use(asset._id, { scenarioId })).rejects.toMatchObject({ + response: { status: 404 }, + }); + }); + }); +}); diff --git a/backend/src/routes/api/assets.js b/backend/src/routes/api/assets.js new file mode 100644 index 000000000..8e66ef79b --- /dev/null +++ b/backend/src/routes/api/assets.js @@ -0,0 +1,182 @@ +import { Router } from "express"; +import { HttpStatusCode } from "axios"; +import auth from "../../middleware/firebaseAuth.js"; +import { isAuthor } from "../../middleware/scenarioAuth.js"; +import { deleteFile, uploadFile } from "../../firebase/storage.js"; +import Asset, { ASSET_NAME_MAX_LENGTH } from "../../db/models/asset.js"; +import { + listAssets, + removeAsset, + retrieveOwnedAsset, + useAssetInScenario, +} from "../../db/daos/assetDao.js"; +import { handle, HttpError } from "../../util/error.js"; +import { parseTags, normaliseTags } from "../../util/tags.js"; +import { fileTypeFor, uploadWrapper } from "../../util/upload.js"; +import { isValidObjectId } from "../../util/validation.js"; + +const router = Router(); + +router.use(auth); + +const VALID_TYPES = ["image", "audio", "document"]; + +function assertValidAssetId(assetId) { + if (!isValidObjectId(assetId)) + throw new HttpError("invalid asset id", HttpStatusCode.BadRequest); +} + +async function ownedAssetOrThrow(assetId, uid) { + assertValidAssetId(assetId); + const asset = await retrieveOwnedAsset(assetId, uid); + if (!asset) throw new HttpError("asset not found", HttpStatusCode.NotFound); + return asset; +} + +function cleanName(raw) { + return typeof raw === "string" + ? raw.trim().slice(0, ASSET_NAME_MAX_LENGTH) + : ""; +} + +/** + * @route GET /api/assets + * @desc The caller's library, newest first. Optional filters: type, tag, q + * (search words matched against name and tags) and limit. + */ +router.get( + "/", + handle(async (req, res) => { + const { type, tag, q, limit } = req.query; + + if (type !== undefined && !VALID_TYPES.includes(type)) + throw new HttpError("invalid asset type", HttpStatusCode.BadRequest); + + const assets = await listAssets(req.uid, { + type, + tag: typeof tag === "string" ? normaliseTags(tag)[0] : undefined, + q: typeof q === "string" ? q : undefined, + limit, + }); + return res.json(assets); + }) +); + +/** + * @route POST /api/assets + * @desc Upload a file to the caller's library. Multipart: `file`, and + * optionally `tags` (comma separated) and `name` (defaults to the file name). + */ +router.post( + "/", + uploadWrapper, + handle(async (req, res) => { + if (!req.file) + throw new HttpError("no file provided", HttpStatusCode.BadRequest); + + const tags = parseTags(req.body.tags); + const name = cleanName(req.body.name) || cleanName(req.file.originalname); + if (!name) throw new HttpError("missing name", HttpStatusCode.BadRequest); + + const firebaseInfo = await uploadFile(req.file.buffer, req.file.mimetype); + + try { + const asset = await Asset.create({ + name, + type: fileTypeFor(req.file.mimetype), + tags, + path: firebaseInfo.path, + url: firebaseInfo.url, + contentType: req.file.mimetype, + size: req.file.size, + ownerUid: req.uid, + }); + return res.status(201).json(asset); + } catch (err) { + try { + await deleteFile(firebaseInfo.path); + } catch (cleanupErr) { + err.cleanupErr = cleanupErr; + } + throw err; + } + }) +); + +/** + * @route PATCH /api/assets/:assetId + * @desc Rename an asset and/or replace its tags + */ +router.patch( + "/:assetId", + handle(async (req, res) => { + const asset = await ownedAssetOrThrow(req.params.assetId, req.uid); + const { name, tags } = req.body; + + if (name === undefined && tags === undefined) + throw new HttpError("nothing to update", HttpStatusCode.BadRequest); + + if (name !== undefined) { + const trimmed = cleanName(name); + if (!trimmed) + throw new HttpError("missing name", HttpStatusCode.BadRequest); + asset.name = trimmed; + } + if (tags !== undefined) asset.tags = parseTags(tags); + + await asset.save(); + return res.json(asset); + }) +); + +/** + * @route DELETE /api/assets/:assetId + * @desc Remove an asset from the caller's library. The stored file is only + * deleted if no scenario is using it. + */ +router.delete( + "/:assetId", + handle(async (req, res) => { + assertValidAssetId(req.params.assetId); + const { removed, storageStillUsed } = await removeAsset( + req.params.assetId, + req.uid + ); + if (!removed) + throw new HttpError("asset not found", HttpStatusCode.NotFound); + + if (!storageStillUsed) { + try { + await deleteFile(removed.path); + } catch (err) { + // the entry is gone either way; a leftover blob is only wasted space + console.error("failed to delete stored asset file", err); + } + } + return res.status(HttpStatusCode.NoContent).send(); + }) +); + +/** + * @route POST /api/assets/:assetId/use + * @desc Make an asset available in a scenario the caller can edit. Answers + * with the scenario's UploadedFile for it, which scenes reference as usual. + * Asking again for the same scenario gives the same file. + */ +router.post( + "/:assetId/use", + handle(async (req, res) => { + const asset = await ownedAssetOrThrow(req.params.assetId, req.uid); + + const { scenarioId } = req.body; + if (!isValidObjectId(scenarioId)) + throw new HttpError("invalid scenario id", HttpStatusCode.BadRequest); + if (!(await isAuthor(scenarioId, req.uid))) + throw new HttpError("unauthorised", HttpStatusCode.Unauthorized); + + const { file, created } = await useAssetInScenario(asset, scenarioId); + return res.status(created ? 201 : 200).json(file); + }) +); + +export default router; diff --git a/backend/src/routes/api/files.js b/backend/src/routes/api/files.js index 6d82d9134..bfc8c0729 100644 --- a/backend/src/routes/api/files.js +++ b/backend/src/routes/api/files.js @@ -1,5 +1,4 @@ import { Router } from "express"; -import multer from "multer"; import auth from "../../middleware/firebaseAuth.js"; import { HttpStatusCode } from "axios"; import { deleteFile, uploadFile } from "../../firebase/storage.js"; @@ -8,62 +7,13 @@ import { handle, HttpError } from "../../util/error.js"; import { retrieveFile, retrieveFiles } from "../../db/daos/fileDao.js"; import scenarioAuth from "../../middleware/scenarioAuth.js"; import { isValidObjectId } from "../../util/validation.js"; +import { fileTypeFor, uploadWrapper } from "../../util/upload.js"; const router = Router(); router.use(auth); router.use("/:scenarioId", scenarioAuth); -// multer config (in-memory storage) -const parsedMaxFileSizeMb = parseFloat(process.env.MAX_FILE_SIZE_MB); -const MAX_FILE_SIZE_MB = Number.isFinite(parsedMaxFileSizeMb) - ? parsedMaxFileSizeMb - : 10; -const MAX_FILE_SIZE_BYTES = Math.floor(MAX_FILE_SIZE_MB * 1024 * 1024); - -const ALLOWED_MIME_SET = new Set( - ( - process.env.ALLOWED_MIME_LIST || - "image/png,image/jpeg,image/webp,image/gif,application/pdf,text/plain,text/markdown,audio/mpeg,audio/wav,audio/ogg,audio/webm,audio/mp4,audio/aac" - ) - .split(",") - .map((s) => s.trim()) -); - -const upload = multer({ - storage: multer.memoryStorage(), - limits: { fileSize: MAX_FILE_SIZE_BYTES }, - fileFilter: (_req, file, cb) => { - if (ALLOWED_MIME_SET.size > 0 && !ALLOWED_MIME_SET.has(file.mimetype)) { - return cb( - new HttpError( - `unsupported file type: ${file.mimetype}`, - HttpStatusCode.UnsupportedMediaType - ) - ); - } - cb(null, true); - }, -}); - -function uploadWrapper(req, res, next) { - upload.single("file")(req, res, (err) => { - if (err) { - if (err.code === "LIMIT_FILE_SIZE") - return next(new HttpError(err.message, HttpStatusCode.PayloadTooLarge)); - if ( - err instanceof multer.MulterError || - err.message === "Unexpected end of form" - ) - return next( - new HttpError("malformed upload", HttpStatusCode.BadRequest) - ); - return next(err); - } - next(); - }); -} - // POST /files/:scenarioId — upload a file to a scenario router.post( "/:scenarioId", @@ -75,11 +25,7 @@ router.post( const firebaseInfo = await uploadFile(req.file.buffer, req.file.mimetype); try { - const type = req.file.mimetype.startsWith("image/") - ? "image" - : req.file.mimetype.startsWith("audio/") - ? "audio" - : "document"; + const type = fileTypeFor(req.file.mimetype); const uploadedFile = await UploadedFile.create({ name: req.file.originalname, diff --git a/backend/src/routes/api/index.js b/backend/src/routes/api/index.js index b44ac4eba..56aea40c7 100644 --- a/backend/src/routes/api/index.js +++ b/backend/src/routes/api/index.js @@ -10,6 +10,7 @@ import navigate from "./navigate/index.js"; import access from "./access.js"; import dashboard from "./dashboard.js"; import files from "./files.js"; +import assets from "./assets.js"; const router = Router(); @@ -23,5 +24,6 @@ router.use("/resources", resource); router.use("/access", access); router.use("/dashboard", dashboard); router.use("/files", files); +router.use("/assets", assets); export default router; diff --git a/backend/src/util/tags.js b/backend/src/util/tags.js new file mode 100644 index 000000000..1f14d565e --- /dev/null +++ b/backend/src/util/tags.js @@ -0,0 +1,52 @@ +import { HttpStatusCode } from "axios"; +import { HttpError } from "./error.js"; + +export const MAX_TAGS = 10; +export const MAX_TAG_LENGTH = 30; + +/** + * Turns whatever a client sent for tags into a clean list: lowercase, trimmed, + * no leading `#`, no duplicates, at most MAX_TAGS tags of MAX_TAG_LENGTH + * characters. Accepts a comma separated string or an array of strings. + * Anything else (including nothing) is no tags. + * @param {unknown} raw + * @returns {string[]} + */ +export function normaliseTags(raw) { + const items = Array.isArray(raw) + ? raw + : typeof raw === "string" + ? raw.split(",") + : []; + + const tags = []; + for (const item of items) { + if (typeof item !== "string") continue; + const tag = item + .trim() + .replace(/^#+/, "") + .replace(/\s+/g, " ") + .slice(0, MAX_TAG_LENGTH) + .trim() + .toLowerCase(); + if (tag && !tags.includes(tag)) tags.push(tag); + if (tags.length === MAX_TAGS) break; + } + return tags; +} + +/** + * Like normaliseTags, but rejects a value that isn't a string or an array, + * rather than quietly treating it as no tags. Nothing at all is fine. + * @param {unknown} raw + * @returns {string[]} + */ +export function parseTags(raw) { + if (raw === undefined || raw === null) return []; + if (typeof raw !== "string" && !Array.isArray(raw)) + throw new HttpError( + "tags must be a string or a list of strings", + HttpStatusCode.BadRequest + ); + return normaliseTags(raw); +} diff --git a/backend/src/util/upload.js b/backend/src/util/upload.js new file mode 100644 index 000000000..87566b76f --- /dev/null +++ b/backend/src/util/upload.js @@ -0,0 +1,71 @@ +import multer from "multer"; +import { HttpStatusCode } from "axios"; +import { HttpError } from "./error.js"; + +// Shared by everything that accepts a file upload, so a file is accepted or +// rejected the same way wherever it's uploaded. + +// multer config (in-memory storage) +const parsedMaxFileSizeMb = parseFloat(process.env.MAX_FILE_SIZE_MB); +const MAX_FILE_SIZE_MB = Number.isFinite(parsedMaxFileSizeMb) + ? parsedMaxFileSizeMb + : 10; +const MAX_FILE_SIZE_BYTES = Math.floor(MAX_FILE_SIZE_MB * 1024 * 1024); + +const ALLOWED_MIME_SET = new Set( + ( + process.env.ALLOWED_MIME_LIST || + "image/png,image/jpeg,image/webp,image/gif,application/pdf,text/plain,text/markdown,audio/mpeg,audio/wav,audio/ogg,audio/webm,audio/mp4,audio/aac" + ) + .split(",") + .map((s) => s.trim()) +); + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: MAX_FILE_SIZE_BYTES }, + fileFilter: (_req, file, cb) => { + if (ALLOWED_MIME_SET.size > 0 && !ALLOWED_MIME_SET.has(file.mimetype)) { + return cb( + new HttpError( + `unsupported file type: ${file.mimetype}`, + HttpStatusCode.UnsupportedMediaType + ) + ); + } + cb(null, true); + }, +}); + +/** + * Express middleware that parses a single multipart `file` field into + * `req.file`, turning multer's errors into HttpErrors. + */ +export function uploadWrapper(req, res, next) { + upload.single("file")(req, res, (err) => { + if (err) { + if (err.code === "LIMIT_FILE_SIZE") + return next(new HttpError(err.message, HttpStatusCode.PayloadTooLarge)); + if ( + err instanceof multer.MulterError || + err.message === "Unexpected end of form" + ) + return next( + new HttpError("malformed upload", HttpStatusCode.BadRequest) + ); + return next(err); + } + next(); + }); +} + +/** + * Which kind of file a mime type is, as stored on UploadedFile and Asset. + * @param {string} mimetype + * @returns {"image" | "audio" | "document"} + */ +export function fileTypeFor(mimetype) { + if (mimetype.startsWith("image/")) return "image"; + if (mimetype.startsWith("audio/")) return "audio"; + return "document"; +} diff --git a/frontend/package.json b/frontend/package.json index 2f12542a7..03c442bbc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "vite build", - "test": "node --test src/components/Properties/propertyTypes.test.js src/features/playScenario/debug/checks.test.js", + "test": "node --test \"src/**/*.test.js\"", "lint": "eslint src", "lint:fix": "eslint --fix src", "prettier": "prettier --check src", diff --git a/frontend/public/library/audio/alert.wav b/frontend/public/library/audio/alert.wav new file mode 100644 index 000000000..3cb99a9a5 Binary files /dev/null and b/frontend/public/library/audio/alert.wav differ diff --git a/frontend/public/library/audio/chime.wav b/frontend/public/library/audio/chime.wav new file mode 100644 index 000000000..7b218b56e Binary files /dev/null and b/frontend/public/library/audio/chime.wav differ diff --git a/frontend/public/library/audio/heart-monitor.wav b/frontend/public/library/audio/heart-monitor.wav new file mode 100644 index 000000000..3ba559dcf Binary files /dev/null and b/frontend/public/library/audio/heart-monitor.wav differ diff --git a/frontend/public/library/images/arrow.png b/frontend/public/library/images/arrow.png new file mode 100644 index 000000000..9c96cb557 Binary files /dev/null and b/frontend/public/library/images/arrow.png differ diff --git a/frontend/public/library/images/check.png b/frontend/public/library/images/check.png new file mode 100644 index 000000000..f1787200e Binary files /dev/null and b/frontend/public/library/images/check.png differ diff --git a/frontend/public/library/images/clipboard.png b/frontend/public/library/images/clipboard.png new file mode 100644 index 000000000..fe75f4f2a Binary files /dev/null and b/frontend/public/library/images/clipboard.png differ diff --git a/frontend/public/library/images/first-aid.png b/frontend/public/library/images/first-aid.png new file mode 100644 index 000000000..fb60c04c8 Binary files /dev/null and b/frontend/public/library/images/first-aid.png differ diff --git a/frontend/public/library/images/heart.png b/frontend/public/library/images/heart.png new file mode 100644 index 000000000..891ccbacb Binary files /dev/null and b/frontend/public/library/images/heart.png differ diff --git a/frontend/public/library/images/info.png b/frontend/public/library/images/info.png new file mode 100644 index 000000000..8985fe6b5 Binary files /dev/null and b/frontend/public/library/images/info.png differ diff --git a/frontend/public/library/images/pill.png b/frontend/public/library/images/pill.png new file mode 100644 index 000000000..4af87844a Binary files /dev/null and b/frontend/public/library/images/pill.png differ diff --git a/frontend/public/library/images/speech.png b/frontend/public/library/images/speech.png new file mode 100644 index 000000000..6471788c5 Binary files /dev/null and b/frontend/public/library/images/speech.png differ diff --git a/frontend/public/library/images/thermometer.png b/frontend/public/library/images/thermometer.png new file mode 100644 index 000000000..e21da2786 Binary files /dev/null and b/frontend/public/library/images/thermometer.png differ diff --git a/frontend/public/library/images/warning.png b/frontend/public/library/images/warning.png new file mode 100644 index 000000000..357bc7d02 Binary files /dev/null and b/frontend/public/library/images/warning.png differ diff --git a/frontend/scripts/generate-library-assets.mjs b/frontend/scripts/generate-library-assets.mjs new file mode 100644 index 000000000..19a480133 --- /dev/null +++ b/frontend/scripts/generate-library-assets.mjs @@ -0,0 +1,412 @@ +// Draws the library's preset images and sounds into public/library. +// +// node scripts/generate-library-assets.mjs +// +// Everything is made here from simple shapes and sine waves, so the files carry +// no licence, and re-running after a palette change keeps them in step with the +// theme. It needs nothing beyond Node. Which files exist, and what they're +// called, comes from src/features/authoring/library/presets.js. + +import { mkdirSync, writeFileSync } from "node:fs"; +import { deflateSync } from "node:zlib"; +import { PALETTE } from "../src/features/authoring/library/palette.js"; +import { + AUDIO_PRESETS, + IMAGE_PRESETS, +} from "../src/features/authoring/library/presets.js"; + +const OUT = new URL("../public/library/", import.meta.url); + +/* ------------------------------ shapes ------------------------------ */ +// Each shape is a function (x, y) => boolean on a 0..100 square. + +const circle = (cx, cy, r) => (x, y) => (x - cx) ** 2 + (y - cy) ** 2 <= r * r; + +const rect = (x0, y0, w, h) => (x, y) => + x >= x0 && x <= x0 + w && y >= y0 && y <= y0 + h; + +const roundRect = (x0, y0, w, h, r) => (x, y) => { + const dx = Math.max(x0 + r - x, 0, x - (x0 + w - r)); + const dy = Math.max(y0 + r - y, 0, y - (y0 + h - r)); + return dx * dx + dy * dy <= r * r; +}; + +function distanceToSegment(x, y, x1, y1, x2, y2) { + const dx = x2 - x1; + const dy = y2 - y1; + const lengthSquared = dx * dx + dy * dy; + const t = lengthSquared + ? Math.max(0, Math.min(1, ((x - x1) * dx + (y - y1) * dy) / lengthSquared)) + : 0; + return Math.hypot(x - (x1 + t * dx), y - (y1 + t * dy)); +} + +const capsule = (x1, y1, x2, y2, r) => (x, y) => + distanceToSegment(x, y, x1, y1, x2, y2) <= r; + +const polygon = (points) => (x, y) => { + let inside = false; + for (let i = 0, j = points.length - 1; i < points.length; j = i++) { + const [xi, yi] = points[i]; + const [xj, yj] = points[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) + inside = !inside; + } + return inside; +}; + +// a polygon with its corners rounded off by r +const roundPolygon = (points, r) => { + const inner = polygon(points); + return (x, y) => + inner(x, y) || + points.some((p, i) => { + const q = points[(i + 1) % points.length]; + return distanceToSegment(x, y, p[0], p[1], q[0], q[1]) <= r; + }); +}; + +const heart = (cx, cy, s) => (x, y) => { + const a = (x - cx) / s; + const b = (cy - y) / s; + return (a * a + b * b - 1) ** 3 - a * a * b ** 3 <= 0; +}; + +const union = + (...shapes) => + (x, y) => + shapes.some((shape) => shape(x, y)); + +const intersect = (a, b) => (x, y) => a(x, y) && b(x, y); + +const rotate = (shape, cx, cy, degrees) => { + const angle = (-degrees * Math.PI) / 180; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + return (x, y) => { + const dx = x - cx; + const dy = y - cy; + return shape(cx + dx * cos - dy * sin, cy + dx * sin + dy * cos); + }; +}; + +/* ----------------------------- the icons ----------------------------- */ +// A drawing is a list of layers, painted in order. + +const { accent, success, warning, error, info, neutral, light, white } = + PALETTE; +const layer = (color, shape) => ({ color, shape }); + +const ICONS = { + "first-aid": () => [ + layer(error, roundRect(6, 6, 88, 88, 20)), + layer(white, union(rect(40, 20, 20, 60), rect(20, 40, 60, 20))), + ], + + heart: () => [layer(error, heart(50, 54, 33))], + + pill: () => { + const body = (r) => rotate(capsule(30, 50, 70, 50, r), 50, 50, 45); + const leftHalf = rotate(rect(0, 0, 50, 100), 50, 50, 45); + return [ + layer(neutral, body(22)), + layer(light, body(19)), + layer(accent, intersect(body(19), leftHalf)), + ]; + }, + + thermometer: () => [ + layer(neutral, union(capsule(46, 18, 46, 62, 12), circle(46, 74, 20))), + layer(light, union(capsule(46, 18, 46, 62, 9), circle(46, 74, 17))), + layer(error, union(capsule(46, 40, 46, 66, 4.5), circle(46, 74, 12))), + layer( + neutral, + union(rect(64, 24, 14, 3.5), rect(64, 38, 10, 3.5), rect(64, 52, 14, 3.5)) + ), + ], + + clipboard: () => [ + layer(neutral, roundRect(16, 10, 68, 84, 7)), + layer(light, roundRect(22, 20, 56, 68, 3)), + layer(accent, roundRect(34, 4, 32, 18, 6)), + layer( + neutral, + union(rect(30, 38, 40, 4.5), rect(30, 52, 40, 4.5), rect(30, 66, 26, 4.5)) + ), + ], + + check: () => [ + layer(success, circle(50, 50, 44)), + layer( + white, + union(capsule(30, 52, 44, 66, 5.5), capsule(44, 66, 71, 35, 5.5)) + ), + ], + + warning: () => [ + layer( + warning, + roundPolygon( + [ + [50, 22], + [83, 78], + [17, 78], + ], + 9 + ) + ), + layer(white, capsule(50, 40, 50, 60, 4.5)), + layer(white, circle(50, 71, 4.8)), + ], + + info: () => [ + layer(info, circle(50, 50, 44)), + layer(white, circle(50, 30, 6)), + layer(white, capsule(50, 46, 50, 72, 6)), + ], + + arrow: () => [ + layer( + accent, + union( + capsule(14, 50, 66, 50, 8.5), + roundPolygon( + [ + [62, 28], + [88, 50], + [62, 72], + ], + 6 + ) + ) + ), + ], + + speech: () => [ + layer( + neutral, + union( + roundRect(6, 14, 88, 58, 14), + roundPolygon( + [ + [22, 66], + [22, 90], + [46, 68], + ], + 3 + ) + ) + ), + layer(white, circle(29, 43, 6)), + layer(white, circle(50, 43, 6)), + layer(white, circle(71, 43, 6)), + ], +}; + +/* ---------------------------- rasterising ---------------------------- */ + +const SIZE = 512; +const SAMPLES = 4; // per axis, per pixel, so edges are smooth + +const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + +function render(layers) { + const painted = layers.map(({ color, shape }) => ({ + rgb: channels(color), + shape, + })); + const out = Buffer.alloc(SIZE * SIZE * 4); + const unit = 100 / SIZE; + + for (let py = 0; py < SIZE; py++) { + for (let px = 0; px < SIZE; px++) { + // premultiplied colour accumulated over the layers + let r = 0; + let g = 0; + let b = 0; + let a = 0; + + for (const { rgb, shape } of painted) { + let hits = 0; + for (let sy = 0; sy < SAMPLES; sy++) { + for (let sx = 0; sx < SAMPLES; sx++) { + const x = (px + (sx + 0.5) / SAMPLES) * unit; + const y = (py + (sy + 0.5) / SAMPLES) * unit; + if (shape(x, y)) hits++; + } + } + if (!hits) continue; + const coverage = hits / (SAMPLES * SAMPLES); + r = rgb[0] * coverage + r * (1 - coverage); + g = rgb[1] * coverage + g * (1 - coverage); + b = rgb[2] * coverage + b * (1 - coverage); + a = coverage + a * (1 - coverage); + } + + const at = (py * SIZE + px) * 4; + if (a > 0) { + out[at] = Math.round(r / a); + out[at + 1] = Math.round(g / a); + out[at + 2] = Math.round(b / a); + out[at + 3] = Math.round(a * 255); + } + } + } + return out; +} + +/* ------------------------------- PNG -------------------------------- */ + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +function crc32(buffer) { + let c = 0xffffffff; + for (const byte of buffer) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function pngChunk(type, data) { + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const checksum = Buffer.alloc(4); + checksum.writeUInt32BE(crc32(body)); + return Buffer.concat([length, body, checksum]); +} + +function encodePng(rgba, width, height) { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; // bit depth + header[9] = 6; // RGBA + + const stride = width * 4; + const scanlines = Buffer.alloc((stride + 1) * height); + for (let y = 0; y < height; y++) { + // filter byte 0 (none), then the row + rgba.copy(scanlines, y * (stride + 1) + 1, y * stride, (y + 1) * stride); + } + + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk("IHDR", header), + pngChunk("IDAT", deflateSync(scanlines, { level: 9 })), + pngChunk("IEND", Buffer.alloc(0)), + ]); +} + +/* ------------------------------- sounds ------------------------------ */ + +const RATE = 22050; +const PEAK = 0.6; + +function synth(seconds, sample) { + const samples = new Float32Array(Math.round(seconds * RATE)); + for (let i = 0; i < samples.length; i++) samples[i] = sample(i / RATE); + return samples; +} + +const sine = (frequency, t) => Math.sin(2 * Math.PI * frequency * t); + +// fades a tone in and out over `edge` seconds so it never clicks +const windowed = (t, start, length, edge = 0.01) => + t < start || t > start + length + ? 0 + : Math.min(1, (t - start) / edge, (start + length - t) / edge); + +const SOUNDS = { + // a single beep, then quiet, for one beat per second when looped + "heart-monitor": () => + synth(1, (t) => PEAK * windowed(t, 0, 0.14) * sine(1000, t)), + + // two rising-and-falling pairs of beeps + alert: () => + synth(0.8, (t) => { + const beeps = [ + [0, 880], + [0.2, 660], + [0.4, 880], + [0.6, 660], + ]; + const sum = beeps.reduce((total, [start, frequency]) => { + const tone = sine(frequency, t) + 0.25 * sine(frequency * 2, t); + return total + windowed(t, start, 0.15) * tone; + }, 0); + return PEAK * 0.8 * sum; + }), + + // a bell: a few partials that each fade away at their own rate + chime: () => + synth(1.5, (t) => { + const partials = [ + [880, 1, 3.2], + [1318.5, 0.5, 4.5], + [1760, 0.3, 6.5], + [2637, 0.12, 9], + ]; + const sum = partials.reduce( + (total, [frequency, level, decay]) => + total + level * Math.exp(-decay * t) * sine(frequency, t), + 0 + ); + return PEAK * windowed(t, 0, 1.5, 0.004) * (sum / 1.6); + }), +}; + +function encodeWav(samples) { + const data = Buffer.alloc(samples.length * 2); + samples.forEach((value, i) => { + const clamped = Math.max(-1, Math.min(1, value)); + data.writeInt16LE(Math.round(clamped * 32767), i * 2); + }); + + const header = Buffer.alloc(44); + header.write("RIFF", 0); + header.writeUInt32LE(36 + data.length, 4); + header.write("WAVEfmt ", 8); + header.writeUInt32LE(16, 16); // fmt chunk size + header.writeUInt16LE(1, 20); // PCM + header.writeUInt16LE(1, 22); // mono + header.writeUInt32LE(RATE, 24); + header.writeUInt32LE(RATE * 2, 28); // byte rate + header.writeUInt16LE(2, 32); // block align + header.writeUInt16LE(16, 34); // bits per sample + header.write("data", 36); + header.writeUInt32LE(data.length, 40); + return Buffer.concat([header, data]); +} + +/* ------------------------------- output ------------------------------ */ + +function check(kind, wanted, drawn) { + const missing = wanted.filter((id) => !drawn.includes(id)); + const unlisted = drawn.filter((id) => !wanted.includes(id)); + if (missing.length || unlisted.length) + throw new Error( + `${kind}: presets.js and this script disagree. ` + + `Not drawn: [${missing}]. Not in presets.js: [${unlisted}]` + ); +} + +check("images", IMAGE_PRESETS.map((p) => p.id), Object.keys(ICONS)); +check("sounds", AUDIO_PRESETS.map((p) => p.id), Object.keys(SOUNDS)); + +mkdirSync(new URL("images/", OUT), { recursive: true }); +mkdirSync(new URL("audio/", OUT), { recursive: true }); + +for (const [id, draw] of Object.entries(ICONS)) { + const png = encodePng(render(draw()), SIZE, SIZE); + writeFileSync(new URL(`images/${id}.png`, OUT), png); + console.log(`images/${id}.png ${(png.length / 1024).toFixed(1)} KB`); +} + +for (const [id, make] of Object.entries(SOUNDS)) { + const wav = encodeWav(make()); + writeFileSync(new URL(`audio/${id}.wav`, OUT), wav); + console.log(`audio/${id}.wav ${(wav.length / 1024).toFixed(1)} KB`); +} diff --git a/frontend/src/features/authoring/CanvasSideBar/CanvasSideBar.jsx b/frontend/src/features/authoring/CanvasSideBar/CanvasSideBar.jsx index dfeccbf63..8428e31ec 100644 --- a/frontend/src/features/authoring/CanvasSideBar/CanvasSideBar.jsx +++ b/frontend/src/features/authoring/CanvasSideBar/CanvasSideBar.jsx @@ -3,12 +3,14 @@ import { BoxIcon, BracesIcon, HeadphonesIcon, + LibraryIcon, MonitorCog, ZapIcon, } from "lucide-react"; import useEditorStore from "../stores/editor"; import useVisualScene from "../stores/visual"; import AudioManager from "../audio/AudioManager"; +import AssetLibrary from "../library/AssetLibrary"; import PanelIcon from "./PanelIcon"; import SidePanel from "./SidePanel"; import SceneSettings from "./SceneSettings"; @@ -19,6 +21,7 @@ import PropertyOperationMenu from "../../../components/Properties/PropertyOperat const ALWAYS_PANELS = [ { key: "scene", label: "Scene Details", Icon: MonitorCog }, { key: "audio", label: "Audio Elements", Icon: HeadphonesIcon }, + { key: "library", label: "Asset Library", Icon: LibraryIcon }, ]; const CONTEXTUAL_PANELS = [ @@ -112,6 +115,7 @@ export default function CanvasSideBar() { > {activePanel === "scene" && } {activePanel === "audio" && } + {activePanel === "library" && } {activePanel === "bindings" && ( )} diff --git a/frontend/src/features/authoring/images.ts b/frontend/src/features/authoring/images.ts index 09a20fab5..4a192405a 100644 --- a/frontend/src/features/authoring/images.ts +++ b/frontend/src/features/authoring/images.ts @@ -57,18 +57,21 @@ const HANDOFF_MS = 250; export type ModifyScene = (scene: Scene) => Promise | undefined; +// Resolves to the new component's id. An image without an `_id` isn't one of +// the scenario's uploads (the library's built-in images, say): it's shown from +// its url and isn't reference counted. export async function addImageToScene( - image: UploadedFile, + image: Pick & Partial>, originScene: Scene, modifyScene: ModifyScene, verts?: Vec2[] -) { +): Promise { const newImage = structuredClone(defaults.image) as Partial; const imageId = v4(); newImage.id = imageId; - newImage.fileId = image._id; + if (image._id) newImage.fileId = image._id; newImage.href = image.url; newImage.bounds!.verts = verts ?? (await getImageVerts(image.url)); @@ -79,7 +82,7 @@ export async function addImageToScene( // every other component type gets on creation has to be assigned here. newImage.zIndex = getNextZIndex(); add(newImage); - return; + return imageId; } // User moved to another slide while the image was loading. @@ -88,6 +91,7 @@ export async function addImageToScene( originScene.components[imageId] = newImage as ImageComponent; await modifyScene(originScene); + return imageId; } export async function addNewImage( diff --git a/frontend/src/features/authoring/library/AssetCard.tsx b/frontend/src/features/authoring/library/AssetCard.tsx new file mode 100644 index 000000000..d2994d554 --- /dev/null +++ b/frontend/src/features/authoring/library/AssetCard.tsx @@ -0,0 +1,184 @@ +import { useEffect, useRef, useState } from "react"; +import { FileTextIcon, PauseIcon, PlayIcon, Trash2Icon } from "lucide-react"; +import type { LibraryItem, UploadItem } from "./items.js"; + +// Cards sit on the panel's own surface, and anything that will appear on the +// canvas is previewed on white, because that's what the canvas is. + +function formatSize(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function subtitle(item: LibraryItem) { + // a tag that only repeats what the card already is says nothing + const tags = item.tags.filter((tag) => tag !== item.kind); + if (tags.length) + return tags + .slice(0, 2) + .map((tag) => `#${tag}`) + .join(" "); + return item.source === "upload" ? formatSize(item.size) : ""; +} + +function ButtonPreview({ + item, +}: { + item: Extract; +}) { + return ( +
0 ? `2px solid ${item.stroke}` : undefined, + }} + > + {item.label} +
+ ); +} + +/** Plays and pauses a sound without leaving the library. */ +function PlayButton({ url, name }: { url: string; name: string }) { + const audioRef = useRef(null); + const [playing, setPlaying] = useState(false); + + useEffect(() => { + return () => audioRef.current?.pause(); + }, []); + + function toggle() { + if (!audioRef.current) { + audioRef.current = new Audio(url); + audioRef.current.addEventListener("ended", () => setPlaying(false)); + } + const audio = audioRef.current; + if (playing) { + audio.pause(); + audio.currentTime = 0; + setPlaying(false); + } else { + void audio.play().catch(() => setPlaying(false)); + setPlaying(true); + } + } + + return ( + + ); +} + +function Preview({ item }: { item: LibraryItem }) { + const frame = "flex aspect-[4/3] items-center justify-center rounded-sm"; + switch (item.kind) { + case "button": + return ( +
+ +
+ ); + case "image": + return ( +
+ +
+ ); + case "audio": + return ( +
+ +
+ ); + case "document": + return ( +
+ + + {item.name.split(".").pop()?.slice(0, 5)} + +
+ ); + } +} + +/** + * 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)} + + + + )} + + {busy && ( + + )} +
  • + ); +} 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 ( +
    +
    + + + { + chooseFile(e.target.files?.[0]); + e.target.value = ""; + }} + /> +
    + +
    + {FILTERS.map(({ id, label }) => ( + + ))} +
    + + {pending && ( +
    { + e.preventDefault(); + void submitUpload(); + }} + onKeyDown={(e) => { + if (e.key === "Escape") cancelUpload(); + }} + > +

    + {pending.name} +

    + setTags(e.target.value)} + // the point of picking a file is to tag it + autoFocus + /> +
    + + +
    +
    + )} + + {assets.isError && ( +
    + Couldn't load your uploads. + +
    + )} + + {showUploadHint && ( +
    +

    Your uploads will appear here

    +

    + Drop a file here, or press Upload. Anything you add can be used in + every scenario. +

    +
    + )} + + {sections.map((section) => ( +
    +

    + {section.title} +

    +
      + {section.items.map((item) => ( + void use(chosen)} + onDelete={(chosen) => void removeUpload(chosen)} + /> + ))} +
    +
    + ))} + + {sections.length === 0 && ( +

    + Nothing matches{query.trim() ? ` “${query.trim()}”` : " that"}. +

    + )} + + {dragging && ( +
    + Drop to upload +
    + )} +
    + ); +} diff --git a/frontend/src/features/authoring/library/buttonComponent.js b/frontend/src/features/authoring/library/buttonComponent.js new file mode 100644 index 000000000..9de41da74 --- /dev/null +++ b/frontend/src/features/authoring/library/buttonComponent.js @@ -0,0 +1,49 @@ +import { BUTTON_SIZE } from "./presets.js"; + +/** + * The component for a preset button, centred on the canvas. It's an ordinary + * box or ellipse with text in it, so it can be restyled, moved and given a + * destination or property change like any shape the author draws. + * + * @param {object} preset - one of BUTTON_PRESETS + * @param {object} options + * @param {string} options.id + * @param {number} options.zIndex - where to put it in the stack + * @param {{ width: number, height: number }} options.canvas + */ +export function buttonComponent(preset, { id, zIndex, canvas }) { + const { width, height } = BUTTON_SIZE; + const x = Math.round((canvas.width - width) / 2); + const y = Math.round((canvas.height - height) / 2); + + return { + id, + type: preset.shape, + clickable: true, + fill: preset.fill, + stroke: preset.stroke, + strokeWidth: preset.strokeWidth, + bounds: { + verts: [ + { x, y }, + { x: x + width, y: y + height }, + ], + rotation: 0, + }, + zIndex, + document: { + style: { + fontFamily: "Arial", + fontSize: 44, + fontWeight: "bold", + textColor: preset.textColor, + }, + blocks: [ + { + style: { alignment: "center" }, + spans: [{ style: {}, text: preset.label }], + }, + ], + }, + }; +} diff --git a/frontend/src/features/authoring/library/buttonComponent.test.js b/frontend/src/features/authoring/library/buttonComponent.test.js new file mode 100644 index 000000000..22e781faf --- /dev/null +++ b/frontend/src/features/authoring/library/buttonComponent.test.js @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buttonComponent } from "./buttonComponent.js"; +import { BUTTON_PRESETS, BUTTON_SIZE } from "./presets.js"; + +const canvas = { width: 1920, height: 1080 }; +const options = { id: "new-id", zIndex: 7, canvas }; + +test("a button is centred on the canvas at the preset size", () => { + const { bounds } = buttonComponent(BUTTON_PRESETS[0], options); + const [topLeft, bottomRight] = bounds.verts; + + assert.equal(bottomRight.x - topLeft.x, BUTTON_SIZE.width); + assert.equal(bottomRight.y - topLeft.y, BUTTON_SIZE.height); + assert.equal((topLeft.x + bottomRight.x) / 2, canvas.width / 2); + assert.equal((topLeft.y + bottomRight.y) / 2, canvas.height / 2); + assert.equal(bounds.rotation, 0); +}); + +test("a button carries its preset's look and its place in the stack", () => { + for (const preset of BUTTON_PRESETS) { + const button = buttonComponent(preset, options); + assert.equal(button.id, "new-id"); + assert.equal(button.type, preset.shape); + assert.equal(button.zIndex, 7); + assert.equal(button.fill, preset.fill); + assert.equal(button.stroke, preset.stroke); + assert.equal(button.strokeWidth, preset.strokeWidth); + assert.equal(button.clickable, true); + } +}); + +test("a button's text is its label, in the preset's text colour", () => { + const preset = BUTTON_PRESETS[0]; + const { document } = buttonComponent(preset, options); + + assert.equal(document.blocks[0].spans[0].text, preset.label); + assert.equal(document.style.textColor, preset.textColor); + assert.equal(document.blocks[0].style.alignment, "center"); +}); + +test("buttons don't share state with each other", () => { + const a = buttonComponent(BUTTON_PRESETS[0], options); + const b = buttonComponent(BUTTON_PRESETS[0], options); + a.document.blocks[0].spans[0].text = "changed"; + a.bounds.verts[0].x = -1; + + assert.notEqual(b.document.blocks[0].spans[0].text, "changed"); + assert.notEqual(b.bounds.verts[0].x, -1); +}); + +test("an oval preset makes an ellipse", () => { + const oval = BUTTON_PRESETS.find((preset) => preset.id === "button-oval"); + assert.equal(buttonComponent(oval, options).type, "ellipse"); +}); diff --git a/frontend/src/features/authoring/library/items.js b/frontend/src/features/authoring/library/items.js new file mode 100644 index 000000000..41b4c2a7b --- /dev/null +++ b/frontend/src/features/authoring/library/items.js @@ -0,0 +1,134 @@ +import { KINDS } from "./presets.js"; + +// What the panel shows is a flat list of "items": presets and the user's +// uploads, in one shape, so searching and grouping don't care which is which. + +/** @typedef {import("./presets.js").Kind} Kind */ +/** @typedef {import("./presets.js").PresetItem} PresetItem */ +/** + * An uploaded file, as the API returns it. + * @typedef {{ + * _id: string, + * name: string, + * type: "image" | "audio" | "document", + * tags: string[], + * url: string, + * size: number, + * contentType: string, + * }} Asset + */ +/** + * @typedef {{ + * key: string, + * source: "upload", + * kind: "image" | "audio" | "document", + * name: string, + * tags: string[], + * url: string, + * size: number, + * asset: Asset, + * }} UploadItem + */ +/** @typedef {PresetItem | UploadItem} LibraryItem */ +/** @typedef {{ id: string, title: string, items: LibraryItem[] }} Section */ + +/** + * The heading each kind of thing goes under. + * @type {Record} + */ +export const KIND_LABELS = { + button: "Buttons", + image: "Images", + audio: "Sounds", + document: "Documents", +}; + +/** @type {{ id: "all" | Kind, label: string }[]} */ +export const FILTERS = [ + { id: "all", label: "All" }, + ...KINDS.map((kind) => ({ id: kind, label: KIND_LABELS[kind] })), +]; + +/** + * An uploaded asset as a library item. + * @param {Asset} asset + * @returns {UploadItem} + */ +export function assetToItem(asset) { + return { + key: `asset:${asset._id}`, + source: "upload", + kind: asset.type, + name: asset.name, + tags: asset.tags ?? [], + url: asset.url, + size: asset.size, + asset, + }; +} + +/** + * Search text as lowercase words, ignoring the `#` people put on tags. + * @param {string} [query] + * @returns {string[]} + */ +export function searchWords(query) { + return (query ?? "") + .toLowerCase() + .split(/\s+/) + .map((word) => word.replace(/^#+/, "")) + .filter(Boolean); +} + +/** + * @param {LibraryItem} item + * @param {string[]} words + */ +function matches(item, words) { + const name = item.name.toLowerCase(); + const tags = item.tags.map((tag) => tag.toLowerCase()); + return words.every( + (word) => name.includes(word) || tags.some((tag) => tag.includes(word)) + ); +} + +/** + * The items of a kind (or all of them) whose name or tags contain every word + * of the query. No query matches everything. Order is kept. + * + * @param {LibraryItem[]} items + * @param {{ query?: string, kind?: "all" | Kind }} [options] + * @returns {LibraryItem[]} + */ +export function filterItems(items, { query = "", kind = "all" } = {}) { + const words = searchWords(query); + return items.filter( + (item) => (kind === "all" || item.kind === kind) && matches(item, words) + ); +} + +/** + * Items under headings: the user's uploads first, then the presets by kind. + * Empty sections are left out. + * + * @param {LibraryItem[]} items + * @returns {Section[]} + */ +export function groupItems(items) { + /** @type {Section[]} */ + const sections = [ + { + id: "uploads", + title: "My uploads", + items: items.filter((item) => item.source === "upload"), + }, + ...KINDS.map((kind) => ({ + id: kind, + title: KIND_LABELS[kind], + items: items.filter( + (item) => item.source === "preset" && item.kind === kind + ), + })), + ]; + return sections.filter((section) => section.items.length > 0); +} diff --git a/frontend/src/features/authoring/library/items.test.js b/frontend/src/features/authoring/library/items.test.js new file mode 100644 index 000000000..bc4c7a793 --- /dev/null +++ b/frontend/src/features/authoring/library/items.test.js @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { assetToItem, filterItems, groupItems, searchWords } from "./items.js"; +import { PRESET_ITEMS } from "./presets.js"; + +const upload = (name, type, tags = []) => + assetToItem({ + _id: name, + name, + type, + tags, + url: `https://x/${name}`, + size: 1, + }); + +const uploads = [ + upload("Ward round.png", "image", ["ward", "hospital"]), + upload("Heart monitor.wav", "audio", ["beep", "monitor"]), + upload("Protocol.pdf", "document", ["guide", "ward"]), +]; +const all = [...uploads, ...PRESET_ITEMS]; +const names = (items) => items.map((item) => item.name); + +test("an uploaded asset becomes a library item", () => { + const item = uploads[0]; + assert.equal(item.key, "asset:Ward round.png"); + assert.equal(item.source, "upload"); + assert.equal(item.kind, "image"); + assert.deepEqual(item.tags, ["ward", "hospital"]); +}); + +test("an asset without tags still works", () => { + const item = assetToItem({ _id: "1", name: "x", type: "image", url: "u" }); + assert.deepEqual(item.tags, []); + assert.equal(filterItems([item], { query: "x" }).length, 1); +}); + +test("no query and no kind matches everything, in order", () => { + assert.deepEqual(filterItems(all), all); +}); + +test("a query matches names, ignoring case", () => { + assert.deepEqual(names(filterItems(uploads, { query: "WARD" })), [ + "Ward round.png", + "Protocol.pdf", // via its tag + ]); +}); + +test("a query matches tags, with or without a leading #", () => { + for (const query of ["beep", "#beep", "BEE"]) { + const found = filterItems(uploads, { query }); + assert.deepEqual(names(found), ["Heart monitor.wav"], query); + } +}); + +test("every word of a query has to match", () => { + assert.deepEqual(names(filterItems(uploads, { query: "ward guide" })), [ + "Protocol.pdf", + ]); + assert.deepEqual(filterItems(uploads, { query: "ward nothing" }), []); +}); + +test("extra whitespace in a query is ignored", () => { + assert.deepEqual(searchWords(" Ward #Guide "), ["ward", "guide"]); + assert.equal(filterItems(uploads, { query: " " }).length, uploads.length); +}); + +test("kind narrows to one sort of thing, presets and uploads alike", () => { + const audio = filterItems(all, { kind: "audio" }); + assert.ok(audio.length > 1); + assert.ok(audio.every((item) => item.kind === "audio")); + assert.ok(names(audio).includes("Heart monitor.wav")); + assert.ok(names(audio).includes("Heart monitor")); // the preset +}); + +test("kind and query combine", () => { + const found = filterItems(all, { kind: "audio", query: "monitor" }); + assert.deepEqual(names(found), ["Heart monitor.wav", "Heart monitor"]); +}); + +test("a preset can be found by tag (the whole point of tags)", () => { + assert.ok( + names(filterItems(all, { query: "fever" })).includes("Thermometer") + ); + assert.ok( + names(filterItems(all, { query: "emergency" })).includes("First aid") + ); +}); + +test("nothing matching gives an empty list", () => { + assert.deepEqual(filterItems(all, { query: "zzzzzz" }), []); +}); + +test("items are grouped with uploads first, then presets by kind", () => { + const sections = groupItems(all); + assert.deepEqual( + sections.map((s) => s.title), + ["My uploads", "Buttons", "Images", "Sounds"] + ); + assert.equal(sections[0].items.length, uploads.length); + assert.ok(sections[1].items.every((i) => i.kind === "button")); +}); + +test("sections with nothing in them are left out", () => { + const sections = groupItems(filterItems(all, { kind: "button" })); + assert.deepEqual( + sections.map((s) => s.title), + ["Buttons"] + ); + assert.deepEqual(groupItems([]), []); +}); + +test("uploaded documents are grouped with the uploads", () => { + const sections = groupItems(filterItems(all, { kind: "document" })); + assert.deepEqual( + sections.map((s) => s.title), + ["My uploads"] + ); + assert.deepEqual(names(sections[0].items), ["Protocol.pdf"]); +}); diff --git a/frontend/src/features/authoring/library/palette.js b/frontend/src/features/authoring/library/palette.js new file mode 100644 index 000000000..518bc63e9 --- /dev/null +++ b/frontend/src/features/authoring/library/palette.js @@ -0,0 +1,36 @@ +// The colours used by the library's preset buttons and icons. +// +// Presets end up inside scenes, which store colours as plain hex values and +// can't refer to CSS variables, so the theme's colours are written out here. +// PALETTE_SOURCES says which theme token in index.css each one mirrors, and +// palette.test.js fails if they ever drift apart. The generator script that +// draws the preset images reads this same palette. + +export const PALETTE = { + accent: "#0080a7", + success: "#367a2f", + warning: "#8a5f16", + error: "#c13216", + info: "#316b8a", + neutral: "#45454b", + // text on the page, and the colour of text sitting on a solid fill + content: "#252529", + onSolid: "#f4f4f1", + // light surfaces + light: "#e6e6e6", + white: "#ffffff", +}; + +/** The index.css custom property each palette colour is taken from. */ +export const PALETTE_SOURCES = { + accent: "--color-accent", + success: "--color-success", + warning: "--color-light-warning", + error: "--color-error", + info: "--color-light-info", + neutral: "--color-light-neutral", + content: "--color-light-content", + onSolid: "--color-light-on-solid", + light: "--color-light-base", + white: "--color-white", +}; diff --git a/frontend/src/features/authoring/library/place.ts b/frontend/src/features/authoring/library/place.ts new file mode 100644 index 000000000..74046cd94 --- /dev/null +++ b/frontend/src/features/authoring/library/place.ts @@ -0,0 +1,121 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { AxiosResponse } from "axios"; +import type { User } from "firebase/auth"; +import toast from "react-hot-toast"; +import { v4 } from "uuid"; +import { api } from "../../../util/api"; +import { CANVAS_HEIGHT, CANVAS_WIDTH } from "../../../util/canvas"; +import { addImageToScene, type ModifyScene } from "../images"; +import { getNextZIndex } from "../scene/operations/component"; +import { add } from "../scene/operations/modifiers"; +import { getScene, getSceneId } from "../scene/scene"; +import useEditorStore from "../stores/editor"; +import type { UploadedFile } from "../types"; +import { buttonComponent } from "./buttonComponent.js"; +import type { LibraryItem, UploadItem } from "./items.js"; + +export interface PlaceContext { + user: User; + scenarioId: string; + modifyScene: ModifyScene; + queryClient: QueryClient; +} + +function select(id: string) { + useEditorStore.getState().setSelected([id]); +} + +/** + * The scenario's own file for an upload: the first use in a scenario makes it + * (pointing at the same stored object), after that it's the same file again. + * It's what scenes reference, so reference counting works as for any upload. + */ +async function fileForScenario(item: UploadItem, ctx: PlaceContext) { + const response = (await api.post( + ctx.user, + `api/assets/${item.asset._id}/use`, + { scenarioId: ctx.scenarioId } + )) as AxiosResponse; + return response.data; +} + +/** + * Puts a library item into the scene being edited (or, for a document, into + * the scenario's resources). Whatever lands on the canvas ends up selected, so + * the author can carry straight on editing it. + */ +export async function placeItem(item: LibraryItem, ctx: PlaceContext) { + if (item.source === "preset") { + switch (item.kind) { + case "button": { + const id = v4(); + add( + buttonComponent(item, { + id, + zIndex: getNextZIndex(), + canvas: { width: CANVAS_WIDTH, height: CANVAS_HEIGHT }, + }) + ); + select(id); + return; + } + case "image": { + const originScene = getScene(); + const id = await addImageToScene( + { url: item.url }, + originScene, + ctx.modifyScene + ); + // the author may have moved to another slide while it loaded + if (getSceneId() === originScene._id) select(id); + return; + } + case "audio": + add({ + type: "audio", + name: item.name, + url: item.url, + loop: item.loops, + }); + toast.success(`Added "${item.name}" to this scene`); + return; + } + } + + const file = await fileForScenario(item, ctx); + + switch (item.kind) { + case "image": { + const originScene = getScene(); + const id = await addImageToScene(file, originScene, ctx.modifyScene); + if (getSceneId() === originScene._id) select(id); + void ctx.queryClient.invalidateQueries({ + queryKey: ["images", ctx.scenarioId], + }); + return; + } + case "audio": + add({ + fileId: file._id, + type: "audio", + name: file.name, + url: file.url, + loop: false, + }); + void ctx.queryClient.invalidateQueries({ + queryKey: ["audios", ctx.scenarioId], + }); + toast.success(`Added "${file.name}" to this scene`); + return; + case "document": + await api.post(ctx.user, `api/resources/${ctx.scenarioId}`, { + name: file.name, + fileId: file._id, + }); + void ctx.queryClient.invalidateQueries({ + queryKey: ["resources", ctx.scenarioId], + }); + toast.success(`Added "${file.name}" to this scenario's resources`); + return; + } +} diff --git a/frontend/src/features/authoring/library/presets.js b/frontend/src/features/authoring/library/presets.js new file mode 100644 index 000000000..fad5a9739 --- /dev/null +++ b/frontend/src/features/authoring/library/presets.js @@ -0,0 +1,198 @@ +import { PALETTE } from "./palette.js"; + +// What the library ships with: styled buttons, a set of icons and a few +// sounds. The images and sounds are static files in public/library, drawn by +// scripts/generate-library-assets.mjs, so there is nothing to license and +// nothing in the database. + +/** @typedef {"button" | "image" | "audio" | "document"} Kind */ +/** @typedef {{ id: string, name: string, tags: string[] }} PresetBase */ +/** + * @typedef {PresetBase & { + * label: string, + * shape: "box" | "ellipse", + * fill: string, + * stroke: string, + * strokeWidth: number, + * textColor: string, + * }} ButtonPreset + */ +/** @typedef {PresetBase & { url: string }} ImagePreset */ +/** @typedef {PresetBase & { url: string, loops: boolean }} AudioPreset */ +/** + * @typedef {{ key: string, source: "preset" }} PresetMark + * @typedef {(ButtonPreset & PresetMark & { kind: "button" }) + * | (ImagePreset & PresetMark & { kind: "image" }) + * | (AudioPreset & PresetMark & { kind: "audio" })} PresetItem + */ + +/** Fully opaque, in the 8 digit form the editor uses for its own colours. */ +const solid = (/** @type {string} */ hex) => `${hex}ff`; +const CLEAR = "#00000000"; + +export const BUTTON_SIZE = { width: 420, height: 120 }; + +/** + * @param {string} id + * @param {string} name + * @param {string} label + * @param {string} color + * @param {string[]} tags + * @param {"box" | "ellipse"} [shape] + * @returns {ButtonPreset} + */ +const filled = (id, name, label, color, tags, shape = "box") => ({ + id, + name, + label, + shape, + fill: solid(color), + stroke: CLEAR, + strokeWidth: 0, + textColor: PALETTE.onSolid, + tags: ["button", ...tags], +}); + +/** + * Buttons are ordinary box/ellipse components with text in them, ready to be + * given a destination or a property change in the Button Actions panel. + * @type {ButtonPreset[]} + */ +export const BUTTON_PRESETS = [ + filled("button-primary", "Primary button", "Continue", PALETTE.accent, [ + "primary", + "next", + "teal", + ]), + filled("button-neutral", "Neutral button", "Back", PALETTE.neutral, [ + "secondary", + "grey", + "back", + ]), + filled("button-success", "Success button", "Confirm", PALETTE.success, [ + "green", + "yes", + "done", + ]), + filled("button-warning", "Warning button", "Wait", PALETTE.warning, [ + "amber", + "caution", + "pause", + ]), + filled("button-danger", "Danger button", "Stop", PALETTE.error, [ + "red", + "no", + "emergency", + ]), + filled("button-info", "Info button", "More info", PALETTE.info, [ + "blue", + "help", + "details", + ]), + { + id: "button-outline", + name: "Outline button", + label: "Skip", + shape: "box", + fill: CLEAR, + stroke: solid(PALETTE.content), + strokeWidth: 6, + textColor: PALETTE.content, + tags: ["button", "outline", "ghost", "secondary"], + }, + filled( + "button-oval", + "Oval button", + "Start", + PALETTE.accent, + ["oval", "round", "pill", "primary"], + "ellipse" + ), +]; + +/** + * @param {string} id + * @param {string} name + * @param {string[]} tags + * @returns {ImagePreset} + */ +const image = (id, name, tags) => ({ + id, + name, + tags, + url: `/library/images/${id}.png`, +}); + +/** @type {ImagePreset[]} */ +export const IMAGE_PRESETS = [ + image("first-aid", "First aid", ["medical", "emergency", "cross", "health"]), + image("heart", "Heart", ["cardiac", "heart", "health", "love"]), + image("pill", "Pill", ["medication", "drug", "capsule", "pharmacy"]), + image("thermometer", "Thermometer", ["temperature", "fever", "vitals"]), + image("clipboard", "Clipboard", ["notes", "chart", "records", "list"]), + image("check", "Tick", ["check", "success", "correct", "done"]), + image("warning", "Warning", ["alert", "caution", "danger", "triangle"]), + image("info", "Information", ["help", "about", "details", "i"]), + image("arrow", "Arrow", ["next", "direction", "right", "continue"]), + image("speech", "Speech bubble", ["chat", "dialogue", "talk", "message"]), +]; + +/** + * @param {string} id + * @param {string} name + * @param {string[]} tags + * @param {boolean} [loops] + * @returns {AudioPreset} + */ +const sound = (id, name, tags, loops = false) => ({ + id, + name, + tags, + loops, + url: `/library/audio/${id}.wav`, +}); + +/** @type {AudioPreset[]} */ +export const AUDIO_PRESETS = [ + // one beep a second, so it works as a looping bedside monitor + sound( + "heart-monitor", + "Heart monitor", + ["beep", "ecg", "vitals", "monitor"], + true + ), + sound("alert", "Alert", ["alarm", "warning", "urgent", "beep"]), + sound("chime", "Chime", ["bell", "notification", "success", "ding"]), +]; + +/** + * The kinds of thing in the library, in the order they're offered. + * @type {Kind[]} + */ +export const KINDS = ["button", "image", "audio", "document"]; + +/** + * Every preset as a library item, the same shape uploads are turned into, so + * the panel can treat the two alike. + * @type {PresetItem[]} + */ +export const PRESET_ITEMS = [ + ...BUTTON_PRESETS.map((preset) => ({ + ...preset, + key: `preset:${preset.id}`, + source: /** @type {const} */ ("preset"), + kind: /** @type {const} */ ("button"), + })), + ...IMAGE_PRESETS.map((preset) => ({ + ...preset, + key: `preset:${preset.id}`, + source: /** @type {const} */ ("preset"), + kind: /** @type {const} */ ("image"), + })), + ...AUDIO_PRESETS.map((preset) => ({ + ...preset, + key: `preset:${preset.id}`, + source: /** @type {const} */ ("preset"), + kind: /** @type {const} */ ("audio"), + })), +]; diff --git a/frontend/src/features/authoring/library/presets.test.js b/frontend/src/features/authoring/library/presets.test.js new file mode 100644 index 000000000..512d230d6 --- /dev/null +++ b/frontend/src/features/authoring/library/presets.test.js @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { PALETTE, PALETTE_SOURCES } from "./palette.js"; +import { + AUDIO_PRESETS, + BUTTON_PRESETS, + IMAGE_PRESETS, + PRESET_ITEMS, +} from "./presets.js"; + +const themeCss = readFileSync( + new URL("../../../index.css", import.meta.url), + "utf8" +); +const publicFile = (url) => + readFileSync(new URL(`../../../../public${url}`, import.meta.url)); + +// WCAG relative luminance and contrast ratio +function luminance(hex) { + const [r, g, b] = [1, 3, 5] + .map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) + .map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrast(a, b) { + const [lighter, darker] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (lighter + 0.05) / (darker + 0.05); +} + +/* ------------------------------ theme ------------------------------- */ + +test("every palette colour is taken from a theme token", () => { + assert.deepEqual( + Object.keys(PALETTE).sort(), + Object.keys(PALETTE_SOURCES).sort() + ); +}); + +test("the palette matches the theme in index.css", () => { + for (const [name, token] of Object.entries(PALETTE_SOURCES)) { + const declaration = new RegExp(`${token}:\\s*(#[0-9a-fA-F]{6})\\b`); + const found = themeCss.match(declaration); + assert.ok(found, `${token} has no hex value in index.css`); + assert.equal( + PALETTE[name], + found[1].toLowerCase(), + `palette.${name} has drifted from ${token}` + ); + } +}); + +test("button text is readable on its fill", () => { + for (const preset of BUTTON_PRESETS) { + const fillIsClear = preset.fill.endsWith("00"); + // outline buttons sit straight on the white canvas + const background = fillIsClear ? PALETTE.white : preset.fill.slice(0, 7); + // the text is 44px bold, which counts as large text (3:1) + assert.ok( + contrast(preset.textColor, background) >= 3, + `${preset.id}: text is hard to read on its background` + ); + } +}); + +test("button colours are in the editor's 8 digit hex form", () => { + for (const preset of BUTTON_PRESETS) { + assert.match(preset.fill, /^#[0-9a-f]{8}$/, preset.id); + assert.match(preset.stroke, /^#[0-9a-f]{8}$/, preset.id); + assert.match(preset.textColor, /^#[0-9a-f]{6}$/, preset.id); + } +}); + +/* ----------------------------- manifest ----------------------------- */ + +test("preset ids and keys are unique", () => { + const keys = PRESET_ITEMS.map((item) => item.key); + assert.equal(new Set(keys).size, keys.length); + const ids = PRESET_ITEMS.map((item) => item.id); + assert.equal(new Set(ids).size, ids.length); +}); + +test("every preset has a name and clean, searchable tags", () => { + for (const item of PRESET_ITEMS) { + assert.ok(item.name.trim(), `${item.id} has no name`); + assert.ok(item.tags.length > 0, `${item.id} has no tags`); + assert.equal(new Set(item.tags).size, item.tags.length, `${item.id} tags`); + for (const tag of item.tags) + assert.equal(tag, tag.trim().toLowerCase(), `${item.id}: "${tag}"`); + } +}); + +test("buttons can be found by the colour or role in their tags", () => { + const find = (word) => + PRESET_ITEMS.filter((i) => i.tags.includes(word)).map((i) => i.id); + assert.ok(find("red").includes("button-danger")); + assert.ok(find("primary").includes("button-primary")); +}); + +/* ------------------------- generated files -------------------------- */ + +test("each preset image is a 512px transparent PNG", () => { + for (const preset of IMAGE_PRESETS) { + assert.equal(preset.url, `/library/images/${preset.id}.png`); + const png = publicFile(preset.url); + + assert.deepEqual( + [...png.subarray(0, 8)], + [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], + `${preset.id} isn't a PNG` + ); + assert.equal(png.readUInt32BE(16), 512, `${preset.id} width`); + assert.equal(png.readUInt32BE(20), 512, `${preset.id} height`); + assert.equal(png[24], 8, `${preset.id} bit depth`); + assert.equal(png[25], 6, `${preset.id} should have an alpha channel`); + assert.ok(png.length < 50 * 1024, `${preset.id} is oddly large`); + } +}); + +test("each preset sound is a short mono 16-bit WAV that doesn't clip", () => { + for (const preset of AUDIO_PRESETS) { + assert.equal(preset.url, `/library/audio/${preset.id}.wav`); + const wav = publicFile(preset.url); + + assert.equal(wav.toString("ascii", 0, 4), "RIFF", preset.id); + assert.equal(wav.toString("ascii", 8, 12), "WAVE", preset.id); + assert.equal(wav.readUInt16LE(20), 1, `${preset.id} should be PCM`); + assert.equal(wav.readUInt16LE(22), 1, `${preset.id} should be mono`); + assert.equal(wav.readUInt16LE(34), 16, `${preset.id} bit depth`); + + const rate = wav.readUInt32LE(24); + const dataBytes = wav.readUInt32LE(40); + assert.equal(dataBytes, wav.length - 44, `${preset.id} data length`); + const seconds = dataBytes / 2 / rate; + assert.ok(seconds >= 0.2 && seconds <= 3, `${preset.id} is ${seconds}s`); + + let peak = 0; + for (let i = 44; i < wav.length; i += 2) + peak = Math.max(peak, Math.abs(wav.readInt16LE(i))); + assert.ok(peak > 8000, `${preset.id} is nearly silent`); + assert.ok(peak < 32767, `${preset.id} clips`); + } +}); diff --git a/frontend/src/features/authoring/library/useAssets.ts b/frontend/src/features/authoring/library/useAssets.ts new file mode 100644 index 000000000..f3ea33b30 --- /dev/null +++ b/frontend/src/features/authoring/library/useAssets.ts @@ -0,0 +1,70 @@ +import { useContext, type Context } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosResponse } from "axios"; +import type { User } from "firebase/auth"; +import AuthenticationContext from "../../../context/AuthenticationContext"; +import { api } from "../../../util/api"; +import type { Asset } from "./items.js"; + +const ASSETS_KEY = ["assets"]; + +/** The signed-in user's library. */ +export function useAssets() { + const { user } = useContext(AuthenticationContext as Context<{ user: User }>); + + return useQuery({ + queryKey: ASSETS_KEY, + queryFn: async () => { + const response = (await api.get(user, "api/assets")) as AxiosResponse< + Asset[] + >; + return response.data; + }, + enabled: !!user, + }); +} + +/** Uploads a file to the library, with tags as comma separated text. */ +export function useUploadAsset() { + const { user } = useContext(AuthenticationContext as Context<{ user: User }>); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ file, tags }: { file: File; tags: string }) => { + const form = new FormData(); + form.append("file", file); + if (tags.trim()) form.append("tags", tags); + + const response = (await api.post( + user, + "api/assets", + form + )) as AxiosResponse; + return response.data; + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ASSETS_KEY }), + }); +} + +/** Removes an upload from the library. Scenes already using it keep it. */ +export function useDeleteAsset() { + const { user } = useContext(AuthenticationContext as Context<{ user: User }>); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (assetId: string) => { + await api.delete(user, `api/assets/${assetId}`); + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ASSETS_KEY }), + }); +} + +/** + * The message to show for a failed request: the server's own, if it sent one + * ("unsupported file type: ..."), otherwise the fallback. + */ +export function failureMessage(error: unknown, fallback: string) { + const message = (error as { response?: { data?: { error?: unknown } } }) + ?.response?.data?.error; + return typeof message === "string" && message ? message : fallback; +} diff --git a/wiki/Backend API.md b/wiki/Backend API.md index 72a2cec5b..2492f0619 100644 --- a/wiki/Backend API.md +++ b/wiki/Backend API.md @@ -433,3 +433,75 @@ Returns: "message": "Scenes reordered successfully" } ``` + +--- + +### Asset library + +A user's library of reusable files. Every route needs a signed-in user and only ever touches that user's own assets. See [Asset Library](./Specifications/Asset%20Library.md). + +**List the library:** + +`GET /api/assets` + +Newest first. Optional query parameters: `type` (`image`, `audio` or `document`), `tag` (one exact tag), `q` (search words, each of which must match the name or a tag) and `limit` (default 200, at most 500). + +Returns: + +```json +[ + { + "_id": "000001", + "name": "heart.png", + "type": "image", + "tags": ["heart", "cardiac"], + "url": "https://firebasestorage.googleapis.com/...", + "contentType": "image/png", + "size": 4408, + "ownerUid": "uid" + } +] +``` + +**Upload to the library:** + +`POST /api/assets` + +Multipart form: `file` (required), `tags` (optional, comma separated) and `name` (optional, defaults to the file name). Subject to the same size and type limits as scenario uploads (`415` for a type that isn't allowed, `413` if it's too large). + +Returns the new asset with status `201`. + +**Rename or re-tag:** + +`PATCH /api/assets/:assetId` + +Body (either or both): + +```json +{ + "name": "Red heart", + "tags": ["cardiac", "red"] +} +``` + +Returns the updated asset. `404` if the asset isn't the caller's. + +**Remove from the library:** + +`DELETE /api/assets/:assetId` + +Returns `204`. The stored file is only deleted if no scenario is using it. + +**Use an asset in a scenario:** + +`POST /api/assets/:assetId/use` + +Body: + +```json +{ + "scenarioId": "000001" +} +``` + +The caller must be able to edit the scenario (`401` otherwise). Returns the scenario's `UploadedFile` for the asset: status `201` the first time, `200` with the same file after that. diff --git a/wiki/Specifications/Asset Library.md b/wiki/Specifications/Asset Library.md new file mode 100644 index 000000000..b2382f05d --- /dev/null +++ b/wiki/Specifications/Asset Library.md @@ -0,0 +1,76 @@ +# Asset Library + +The asset library is a panel in the editor (the library icon in the right-hand icon stack, under Scene Details and Audio) where authors find things to put in their scenes: ready-made buttons, icons and sounds, and files they've uploaded themselves. + +The point is reuse. Before this, an upload belonged to one scenario, so using the same logo or sound in a second scenario meant uploading it again. Now an upload goes into the author's library once and can be used in any scenario they can edit. + +## What's in it + +There are two sources, shown as separate headings: + +- **My uploads** are files the author uploaded: images, audio and documents. They are private to the uploader. +- **Presets** are built in and the same for everyone: + - 8 **buttons**: primary, neutral, success, warning, danger, info, outline and oval + - 10 **images**: simple medical and interface icons (first aid, heart, pill, thermometer, clipboard, tick, warning, info, arrow, speech bubble) + - 3 **sounds**: a looping heart monitor, an alert and a chime + +Everything has a name and **tags**. The search box matches every word against names and tags, so "fever" finds the thermometer and "emergency" finds first aid. The filter chips (All, Buttons, Images, Sounds, Documents) narrow it to one kind. + +## Using an item + +Clicking an item adds it, and whatever lands on the canvas ends up selected so the author can carry on editing it. + +| Item | What happens | +| --- | --- | +| Preset button | A box (or ellipse) with text in it is added, centred on the canvas. It's an ordinary shape, so it can be restyled, moved and given a destination or property change in the Button Actions panel | +| Preset image | An image component is added at its natural size. It's shown from its static url and has no `fileId`, so it isn't reference counted | +| Preset sound | An audio element is added (the heart monitor starts with Loop on) | +| Uploaded image | The upload is made available to the scenario (see below), then added as an image component | +| Uploaded audio | Made available to the scenario, then added as an audio element | +| Uploaded document | Made available to the scenario, then added to the scenario's resources | + +### Uploading + +The Upload button, or dropping a file on the panel, asks for optional tags (comma separated) and adds the file to the library. Files are limited in the same way as any other upload (see [Uploading Media Files](../Uplading%20Media%20Files.md)): the size limit and allowed types come from `MAX_FILE_SIZE_MB` and `ALLOWED_MIME_LIST`. + +Tags are tidied on the server: lowercase, no leading `#`, no duplicates, at most 10 tags of at most 30 characters. + +### Removing + +The bin on an upload removes it from the library. It never breaks a scene: scenarios that already used the file keep it, and the stored file is only deleted when nothing uses it any more. + +## How it fits with scenario files + +A scenario's `UploadedFile` records are what scenes reference, and they carry the reference counts and orphan tracking. The library doesn't change that. It adds one more collection: + +- `Asset` is a library entry: name, type, tags, the stored file's `path` and `url`, and the `ownerUid`. +- Using an asset in a scenario (`POST /api/assets/:assetId/use`) creates an `UploadedFile` in that scenario pointing at the **same stored object**, with `assetId` set so it can be traced back. Using the same asset again in the same scenario returns the same file. + +So the editor, scene saving and reference counting treat a library upload exactly like a direct one. + +> [!WARNING] +> Several `UploadedFile` records (and the asset) can share one stored object. Whatever eventually deletes orphaned files from storage must not delete a `path` while any record still points at it. Removing an asset already checks this. + +## Theming + +The panel is built from the same daisyUI tokens and patterns as the other side panels (`bg-base-200`, `bg-base-300`, `btn`, `input`), so it follows `vps-dark` and `vps-light` without any theme-specific code. Things that will appear on the canvas are previewed on white, because the canvas is white in both themes. + +Preset buttons and icons end up inside scenes, which store colours as plain hex values and can't use CSS variables. So their colours are written out in `library/palette.js`, with the theme token each one mirrors. A test (`presets.test.js`) reads `index.css` and fails if the palette drifts from the theme, and another checks that button text stays readable on its fill. If the theme changes, update the palette and regenerate the images. + +## Changing the presets + +Buttons, icons and sounds are listed in `library/presets.js`. The images and sounds themselves are generated, not downloaded, so there's nothing to license: + +``` +node scripts/generate-library-assets.mjs +``` + +This draws every icon from simple shapes using the palette colours and writes `public/library/images/*.png` and `public/library/audio/*.wav`. It needs nothing but Node, and it refuses to run if `presets.js` and the script disagree about which files should exist. + +To add an icon: add an entry to `IMAGE_PRESETS`, draw it in `ICONS` in the script, run the script, and commit the new PNG. The tests check that every listed file exists, is a 512px PNG with transparency, and that tags are clean. + +## Not done yet + +- Renaming and re-tagging an upload from the panel. The API supports it (`PATCH /api/assets/:assetId`), there's just no UI for it yet. +- A shared pool that everyone can see. Uploads are private to the uploader on purpose, since they may contain student or patient material. +- Per-user storage limits.