From 0272c27fc8d2d65500c84697a3b656003260923b Mon Sep 17 00:00:00 2001 From: gqcorneby <185756521+gqcorneby@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:13:55 +0800 Subject: [PATCH 1/3] show/hide interactive training related config from settings page --- .../interactive-training/utils.ts | 2 +- .../module-list-table/useModuleList.tsx | 9 +++++--- .../page-editor/PageEditorDialog.tsx | 11 +++++++-- src/webapp/entities/AppState.ts | 18 +++++++++++---- .../hooks/useShowInteractiveTrainingConfig.ts | 6 +++++ src/webapp/pages/settings/SettingsConfig.tsx | 23 +++++++++++-------- src/webapp/router/Router.tsx | 5 ++-- 7 files changed, 52 insertions(+), 22 deletions(-) create mode 100644 src/webapp/hooks/useShowInteractiveTrainingConfig.ts diff --git a/src/tutorial-module/interactive-training/utils.ts b/src/tutorial-module/interactive-training/utils.ts index 1bff3966..8842a19c 100644 --- a/src/tutorial-module/interactive-training/utils.ts +++ b/src/tutorial-module/interactive-training/utils.ts @@ -45,7 +45,7 @@ export function getSectionPageIds(currentUrl: string, pages: TrainingModulePage[ } export function generateSettingsUrl(baseUrl: string, appKey: string) { - return `${generateTrainingAppBaseUrl(baseUrl, appKey)}/index.html#/settings`; + return `${generateTrainingAppBaseUrl(baseUrl, appKey)}/index.html#/settings?showInteractiveTrainingConfig`; } export function generateTrainingAppBaseUrl(baseUrl: string, appKey: string) { diff --git a/src/webapp/components/module-list-table/useModuleList.tsx b/src/webapp/components/module-list-table/useModuleList.tsx index 5ef56489..22fe87bc 100644 --- a/src/webapp/components/module-list-table/useModuleList.tsx +++ b/src/webapp/components/module-list-table/useModuleList.tsx @@ -28,6 +28,7 @@ import { usePagePermissions } from "./usePagePermissions"; import { PageEditorProps } from "../page-editor/PageEditorDialog"; import { Maybe } from "../../../types/utils"; import { PageBindingPreview } from "./PageBindingPreview"; +import { useShowInteractiveTrainingConfig } from "../../hooks/useShowInteractiveTrainingConfig"; type UseModuleListProps = { rows: ListItem[]; @@ -46,6 +47,8 @@ export function useModuleList(props: UseModuleListProps) { const loading = useLoading(); const snackbar = useSnackbar(); + const showInteractiveTrainingConfig = useShowInteractiveTrainingConfig(); + const [selection, setSelection] = useState([]); const [inputDialogProps, updateInputDialog] = useState>(); const [dialogProps, updateDialog] = useState>(); @@ -595,7 +598,7 @@ export function useModuleList(props: UseModuleListProps) { [openImportDialog, translationImportRef] ); - const columns = useMemo(() => buildTableColumns(), []); + const columns = useMemo(() => buildTableColumns(showInteractiveTrainingConfig), [showInteractiveTrainingConfig]); return { globalActions, @@ -629,7 +632,7 @@ const StepPreview: React.FC<{ ); }; -function buildTableColumns(): TableColumn[] { +function buildTableColumns(showInteractiveTrainingConfig: boolean): TableColumn[] { return [ { name: "name", @@ -651,7 +654,7 @@ function buildTableColumns(): TableColumn[] { )} /> ) : null} - {item.rowType === "page" && item.bindings?.length ? ( + {showInteractiveTrainingConfig && item.rowType === "page" && item.bindings?.length ? ( ) : null} diff --git a/src/webapp/components/page-editor/PageEditorDialog.tsx b/src/webapp/components/page-editor/PageEditorDialog.tsx index d6dc2b5c..abada7d6 100644 --- a/src/webapp/components/page-editor/PageEditorDialog.tsx +++ b/src/webapp/components/page-editor/PageEditorDialog.tsx @@ -6,6 +6,7 @@ import { BINDING_TYPE, getDefaultBinding, PageBinding } from "../../../domain/en import i18n from "../../../utils/i18n"; import { StepPreview } from "../markdown-editor/StepPreview"; import { PageBindingEditor } from "./PageBindingEditor"; +import { useShowInteractiveTrainingConfig } from "../../hooks/useShowInteractiveTrainingConfig"; type Page = Pick & { name: string }; @@ -19,6 +20,8 @@ export type PageEditorProps = { export const PageEditorDialog: React.FC = props => { const { page: initialPage, onSave, onCancel, onUpload } = props; + const showInteractiveTrainingConfig = useShowInteractiveTrainingConfig(); + const { bindings, ...bindingActions } = usePageBindings(initialPage?.bindings); const title = initialPage ? i18n.t("Edit contents of {{name}}", initialPage) : i18n.t("Add new page"); @@ -44,8 +47,12 @@ export const PageEditorDialog: React.FC = props => { onSave={handleSave} markdownPreview={markdown => } > -

{i18n.t("Interactive training bindings")}

- + {showInteractiveTrainingConfig && ( + <> +

{i18n.t("Interactive training bindings")}

+ + + )} ); }; diff --git a/src/webapp/entities/AppState.ts b/src/webapp/entities/AppState.ts index 0bf5ae16..5198cdbb 100644 --- a/src/webapp/entities/AppState.ts +++ b/src/webapp/entities/AppState.ts @@ -73,7 +73,15 @@ export type AppState = | CloneAppState | CreateAppState; -export const buildPathFromState = (state: AppState): string => { +const INTERACTIVE_CONFIG_ROUTES = new Set(["SETTINGS", "EDIT_MODULE", "CLONE_MODULE", "CREATE_MODULE"]); + +export const buildPathFromState = (state: AppState, currentSearch = ""): string => { + const search = + INTERACTIVE_CONFIG_ROUTES.has(state.type) && + new URLSearchParams(currentSearch).has("showInteractiveTrainingConfig") + ? "?showInteractiveTrainingConfig" + : ""; + switch (state.type) { case "HOME": return `/`; @@ -82,15 +90,15 @@ export const buildPathFromState = (state: AppState): string => { case "TRAINING_DIALOG": return `/tutorial/${state.module}/${state.dialog}`; case "SETTINGS": - return `/settings`; + return `/settings${search}`; case "ABOUT": return `/about`; case "EDIT_MODULE": - return `/edit/${state.module}`; + return `/edit/${state.module}${search}`; case "CLONE_MODULE": - return `/clone/${state.module}`; + return `/clone/${state.module}${search}`; case "CREATE_MODULE": - return `/create`; + return `/create${search}`; default: return "/"; } diff --git a/src/webapp/hooks/useShowInteractiveTrainingConfig.ts b/src/webapp/hooks/useShowInteractiveTrainingConfig.ts new file mode 100644 index 00000000..1626b3f8 --- /dev/null +++ b/src/webapp/hooks/useShowInteractiveTrainingConfig.ts @@ -0,0 +1,6 @@ +import { useLocation } from "react-router-dom"; + +export function useShowInteractiveTrainingConfig(): boolean { + const location = useLocation(); + return new URLSearchParams(location.search).has("showInteractiveTrainingConfig"); +} diff --git a/src/webapp/pages/settings/SettingsConfig.tsx b/src/webapp/pages/settings/SettingsConfig.tsx index e92b17c4..fbf2fe47 100644 --- a/src/webapp/pages/settings/SettingsConfig.tsx +++ b/src/webapp/pages/settings/SettingsConfig.tsx @@ -18,6 +18,7 @@ import { } from "../../components/permissions-dialog/PermissionsDialog"; import { useContainerDialog } from "./useContainerDialog"; import { ContainerConfigDialog } from "../../components/container-config-dialog/ContainerConfigDialog"; +import { useShowInteractiveTrainingConfig } from "../../hooks/useShowInteractiveTrainingConfig"; type SettingsConfigProps = { modules: TrainingModule[]; @@ -36,6 +37,8 @@ export const SettingsConfig: React.FC = props => { save: save, }); + const showInteractiveTrainingConfig = useShowInteractiveTrainingConfig(); + const [danglingDocuments, setDanglingDocuments] = useState([]); const [dialogProps, updateDialog] = useState(null); const [showCustomSettings, setShowCustomSettings] = useState(false); @@ -163,15 +166,17 @@ export const SettingsConfig: React.FC = props => {
)} - - - web_asset - - - + {showInteractiveTrainingConfig && ( + + + web_asset + + + + )} ); diff --git a/src/webapp/router/Router.tsx b/src/webapp/router/Router.tsx index 5b426f3f..135ada8c 100644 --- a/src/webapp/router/Router.tsx +++ b/src/webapp/router/Router.tsx @@ -46,8 +46,9 @@ export const Router: React.FC<{ baseUrl: string }> = ({ baseUrl }) => { if (appState.type === "UNKNOWN") { return; } else { - const path = buildPathFromState(appState); - if (path !== location.pathname) navigate(path); + const path = buildPathFromState(appState, location.search); + const currentPath = location.pathname + location.search; + if (path !== currentPath) navigate(path); } }, [appState, navigate, location, baseUrl]); From 1f2665bfeeae999ea9c9594066870d586df3751c Mon Sep 17 00:00:00 2001 From: gqcorneby <185756521+gqcorneby@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:34:59 +0800 Subject: [PATCH 2/3] badge + pulsing --- .../InteractiveTrainingDrawer.tsx | 19 +++- .../InteractiveTrainingModal.tsx | 7 +- .../InteractiveTrainingProvider.tsx | 20 +++- .../TrainingContainer.tsx | 5 + .../components/NotificationBadge.tsx | 46 +++++++++ .../hooks/useContentChangeIndicator.ts | 93 +++++++++++++++++++ 6 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 src/tutorial-module/interactive-training/components/NotificationBadge.tsx create mode 100644 src/tutorial-module/interactive-training/hooks/useContentChangeIndicator.ts diff --git a/src/tutorial-module/interactive-training/InteractiveTrainingDrawer.tsx b/src/tutorial-module/interactive-training/InteractiveTrainingDrawer.tsx index aa029990..156f7b8f 100644 --- a/src/tutorial-module/interactive-training/InteractiveTrainingDrawer.tsx +++ b/src/tutorial-module/interactive-training/InteractiveTrainingDrawer.tsx @@ -11,6 +11,8 @@ import i18n from "../../utils/i18n"; import { ScrollableContainer } from "./ScrollableContainer"; import { ActionButton } from "../../webapp/components/action-button/ActionButton"; import { useDrawerCollapseMode } from "./hooks/useDrawerCollapseMode"; +import { NotificationBadgeState } from "./hooks/useContentChangeIndicator"; +import { NotificationBadge } from "./components/NotificationBadge"; const DRAWER_COLLAPSED_WIDTH = 40; @@ -24,6 +26,7 @@ type SideDrawerProps = { triggerKey: string; containerConfig: SideBarConfig; drawerContent: React.ReactNode; + badgeProps?: NotificationBadgeState; }; export const InteractiveTrainingDrawer: React.FC = props => { @@ -38,6 +41,7 @@ export const InteractiveTrainingDrawer: React.FC = props => { triggerKey, containerConfig, drawerContent, + badgeProps, } = props; const isRight = containerConfig.position === "right"; @@ -95,6 +99,7 @@ export const InteractiveTrainingDrawer: React.FC = props => { tooltip={toggleTooltip} tooltipPlacement={toggleTooltipPlacement} isMinimized={isMinimized} + badgeProps={showMini && isMinimized ? badgeProps : undefined} > {isMinimized && {i18n.t("help")}} @@ -114,6 +119,7 @@ export const InteractiveTrainingDrawer: React.FC = props => { ? + )} @@ -127,6 +133,7 @@ type DrawerToggleButtonProps = PropsWithChildren<{ tooltip: string; tooltipPlacement: "left" | "right"; isMinimized: boolean; + badgeProps?: NotificationBadgeState; }>; const DrawerToggleButton: React.FC = ({ @@ -135,16 +142,22 @@ const DrawerToggleButton: React.FC = ({ tooltip, tooltipPlacement, isMinimized, + badgeProps, children, }) => ( -
+ {children} -
+ + ); +const DrawerToggleContainer = styled.div` + position: relative; +`; + const Content = styled(ScrollableContainer)` display: flex; flex-direction: column; @@ -242,6 +255,8 @@ const closedStyles = css` `; const ActionButtonContainer = styled.div` + position: relative; + .MuiFab-root { padding: 0; } diff --git a/src/tutorial-module/interactive-training/InteractiveTrainingModal.tsx b/src/tutorial-module/interactive-training/InteractiveTrainingModal.tsx index 14e6f59e..981168d5 100644 --- a/src/tutorial-module/interactive-training/InteractiveTrainingModal.tsx +++ b/src/tutorial-module/interactive-training/InteractiveTrainingModal.tsx @@ -5,15 +5,18 @@ import { Modal, ModalContent } from "../../webapp/components/modal"; import { ScrollableContainer } from "./ScrollableContainer"; import { ActionButton } from "../../webapp/components/action-button/ActionButton"; import { DialogConfig } from "../../domain/entities/Config"; +import { NotificationBadgeState } from "./hooks/useContentChangeIndicator"; +import { NotificationBadge } from "./components/NotificationBadge"; type InteractiveTrainingModalProps = React.ComponentProps & { triggerKey: string; showTraining: () => void; containerConfig: DialogConfig; + badgeProps?: NotificationBadgeState; }; export const InteractiveTrainingModal: React.FC = props => { - const { children, triggerKey, showTraining, containerConfig, ...modalProps } = props; + const { children, triggerKey, showTraining, containerConfig, badgeProps, ...modalProps } = props; const position = containerConfig.buttonPosition === "top-right" @@ -36,6 +39,7 @@ export const InteractiveTrainingModal: React.FC = @@ -57,6 +61,7 @@ const StyledModal = styled(Modal)` `; const ActionButtonContainer = styled.div<{ hidden: boolean }>` + position: relative; visibility: ${({ hidden }) => (hidden ? "hidden" : "visible")}; .MuiFab-root { diff --git a/src/tutorial-module/interactive-training/InteractiveTrainingProvider.tsx b/src/tutorial-module/interactive-training/InteractiveTrainingProvider.tsx index 3bfdf83a..1a1a5214 100644 --- a/src/tutorial-module/interactive-training/InteractiveTrainingProvider.tsx +++ b/src/tutorial-module/interactive-training/InteractiveTrainingProvider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, PropsWithChildren, useMemo } from "react"; +import React, { createContext, PropsWithChildren, useCallback, useMemo } from "react"; import { TrainingModulePage } from "../../domain/entities/TrainingModule"; import { Maybe } from "../../types/utils"; @@ -13,6 +13,7 @@ import { } from "./hooks/useInteractiveTraining"; import { TrainingLanding } from "./TrainingLanding"; import { useScrollableContainerKey } from "./hooks/useScrollableContainerKey"; +import { useContentChangeIndicator } from "./hooks/useContentChangeIndicator"; const trainingEventKinds = ["click", "focus", "section"] as const; type TrainingEventKind = typeof trainingEventKinds[number]; @@ -30,6 +31,7 @@ type TutorialModuleProps = PropsWithChildren<{ events?: TrainingEventKind[]; highlightElementsWithBindings?: boolean; trainingAppKey?: string; + showContentChangeIndicator?: boolean; }>; const defaultAppKey = "Training-App"; @@ -41,6 +43,7 @@ export const InteractiveTrainingProvider: React.FC = props events = [...trainingEventKinds], highlightElementsWithBindings, trainingAppKey = defaultAppKey, + showContentChangeIndicator = true, children, } = props; @@ -73,6 +76,18 @@ export const InteractiveTrainingProvider: React.FC = props loadedModule: moduleHandling.loadedModule, }); + const { badgeProps, clearIndicator } = useContentChangeIndicator({ + targetIds, + textContent, + isMinimized, + enabled: showContentChangeIndicator, + }); + + const handleShowTraining = useCallback(() => { + clearIndicator(); + showTraining(); + }, [clearIndicator, showTraining]); + const containerClass = `training-scope ${highlightElementsWithBindings ? "highlight-training-elements" : ""}`; const contextValue = useMemo(() => ({ pages, trigger, events }), [pages, trigger, events]); @@ -87,7 +102,8 @@ export const InteractiveTrainingProvider: React.FC = props triggerKey={triggerKey} isMinimized={isMinimized} onMinimize={minimizeTraining} - showTraining={showTraining} + showTraining={handleShowTraining} + badgeProps={badgeProps} settingsAccess={settingsAccess} goBack={onGoBack} goHome={onGoHome} diff --git a/src/tutorial-module/interactive-training/TrainingContainer.tsx b/src/tutorial-module/interactive-training/TrainingContainer.tsx index fff9df0e..5767329a 100644 --- a/src/tutorial-module/interactive-training/TrainingContainer.tsx +++ b/src/tutorial-module/interactive-training/TrainingContainer.tsx @@ -6,6 +6,7 @@ import { InteractiveTrainingModal } from "./InteractiveTrainingModal"; import { SettingsAccess } from "./hooks/useInteractiveTraining"; import { MarkdownViewer } from "../../webapp/components/markdown-viewer/MarkdownViewer"; import { InteractiveTrainingDrawer } from "./InteractiveTrainingDrawer"; +import { NotificationBadgeState } from "./hooks/useContentChangeIndicator"; type TrainingContainerProps = { containerConfig: ContainerConfig; @@ -19,6 +20,7 @@ type TrainingContainerProps = { goBack?: () => void; goHome?: () => void; isLoading?: boolean; + badgeProps?: NotificationBadgeState; }; export const TrainingContainer: React.FC = props => { @@ -32,6 +34,7 @@ export const TrainingContainer: React.FC = props => { goHome, goBack, isLoading = true, + badgeProps, ...containerProps } = props; @@ -53,6 +56,7 @@ export const TrainingContainer: React.FC = props => { onBack={goBack} onSettings={settingsAccess.hasAccess ? onSettings : undefined} containerConfig={containerConfig} + badgeProps={badgeProps} drawerContent={} > {children} @@ -69,6 +73,7 @@ export const TrainingContainer: React.FC = props => { onGoBack={goBack} onSettings={settingsAccess.hasAccess ? onSettings : undefined} containerConfig={containerConfig} + badgeProps={badgeProps} > diff --git a/src/tutorial-module/interactive-training/components/NotificationBadge.tsx b/src/tutorial-module/interactive-training/components/NotificationBadge.tsx new file mode 100644 index 00000000..3d8d485e --- /dev/null +++ b/src/tutorial-module/interactive-training/components/NotificationBadge.tsx @@ -0,0 +1,46 @@ +import styled, { css, keyframes } from "styled-components"; + +const pulseAnimation = keyframes` + 0%, 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.4); + opacity: 0.8; + } +`; + +type NotificationBadgeProps = { + isVisible?: boolean; + isPulsing?: boolean; +}; + +export const NotificationBadge = styled.div` + position: absolute; + top: -4px; + right: -4px; + width: 10px; + height: 10px; + border-radius: 50%; + background-color: #f5a623; + box-shadow: 0 0 4px rgba(245, 166, 35, 0.6); + transition: opacity 200ms ease; + z-index: 9999; + + ${({ isVisible }) => + isVisible + ? css` + opacity: 1; + ` + : css` + opacity: 0; + pointer-events: none; + `} + + ${({ isPulsing }) => + isPulsing && + css` + animation: ${pulseAnimation} 350ms ease 3; + `} +`; diff --git a/src/tutorial-module/interactive-training/hooks/useContentChangeIndicator.ts b/src/tutorial-module/interactive-training/hooks/useContentChangeIndicator.ts new file mode 100644 index 00000000..9c9d3a29 --- /dev/null +++ b/src/tutorial-module/interactive-training/hooks/useContentChangeIndicator.ts @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +export type IndicatorState = "hidden" | "visible" | "pulsing"; + +export type UseContentChangeIndicatorProps = { + targetIds: string[]; + textContent: string; + isMinimized: boolean; + enabled: boolean; +}; + +export type NotificationBadgeState = { + isVisible: boolean; + isPulsing: boolean; +}; + +export type UseContentChangeIndicatorResult = { + indicatorState: IndicatorState; + badgeProps: NotificationBadgeState; + clearIndicator: () => void; +}; + +export function useContentChangeIndicator(props: UseContentChangeIndicatorProps): UseContentChangeIndicatorResult { + const { targetIds, textContent, isMinimized, enabled } = props; + + const [indicatorState, setIndicatorState] = useState("hidden"); + + const previousTargetIdsKey = useRef(""); + const lastSeenTargetKey = useRef(""); + + const currentTargetKey = useMemo(() => targetIds.join(","), [targetIds]); + const hasContent = useMemo(() => textContent.trim().length > 0, [textContent]); + + useEffect(() => { + if (!enabled) { + setIndicatorState("hidden"); + return; + } + + // reset on default content + if (!hasContent) { + setIndicatorState("hidden"); + lastSeenTargetKey.current = ""; + previousTargetIdsKey.current = ""; + return; + } + + if (!isMinimized) { + setIndicatorState("hidden"); + lastSeenTargetKey.current = currentTargetKey; + previousTargetIdsKey.current = currentTargetKey; + return; + } + + const contentChanged = currentTargetKey !== previousTargetIdsKey.current && currentTargetKey !== ""; + + if (contentChanged) { + const seenByUser = currentTargetKey === lastSeenTargetKey.current; + previousTargetIdsKey.current = currentTargetKey; + + if (!seenByUser) { + setIndicatorState("pulsing"); + } + } + }, [currentTargetKey, isMinimized, enabled, hasContent]); + + // transition pulsing + useEffect(() => { + if (indicatorState === "pulsing") { + //1050 -> pulsing animation 350 x 3 + const timeoutId = setTimeout(() => { + setIndicatorState("visible"); + }, 1050); + + return () => clearTimeout(timeoutId); + } + return undefined; + }, [indicatorState]); + + const clearIndicator = useCallback(() => { + setIndicatorState("hidden"); + lastSeenTargetKey.current = currentTargetKey; + }, [currentTargetKey]); + + return { + indicatorState, + badgeProps: { + isVisible: indicatorState !== "hidden", + isPulsing: indicatorState === "pulsing", + }, + clearIndicator, + }; +} From 3761b0d1ced6999bbd7bb09df409975f0ac261fd Mon Sep 17 00:00:00 2001 From: Adrian Quintana Date: Thu, 26 Mar 2026 10:20:10 +0000 Subject: [PATCH 3/3] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 596b6075..d91cb63a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@eyeseetea/training-app", "description": "Training App", - "version": "1.7.1", + "version": "1.7.2", "license": "GPL-3.0", "author": "EyeSeeTea team", "main": "index.js",