From 9f241e14a49f6747e391856da715dac679b2e12f Mon Sep 17 00:00:00 2001 From: justinedagreat Date: Wed, 2 Sep 2026 12:23:34 +1200 Subject: [PATCH 01/22] =?UTF-8?q?chore(sidebar):=20accessibility=20?= =?UTF-8?q?=E2=80=94=20add=20aria-controls,=20region=20+=20labelledby=20id?= =?UTF-8?q?s=20to=20SidePanel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../authoring/CanvasSideBar/SidePanel.jsx | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 frontend/src/features/authoring/CanvasSideBar/SidePanel.jsx diff --git a/frontend/src/features/authoring/CanvasSideBar/SidePanel.jsx b/frontend/src/features/authoring/CanvasSideBar/SidePanel.jsx new file mode 100644 index 000000000..fa8214f47 --- /dev/null +++ b/frontend/src/features/authoring/CanvasSideBar/SidePanel.jsx @@ -0,0 +1,51 @@ +import { XIcon } from "lucide-react"; + +export default function SidePanel({ label, Icon, open, onToggle, children }) { + const safe = label + ? label.replace(/\s+/g, "-").replace(/[^a-zA-Z0-9-_]/g, "").toLowerCase() + : "panel"; + const panelId = `panel-${safe}`; + const titleId = `panel-title-${safe}`; + + return ( +
+ + + {open && ( +
+
+

+ {label} +

+ +
+ {children} +
+ )} +
+ ); +} From 318d8a8545073a894178aad8113f9afec6aa4edf Mon Sep 17 00:00:00 2001 From: justinedagreat Date: Wed, 2 Sep 2026 17:46:06 +1200 Subject: [PATCH 02/22] feat: Vps 186 change the side drop downs to icons (#518) * feat: show just icon and label in side panel * feat: add reusable authoring side panel * feat: implement side panel * feat: implement side panel to object property editor * feat: center sidebar icons vertically * feat: add animation * fix: prettier * fix: guard sidebar animation if API is unavailable * feat: expand canvas when sidebar panel is closed * fix: adjust icon sizes * feat: make icons seem clickable * fix: preserve keyboard tab order in side panels * fix: use useLayoutEffect as suggested by CodeRabbit * fix: refresh canvas sidebar position on panel changes * fix: adjust button size * fix: reorder side panel to natural order * restore side panel keyboard tab order # Conflicts: # frontend/src/features/authoring/CanvasSideBar/SidePanel.jsx * fix: prettier * fix: prettier * fix: restore side panel keyboard tab order * fix: fix animation when an icon is selected * fix: prettier * Fix CodeRabbit issues in PR #518 --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../Properties/PropertyBindingMenu.jsx | 39 +-- .../Properties/PropertyOperationMenu.jsx | 41 ++-- .../authoring/CanvasSideBar/CanvasSideBar.jsx | 99 +++++++- .../CanvasSideBar/ComponentSettings.jsx | 59 +++-- .../CanvasSideBar/ObjectPropertyEditor.jsx | 184 +++++++------- .../authoring/CanvasSideBar/SceneSettings.jsx | 230 +++++++++--------- .../authoring/CanvasSideBar/SidePanel.jsx | 9 +- .../features/authoring/audio/AudioManager.tsx | 96 ++++---- frontend/src/index.css | 10 + 9 files changed, 446 insertions(+), 321 deletions(-) diff --git a/frontend/src/components/Properties/PropertyBindingMenu.jsx b/frontend/src/components/Properties/PropertyBindingMenu.jsx index 5f60e673c..19af50af2 100644 --- a/frontend/src/components/Properties/PropertyBindingMenu.jsx +++ b/frontend/src/components/Properties/PropertyBindingMenu.jsx @@ -1,28 +1,32 @@ import { useState } from "react"; -import { PlusIcon } from "lucide-react"; +import { BracesIcon, PlusIcon } from "lucide-react"; import CreatePropertyBinding from "./CreatePropertyBinding"; import PropertyBinding from "./PropertyBinding"; +import SidePanel from "../../features/authoring/CanvasSideBar/SidePanel"; -export default function PropertyBindingMenu({ component }) { +export default function PropertyBindingMenu({ component, open, onToggle }) { const [createOpen, setCreateOpen] = useState(false); const bindings = component?.stateBindings ?? []; return ( <> -
- -
- Property Bindings - { - event.stopPropagation(); - setCreateOpen(true); - }} - /> + +
+
-
+
{bindings.map((binding, index) => ( ))} + {bindings.length === 0 && ( +

No property bindings yet.

+ )}
-
+
{ +const PropertyOperationMenu = ({ component, open, onToggle }) => { const [createOpen, setCreateOpen] = useState(false); const propertyOperations = component?.stateOperations ?? []; @@ -20,24 +21,24 @@ const PropertyOperationMenu = ({ component }) => { return ( <> -
- {hasPropertyOperations && } - -
- Property Operations - +
+
- - {hasPropertyOperations && ( -
+ {hasPropertyOperations ? ( +
{propertyOperations.map((operation, i) => ( { /> ))}
+ ) : ( +

No property operations yet.

)} -
+ state.selected); const component = useVisualScene((state) => selected ? state.components[selected] : null ); + + function togglePanel(panel) { + setActivePanel((current) => (current === panel ? null : panel)); + } + + useLayoutEffect(() => { + setActivePanel(null); + }, [selected, component?.id, component?.clickable]); + + //side panel animation + useLayoutEffect(() => { + const iconStack = iconStackRef.current; + if (!iconStack) return; + + const currentTop = iconStack.getBoundingClientRect().top; + const previousTop = previousStackTopRef.current; + const contextualIcons = contextualIconsRef.current; + const hasSelection = Boolean(component); + const selectionPresenceChanged = + previousSelectionPresenceRef.current !== null && + previousSelectionPresenceRef.current !== hasSelection; + const reduceMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)" + ).matches; + + if ( + !reduceMotion && + selectionPresenceChanged && + previousTop !== null && + previousTop !== currentTop && + typeof iconStack.animate === "function" + ) { + iconStack.animate( + [ + { transform: `translateY(${previousTop - currentTop}px)` }, + { transform: "translateY(0)" }, + ], + { duration: 250, easing: "ease-out" } + ); + } + + if ( + !reduceMotion && + selectionPresenceChanged && + hasSelection && + contextualIcons && + typeof contextualIcons.animate === "function" + ) { + contextualIcons.animate( + [ + { opacity: 0, transform: "translateY(12px)" }, + { opacity: 1, transform: "translateY(0)" }, + ], + { duration: 250, easing: "ease-out" } + ); + } + + previousStackTopRef.current = currentTop; + previousSelectionPresenceRef.current = hasSelection; + }, [selected, component?.clickable, activePanel]); + return ( -
- - - - {component && ( - - )} +
+
+ togglePanel("scene")} + /> + togglePanel("audio")} + /> + {component && ( +
+ +
+ )} +
); } diff --git a/frontend/src/features/authoring/CanvasSideBar/ComponentSettings.jsx b/frontend/src/features/authoring/CanvasSideBar/ComponentSettings.jsx index 194850827..c03d0f3f5 100644 --- a/frontend/src/features/authoring/CanvasSideBar/ComponentSettings.jsx +++ b/frontend/src/features/authoring/CanvasSideBar/ComponentSettings.jsx @@ -5,12 +5,19 @@ import { modifyComponentProp } from "../scene/operations/component"; import PropertyOperationMenu from "../../../components/Properties/PropertyOperationMenu"; import SelectInput from "../components/Select"; import PropertyBindingMenu from "../../../components/Properties/PropertyBindingMenu"; +import SidePanel from "./SidePanel"; +import { LinkIcon } from "lucide-react"; +import { ObjectPropertyEditor } from "./ObjectPropertyEditor"; /** * This component displays the properties the selected scene component * @component */ -export default function ComponentSettings({ component }) { +export default function ComponentSettings({ + component, + activePanel, + onTogglePanel, +}) { const { scenes } = useContext(SceneContext); const [value, setValue] = useState(component?.nextScene); @@ -28,28 +35,42 @@ export default function ComponentSettings({ component }) { return ( <> - + onTogglePanel("bindings")} + /> {component.clickable && ( <> -
- -
Link Details
-
-
- - s._id)} - value={value} - onChange={saveLink} - display={(v) => scenes.find((s) => s._id === v)?.name} - /> -
-
-
- + onTogglePanel("link")} + > +
+ + s._id)} + value={value} + onChange={saveLink} + display={(v) => scenes.find((s) => s._id === v)?.name} + /> +
+
+ onTogglePanel("operations")} + /> )} + onTogglePanel("object-properties")} + /> ); } diff --git a/frontend/src/features/authoring/CanvasSideBar/ObjectPropertyEditor.jsx b/frontend/src/features/authoring/CanvasSideBar/ObjectPropertyEditor.jsx index 623330a1a..75e74fa97 100644 --- a/frontend/src/features/authoring/CanvasSideBar/ObjectPropertyEditor.jsx +++ b/frontend/src/features/authoring/CanvasSideBar/ObjectPropertyEditor.jsx @@ -1,9 +1,10 @@ import { getBoxCenter, translate, correct } from "../../authoring/util"; import { useEffect, useState, useRef } from "react"; import { modifyComponentProp } from "../scene/operations/component"; -import { FlipHorizontal2, FlipVertical2 } from "lucide-react"; +import { BoxIcon, FlipHorizontal2, FlipVertical2 } from "lucide-react"; +import SidePanel from "./SidePanel"; -export function ObjectPropertyEditor({ component }) { +export function ObjectPropertyEditor({ component, open, onToggle }) { // x and y vals used for setting and current const [inputX, setInputX] = useState( Math.round(component.bounds.verts[0].x * 100) / 100 @@ -171,97 +172,96 @@ export function ObjectPropertyEditor({ component }) { } return ( - <> -
- -
Object Properties
-
-
- {/* Width and Height num inputs*/} - - - - -
- { - saveProp(e.target.value, "width", setInputWidth); - }} - /> - { - saveProp(e.target.value, "height", setInputHeight); - }} - /> -
+ +
+ {/* Width and Height num inputs*/} + + + + +
+ { + saveProp(e.target.value, "width", setInputWidth); + }} + /> + { + saveProp(e.target.value, "height", setInputHeight); + }} + /> +
- {/* positoin x and y num inputs*/} - - - - -
- { - saveProp(e.target.value, "x", setInputX); - }} - // onBlur={(e) => { - // noFields(e.target.value, "x", setInputX); - // }} - /> - { - saveProp(e.target.value, "y", setInputY); - }} - // onBlur={(e) => { - // noFields(e.target.value, "y", setInputY); - // }} - /> -
- -
- - saveProp(e.target.value, "rotation", setInputAngle) - } - // onBlur={(e) => { - // noFields(e.target.value, "rotation", setInputAngle); - // }} - /> -
- - -
-
-
+ {/* positoin x and y num inputs*/} + + + + +
+ { + saveProp(e.target.value, "x", setInputX); + }} + // onBlur={(e) => { + // noFields(e.target.value, "x", setInputX); + // }} + /> + { + saveProp(e.target.value, "y", setInputY); + }} + // onBlur={(e) => { + // noFields(e.target.value, "y", setInputY); + // }} + /> +
+ +
+ + saveProp(e.target.value, "rotation", setInputAngle) + } + // onBlur={(e) => { + // noFields(e.target.value, "rotation", setInputAngle); + // }} + /> +
+ + +
-
- + + ); } diff --git a/frontend/src/features/authoring/CanvasSideBar/SceneSettings.jsx b/frontend/src/features/authoring/CanvasSideBar/SceneSettings.jsx index aab4b0e24..b35159be4 100644 --- a/frontend/src/features/authoring/CanvasSideBar/SceneSettings.jsx +++ b/frontend/src/features/authoring/CanvasSideBar/SceneSettings.jsx @@ -1,5 +1,5 @@ import { useContext, useState, useEffect } from "react"; -import { Check, ChevronDown } from "lucide-react"; +import { Check, ChevronDown, MonitorCog } from "lucide-react"; import ScenarioContext from "context/ScenarioContext"; import SceneContext from "context/SceneContext"; import { generateUniqueSceneName } from "../../../utils/sceneUtils"; @@ -12,12 +12,13 @@ import shallow from "zustand/shallow"; import toast from "react-hot-toast"; import TimerPropertyOperationMenu from "../../../components/Properties/TimerPropertyOperationMenu"; import SelectInput from "../components/Select"; +import SidePanel from "./SidePanel"; /** * This component displays the settings of a scene, such as the scene name * @component */ -export default function SceneSettings() { +export default function SceneSettings({ open, onToggle }) { const { scenes, modifyScene } = useContext(SceneContext); const { roleList } = useContext(ScenarioContext); @@ -100,123 +101,124 @@ export default function SceneSettings() { return ( <> -
- -
Scene Details
-
-
- - - + +
+ + + + setTimerDuration(e.target.value)} + onBlur={saveTimerDuration} + className="input" + placeholder="No timer" + /> + {time > 0 && } + +
+
+ + {selectedRoles?.join(", ") || "All"} + + +
+ +
+
-
-
+ )} + + ⓘ + + + scene._id !== sceneId) + .map((scene) => scene._id) ?? [] + } + display={(targetId) => + scenes?.find((scene) => scene._id === targetId)?.name ?? + "Unknown scene" + } + onChange={(targetId) => + modifySceneProp("directLink", targetId || null) + } + /> + + ); } diff --git a/frontend/src/features/authoring/CanvasSideBar/SidePanel.jsx b/frontend/src/features/authoring/CanvasSideBar/SidePanel.jsx index fa8214f47..4049b3ea4 100644 --- a/frontend/src/features/authoring/CanvasSideBar/SidePanel.jsx +++ b/frontend/src/features/authoring/CanvasSideBar/SidePanel.jsx @@ -2,7 +2,10 @@ import { XIcon } from "lucide-react"; export default function SidePanel({ label, Icon, open, onToggle, children }) { const safe = label - ? label.replace(/\s+/g, "-").replace(/[^a-zA-Z0-9-_]/g, "").toLowerCase() + ? label + .replace(/\s+/g, "-") + .replace(/[^a-zA-Z0-9-_]/g, "") + .toLowerCase() : "panel"; const panelId = `panel-${safe}`; const titleId = `panel-title-${safe}`; @@ -11,7 +14,7 @@ export default function SidePanel({ label, Icon, open, onToggle, children }) {
- -
  • - -
  • - -
    +
    + +
    - - - {hasAudios && ( -
    - {audios.map((audio) => ( - - ))} -
    + {audios.length > 0 ? ( + audios.map((audio) => ( + + )) + ) : ( +

    No audio elements yet.

    )} -
    + + + diff --git a/frontend/src/index.css b/frontend/src/index.css index 99d377266..2dd377bd4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,6 +1,16 @@ @import "tailwindcss"; @plugin "daisyui"; +@keyframes side-panel-slide-in { + from { + transform: translateX(100%); + } + + to { + transform: translateX(0); + } +} + @theme { --font-ibm: "IBMPlexSans", sans-serif; --font-dm: "DMSans", sans-serif; From a5c7d19001f3f72834ce8b7241ec3ec3f154edee Mon Sep 17 00:00:00 2001 From: del Date: Sat, 19 Sep 2026 15:05:06 +1200 Subject: [PATCH 03/22] feat: "seen" resources map on user model --- backend/src/db/daos/userDao.js | 31 +++++++++++++++++++++++++++++++ backend/src/db/models/user.js | 6 ++++++ 2 files changed, 37 insertions(+) diff --git a/backend/src/db/daos/userDao.js b/backend/src/db/daos/userDao.js index 83e654702..bb9520b8f 100644 --- a/backend/src/db/daos/userDao.js +++ b/backend/src/db/daos/userDao.js @@ -110,3 +110,34 @@ export const setUserProperties = async (userId, scenarioId, properties) => { user.stateVersions.get(scenarioId), ]; }; + +/** + * Retrieves the ids of resources a user has seen in a scenario. + * + * @param {string} uid - Firebase user ID. + * @param {string} scenarioId - Scenario ID the "seen" list belongs to. + * @returns {Promise} "Seen" resource IDs (empty = none). + */ +export const getSeenResources = async (uid, scenarioId) => { + const user = await User.findOne({ uid }, { seenResources: 1 }); + if (!user) throw new HttpError("user not found", 404); + return user.seenResources?.get(scenarioId) ?? []; +}; + +/** + * Replaces resource ids a user has seen within a scenario. + * + * @param {string} uid - Firebase user ID. + * @param {string} scenarioId - Scenario ID the "seen" list belongs to. + * @param {string[]} resourceIds - Full list of "seen" resource IDs. + * @returns {Promise} Persisted seen resource IDs. + */ +export const setSeenResources = async (uid, scenarioId, resourceIds) => { + const user = await User.findOneAndUpdate( + { uid }, + { $set: { [`seenResources.${scenarioId}`]: resourceIds } }, + { new: true, projection: { seenResources: 1 } } + ); + if (!user) throw new HttpError("user not found", 404); + return user.seenResources.get(scenarioId); +}; diff --git a/backend/src/db/models/user.js b/backend/src/db/models/user.js index ce644d9c6..feb96f2bc 100644 --- a/backend/src/db/models/user.js +++ b/backend/src/db/models/user.js @@ -48,6 +48,12 @@ const userSchema = new Schema({ of: { type: Number, default: 0 }, default: {}, }, + // Map of scenarioId to "seen" resource ids + seenResources: { + type: Map, + of: [String], + default: {}, + }, }); const User = mongoose.model("model", userSchema, "users"); From 98ffdae57f3d1447b023569268a76c078a8087ad Mon Sep 17 00:00:00 2001 From: del Date: Sat, 19 Sep 2026 15:06:38 +1200 Subject: [PATCH 04/22] feat: endpoints for "seen" resource --- backend/src/routes/api/user.js | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/backend/src/routes/api/user.js b/backend/src/routes/api/user.js index d341a288e..19f2fe592 100644 --- a/backend/src/routes/api/user.js +++ b/backend/src/routes/api/user.js @@ -3,6 +3,8 @@ import { retrieveUserByEmail, createUser, assignScenarioToUsers, + getSeenResources, + setSeenResources, } from "../../db/daos/userDao.js"; import User from "../../db/models/user.js"; import Group from "../../db/models/group.js"; @@ -11,6 +13,7 @@ import auth from "../../middleware/firebaseAuth.js"; import STATUS from "../../util/status.js"; import { handle, HttpError } from "../../util/error.js"; import scenarioAuth from "../../middleware/scenarioAuth.js"; +import { isValidObjectId } from "../../util/validation.js"; const router = Router(); @@ -64,6 +67,37 @@ router.get( }) ); +//get resource ids that user has seen +router.get( + "/seen-resources/:scenarioId", + handle(async (req, res) => { + const { scenarioId } = req.params; + const { uid } = req.body; + const seenResources = await getSeenResources(uid, scenarioId); + return res.status(STATUS.OK).json({ seenResources }); + }) +); + +//replace resource ids the user has seen +router.put( + "/seen-resources/:scenarioId", + handle(async (req, res) => { + const { scenarioId } = req.params; + const { uid, resourceIds } = req.body; + + if (!Array.isArray(resourceIds) || !resourceIds.every(isValidObjectId)) + throw new HttpError( + "resourceIds must be an array of resource ids", + STATUS.BAD_REQUEST + ); + + const seenResources = await setSeenResources(uid, scenarioId, [ + ...new Set(resourceIds), + ]); + return res.status(STATUS.OK).json({ seenResources }); + }) +); + // NOTE: not currently used, but associated ui functionality will be added // assign scenario to users From 534a820f87ed9496b11f6fe54373689654f7efbc Mon Sep 17 00:00:00 2001 From: del Date: Sat, 19 Sep 2026 15:35:49 +1200 Subject: [PATCH 05/22] refactor: atomic add/remove "seen" resource ids w patch --- backend/src/db/daos/userDao.js | 28 +++++++++++++--------- backend/src/routes/api/user.js | 44 +++++++++++++++++++++++++++------- 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/backend/src/db/daos/userDao.js b/backend/src/db/daos/userDao.js index bb9520b8f..916dfc6b1 100644 --- a/backend/src/db/daos/userDao.js +++ b/backend/src/db/daos/userDao.js @@ -121,23 +121,29 @@ export const setUserProperties = async (userId, scenarioId, properties) => { export const getSeenResources = async (uid, scenarioId) => { const user = await User.findOne({ uid }, { seenResources: 1 }); if (!user) throw new HttpError("user not found", 404); - return user.seenResources?.get(scenarioId) ?? []; + return user.seenResources.get(scenarioId) ?? []; }; /** - * Replaces resource ids a user has seen within a scenario. + * Atomically add/remove seen resource IDs in a scenario. * * @param {string} uid - Firebase user ID. - * @param {string} scenarioId - Scenario ID the "seen" list belongs to. - * @param {string[]} resourceIds - Full list of "seen" resource IDs. + * @param {string} scenarioId - Scenario ID. + * @param {{ add?: string[], remove?: string[] }} operation * @returns {Promise} Persisted seen resource IDs. */ -export const setSeenResources = async (uid, scenarioId, resourceIds) => { - const user = await User.findOneAndUpdate( - { uid }, - { $set: { [`seenResources.${scenarioId}`]: resourceIds } }, - { new: true, projection: { seenResources: 1 } } - ); +export const updateSeenResources = async (uid, scenarioId, operation) => { + const path = `seenResources.${scenarioId}`; + const update = operation.add + ? { $addToSet: { [path]: { $each: operation.add } } } + : { $pull: { [path]: { $in: operation.remove } } }; + + const user = await User.findOneAndUpdate({ uid }, update, { + new: true, + projection: { seenResources: 1 }, + }); + if (!user) throw new HttpError("user not found", 404); - return user.seenResources.get(scenarioId); + + return user.seenResources.get(scenarioId) ?? []; }; diff --git a/backend/src/routes/api/user.js b/backend/src/routes/api/user.js index 19f2fe592..f9a6016ab 100644 --- a/backend/src/routes/api/user.js +++ b/backend/src/routes/api/user.js @@ -4,7 +4,7 @@ import { createUser, assignScenarioToUsers, getSeenResources, - setSeenResources, + updateSeenResources, } from "../../db/daos/userDao.js"; import User from "../../db/models/user.js"; import Group from "../../db/models/group.js"; @@ -73,27 +73,53 @@ router.get( handle(async (req, res) => { const { scenarioId } = req.params; const { uid } = req.body; + + if (!isValidObjectId(scenarioId)) { + throw new HttpError("Invalid scenario ID", STATUS.BAD_REQUEST); + } + const seenResources = await getSeenResources(uid, scenarioId); + return res.status(STATUS.OK).json({ seenResources }); }) ); -//replace resource ids the user has seen -router.put( +//add or remove resource ids the user has seen +router.patch( "/seen-resources/:scenarioId", handle(async (req, res) => { const { scenarioId } = req.params; - const { uid, resourceIds } = req.body; + const { uid, add, remove } = req.body; - if (!Array.isArray(resourceIds) || !resourceIds.every(isValidObjectId)) + if (!isValidObjectId(scenarioId)) { + throw new HttpError("Invalid scenario ID", STATUS.BAD_REQUEST); + } + + const hasAdd = add !== undefined; + const hasRemove = remove !== undefined; + + if (hasAdd === hasRemove) { throw new HttpError( - "resourceIds must be an array of resource ids", + "Provide just one (add or remove)", STATUS.BAD_REQUEST ); + } + + const resourceIds = hasAdd ? add : remove; + + if ( + !Array.isArray(resourceIds) || + !resourceIds.every((id) => typeof id === "string" && isValidObjectId(id)) + ) { + throw new HttpError( + "Resource IDs must be a valid ID string array", + STATUS.BAD_REQUEST + ); + } + + const op = hasAdd ? { add: resourceIds } : { remove: resourceIds }; + const seenResources = await updateSeenResources(uid, scenarioId, op); - const seenResources = await setSeenResources(uid, scenarioId, [ - ...new Set(resourceIds), - ]); return res.status(STATUS.OK).json({ seenResources }); }) ); From 4b5be57dcaa0c34ba646f565cf910c55ec8fb8b5 Mon Sep 17 00:00:00 2001 From: del Date: Sat, 19 Sep 2026 15:37:09 +1200 Subject: [PATCH 06/22] feat: frontend hook + resource helpers --- frontend/src/features/resources/seen.js | 26 +++++++++ .../features/resources/useSeenResources.js | 56 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 frontend/src/features/resources/seen.js create mode 100644 frontend/src/features/resources/useSeenResources.js diff --git a/frontend/src/features/resources/seen.js b/frontend/src/features/resources/seen.js new file mode 100644 index 000000000..c1af83a17 --- /dev/null +++ b/frontend/src/features/resources/seen.js @@ -0,0 +1,26 @@ +//helpers that derive "new" resources from visible tree + "seen" list +//collection is new if any of its children are new + +export function collectVisibleFileIds(tree) { + const ids = []; + for (const resource of tree) { + if (resource.type === "collection") { + for (const child of resource.children) ids.push(child._id); + } else { + ids.push(resource._id); + } + } + return ids; +} + +export function getUnseenIds(visibleIds, seenIds) { + const seen = new Set(seenIds); + return visibleIds.filter((id) => !seen.has(id)); +} + +//prune "seen" for no-longer viewable resources +//so that users are notified if they become available again +export function getStaleSeenIds(visibleIds, seenIds) { + const visible = new Set(visibleIds); + return seenIds.filter((id) => !visible.has(id)); +} diff --git a/frontend/src/features/resources/useSeenResources.js b/frontend/src/features/resources/useSeenResources.js new file mode 100644 index 000000000..e46a9bf57 --- /dev/null +++ b/frontend/src/features/resources/useSeenResources.js @@ -0,0 +1,56 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useContext } from "react"; +import { useParams } from "react-router-dom"; +import AuthenticationContext from "../../context/AuthenticationContext"; +import { api, handleGeneric } from "../../util/api"; + +async function getSeenResources(user, scenarioId) { + const res = await api.get(user, `/api/user/seen-resources/${scenarioId}`); + return res.data.seenResources; +} + +async function updateSeenResources(user, scenarioId, operation) { + const res = await api.patch( + user, + `/api/user/seen-resources/${scenarioId}`, + operation + ); + return res.data.seenResources; +} + +export function useSeenResources() { + const { scenarioId } = useParams(); + const { user } = useContext(AuthenticationContext); + const queryClient = useQueryClient(); + const queryKey = ["seenResources", user.uid, scenarioId]; + + const seenQuery = useQuery({ + queryKey, + queryFn: () => getSeenResources(user, scenarioId), + }); + + const { mutate } = useMutation({ + mutationFn: (operation) => updateSeenResources(user, scenarioId, operation), + onError: handleGeneric, + onSettled: () => queryClient.invalidateQueries(queryKey), + }); + + const seenIds = seenQuery.data ?? []; + + const markSeen = (resourceId) => { + if (seenIds.includes(resourceId)) return; + mutate({ add: [resourceId] }); + }; + + const unmarkSeen = (resourceIds) => { + if (resourceIds.length === 0) return; + mutate({ remove: resourceIds }); + }; + + return { + seenIds, + isLoaded: seenQuery.isSuccess, + markSeen, + unmarkSeen, + }; +} From 8ea0713ba01e3dc170b8b52af321eb273971da7f Mon Sep 17 00:00:00 2001 From: del Date: Sat, 19 Sep 2026 16:17:08 +1200 Subject: [PATCH 07/22] feat: hook derives new resources, toasts, prunes --- .../resources/useResourceVisibility.js | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 frontend/src/features/resources/useResourceVisibility.js diff --git a/frontend/src/features/resources/useResourceVisibility.js b/frontend/src/features/resources/useResourceVisibility.js new file mode 100644 index 000000000..b45bfc726 --- /dev/null +++ b/frontend/src/features/resources/useResourceVisibility.js @@ -0,0 +1,48 @@ +import { useEffect, useMemo, useRef } from "react"; +import toast from "react-hot-toast"; +import { filterTreeByConditions } from "../../utils/propertyConditionalEvaluator"; +import { buildResourceTree } from "./util"; +import { useResources } from "./useResources"; +import { useSeenResources } from "./useSeenResources"; +import { collectVisibleFileIds, getStaleSeenIds, getUnseenIds } from "./seen"; + +export function useResourceVisibility(properties, enabled) { + const { resourcesQuery } = useResources(); + const { seenIds, isLoaded, markSeen, unmarkSeen } = useSeenResources(); + const lastVisibleKey = useRef(null); + const ready = enabled && isLoaded && resourcesQuery.isSuccess; + + const filteredTree = useMemo( + () => + filterTreeByConditions( + buildResourceTree(resourcesQuery.data ?? []), + properties + ), + [resourcesQuery.data, properties] + ); + const visibleIds = useMemo( + () => collectVisibleFileIds(filteredTree), + [filteredTree] + ); + const unseenIds = useMemo( + () => (ready ? getUnseenIds(visibleIds, seenIds) : []), + [ready, visibleIds, seenIds] + ); + const visibleKey = [...visibleIds].sort().join(","); + + //only toast + prune when visible resources set changes + useEffect(() => { + if (!ready || lastVisibleKey.current === visibleKey) return; + lastVisibleKey.current = visibleKey; + unmarkSeen(getStaleSeenIds(visibleIds, seenIds)); + + const count = unseenIds.length; + if (count > 0) { + toast( + `You have ${count} new resource${count === 1 ? "" : "s"} available` + ); + } + }, [ready, visibleKey]); + + return { filteredTree, unseenIds, markSeen, resourcesQuery }; +} From 62b9632ea8d0be2b6369f7fdef30b82c4929936f Mon Sep 17 00:00:00 2001 From: del Date: Sat, 19 Sep 2026 16:19:52 +1200 Subject: [PATCH 08/22] refactor: tree/query derived from playpage to avoid repeat calc --- .../playScenario/PlayScenarioPage.jsx | 9 +++- .../features/resources/ResourcesOverlay.jsx | 41 +++++++++---------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/frontend/src/features/playScenario/PlayScenarioPage.jsx b/frontend/src/features/playScenario/PlayScenarioPage.jsx index 4415861e5..6efa8105e 100644 --- a/frontend/src/features/playScenario/PlayScenarioPage.jsx +++ b/frontend/src/features/playScenario/PlayScenarioPage.jsx @@ -19,6 +19,7 @@ import { VolumeOffIcon, } from "lucide-react"; import ResourcesPanel from "../resources/ResourcesOverlay"; +import { useResourceVisibility } from "../resources/useResourceVisibility"; const sceneCache = new Map(); @@ -172,6 +173,8 @@ export default function PlayScenarioPage({ group }) { const audioRefs = useRef([]); const currScene = sceneCache.get(sceneId); + const { filteredTree, unseenIds, markSeen, resourcesQuery } = + useResourceVisibility(properties, Boolean(currScene)); const handleError = async (error) => { if (!error) return; @@ -501,8 +504,10 @@ export default function PlayScenarioPage({ group }) { /> )} setResourcesOpen(false)} /> diff --git a/frontend/src/features/resources/ResourcesOverlay.jsx b/frontend/src/features/resources/ResourcesOverlay.jsx index 3e3d096a8..3a821aaf6 100644 --- a/frontend/src/features/resources/ResourcesOverlay.jsx +++ b/frontend/src/features/resources/ResourcesOverlay.jsx @@ -1,10 +1,7 @@ import React, { useEffect, useState } from "react"; import ResourceTree from "./ResourceTree"; import { FileTextIcon, SearchIcon, XIcon } from "lucide-react"; -import { useResources } from "./useResources"; -import { findById } from "../../util/search"; -import { buildResourceTree, filterTreeBySearch, normaliseFile } from "./util"; -import { filterTreeByConditions } from "../../utils/propertyConditionalEvaluator"; +import { filterTreeBySearch } from "./util"; import SkeletonBody from "./ResourcesSkeleton"; import ResourcePreview from "./ResourcePreview"; import PanelOverlay from "../../components/PanelOverlay"; @@ -12,9 +9,15 @@ import PanelOverlay from "../../components/PanelOverlay"; // NOTE: property filters can't change while the resources panel is // open, so deselecting on resource hiding isn't a concern -export default function ResourcesPanel({ properties, open, onClose }) { - const { resourcesQuery } = useResources(); - +export default function ResourcesPanel({ + tree, + unseenIds, + markSeen, + resourcesQuery, + open, + onClose, +}) { + const { isLoading, isError, error } = resourcesQuery; const [selectedResourceId, setSelectedResourceId] = useState(null); const [search, setSearch] = useState(""); @@ -28,20 +31,12 @@ export default function ResourcesPanel({ properties, open, onClose }) { return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); - const { data, isLoading, isError, error } = resourcesQuery; - - const foundResource = findById(data, selectedResourceId); - const selectedResource = foundResource ? normaliseFile(foundResource) : null; - - // NOTE: the filtering by properties should ideally be done on the - // server to prevent cheating, but here we filter before rendering - - const resourceTree = buildResourceTree(data ?? []); - const conditionFilteredTree = filterTreeByConditions( - resourceTree, - properties - ); - const filteredTree = filterTreeBySearch(conditionFilteredTree, search); + const selectedResource = tree + .flatMap((resource) => + resource.type === "collection" ? resource.children : [resource] + ) + .find((resource) => resource._id === selectedResourceId); + const filteredTree = filterTreeBySearch(tree, search); return ( <> @@ -80,7 +75,7 @@ export default function ResourcesPanel({ properties, open, onClose }) { {error.message}
    - ) : !conditionFilteredTree?.length ? ( + ) : !tree.length ? (

    @@ -116,6 +111,8 @@ export default function ResourcesPanel({ properties, open, onClose }) { )} From a2da56b77401a692f3016d81e3b981a3b864f4ad Mon Sep 17 00:00:00 2001 From: del Date: Sat, 19 Sep 2026 16:20:50 +1200 Subject: [PATCH 09/22] feat: "new" marker/notif for unopened resources --- .../playScenario/PlayScenarioPage.jsx | 13 +++++-- .../src/features/resources/ResourceTree.jsx | 36 +++++++++++++++---- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/frontend/src/features/playScenario/PlayScenarioPage.jsx b/frontend/src/features/playScenario/PlayScenarioPage.jsx index 6efa8105e..5154b993a 100644 --- a/frontend/src/features/playScenario/PlayScenarioPage.jsx +++ b/frontend/src/features/playScenario/PlayScenarioPage.jsx @@ -481,12 +481,21 @@ export default function PlayScenarioPage({ group }) { )}

    diff --git a/frontend/src/features/resources/ResourceTree.jsx b/frontend/src/features/resources/ResourceTree.jsx index 6f7378b69..6b4b804e8 100644 --- a/frontend/src/features/resources/ResourceTree.jsx +++ b/frontend/src/features/resources/ResourceTree.jsx @@ -2,9 +2,18 @@ import React from "react"; export default function ResourceTree({ tree, + unseenIds, + markSeen, selectedResourceId, setSelectedResourceId, }) { + const unseen = new Set(unseenIds); + + const selectResource = (id) => { + setSelectedResourceId(id); + markSeen(id); + }; + return ( <> {tree.map((resource) => ( @@ -15,6 +24,11 @@ export default function ResourceTree({ {resource.name} + {resource.children.some((child) => unseen.has(child._id)) && ( + + New + + )}
      @@ -28,11 +42,16 @@ export default function ResourceTree({ >
    @@ -45,11 +64,16 @@ export default function ResourceTree({ >
    )} From cfa74138e77cab83aa952322c3d6cbf07586daa3 Mon Sep 17 00:00:00 2001 From: Del Huang Date: Sat, 26 Sep 2026 03:27:00 +1200 Subject: [PATCH 10/22] fix: clicking same resource twice only sends one seen req --- .../src/features/resources/useSeenResources.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/resources/useSeenResources.js b/frontend/src/features/resources/useSeenResources.js index e46a9bf57..a3af677b5 100644 --- a/frontend/src/features/resources/useSeenResources.js +++ b/frontend/src/features/resources/useSeenResources.js @@ -31,7 +31,22 @@ export function useSeenResources() { const { mutate } = useMutation({ mutationFn: (operation) => updateSeenResources(user, scenarioId, operation), - onError: handleGeneric, + onMutate: async ({ add, remove }) => { + await queryClient.cancelQueries(queryKey); + const previous = queryClient.getQueryData(queryKey); + queryClient.setQueryData(queryKey, (prev = []) => + add + ? [...new Set([...prev, ...add])] + : prev.filter((id) => !remove.includes(id)) + ); + return { previous }; + }, + onError: (e, _, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKey, context.previous); + } + handleGeneric(e); + }, onSettled: () => queryClient.invalidateQueries(queryKey), }); From f4e04f40de366ebbe0829ceab2410834d3a53138 Mon Sep 17 00:00:00 2001 From: Del Huang Date: Sat, 26 Sep 2026 03:43:57 +1200 Subject: [PATCH 11/22] refactor: remove "seen" prune when condition no longer being met --- backend/src/db/daos/userDao.js | 20 +++++-------- backend/src/routes/api/user.js | 23 ++++----------- frontend/src/features/resources/seen.js | 10 +------ .../resources/useResourceVisibility.js | 7 ++--- .../features/resources/useSeenResources.js | 29 ++++++------------- 5 files changed, 26 insertions(+), 63 deletions(-) diff --git a/backend/src/db/daos/userDao.js b/backend/src/db/daos/userDao.js index 916dfc6b1..8867a4882 100644 --- a/backend/src/db/daos/userDao.js +++ b/backend/src/db/daos/userDao.js @@ -125,23 +125,19 @@ export const getSeenResources = async (uid, scenarioId) => { }; /** - * Atomically add/remove seen resource IDs in a scenario. + * Atomically add seen resource IDs in a scenario. * * @param {string} uid - Firebase user ID. * @param {string} scenarioId - Scenario ID. - * @param {{ add?: string[], remove?: string[] }} operation + * @param {string[]} resourceIds - Resource IDs to mark as seen. * @returns {Promise} Persisted seen resource IDs. */ -export const updateSeenResources = async (uid, scenarioId, operation) => { - const path = `seenResources.${scenarioId}`; - const update = operation.add - ? { $addToSet: { [path]: { $each: operation.add } } } - : { $pull: { [path]: { $in: operation.remove } } }; - - const user = await User.findOneAndUpdate({ uid }, update, { - new: true, - projection: { seenResources: 1 }, - }); +export const addSeenResources = async (uid, scenarioId, resourceIds) => { + const user = await User.findOneAndUpdate( + { uid }, + { $addToSet: { [`seenResources.${scenarioId}`]: { $each: resourceIds } } }, + { new: true, projection: { seenResources: 1 } } + ); if (!user) throw new HttpError("user not found", 404); diff --git a/backend/src/routes/api/user.js b/backend/src/routes/api/user.js index f9a6016ab..3c429da15 100644 --- a/backend/src/routes/api/user.js +++ b/backend/src/routes/api/user.js @@ -4,7 +4,7 @@ import { createUser, assignScenarioToUsers, getSeenResources, - updateSeenResources, + addSeenResources, } from "../../db/daos/userDao.js"; import User from "../../db/models/user.js"; import Group from "../../db/models/group.js"; @@ -67,7 +67,7 @@ router.get( }) ); -//get resource ids that user has seen +// get resource ids that user has seen router.get( "/seen-resources/:scenarioId", handle(async (req, res) => { @@ -84,29 +84,17 @@ router.get( }) ); -//add or remove resource ids the user has seen +// mark resource ids as seen by the user router.patch( "/seen-resources/:scenarioId", handle(async (req, res) => { const { scenarioId } = req.params; - const { uid, add, remove } = req.body; + const { uid, resourceIds } = req.body; if (!isValidObjectId(scenarioId)) { throw new HttpError("Invalid scenario ID", STATUS.BAD_REQUEST); } - const hasAdd = add !== undefined; - const hasRemove = remove !== undefined; - - if (hasAdd === hasRemove) { - throw new HttpError( - "Provide just one (add or remove)", - STATUS.BAD_REQUEST - ); - } - - const resourceIds = hasAdd ? add : remove; - if ( !Array.isArray(resourceIds) || !resourceIds.every((id) => typeof id === "string" && isValidObjectId(id)) @@ -117,8 +105,7 @@ router.patch( ); } - const op = hasAdd ? { add: resourceIds } : { remove: resourceIds }; - const seenResources = await updateSeenResources(uid, scenarioId, op); + const seenResources = await addSeenResources(uid, scenarioId, resourceIds); return res.status(STATUS.OK).json({ seenResources }); }) diff --git a/frontend/src/features/resources/seen.js b/frontend/src/features/resources/seen.js index c1af83a17..e51072b2c 100644 --- a/frontend/src/features/resources/seen.js +++ b/frontend/src/features/resources/seen.js @@ -1,5 +1,4 @@ -//helpers that derive "new" resources from visible tree + "seen" list -//collection is new if any of its children are new +// helpers that derive "new" resources from visible tree + "seen" list export function collectVisibleFileIds(tree) { const ids = []; @@ -17,10 +16,3 @@ export function getUnseenIds(visibleIds, seenIds) { const seen = new Set(seenIds); return visibleIds.filter((id) => !seen.has(id)); } - -//prune "seen" for no-longer viewable resources -//so that users are notified if they become available again -export function getStaleSeenIds(visibleIds, seenIds) { - const visible = new Set(visibleIds); - return seenIds.filter((id) => !visible.has(id)); -} diff --git a/frontend/src/features/resources/useResourceVisibility.js b/frontend/src/features/resources/useResourceVisibility.js index b45bfc726..cc1fcf89a 100644 --- a/frontend/src/features/resources/useResourceVisibility.js +++ b/frontend/src/features/resources/useResourceVisibility.js @@ -4,11 +4,11 @@ import { filterTreeByConditions } from "../../utils/propertyConditionalEvaluator import { buildResourceTree } from "./util"; import { useResources } from "./useResources"; import { useSeenResources } from "./useSeenResources"; -import { collectVisibleFileIds, getStaleSeenIds, getUnseenIds } from "./seen"; +import { collectVisibleFileIds, getUnseenIds } from "./seen"; export function useResourceVisibility(properties, enabled) { const { resourcesQuery } = useResources(); - const { seenIds, isLoaded, markSeen, unmarkSeen } = useSeenResources(); + const { seenIds, isLoaded, markSeen } = useSeenResources(); const lastVisibleKey = useRef(null); const ready = enabled && isLoaded && resourcesQuery.isSuccess; @@ -30,11 +30,10 @@ export function useResourceVisibility(properties, enabled) { ); const visibleKey = [...visibleIds].sort().join(","); - //only toast + prune when visible resources set changes + // only toast when visible resources set changes useEffect(() => { if (!ready || lastVisibleKey.current === visibleKey) return; lastVisibleKey.current = visibleKey; - unmarkSeen(getStaleSeenIds(visibleIds, seenIds)); const count = unseenIds.length; if (count > 0) { diff --git a/frontend/src/features/resources/useSeenResources.js b/frontend/src/features/resources/useSeenResources.js index a3af677b5..a0dcc70f7 100644 --- a/frontend/src/features/resources/useSeenResources.js +++ b/frontend/src/features/resources/useSeenResources.js @@ -9,12 +9,10 @@ async function getSeenResources(user, scenarioId) { return res.data.seenResources; } -async function updateSeenResources(user, scenarioId, operation) { - const res = await api.patch( - user, - `/api/user/seen-resources/${scenarioId}`, - operation - ); +async function addSeenResources(user, scenarioId, resourceIds) { + const res = await api.patch(user, `/api/user/seen-resources/${scenarioId}`, { + resourceIds, + }); return res.data.seenResources; } @@ -30,15 +28,12 @@ export function useSeenResources() { }); const { mutate } = useMutation({ - mutationFn: (operation) => updateSeenResources(user, scenarioId, operation), - onMutate: async ({ add, remove }) => { + mutationFn: (resourceId) => + addSeenResources(user, scenarioId, [resourceId]), + onMutate: async (resourceId) => { await queryClient.cancelQueries(queryKey); const previous = queryClient.getQueryData(queryKey); - queryClient.setQueryData(queryKey, (prev = []) => - add - ? [...new Set([...prev, ...add])] - : prev.filter((id) => !remove.includes(id)) - ); + queryClient.setQueryData(queryKey, (prev = []) => [...prev, resourceId]); return { previous }; }, onError: (e, _, context) => { @@ -54,18 +49,12 @@ export function useSeenResources() { const markSeen = (resourceId) => { if (seenIds.includes(resourceId)) return; - mutate({ add: [resourceId] }); - }; - - const unmarkSeen = (resourceIds) => { - if (resourceIds.length === 0) return; - mutate({ remove: resourceIds }); + mutate(resourceId); }; return { seenIds, isLoaded: seenQuery.isSuccess, markSeen, - unmarkSeen, }; } From e19ccc57b424953d44e24157000527924073c000 Mon Sep 17 00:00:00 2001 From: Del Huang Date: Sat, 26 Sep 2026 03:48:16 +1200 Subject: [PATCH 12/22] fix: toast announces the same resource repeatedly --- .../features/resources/useResourceVisibility.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/resources/useResourceVisibility.js b/frontend/src/features/resources/useResourceVisibility.js index cc1fcf89a..c59e3ae52 100644 --- a/frontend/src/features/resources/useResourceVisibility.js +++ b/frontend/src/features/resources/useResourceVisibility.js @@ -9,7 +9,7 @@ import { collectVisibleFileIds, getUnseenIds } from "./seen"; export function useResourceVisibility(properties, enabled) { const { resourcesQuery } = useResources(); const { seenIds, isLoaded, markSeen } = useSeenResources(); - const lastVisibleKey = useRef(null); + const prevVisibleIds = useRef(new Set()); const ready = enabled && isLoaded && resourcesQuery.isSuccess; const filteredTree = useMemo( @@ -28,20 +28,20 @@ export function useResourceVisibility(properties, enabled) { () => (ready ? getUnseenIds(visibleIds, seenIds) : []), [ready, visibleIds, seenIds] ); - const visibleKey = [...visibleIds].sort().join(","); - // only toast when visible resources set changes + // toast for unseen resources not visible last run useEffect(() => { - if (!ready || lastVisibleKey.current === visibleKey) return; - lastVisibleKey.current = visibleKey; + if (!ready) return; + const prevVisible = prevVisibleIds.current; + prevVisibleIds.current = new Set(visibleIds); - const count = unseenIds.length; + const count = unseenIds.filter((id) => !prevVisible.has(id)).length; if (count > 0) { toast( `You have ${count} new resource${count === 1 ? "" : "s"} available` ); } - }, [ready, visibleKey]); + }, [ready, visibleIds, unseenIds]); return { filteredTree, unseenIds, markSeen, resourcesQuery }; } From 11bdb6671bbf5ee44731b29c3bd952ecb1cdf2cc Mon Sep 17 00:00:00 2001 From: Del Huang Date: Sat, 26 Sep 2026 03:51:20 +1200 Subject: [PATCH 13/22] refactor: ResourceTree is presentation func only --- frontend/src/features/resources/ResourceTree.jsx | 12 +++--------- frontend/src/features/resources/ResourcesOverlay.jsx | 8 ++++++-- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/frontend/src/features/resources/ResourceTree.jsx b/frontend/src/features/resources/ResourceTree.jsx index 6b4b804e8..d9e60f288 100644 --- a/frontend/src/features/resources/ResourceTree.jsx +++ b/frontend/src/features/resources/ResourceTree.jsx @@ -3,17 +3,11 @@ import React from "react"; export default function ResourceTree({ tree, unseenIds, - markSeen, selectedResourceId, - setSelectedResourceId, + onSelect, }) { const unseen = new Set(unseenIds); - const selectResource = (id) => { - setSelectedResourceId(id); - markSeen(id); - }; - return ( <> {tree.map((resource) => ( @@ -44,7 +38,7 @@ export default function ResourceTree({ type="button" className="flex items-center gap-2 min-w-0 text-left text--1 border-none cursor-pointer flex-1 px-3 py-1.5 h-9" title={child.name} - onClick={() => selectResource(child._id)} + onClick={() => onSelect(child._id)} > {child.name} {unseen.has(child._id) && ( @@ -66,7 +60,7 @@ export default function ResourceTree({ type="button" className="flex items-center gap-2 min-w-0 text-left text--1 border-none cursor-pointer flex-1 px-3 py-1.5 h-9" title={resource.name} - onClick={() => selectResource(resource._id)} + onClick={() => onSelect(resource._id)} > {resource.name} {unseen.has(resource._id) && ( diff --git a/frontend/src/features/resources/ResourcesOverlay.jsx b/frontend/src/features/resources/ResourcesOverlay.jsx index 3a821aaf6..5fcda5960 100644 --- a/frontend/src/features/resources/ResourcesOverlay.jsx +++ b/frontend/src/features/resources/ResourcesOverlay.jsx @@ -38,6 +38,11 @@ export default function ResourcesPanel({ .find((resource) => resource._id === selectedResourceId); const filteredTree = filterTreeBySearch(tree, search); + const selectResource = (resourceId) => { + setSelectedResourceId(resourceId); + markSeen(resourceId); + }; + return ( <> {}} /> @@ -112,9 +117,8 @@ export default function ResourcesPanel({
    From 714a2f29b3a215f20c66443efa717744477811b6 Mon Sep 17 00:00:00 2001 From: Del Huang Date: Sat, 26 Sep 2026 03:53:34 +1200 Subject: [PATCH 14/22] refactor: extract reused badge code to make future edits easier --- .../src/features/resources/ResourceTree.jsx | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/frontend/src/features/resources/ResourceTree.jsx b/frontend/src/features/resources/ResourceTree.jsx index d9e60f288..5dca3b1b7 100644 --- a/frontend/src/features/resources/ResourceTree.jsx +++ b/frontend/src/features/resources/ResourceTree.jsx @@ -1,5 +1,9 @@ import React from "react"; +function NewBadge() { + return New; +} + export default function ResourceTree({ tree, unseenIds, @@ -19,9 +23,7 @@ export default function ResourceTree({ {resource.name} {resource.children.some((child) => unseen.has(child._id)) && ( - - New - + )} @@ -41,11 +43,7 @@ export default function ResourceTree({ onClick={() => onSelect(child._id)} > {child.name} - {unseen.has(child._id) && ( - - New - - )} + {unseen.has(child._id) && }
    @@ -63,11 +61,7 @@ export default function ResourceTree({ onClick={() => onSelect(resource._id)} > {resource.name} - {unseen.has(resource._id) && ( - - New - - )} + {unseen.has(resource._id) && } )} From 200e48f3c7e6b078d4fdb79ac5ea4aceccc3d8c5 Mon Sep 17 00:00:00 2001 From: Del Huang Date: Sat, 26 Sep 2026 03:57:07 +1200 Subject: [PATCH 15/22] refactor: dedupe tree flattening --- .../features/resources/ResourcesOverlay.jsx | 10 ++++------ frontend/src/features/resources/seen.js | 18 ------------------ .../resources/useResourceVisibility.js | 14 +++++++------- frontend/src/features/resources/util.js | 6 ++++++ 4 files changed, 17 insertions(+), 31 deletions(-) delete mode 100644 frontend/src/features/resources/seen.js diff --git a/frontend/src/features/resources/ResourcesOverlay.jsx b/frontend/src/features/resources/ResourcesOverlay.jsx index 5fcda5960..2eeb76c68 100644 --- a/frontend/src/features/resources/ResourcesOverlay.jsx +++ b/frontend/src/features/resources/ResourcesOverlay.jsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import ResourceTree from "./ResourceTree"; import { FileTextIcon, SearchIcon, XIcon } from "lucide-react"; -import { filterTreeBySearch } from "./util"; +import { filterTreeBySearch, flattenFiles } from "./util"; import SkeletonBody from "./ResourcesSkeleton"; import ResourcePreview from "./ResourcePreview"; import PanelOverlay from "../../components/PanelOverlay"; @@ -31,11 +31,9 @@ export default function ResourcesPanel({ return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); - const selectedResource = tree - .flatMap((resource) => - resource.type === "collection" ? resource.children : [resource] - ) - .find((resource) => resource._id === selectedResourceId); + const selectedResource = flattenFiles(tree).find( + (resource) => resource._id === selectedResourceId + ); const filteredTree = filterTreeBySearch(tree, search); const selectResource = (resourceId) => { diff --git a/frontend/src/features/resources/seen.js b/frontend/src/features/resources/seen.js deleted file mode 100644 index e51072b2c..000000000 --- a/frontend/src/features/resources/seen.js +++ /dev/null @@ -1,18 +0,0 @@ -// helpers that derive "new" resources from visible tree + "seen" list - -export function collectVisibleFileIds(tree) { - const ids = []; - for (const resource of tree) { - if (resource.type === "collection") { - for (const child of resource.children) ids.push(child._id); - } else { - ids.push(resource._id); - } - } - return ids; -} - -export function getUnseenIds(visibleIds, seenIds) { - const seen = new Set(seenIds); - return visibleIds.filter((id) => !seen.has(id)); -} diff --git a/frontend/src/features/resources/useResourceVisibility.js b/frontend/src/features/resources/useResourceVisibility.js index c59e3ae52..156efc9d1 100644 --- a/frontend/src/features/resources/useResourceVisibility.js +++ b/frontend/src/features/resources/useResourceVisibility.js @@ -1,10 +1,9 @@ import { useEffect, useMemo, useRef } from "react"; import toast from "react-hot-toast"; import { filterTreeByConditions } from "../../utils/propertyConditionalEvaluator"; -import { buildResourceTree } from "./util"; +import { buildResourceTree, flattenFiles } from "./util"; import { useResources } from "./useResources"; import { useSeenResources } from "./useSeenResources"; -import { collectVisibleFileIds, getUnseenIds } from "./seen"; export function useResourceVisibility(properties, enabled) { const { resourcesQuery } = useResources(); @@ -21,13 +20,14 @@ export function useResourceVisibility(properties, enabled) { [resourcesQuery.data, properties] ); const visibleIds = useMemo( - () => collectVisibleFileIds(filteredTree), + () => flattenFiles(filteredTree).map((resource) => resource._id), [filteredTree] ); - const unseenIds = useMemo( - () => (ready ? getUnseenIds(visibleIds, seenIds) : []), - [ready, visibleIds, seenIds] - ); + const unseenIds = useMemo(() => { + if (!ready) return []; + const seen = new Set(seenIds); + return visibleIds.filter((id) => !seen.has(id)); + }, [ready, visibleIds, seenIds]); // toast for unseen resources not visible last run useEffect(() => { diff --git a/frontend/src/features/resources/util.js b/frontend/src/features/resources/util.js index 67ca761f2..6cf576e28 100644 --- a/frontend/src/features/resources/util.js +++ b/frontend/src/features/resources/util.js @@ -26,6 +26,12 @@ export function normaliseFile(resource) { }; } +export function flattenFiles(tree) { + return tree.flatMap((resource) => + resource.type === "collection" ? resource.children : [resource] + ); +} + export function filterTreeBySearch(tree, string) { const q = string.trim()?.toLowerCase(); if (!q) return tree; From b8a024d61683fa0a0217dcd270e88b2a4c616cff Mon Sep 17 00:00:00 2001 From: Del Huang Date: Sat, 26 Sep 2026 04:03:06 +1200 Subject: [PATCH 16/22] refactor: playerResources naming + trimmed code bloat --- .../playScenario/PlayScenarioPage.jsx | 19 ++++++++----------- .../features/resources/ResourcesOverlay.jsx | 5 +++-- ...rceVisibility.js => usePlayerResources.js} | 12 +++++++----- 3 files changed, 18 insertions(+), 18 deletions(-) rename frontend/src/features/resources/{useResourceVisibility.js => usePlayerResources.js} (83%) diff --git a/frontend/src/features/playScenario/PlayScenarioPage.jsx b/frontend/src/features/playScenario/PlayScenarioPage.jsx index 5154b993a..c0b59bc66 100644 --- a/frontend/src/features/playScenario/PlayScenarioPage.jsx +++ b/frontend/src/features/playScenario/PlayScenarioPage.jsx @@ -19,7 +19,7 @@ import { VolumeOffIcon, } from "lucide-react"; import ResourcesPanel from "../resources/ResourcesOverlay"; -import { useResourceVisibility } from "../resources/useResourceVisibility"; +import { usePlayerResources } from "../resources/usePlayerResources"; const sceneCache = new Map(); @@ -173,8 +173,8 @@ export default function PlayScenarioPage({ group }) { const audioRefs = useRef([]); const currScene = sceneCache.get(sceneId); - const { filteredTree, unseenIds, markSeen, resourcesQuery } = - useResourceVisibility(properties, Boolean(currScene)); + const playerResources = usePlayerResources(properties, Boolean(currScene)); + const newResourceCount = playerResources.unseenIds.length; const handleError = async (error) => { if (!error) return; @@ -485,15 +485,15 @@ export default function PlayScenarioPage({ group }) { onClick={() => setResourcesOpen(true)} type="button" aria-label={ - unseenIds.length > 0 - ? `Open resources (${unseenIds.length} new)` + newResourceCount > 0 + ? `Open resources (${newResourceCount} new)` : "Open resources" } > - {unseenIds.length > 0 && ( + {newResourceCount > 0 && ( - {unseenIds.length} + {newResourceCount} )} @@ -513,10 +513,7 @@ export default function PlayScenarioPage({ group }) { /> )} setResourcesOpen(false)} /> diff --git a/frontend/src/features/resources/ResourcesOverlay.jsx b/frontend/src/features/resources/ResourcesOverlay.jsx index 2eeb76c68..831521fa9 100644 --- a/frontend/src/features/resources/ResourcesOverlay.jsx +++ b/frontend/src/features/resources/ResourcesOverlay.jsx @@ -13,11 +13,12 @@ export default function ResourcesPanel({ tree, unseenIds, markSeen, - resourcesQuery, + isLoading, + isError, + error, open, onClose, }) { - const { isLoading, isError, error } = resourcesQuery; const [selectedResourceId, setSelectedResourceId] = useState(null); const [search, setSearch] = useState(""); diff --git a/frontend/src/features/resources/useResourceVisibility.js b/frontend/src/features/resources/usePlayerResources.js similarity index 83% rename from frontend/src/features/resources/useResourceVisibility.js rename to frontend/src/features/resources/usePlayerResources.js index 156efc9d1..48626b050 100644 --- a/frontend/src/features/resources/useResourceVisibility.js +++ b/frontend/src/features/resources/usePlayerResources.js @@ -5,13 +5,13 @@ import { buildResourceTree, flattenFiles } from "./util"; import { useResources } from "./useResources"; import { useSeenResources } from "./useSeenResources"; -export function useResourceVisibility(properties, enabled) { +export function usePlayerResources(properties, enabled) { const { resourcesQuery } = useResources(); const { seenIds, isLoaded, markSeen } = useSeenResources(); const prevVisibleIds = useRef(new Set()); const ready = enabled && isLoaded && resourcesQuery.isSuccess; - const filteredTree = useMemo( + const tree = useMemo( () => filterTreeByConditions( buildResourceTree(resourcesQuery.data ?? []), @@ -20,8 +20,8 @@ export function useResourceVisibility(properties, enabled) { [resourcesQuery.data, properties] ); const visibleIds = useMemo( - () => flattenFiles(filteredTree).map((resource) => resource._id), - [filteredTree] + () => flattenFiles(tree).map((resource) => resource._id), + [tree] ); const unseenIds = useMemo(() => { if (!ready) return []; @@ -43,5 +43,7 @@ export function useResourceVisibility(properties, enabled) { } }, [ready, visibleIds, unseenIds]); - return { filteredTree, unseenIds, markSeen, resourcesQuery }; + const { isLoading, isError, error } = resourcesQuery; + + return { tree, unseenIds, markSeen, isLoading, isError, error }; } From 35d6694a26e87033aa72abf90ccd176c64b7fcff Mon Sep 17 00:00:00 2001 From: Del Huang Date: Sat, 26 Sep 2026 04:07:13 +1200 Subject: [PATCH 17/22] chore: add old comment back --- frontend/src/features/resources/usePlayerResources.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/features/resources/usePlayerResources.js b/frontend/src/features/resources/usePlayerResources.js index 48626b050..9568aed25 100644 --- a/frontend/src/features/resources/usePlayerResources.js +++ b/frontend/src/features/resources/usePlayerResources.js @@ -11,6 +11,8 @@ export function usePlayerResources(properties, enabled) { const prevVisibleIds = useRef(new Set()); const ready = enabled && isLoaded && resourcesQuery.isSuccess; + // NOTE: the filtering by properties should ideally be done on the + // server to prevent cheating, but here we filter before rendering const tree = useMemo( () => filterTreeByConditions( From 767fdcb2fc0e75b0ef32a83407247b7ca470bea5 Mon Sep 17 00:00:00 2001 From: Del Huang Date: Sat, 26 Sep 2026 04:18:46 +1200 Subject: [PATCH 18/22] fix: separate seen marks from history fetch --- .../features/resources/useSeenResources.js | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/frontend/src/features/resources/useSeenResources.js b/frontend/src/features/resources/useSeenResources.js index a0dcc70f7..3dfa2e32a 100644 --- a/frontend/src/features/resources/useSeenResources.js +++ b/frontend/src/features/resources/useSeenResources.js @@ -1,5 +1,5 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useContext } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useContext, useMemo, useState } from "react"; import { useParams } from "react-router-dom"; import AuthenticationContext from "../../context/AuthenticationContext"; import { api, handleGeneric } from "../../util/api"; @@ -19,36 +19,33 @@ async function addSeenResources(user, scenarioId, resourceIds) { export function useSeenResources() { const { scenarioId } = useParams(); const { user } = useContext(AuthenticationContext); - const queryClient = useQueryClient(); - const queryKey = ["seenResources", user.uid, scenarioId]; + + //ids seen this session separate from fetched history, merged on read + //this way a fetch that started before a save cannot drop it + const [markedIds, setMarkedIds] = useState([]); const seenQuery = useQuery({ - queryKey, + queryKey: ["seenResources", user.uid, scenarioId], queryFn: () => getSeenResources(user, scenarioId), }); const { mutate } = useMutation({ mutationFn: (resourceId) => addSeenResources(user, scenarioId, [resourceId]), - onMutate: async (resourceId) => { - await queryClient.cancelQueries(queryKey); - const previous = queryClient.getQueryData(queryKey); - queryClient.setQueryData(queryKey, (prev = []) => [...prev, resourceId]); - return { previous }; - }, - onError: (e, _, context) => { - if (context?.previous) { - queryClient.setQueryData(queryKey, context.previous); - } + onError: (e, resourceId) => { + setMarkedIds((prev) => prev.filter((id) => id !== resourceId)); handleGeneric(e); }, - onSettled: () => queryClient.invalidateQueries(queryKey), }); - const seenIds = seenQuery.data ?? []; + const seenIds = useMemo( + () => [...new Set([...(seenQuery.data ?? []), ...markedIds])], + [seenQuery.data, markedIds] + ); const markSeen = (resourceId) => { if (seenIds.includes(resourceId)) return; + setMarkedIds((prev) => [...prev, resourceId]); mutate(resourceId); }; From 23bb548a69f2303331d4e7b65fee8b0f2a5df7a8 Mon Sep 17 00:00:00 2001 From: Del Huang Date: Sat, 26 Sep 2026 04:20:22 +1200 Subject: [PATCH 19/22] feat: toast on error loading notifs --- frontend/src/features/resources/useSeenResources.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/resources/useSeenResources.js b/frontend/src/features/resources/useSeenResources.js index 3dfa2e32a..37cc0eb01 100644 --- a/frontend/src/features/resources/useSeenResources.js +++ b/frontend/src/features/resources/useSeenResources.js @@ -1,6 +1,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { useContext, useMemo, useState } from "react"; import { useParams } from "react-router-dom"; +import toast from "react-hot-toast"; import AuthenticationContext from "../../context/AuthenticationContext"; import { api, handleGeneric } from "../../util/api"; @@ -27,6 +28,10 @@ export function useSeenResources() { const seenQuery = useQuery({ queryKey: ["seenResources", user.uid, scenarioId], queryFn: () => getSeenResources(user, scenarioId), + onError: (e) => { + console.error(e); + toast.error("Couldn't load new resource notifications"); + }, }); const { mutate } = useMutation({ @@ -51,7 +56,8 @@ export function useSeenResources() { return { seenIds, - isLoaded: seenQuery.isSuccess, + //a failed refetch keeps the last history, so don't hide indicators + isLoaded: seenQuery.data !== undefined, markSeen, }; } From 69bf43d646b1c5ee9e595114935ee97db9e74fb2 Mon Sep 17 00:00:00 2001 From: Del Huang Date: Sat, 26 Sep 2026 04:26:28 +1200 Subject: [PATCH 20/22] fix: "new" resource toast once a session, no retrigger repeats --- .../src/features/resources/usePlayerResources.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/resources/usePlayerResources.js b/frontend/src/features/resources/usePlayerResources.js index 9568aed25..613f8810b 100644 --- a/frontend/src/features/resources/usePlayerResources.js +++ b/frontend/src/features/resources/usePlayerResources.js @@ -8,7 +8,7 @@ import { useSeenResources } from "./useSeenResources"; export function usePlayerResources(properties, enabled) { const { resourcesQuery } = useResources(); const { seenIds, isLoaded, markSeen } = useSeenResources(); - const prevVisibleIds = useRef(new Set()); + const announcedIds = useRef(new Set()); const ready = enabled && isLoaded && resourcesQuery.isSuccess; // NOTE: the filtering by properties should ideally be done on the @@ -31,19 +31,18 @@ export function usePlayerResources(properties, enabled) { return visibleIds.filter((id) => !seen.has(id)); }, [ready, visibleIds, seenIds]); - // toast for unseen resources not visible last run + // toast unseen resources once a session useEffect(() => { - if (!ready) return; - const prevVisible = prevVisibleIds.current; - prevVisibleIds.current = new Set(visibleIds); + const fresh = unseenIds.filter((id) => !announcedIds.current.has(id)); + fresh.forEach((id) => announcedIds.current.add(id)); - const count = unseenIds.filter((id) => !prevVisible.has(id)).length; + const count = fresh.length; if (count > 0) { toast( `You have ${count} new resource${count === 1 ? "" : "s"} available` ); } - }, [ready, visibleIds, unseenIds]); + }, [unseenIds]); const { isLoading, isError, error } = resourcesQuery; From b047d84f6b3619cf61ac91d1d12430851a12c324 Mon Sep 17 00:00:00 2001 From: Del Huang Date: Fri, 2 Oct 2026 01:56:42 +1300 Subject: [PATCH 21/22] fix: sync resource cache after marking "seen" --- frontend/src/features/resources/useSeenResources.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/resources/useSeenResources.js b/frontend/src/features/resources/useSeenResources.js index 37cc0eb01..afa83b136 100644 --- a/frontend/src/features/resources/useSeenResources.js +++ b/frontend/src/features/resources/useSeenResources.js @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useContext, useMemo, useState } from "react"; import { useParams } from "react-router-dom"; import toast from "react-hot-toast"; @@ -20,13 +20,15 @@ async function addSeenResources(user, scenarioId, resourceIds) { export function useSeenResources() { const { scenarioId } = useParams(); const { user } = useContext(AuthenticationContext); + const queryClient = useQueryClient(); + const seenKey = ["seenResources", user.uid, scenarioId]; //ids seen this session separate from fetched history, merged on read //this way a fetch that started before a save cannot drop it const [markedIds, setMarkedIds] = useState([]); const seenQuery = useQuery({ - queryKey: ["seenResources", user.uid, scenarioId], + queryKey: seenKey, queryFn: () => getSeenResources(user, scenarioId), onError: (e) => { console.error(e); @@ -37,6 +39,12 @@ export function useSeenResources() { const { mutate } = useMutation({ mutationFn: (resourceId) => addSeenResources(user, scenarioId, [resourceId]), + onSuccess: async (serverSeen) => { + await queryClient.cancelQueries({ queryKey: seenKey }); + queryClient.setQueryData(seenKey, (old) => [ + ...new Set([...(old ?? []), ...serverSeen]), + ]); + }, onError: (e, resourceId) => { setMarkedIds((prev) => prev.filter((id) => id !== resourceId)); handleGeneric(e); From 8af4a9ebf528cc1586d713b673b271985bc0d636 Mon Sep 17 00:00:00 2001 From: Del Huang Date: Sat, 10 Oct 2026 16:17:18 +1300 Subject: [PATCH 22/22] refactor: remove useMemos from usePlayerResources --- .../features/resources/usePlayerResources.js | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/frontend/src/features/resources/usePlayerResources.js b/frontend/src/features/resources/usePlayerResources.js index 613f8810b..0008655d1 100644 --- a/frontend/src/features/resources/usePlayerResources.js +++ b/frontend/src/features/resources/usePlayerResources.js @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useRef } from "react"; import toast from "react-hot-toast"; import { filterTreeByConditions } from "../../utils/propertyConditionalEvaluator"; import { buildResourceTree, flattenFiles } from "./util"; @@ -13,23 +13,16 @@ export function usePlayerResources(properties, enabled) { // NOTE: the filtering by properties should ideally be done on the // server to prevent cheating, but here we filter before rendering - const tree = useMemo( - () => - filterTreeByConditions( - buildResourceTree(resourcesQuery.data ?? []), - properties - ), - [resourcesQuery.data, properties] + const tree = filterTreeByConditions( + buildResourceTree(resourcesQuery.data ?? []), + properties ); - const visibleIds = useMemo( - () => flattenFiles(tree).map((resource) => resource._id), - [tree] - ); - const unseenIds = useMemo(() => { - if (!ready) return []; - const seen = new Set(seenIds); - return visibleIds.filter((id) => !seen.has(id)); - }, [ready, visibleIds, seenIds]); + const seen = new Set(seenIds); + const unseenIds = ready + ? flattenFiles(tree) + .map((resource) => resource._id) + .filter((id) => !seen.has(id)) + : []; // toast unseen resources once a session useEffect(() => {