From 8593248de326779c69f501c92ea5028463f3af13 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Fri, 6 Feb 2026 14:54:45 +0100 Subject: [PATCH 1/4] chore: fetch multiple documents with post instead of query param --- src/api/OfflineApi/index.ts | 83 +++++++++++++++++---------------- src/api/document.ts | 4 +- src/api/documentRoot.ts | 15 +++++- src/stores/DocumentRootStore.ts | 12 ----- 4 files changed, 59 insertions(+), 55 deletions(-) diff --git a/src/api/OfflineApi/index.ts b/src/api/OfflineApi/index.ts index 1c743fe54..dbb32fc3c 100644 --- a/src/api/OfflineApi/index.ts +++ b/src/api/OfflineApi/index.ts @@ -159,14 +159,56 @@ export default class OfflineApi { // Method to handle POST requests async post(url: string, data: T, ...config: any): AxiosPromise { - const { model, id, query } = urlParts(url); + const { model, id, query, parts } = urlParts(url); log('post', url, data); switch (model) { case 'admin': return resolveResponse(data as unknown as T); case 'cms': return resolveResponse({} as unknown as T); + case 'users': + if (parts.length === 1 && parts[0] === 'documentRoots') { + const { documentRootIds, type } = data as { + documentRootIds: string[]; + type?: DocumentType; + }; + if (documentRootIds.length === 0) { + resolveResponse([] as unknown as T); + } + const documentRootDocs = await Promise.all( + documentRootIds.map((id) => this.documentsBy(id)) + ); + const filteredDocs = type + ? documentRootDocs.map((docs) => docs.filter((doc) => doc.type === type)) + : documentRootDocs; + + const documenRoots = documentRootIds.map((rid) => { + return { + id: rid, + access: Access.RW_DocumentRoot, + sharedAccess: Access.RW_DocumentRoot, + userPermissions: [], + groupPermissions: [], + documents: + filteredDocs.find( + (docs) => docs.length > 0 && docs[0].documentRootId === rid + ) || [] + }; + }) as unknown as T; + log('-> post', url, documenRoots); + return resolveResponse(documenRoots); + } + return resolveResponse([OfflineUser] as unknown as T); case 'documents': + if (url === 'documents/multiple') { + const documentRootIds = new Set((data as { documentRootIds: string[] }).documentRootIds); + const allDocuments = await this.dbAdapter.getAll>(DOCUMENTS_STORE); + + const filteredDocuments = allDocuments.filter((doc) => + documentRootIds.has(doc.documentRootId) + ); + return resolveResponse(filteredDocuments as unknown as T); + } const document = await this.upsertDocumentRecord( data as Partial>, query.has('uniqueMain') @@ -214,35 +256,6 @@ export default class OfflineApi { switch (model) { case 'user': return resolveResponse(OfflineUser as unknown as T); - case 'users': - if (parts.length === 1 && parts[0] === 'documentRoots') { - const ids = query.getAll('ids'); - const docType = query.get('type') as DocumentType | null; - if (ids.length === 0) { - resolveResponse([] as unknown as T); - } - const documentRootDocs = await Promise.all(ids.map((id) => this.documentsBy(id))); - const filteredDocs = docType - ? documentRootDocs.map((docs) => docs.filter((doc) => doc.type === docType)) - : documentRootDocs; - - const documenRoots = ids.map((rid) => { - return { - id: rid, - access: Access.RW_DocumentRoot, - sharedAccess: Access.RW_DocumentRoot, - userPermissions: [], - groupPermissions: [], - documents: - filteredDocs.find( - (docs) => docs.length > 0 && docs[0].documentRootId === rid - ) || [] - }; - }) as unknown as T; - log('-> get', url, documenRoots); - return resolveResponse(documenRoots); - } - return resolveResponse([OfflineUser] as unknown as T); case 'admin': return resolveResponse([] as unknown as T); case 'allowedActions': @@ -255,6 +268,7 @@ export default class OfflineApi { } return resolveResponse(null); } + // TODO: is this needed/used at all? if (query.has('ids')) { const ids = query.getAll('ids'); const filteredDocuments: Document[] = []; @@ -266,15 +280,6 @@ export default class OfflineApi { } return resolveResponse(filteredDocuments as unknown as T); } - if (query.has('rids')) { - const rids = query.getAll('rids'); - - const allDocuments = await this.dbAdapter.getAll>(DOCUMENTS_STORE); - - const filteredDocuments = allDocuments.filter((doc) => rids.includes(doc.documentRootId)); - - return resolveResponse(filteredDocuments as unknown as T); - } return resolveResponse( (await this.dbAdapter.getAll>(DOCUMENTS_STORE)) as unknown as T diff --git a/src/api/document.ts b/src/api/document.ts index 5172792f6..51f6c5abc 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -249,9 +249,7 @@ export function update( * TODO: would it be better to only grab documents from a specific student group? */ export function allDocuments(documentRootIds: string[], signal: AbortSignal): AxiosPromise[]> { - return api.get(`/documents?${documentRootIds.map((id) => `rids=${id}`).join('&')}`, { - signal - }); + return api.post('/documents/multiple', { documentRootIds }, { signal }); } export function linkTo( diff --git a/src/api/documentRoot.ts b/src/api/documentRoot.ts index b8710ab64..887adb2c7 100644 --- a/src/api/documentRoot.ts +++ b/src/api/documentRoot.ts @@ -58,7 +58,20 @@ export function findManyFor( if (documentType) { params.append('type', documentType); } - return api.get(`/users/${userId}/documentRoots?${params.toString()}`, { signal }); + const data: { + documentRootIds: string[]; + ignoreMissingRoots?: boolean; + type?: string; + } = { + documentRootIds: ids + }; + if (ignoreMissingRoots) { + data.ignoreMissingRoots = true; + } + if (documentType) { + data.type = documentType; + } + return api.post(`/users/${userId}/documentRoots`, data, { signal }); } export function create( diff --git a/src/stores/DocumentRootStore.ts b/src/stores/DocumentRootStore.ts index 1c5a38daa..db56726b8 100644 --- a/src/stores/DocumentRootStore.ts +++ b/src/stores/DocumentRootStore.ts @@ -161,18 +161,12 @@ export class DocumentRootStore extends iStore { access: accessConfig || {} }); this.loadQueued(); - if (this.queued.size > 42) { - // max 2048 characters in URL - flush if too many - this.loadQueued.flush(); - } } /** * load the documentRoots only * - after 20 ms of "silence" (=no further load-requests during this period) * - or after 25ms have elapsed - * - or when more then 42 records are queued (@see loadInNextBatch) - * (otherwise the URL maxlength would be reached) */ loadQueued = _.debounce(action(this._loadQueued), 25, { leading: false, @@ -192,12 +186,6 @@ export class DocumentRootStore extends iStore { } const batch = [...this.queued]; this.queued.clear(); - if (batch.length > 42) { - const postponed = batch.splice(42); - postponed.forEach((item) => this.queued.set(item[0], item[1])); - this.loadQueued(); - console.log('Postponing', postponed.length, 'document roots for next batch'); - } const currentBatch = new Map(batch); /** * if the user is not logged in, we can't load the documents From 0b42070432ffb2833f2a76486f9a2f5b85ec5c44 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Fri, 6 Feb 2026 20:37:56 +0100 Subject: [PATCH 2/4] only load the current viewed sidebar index --- src/api/OfflineApi/index.ts | 4 +- src/models/Page.ts | 9 ++++ src/stores/PageStore.ts | 75 ++++++++++++++++++++++++----- src/stores/UserStore.ts | 2 +- src/theme/DocItem/Content/index.tsx | 27 ----------- src/theme/Root.tsx | 12 ++++- 6 files changed, 87 insertions(+), 42 deletions(-) delete mode 100644 src/theme/DocItem/Content/index.tsx diff --git a/src/api/OfflineApi/index.ts b/src/api/OfflineApi/index.ts index dbb32fc3c..4d4aad345 100644 --- a/src/api/OfflineApi/index.ts +++ b/src/api/OfflineApi/index.ts @@ -198,7 +198,7 @@ export default class OfflineApi { log('-> post', url, documenRoots); return resolveResponse(documenRoots); } - return resolveResponse([OfflineUser] as unknown as T); + return resolveResponse([] as unknown as T); case 'documents': if (url === 'documents/multiple') { const documentRootIds = new Set((data as { documentRootIds: string[] }).documentRootIds); @@ -284,6 +284,8 @@ export default class OfflineApi { return resolveResponse( (await this.dbAdapter.getAll>(DOCUMENTS_STORE)) as unknown as T ); + case 'users': + return resolveResponse([OfflineUser] as unknown as T); case 'documentRoots': if (parts[0] === 'permissions') { return resolveResponse({ diff --git a/src/models/Page.ts b/src/models/Page.ts index c72527a3d..f25f22164 100644 --- a/src/models/Page.ts +++ b/src/models/Page.ts @@ -113,11 +113,20 @@ export default class Page { .sort((a, b) => a!.root!.meta!.pagePosition - b!.root!.meta.pagePosition); } + @computed + get primaryStudentGroupName() { + return this._primaryStudentGroupName ?? this.store.currentStudentGroupName; + } + @action setPrimaryStudentGroupName(name?: string) { this._primaryStudentGroupName = name; } + get hasCustomPrimaryStudentGroup() { + return !!this._primaryStudentGroupName; + } + @computed get primaryStudentGroup() { return this._primaryStudentGroupName diff --git a/src/stores/PageStore.ts b/src/stores/PageStore.ts index 18e50e2ab..b806a85f9 100644 --- a/src/stores/PageStore.ts +++ b/src/stores/PageStore.ts @@ -12,6 +12,10 @@ import globalData from '@generated/globalData'; const ensureTrailingSlash = (str: string) => { return str.endsWith('/') ? str : `${str}/`; }; +const ensureLeadingSlash = (str: string) => { + return str.startsWith('/') ? str : `/${str}`; +}; +const BasePathRegex = new RegExp(`^${siteConfig.baseUrl}`, 'i'); export const AUTO_GENERATED_PAGE_PREFIX = '__auto_generated__'; export const SidebarVersions = ( globalData['docusaurus-plugin-content-docs'].default as GlobalPluginData @@ -43,12 +47,14 @@ interface PagesIndex { export class PageStore extends iStore { readonly root: RootStore; - pages = observable([]); + pages = observable([], { deep: false }); @observable accessor currentPageId: string | undefined = undefined; @observable accessor runningTurtleScriptId: string | undefined = undefined; @observable.ref accessor _pageIndex: PageIndex[] = []; + @observable accessor currentPath: string | undefined = undefined; + loadedPageIndices = new Set(); constructor(store: RootStore) { super(); @@ -64,9 +70,14 @@ export class PageStore extends iStore { return this.pages.filter((page) => page.isLandingpage); } + @computed + get isPageIndexLoaded() { + return this._pageIndex.length > 0; + } + @action loadPageIndex(force: boolean = false) { - if (!force && this._pageIndex.length > 0) { + if (!force && this.isPageIndexLoaded) { return Promise.resolve(); } return fetch(`${siteConfig.baseUrl}tdev-artifacts/page-progress-state/pageIndex.json`) @@ -95,24 +106,64 @@ export class PageStore extends iStore { this._pageIndex = data.documentRoots; }) ) - .then(() => { - this.loadTaskableDocuments(); - }) .catch((err) => { console.error('Failed to load page index', err); }); } @action - loadTaskableDocuments() { - this.pages.forEach((page) => { - page.taskableDocumentRootIds.forEach((id) => { - this.root.documentRootStore.loadInNextBatch(id, undefined, { - skipCreate: true, - documentRoot: 'addIfMissing' + setCurrentPath(path: string | undefined) { + if (path === this.currentPath) { + return; + } + if (!path) { + this.currentPath = undefined; + return; + } + this.currentPath = path.replace(BasePathRegex, '/'); + if (this.isPageIndexLoaded) { + this.loadTaskableDocuments(this.currentStudentGroupName); + } + this.resetPagesStudentGroups(); + } + + @action + resetPagesStudentGroups() { + this.pages + .filter((p) => p.hasCustomPrimaryStudentGroup) + .forEach((p) => p.setPrimaryStudentGroupName(undefined)); + } + + @computed + get currentPathParts() { + if (!this.currentPath) { + return []; + } + return this.currentPath.split('/').filter((p) => p.length > 0); + } + + @computed + get currentStudentGroupName() { + return this.currentPathParts[0]; + } + + @action + loadTaskableDocuments(pathPrefix: string | undefined, force?: boolean) { + const prefix = ensureLeadingSlash(ensureTrailingSlash(pathPrefix ?? '')); + if (!force && this.loadedPageIndices.has(prefix)) { + return; + } + this.loadedPageIndices.add(prefix); + this.pages + .filter((p) => p.path.startsWith(prefix)) + .forEach((page) => { + page.taskableDocumentRootIds.forEach((id) => { + this.root.documentRootStore.loadInNextBatch(id, undefined, { + skipCreate: true, + documentRoot: 'addIfMissing' + }); }); }); - }); } find = computedFn( diff --git a/src/stores/UserStore.ts b/src/stores/UserStore.ts index 6d41f3378..34b03788d 100644 --- a/src/stores/UserStore.ts +++ b/src/stores/UserStore.ts @@ -160,7 +160,7 @@ export class UserStore extends iStore<`update-${string}`> { return this.withAbortController(`load-all`, async (ct) => { return apiAll(ct.signal).then( action((res) => { - const models = res.data.map((d) => this.createModel(d)); + const models = res.data?.map((d) => this.createModel(d)); this.users.replace(models); }) ); diff --git a/src/theme/DocItem/Content/index.tsx b/src/theme/DocItem/Content/index.tsx deleted file mode 100644 index b8cb753cb..000000000 --- a/src/theme/DocItem/Content/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import Content from '@theme-original/DocItem/Content'; -import type ContentType from '@theme/DocItem/Content'; -import type { WrapperProps } from '@docusaurus/types'; -import { observer } from 'mobx-react-lite'; -import { useStore } from '@tdev-hooks/useStore'; -import { useLocation } from '@docusaurus/router'; -type Props = WrapperProps; - -const ContentWrapper = observer((props: Props): React.ReactNode => { - const pageStore = useStore('pageStore'); - const location = useLocation(); - - React.useEffect(() => { - if (pageStore.current) { - const primaryClass = location.pathname.split('/')[1]; - pageStore.current.setPrimaryStudentGroupName(primaryClass); - } - return () => { - pageStore.current?.setPrimaryStudentGroupName(undefined); - }; - }, [pageStore.current, location.pathname]); - - return ; -}); - -export default ContentWrapper; diff --git a/src/theme/Root.tsx b/src/theme/Root.tsx index 3fba443d5..dd1b10f47 100644 --- a/src/theme/Root.tsx +++ b/src/theme/Root.tsx @@ -6,7 +6,7 @@ import siteConfig from '@generated/docusaurus.config'; import { useStore } from '@tdev-hooks/useStore'; import { reaction } from 'mobx'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; -import { useHistory } from '@docusaurus/router'; +import { useHistory, useLocation } from '@docusaurus/router'; import LoggedOutOverlay from '@tdev-components/LoggedOutOverlay'; import { authClient } from '@tdev/auth-client'; import { getOfflineUser } from '@tdev-api/OfflineApi'; @@ -219,6 +219,15 @@ const DevGlobalDataTracker = observer(() => { return null; }); +const VisitedPagesTracker = observer(() => { + const location = useLocation(); + const pageStore = useStore('pageStore'); + React.useEffect(() => { + pageStore.setCurrentPath(location.pathname); + }, [location]); + return null; +}); + function Root({ children }: { children: React.ReactNode }) { const { siteConfig } = useDocusaurusContext(); @@ -246,6 +255,7 @@ function Root({ children }: { children: React.ReactNode }) { {SENTRY_DSN && } + {children} From bc0fb9ce00e0aa92be05580c6bf2c0224f21bd8e Mon Sep 17 00:00:00 2001 From: bh0fer Date: Fri, 6 Feb 2026 20:48:32 +0100 Subject: [PATCH 3/4] ensure loading page index on initial load --- src/stores/PageStore.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/stores/PageStore.ts b/src/stores/PageStore.ts index b806a85f9..3778b6950 100644 --- a/src/stores/PageStore.ts +++ b/src/stores/PageStore.ts @@ -106,6 +106,11 @@ export class PageStore extends iStore { this._pageIndex = data.documentRoots; }) ) + .then(() => { + if (this.currentPath) { + this.loadTaskableDocuments(this.currentStudentGroupName); + } + }) .catch((err) => { console.error('Failed to load page index', err); }); From 16a6d0a6bf59dd2135a6613e1d919e182a2d2837 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Fri, 6 Feb 2026 21:04:03 +0100 Subject: [PATCH 4/4] make sure only versions can be loaded --- src/stores/PageStore.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/stores/PageStore.ts b/src/stores/PageStore.ts index 3778b6950..32f706d59 100644 --- a/src/stores/PageStore.ts +++ b/src/stores/PageStore.ts @@ -65,6 +65,11 @@ export class PageStore extends iStore { return SidebarVersions; } + @computed + get sidebarVersionPaths() { + return SidebarVersions.map((version) => version.versionPath); + } + @computed get landingPages() { return this.pages.filter((page) => page.isLandingpage); @@ -155,12 +160,16 @@ export class PageStore extends iStore { @action loadTaskableDocuments(pathPrefix: string | undefined, force?: boolean) { const prefix = ensureLeadingSlash(ensureTrailingSlash(pathPrefix ?? '')); + const isEntryPoint = this.sidebarVersionPaths.includes(prefix); + if (!isEntryPoint) { + return; + } if (!force && this.loadedPageIndices.has(prefix)) { return; } this.loadedPageIndices.add(prefix); this.pages - .filter((p) => p.path.startsWith(prefix)) + .filter((p) => p.path.startsWith(prefix) && !p.isAutoGenerated) .forEach((page) => { page.taskableDocumentRootIds.forEach((id) => { this.root.documentRootStore.loadInNextBatch(id, undefined, {