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;
+ }
+}