diff --git a/docusaurus.config.ts b/docusaurus.config.ts index dd4bd95d4..88f01087b 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -23,6 +23,7 @@ import { recommendedBeforeDefaultRemarkPlugins, recommendedRehypePlugins, recomm import { remarkPdfPluginConfig } from '@tdev/remark-pdf'; import { excalidrawPluginConfig } from '@tdev/excalidoc'; import type { EditThisPageOption, ShowEditThisPage, TdevConfig } from '@tdev/siteConfig/siteConfig'; +import type { ParseFrontMatterResult } from '@docusaurus/types/src/markdown'; const siteConfig = getSiteConfig(); @@ -48,6 +49,21 @@ const PROJECT_NAME = siteConfig.gitHub?.projectName ?? 'teaching-dev'; const GH_OAUTH_CLIENT_ID = process.env.GH_OAUTH_CLIENT_ID; const DEFAULT_TEST_USER = process.env.DEFAULT_TEST_USER?.trim(); +/** + * exposes the `page_id` frontmatter as `pid` in `sidebar_custom_props` + * this way the sidebar can access the page_id without additional plugins + * and we can use it to access the page model for the current page in the sidebar + */ +const exposePidToSidebar = (fm: ParseFrontMatterResult) => { + if (!('sidebar_custom_props' in fm.frontMatter)) { + fm.frontMatter.sidebar_custom_props = {}; + } + if (!('pid' in (fm.frontMatter as any).sidebar_custom_props) && ('page_id' in fm.frontMatter)) { + (fm.frontMatter.sidebar_custom_props as any).pid = fm.frontMatter.page_id; + } + return fm; +}; + const config: Config = applyTransformers({ title: TITLE, tagline: siteConfig.tagline ?? 'Eine Plattform zur Gestaltung interaktiver Lernerlebnisse', @@ -138,7 +154,7 @@ const config: Config = applyTransformers({ parseFrontMatter: async (params) => { const result = await params.defaultParseFrontMatter(params); if (process.env.NODE_ENV === 'production') { - return result; + return exposePidToSidebar(result); } /** * don't add frontmatter to partials @@ -146,13 +162,13 @@ const config: Config = applyTransformers({ const fileName = path.basename(params.filePath); if (fileName.startsWith('_')) { // it is a partial, don't add frontmatter - return result; + return exposePidToSidebar(result); } /** * don't edit blogs frontmatter */ if (params.filePath.startsWith(`${BUILD_LOCATION}/blog/`)) { - return result; + return exposePidToSidebar(result); } if (process.env.NODE_ENV !== 'production') { let needsRewrite = false; @@ -182,7 +198,7 @@ const config: Config = applyTransformers({ ) } } - return result; + return exposePidToSidebar(result); }, mermaid: true, hooks: { diff --git a/packages/tdev/page-progress-state/.gitignore b/packages/tdev/page-progress-state/.gitignore new file mode 100644 index 000000000..3a03a092e --- /dev/null +++ b/packages/tdev/page-progress-state/.gitignore @@ -0,0 +1 @@ +.assets \ No newline at end of file diff --git a/packages/tdev/page-progress-state/assets/.gitignore b/packages/tdev/page-progress-state/assets/.gitignore new file mode 100644 index 000000000..94a2dd146 --- /dev/null +++ b/packages/tdev/page-progress-state/assets/.gitignore @@ -0,0 +1 @@ +*.json \ No newline at end of file diff --git a/packages/tdev/page-progress-state/package.json b/packages/tdev/page-progress-state/package.json new file mode 100644 index 000000000..9723aad76 --- /dev/null +++ b/packages/tdev/page-progress-state/package.json @@ -0,0 +1,15 @@ +{ + "name": "@tdev/page-progress-state", + "version": "1.0.0", + "main": "remark-plugin/index.ts", + "types": "remark-plugin/index.ts", + "dependencies": {}, + "devDependencies": { + "vitest": "*", + "@docusaurus/module-type-aliases": "*", + "@docusaurus/core": "*" + }, + "peerDependencies": { + "@tdev/core": "1.0.0" + } +} \ No newline at end of file diff --git a/packages/tdev/page-progress-state/remark-plugin/index.ts b/packages/tdev/page-progress-state/remark-plugin/index.ts new file mode 100644 index 000000000..f564a55b3 --- /dev/null +++ b/packages/tdev/page-progress-state/remark-plugin/index.ts @@ -0,0 +1,95 @@ +import type { Plugin, Transformer } from 'unified'; +import type { Node, Root } from 'mdast'; +import path from 'path'; +import { promises as fs, accessSync, mkdirSync, writeFileSync, readFileSync } from 'fs'; + +export interface PluginOptions { + extractors: { test: (node: Node) => boolean; getDocumentRootIds: (node: Node) => string[] }[]; +} + +const projectRoot = process.cwd(); + +/** + * + * sidebar: + * { + * "1f6db0ee-aa48-44c7-af43-4b66843f665e": [ ... document root ids ] + * } + * + * structure: + * { + * "path" : { + * "to": { + * "doc": + * }, + * } + * } + */ + +const ensureFile = async (indexPath: string) => { + const assetsDir = path.dirname(indexPath); + try { + accessSync(assetsDir); + } catch { + mkdirSync(assetsDir, { recursive: true }); + } + try { + accessSync(indexPath); + } catch { + writeFileSync(indexPath, JSON.stringify({}, null, 2), { + encoding: 'utf-8' + }); + } +}; + +/** + * A remark plugin that adds a ` elements at the top of the current page. + * This is useful to initialize a page model on page load and to trigger side-effects on page display, + * as to load models attached to the `page_id`'s root document. + */ +const remarkPlugin: Plugin = function plugin( + options = { extractors: [] } +): Transformer { + const index = new Map(); + const structurePath = path.resolve(__dirname, '../assets/', 'structure.json'); + const indexPath = path.resolve(__dirname, '../assets/', 'index.json'); + ensureFile(indexPath); + ensureFile(structurePath); + try { + const content = readFileSync(indexPath, { encoding: 'utf-8' }); + const parsed = JSON.parse(content) as { [key: string]: string[] }; + for (const [key, values] of Object.entries(parsed)) { + index.set(key, values); + } + } catch { + console.log('Error parsing existing index file, starting fresh.'); + } + + return async (root, file) => { + const { visit, EXIT } = await import('unist-util-visit'); + const { page_id } = (file.data?.frontMatter || {}) as { page_id?: string }; + if (!page_id) { + return; + } + const filePath = path + .relative(projectRoot, file.path) + .replace(/\/(index|README)\.mdx?$/i, '') + .replace(/\.mdx?$/i, ''); + console.log('file', filePath); + const pageIndex = new Set([]); + visit(root, (node, idx, parent) => { + const extractor = options.extractors.find((ext) => ext.test(node)); + if (!extractor) { + return; + } + const docRootIds = extractor.getDocumentRootIds(node); + docRootIds.forEach((id) => pageIndex.add(id)); + }); + index.set(page_id, [...pageIndex]); + await fs.writeFile(indexPath, JSON.stringify(Object.fromEntries(index), null, 2), { + encoding: 'utf-8' + }); + }; +}; + +export default remarkPlugin; diff --git a/packages/tdev/page-progress-state/tsconfig.json b/packages/tdev/page-progress-state/tsconfig.json new file mode 100644 index 000000000..ea56794f8 --- /dev/null +++ b/packages/tdev/page-progress-state/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../tsconfig.json" +} diff --git a/src/components/Admin/EditUser/index.tsx b/src/components/Admin/EditUser/index.tsx index 418195018..65db66e68 100644 --- a/src/components/Admin/EditUser/index.tsx +++ b/src/components/Admin/EditUser/index.tsx @@ -219,7 +219,7 @@ const EditUser = observer((props: Props) => { authClient.admin .setUserPassword({ userId: user.id, newPassword: password }) .then((res) => { - if (res.data) { + if (res?.data) { setPwState('success'); } else { setPwState('error'); @@ -284,7 +284,7 @@ const EditUser = observer((props: Props) => { setSpinState('deleting'); authClient.admin.removeUser({ userId: user.id }).then( action((res) => { - if (res.data?.success) { + if (res?.data?.success) { userStore.removeFromStore(user.id); props.close(); } diff --git a/src/plugins/remark-page/plugin.ts b/src/plugins/remark-page/plugin.ts index 709756319..90d748676 100644 --- a/src/plugins/remark-page/plugin.ts +++ b/src/plugins/remark-page/plugin.ts @@ -1,6 +1,6 @@ import type { Plugin, Transformer } from 'unified'; import type { MdxJsxFlowElement } from 'mdast-util-mdx'; -import type { Root } from 'mdast'; +import type { Node, Root } from 'mdast'; import { toJsxAttribute } from '../helpers'; /** @@ -8,14 +8,14 @@ import { toJsxAttribute } from '../helpers'; * This is useful to initialize a page model on page load and to trigger side-effects on page display, * as to load models attached to the `page_id`'s root document. */ -const plugin: Plugin = function plugin(): Transformer { +const plugin: Plugin = function plugin(): Transformer { return async (root, file) => { const { visit, EXIT } = await import('unist-util-visit'); const { page_id } = (file.data?.frontMatter || {}) as { page_id?: string }; if (!page_id) { return; } - visit(root, (node, index, parent) => { + visit(root, (node, idx, parent) => { /** add the MdxPage exactly once at the top of the document and exit */ if (root === node && !parent) { const loaderNode: MdxJsxFlowElement = { diff --git a/src/siteConfig/markdownPluginConfigs.ts b/src/siteConfig/markdownPluginConfigs.ts index cbd602295..c725b99b4 100644 --- a/src/siteConfig/markdownPluginConfigs.ts +++ b/src/siteConfig/markdownPluginConfigs.ts @@ -1,5 +1,7 @@ import type { Node } from 'mdast'; +import path from 'path'; import type { LeafDirective } from 'mdast-util-directive'; +import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx'; import strongPlugin, { transformer as captionVisitor } from '../plugins/remark-strong/plugin'; import deflistPlugin from '../plugins/remark-deflist/plugin'; import mdiPlugin from '../plugins/remark-mdi/plugin'; @@ -13,6 +15,7 @@ import linkAnnotationPlugin from '../plugins/remark-link-annotation/plugin'; import mediaPlugin from '../plugins/remark-media/plugin'; import detailsPlugin from '../plugins/remark-details/plugin'; import pagePlugin from '../plugins/remark-page/plugin'; +import pageProgressStatePlugin from '@tdev/page-progress-state/remark-plugin'; import graphvizPlugin from '@tdev/remark-graphviz/remark-plugin'; import pdfPlugin from '@tdev/remark-pdf/remark-plugin'; import codeAsAttributePlugin from '../plugins/remark-code-as-attribute/plugin'; @@ -129,7 +132,41 @@ export const enumerateAnswersPluginConfig = [ export const pdfPluginConfig = pdfPlugin; -export const pagePluginConfig = pagePlugin; +const cwd = process.cwd(); +const indexPath = path.resolve(cwd, './src/.page-index'); +const ComponentsWithId = new Set(['TaskState', 'ProgressState']); +const AnswerTypes = new Set(['state', 'progress']); +export const pagePluginConfig = [pagePlugin, {}]; + +export const pageProgressStatePluginConfig = [ + pageProgressStatePlugin, + { + extractors: [ + { + test: (_node: Node) => { + if (_node.type !== 'mdxJsxFlowElement') { + return false; + } + const node = _node as MdxJsxFlowElement; + const name = node.name as string; + return ( + ComponentsWithId.has(name) || + node.attributes.some( + (a) => + (a as { name?: string }).name === 'type' && AnswerTypes.has(a.value as string) + ) + ); + }, + getDocumentRootIds: (node: Node) => { + const jsxNode = node as MdxJsxFlowElement; + const idAttr = jsxNode.attributes.find((attr) => (attr as any).name === 'id'); + return idAttr ? [idAttr.value] : []; + } + } + ] + } +]; + export const graphvizPluginConfig = graphvizPlugin; export const commentPluginConfig = [ @@ -169,6 +206,7 @@ export const recommendedRemarkPlugins = [ enumerateAnswersPluginConfig, pdfPluginConfig, pagePluginConfig, + pageProgressStatePluginConfig, commentPluginConfig, linkAnnotationPluginConfig, codeAsAttributePluginConfig diff --git a/src/stores/PageStore.ts b/src/stores/PageStore.ts index 059fb283c..fbd74e8fb 100644 --- a/src/stores/PageStore.ts +++ b/src/stores/PageStore.ts @@ -4,6 +4,9 @@ import { RootStore } from '@tdev-stores/rootStore'; import Page from '@tdev-models/Page'; import { computedFn } from 'mobx-utils'; import { allDocuments as apiAllDocuments } from '@tdev-api/document'; +import type { useDocsSidebar } from '@docusaurus/plugin-content-docs/client'; +import { PropSidebarItem } from '@docusaurus/plugin-content-docs'; +type PageIndex = { [key: string]: string[] }; export class PageStore extends iStore { readonly root: RootStore; @@ -13,11 +16,40 @@ export class PageStore extends iStore { @observable accessor currentPageId: string | undefined = undefined; @observable accessor runningTurtleScriptId: string | undefined = undefined; + @observable.ref accessor pageIndex: PageIndex = {}; + sidebars = observable.map>([], { deep: false }); + constructor(store: RootStore) { super(); this.root = store; } + @action + configureSidebar(id: string, sidebar: ReturnType) { + if (this.sidebars.has(id) || !sidebar) { + return; + } + this.sidebars.set(id, sidebar); + sidebar.items.forEach((item) => { + if (item.type !== 'category') { + return; + } + item.items; + }); + } + + @action + load() { + return import('@tdev/page-progress-state/assets/index.json').then((mod) => { + this.updatePageIndex(mod.default as PageIndex); + }); + } + + @action + updatePageIndex(newIndex: PageIndex) { + this.pageIndex = newIndex; + } + find = computedFn( function (this: PageStore, id?: string): Page | undefined { if (!id) { diff --git a/src/stores/rootStore.ts b/src/stores/rootStore.ts index b2b623e50..7c8a6a5de 100644 --- a/src/stores/rootStore.ts +++ b/src/stores/rootStore.ts @@ -55,6 +55,7 @@ export class RootStore { * load stores */ this.userStore.load(); + this.pageStore.load(); this.studentGroupStore.load(); this.cmsStore.initialize(); if (user.hasElevatedAccess) { diff --git a/src/theme/DocItem/Content/index.tsx b/src/theme/DocItem/Content/index.tsx index 12295d8d1..54581cc7f 100644 --- a/src/theme/DocItem/Content/index.tsx +++ b/src/theme/DocItem/Content/index.tsx @@ -6,10 +6,17 @@ import { observer } from 'mobx-react-lite'; import { useStore } from '@tdev-hooks/useStore'; import { useLocation } from '@docusaurus/router'; type Props = WrapperProps; +import { useDocsSidebar } from '@docusaurus/plugin-content-docs/client'; const ContentWrapper = observer((props: Props): React.ReactNode => { const pageStore = useStore('pageStore'); const location = useLocation(); + const sidebar = useDocsSidebar(); + React.useEffect(() => { + if (sidebar?.name) { + pageStore.configureSidebar(sidebar.name, sidebar); + } + }, [sidebar, pageStore]); React.useEffect(() => { if (pageStore.current) { diff --git a/src/theme/DocSidebarItem/index.tsx b/src/theme/DocSidebarItem/index.tsx new file mode 100644 index 000000000..1f674eeac --- /dev/null +++ b/src/theme/DocSidebarItem/index.tsx @@ -0,0 +1,31 @@ +import React, { type ReactNode } from 'react'; +import DocSidebarItem from '@theme-original/DocSidebarItem'; +import type DocSidebarItemType from '@theme/DocSidebarItem'; +import type { WrapperProps } from '@docusaurus/types'; +import { observer } from 'mobx-react-lite'; +import { useStore } from '@tdev-hooks/useStore'; +import Icon from '@mdi/react'; +import { mdiCheck, mdiCheckCircle, mdiProgressQuestion } from '@mdi/js'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; +import { useDocsSidebar } from '@docusaurus/plugin-content-docs/client'; + +type Props = WrapperProps; +const DocSidebarItemWrapper = observer((props: Props): ReactNode => { + const pageStore = useStore('pageStore'); + const { pid } = (props.item.customProps || {}) as { pid?: string }; + const page = pageStore.find(pid); + return ( +
+ + +
+ ); +}); + +export default DocSidebarItemWrapper; diff --git a/src/theme/DocSidebarItem/styles.module.scss b/src/theme/DocSidebarItem/styles.module.scss new file mode 100644 index 000000000..70b5b8da8 --- /dev/null +++ b/src/theme/DocSidebarItem/styles.module.scss @@ -0,0 +1,8 @@ +.item { + position: relative; + .icon { + position: absolute; + right: 0px; + top: 12px; + } +}