From 50db547670074684b304982df236806cfbb2abae Mon Sep 17 00:00:00 2001 From: bh0fer Date: Tue, 21 Jul 2026 00:38:55 +0200 Subject: [PATCH 01/18] feature: create presenting mode for documents --- packages/tdev/brython-code/index.ts | 3 +- packages/tdev/pyodide-code/index.ts | 3 +- src/api/document.ts | 24 +++++++- .../CodeEditor/Editor/Header/Content.tsx | 6 ++ .../shared/RequestFullscreen/index.tsx | 2 +- .../shared/RequestPresentationMode/index.tsx | 47 ++++++++++++++++ .../styles.module.scss | 0 src/models/documents/Code.ts | 3 +- src/models/documents/iCode/index.ts | 50 +++++++++++++++-- src/stores/DocumentStore.ts | 55 ++++++++++++++++++- 10 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 src/components/shared/RequestPresentationMode/index.tsx create mode 100644 src/components/shared/RequestPresentationMode/styles.module.scss diff --git a/packages/tdev/brython-code/index.ts b/packages/tdev/brython-code/index.ts index e696ffd0e..f1d3cb0b3 100644 --- a/packages/tdev/brython-code/index.ts +++ b/packages/tdev/brython-code/index.ts @@ -1,3 +1,4 @@ +import { Presentable } from '@tdev-api/document'; import Script from './models/Script'; /** * Notes @@ -6,7 +7,7 @@ import Script from './models/Script'; * - call brython with arguments: https://github.com/brython-dev/brython/issues/2421 * */ -export interface ScriptData { +export interface ScriptData extends Presentable { code: string; } diff --git a/packages/tdev/pyodide-code/index.ts b/packages/tdev/pyodide-code/index.ts index 880670449..6901d943c 100644 --- a/packages/tdev/pyodide-code/index.ts +++ b/packages/tdev/pyodide-code/index.ts @@ -1,6 +1,7 @@ import type PyodideStore from './stores/PyodideStore'; import PyodideCode from './models/PyodideCode'; -export interface PyodideData { +import { Presentable } from '@tdev-api/document'; +export interface PyodideData extends Presentable { code: string; } diff --git a/src/api/document.ts b/src/api/document.ts index 6c267fb33..fb7ca6ee7 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -32,6 +32,10 @@ export enum Access { None_User = 'None_User' } +export interface Presentable { + isPresenting?: boolean; +} + export interface ScriptVersionData { code: string; pasted?: boolean; @@ -45,7 +49,7 @@ export interface QuillV2Data { delta: Delta; } -export interface CodeData { +export interface CodeData extends Presentable { code: string; } @@ -136,6 +140,12 @@ type KeysWithCode = { export type CodeType = KeysWithCode; +type PresentableKeys = { + [K in keyof T]: 'isPresenting' extends keyof T[K] ? K : never; +}[keyof T]; + +export type PresentableType = PresentableKeys; + export interface ContainerTypeModelMapping { ['_container_placeholder_']: iDocumentContainer; // placeholder to avoid empty interface error } @@ -174,6 +184,16 @@ export type TaskableModelType = TaskableTypeModelMapping[TaskableType]; export type DocumentType = keyof TypeModelMapping; export type DocumentModelType = TypeModelMapping[DocumentType]; +export type PresentableModelType = TypeModelMapping[PresentableType]; + +export interface iPresentable { + setPresenting(isPresenting?: boolean): void; + isPresenting: boolean; +} + +// ensure all PresentableModelTypes implement `iPresentable`: +type EnsureAllPresentable = T; +null as unknown as EnsureAllPresentable>; /** * Document types that can be edited by admins ON BEHALF OF other users. @@ -203,7 +223,7 @@ export type Factory = ( export function find( id: string, signal: AbortSignal -): AxiosPromise> { +): AxiosPromise<{ document: Document; highestPermission: Access }> { return api.get(`/documents/${id}`, { signal }); } diff --git a/src/components/documents/CodeEditor/Editor/Header/Content.tsx b/src/components/documents/CodeEditor/Editor/Header/Content.tsx index de9f49791..5a6139250 100644 --- a/src/components/documents/CodeEditor/Editor/Header/Content.tsx +++ b/src/components/documents/CodeEditor/Editor/Header/Content.tsx @@ -14,6 +14,7 @@ import RequestFullscreen from '@tdev-components/shared/RequestFullscreen'; import { useFullscreenTargetId } from '@tdev-hooks/useFullscreenTargetId'; import { reaction } from 'mobx'; import { useStore } from '@tdev-hooks/useStore'; +import RequestPresentationMode from '@tdev-components/shared/RequestPresentationMode'; interface Props { code: iCode; @@ -38,6 +39,11 @@ const Content = observer((props: Props) => { <>
{code.title}
+ { viewStore.requestFullscreen(id); } }} - className={props.className} + className={className} color={props.color || 'blue'} size={props.size} title={isFullscreen ? 'Vollbildmodus beenden' : 'Vollbildmodus'} diff --git a/src/components/shared/RequestPresentationMode/index.tsx b/src/components/shared/RequestPresentationMode/index.tsx new file mode 100644 index 000000000..110a10279 --- /dev/null +++ b/src/components/shared/RequestPresentationMode/index.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; +import { observer } from 'mobx-react-lite'; +import Button from '../Button'; +import { mdiPresentationPlay, mdiTelevisionStop } from '@mdi/js'; +import { Color } from '../Colors'; +import { useStore } from '@tdev-hooks/useStore'; +import { iPresentable, PresentableModelType } from '@tdev-api/document'; + +interface Props { + document: iPresentable; + size?: number; + color?: Color | string; + adminOnly?: boolean; + className?: string; +} + +const RequestPresentationMode = observer((props: Props) => { + const { document, className } = props; + const userStore = useStore('userStore'); + // React.useEffect(() => { + // return () => { + // if (props.adminOnly && userStore.current?.hasElevatedAccess) { + // document.setPresenting(false); + // } + // }; + // }, [props.adminOnly]); + if (props.adminOnly && !userStore.current?.hasElevatedAccess) { + return null; + } + return ( +
} + modal + overlayStyle={{ background: 'rgba(226, 222, 222, 0.84)' }} + open={!!viewStore.presentedDocument} + repositionOnResize + closeOnDocumentClick={false} + closeOnEscape={false} + > + + + ); +}); + +export default PresentationModal; diff --git a/src/components/PresentationPanel/PresentationModal/styles.module.scss b/src/components/PresentationPanel/PresentationModal/styles.module.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/PresentationPanel/index.tsx b/src/components/PresentationPanel/index.tsx new file mode 100644 index 000000000..b73acd35d --- /dev/null +++ b/src/components/PresentationPanel/index.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; +import { observer } from 'mobx-react-lite'; +import { useStore } from '@tdev-hooks/useStore'; +import { CodeType } from '@tdev-api/document'; +import Editor from '@tdev-components/documents/CodeEditor/Editor'; +import type iCode from '@tdev-models/documents/iCode'; +import Button from '@tdev-components/shared/Button'; +import { mdiClose } from '@mdi/js'; +import CodeEditorComponent from '@tdev-components/documents/CodeEditor'; + +interface Props {} + +const PresentationPanel = observer((props: Props) => { + const documentStore = useStore('documentStore'); + const viewStore = useStore('viewStore'); + const componentStore = useStore('componentStore'); + const doc = viewStore.presentedDocument as iCode; + const EC = doc ? componentStore.editorComponent(doc.type as CodeType) : null; + if (!EC) { + return
Kein Editor für Dokumenttyp {doc?.type}
; + } + + return ( +
+
+ ); +}); + +export default PresentationPanel; diff --git a/src/components/PresentationPanel/styles.module.scss b/src/components/PresentationPanel/styles.module.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/documents/CodeEditor/index.tsx b/src/components/documents/CodeEditor/index.tsx index dcb79f233..588d209c6 100644 --- a/src/components/documents/CodeEditor/index.tsx +++ b/src/components/documents/CodeEditor/index.tsx @@ -55,6 +55,7 @@ export const CodeEditor = observer((props: Props) => { export interface ScriptProps { code: iCode; className?: string; + isPresentation?: boolean; } const CodeEditorComponent = observer((props: ScriptProps) => { @@ -62,6 +63,9 @@ const CodeEditorComponent = observer((props: ScriptProps) const { colorMode } = useCodeTheme(); const viewStore = useStore('viewStore'); const id = React.useId(); + if (!props.isPresentation && code.isPresenting) { + return null; + } return (
{ return (
+
+ +
Stream-Berechtigung
+
+ 0 ? 'red' : 'lightBlue'}> + {streamableGroups.length} Gruppen + +
+
+ + Während Prüfungen sollten diese Berechtigungen deaktiviert werden, da sonst + potenziell sensible Informationen ausgetauscht werden können. + +
+
Aktionen
+
+
+
+
{(() => { let searchRegex; diff --git a/src/components/PresentationPanel/PresentationModal/index.tsx b/src/components/PresentationPanel/PresentationModal/index.tsx index 400621c29..ad2764b2b 100644 --- a/src/components/PresentationPanel/PresentationModal/index.tsx +++ b/src/components/PresentationPanel/PresentationModal/index.tsx @@ -13,7 +13,6 @@ const PresentationModal = observer((props: Props) => { return ( s
} modal overlayStyle={{ background: 'rgba(226, 222, 222, 0.84)' }} open={!!viewStore.presentedDocument} diff --git a/src/components/PresentationPanel/index.tsx b/src/components/PresentationPanel/index.tsx index b73acd35d..8509b8a69 100644 --- a/src/components/PresentationPanel/index.tsx +++ b/src/components/PresentationPanel/index.tsx @@ -4,7 +4,6 @@ import styles from './styles.module.scss'; import { observer } from 'mobx-react-lite'; import { useStore } from '@tdev-hooks/useStore'; import { CodeType } from '@tdev-api/document'; -import Editor from '@tdev-components/documents/CodeEditor/Editor'; import type iCode from '@tdev-models/documents/iCode'; import Button from '@tdev-components/shared/Button'; import { mdiClose } from '@mdi/js'; @@ -13,17 +12,30 @@ import CodeEditorComponent from '@tdev-components/documents/CodeEditor'; interface Props {} const PresentationPanel = observer((props: Props) => { + const groupStore = useStore('studentGroupStore'); const documentStore = useStore('documentStore'); const viewStore = useStore('viewStore'); const componentStore = useStore('componentStore'); const doc = viewStore.presentedDocument as iCode; const EC = doc ? componentStore.editorComponent(doc.type as CodeType) : null; + React.useEffect(() => { + const rid = groupStore.managedStudentGroups[0]?.id; + if (rid && doc?.isPresenting && !doc.isDummy) { + console.log('join room', doc.documentRootId); + documentStore.root.socketStore.joinRoom(rid); + } + return () => { + if (rid) { + documentStore.root.socketStore.leaveRoom(rid); + } + }; + }, [doc?.id]); if (!EC) { return
Kein Editor für Dokumenttyp {doc?.type}
; } return ( -
+
diff --git a/src/components/PresentationPanel/styles.module.scss b/src/components/PresentationPanel/styles.module.scss index e69de29bb..b86ea97fe 100644 --- a/src/components/PresentationPanel/styles.module.scss +++ b/src/components/PresentationPanel/styles.module.scss @@ -0,0 +1,4 @@ +.presentationMode { + width: 75vw; + height: 100%; +} diff --git a/src/components/StudentGroup/index.tsx b/src/components/StudentGroup/index.tsx index bb4c76787..ec9214271 100644 --- a/src/components/StudentGroup/index.tsx +++ b/src/components/StudentGroup/index.tsx @@ -14,12 +14,12 @@ import { mdiCloseBox, mdiCloseCircleOutline, mdiContentSave, - mdiDatabaseExport, mdiDownloadLockOutline, mdiFileExcelOutline, mdiFormTextboxPassword, mdiLanguageHtml5, - mdiLoading, + mdiToggleSwitch, + mdiToggleSwitchOff, mdiTrashCanOutline } from '@mdi/js'; import { useStore } from '@tdev-hooks/useStore'; @@ -36,7 +36,6 @@ import AssignCredentials from './AssignCredentials'; import Card from '@tdev-components/shared/Card'; import Popup from 'reactjs-popup'; import { exportNewPasswordList } from './services/excelNewPwExport'; -import { ApiState } from '@tdev-stores/iStore'; import ExportModal from '@tdev-components/Admin/ExportPanel/ExportModal'; interface Props { @@ -153,6 +152,16 @@ const StudentGroup = observer((props: Props) => { {isAdmin && ( <> +
Stream Updates
+
+
Export
diff --git a/src/components/shared/RequestPresentationMode/index.tsx b/src/components/shared/RequestPresentationMode/index.tsx index fd11eee5e..326ff987c 100644 --- a/src/components/shared/RequestPresentationMode/index.tsx +++ b/src/components/shared/RequestPresentationMode/index.tsx @@ -29,6 +29,9 @@ const RequestPresentationMode = observer((props: Props) => { if (props.adminOnly && !userStore.current?.hasElevatedAccess) { return null; } + if (document.isDummy) { + return null; + } return (
diff --git a/src/components/MdxPage/index.tsx b/src/components/MdxPage/index.tsx index 76051be13..a92d92b89 100644 --- a/src/components/MdxPage/index.tsx +++ b/src/components/MdxPage/index.tsx @@ -10,8 +10,10 @@ interface Props { } export class PageMeta extends TypeMeta { + readonly props: Partial<{}>; constructor() { super('_page_' as DocumentType); + this.props = {}; } get defaultData() { return {}; diff --git a/src/components/StudentGroup/index.tsx b/src/components/StudentGroup/index.tsx index ec9214271..5150ef0b6 100644 --- a/src/components/StudentGroup/index.tsx +++ b/src/components/StudentGroup/index.tsx @@ -152,16 +152,23 @@ const StudentGroup = observer((props: Props) => { {isAdmin && ( <> -
Stream Updates
+
Präsentieren
+ {group.presentedDocument && ( +
+
+                                        {JSON.stringify(group.presentedDocument, null, 2)}
+                                    
+
+ )}
Export
diff --git a/src/components/documents/CodeEditor/Actions/RunCode.tsx b/src/components/documents/CodeEditor/Actions/RunCode.tsx index 89ccafceb..2d0ecf544 100644 --- a/src/components/documents/CodeEditor/Actions/RunCode.tsx +++ b/src/components/documents/CodeEditor/Actions/RunCode.tsx @@ -25,7 +25,10 @@ const RunCode = observer((props: Props) => { className={clsx(styles.runCode, code.meta.slim && styles.slim)} iconSize={code.meta.slim ? '1.15em' : '1.6em'} onClick={() => { - props.onExecute?.(); + if (props.onExecute) { + code.triggerRemoteAction({ action: 'runCode' }); + props.onExecute(); + } }} title={`${code.title} ausführen`} /> diff --git a/src/components/shared/RequestPresentationMode/index.tsx b/src/components/shared/RequestPresentationMode/index.tsx index 326ff987c..825a4ed33 100644 --- a/src/components/shared/RequestPresentationMode/index.tsx +++ b/src/components/shared/RequestPresentationMode/index.tsx @@ -7,43 +7,59 @@ import { mdiPresentationPlay, mdiTelevisionStop } from '@mdi/js'; import { Color } from '../Colors'; import { useStore } from '@tdev-hooks/useStore'; import { iPresentable, PresentableModelType } from '@tdev-api/document'; +import Popup from 'reactjs-popup'; +import Card from '../Card'; +import iDocument from '@tdev-models/iDocument'; interface Props { - document: iPresentable; + document: iDocument; size?: number; color?: Color | string; - adminOnly?: boolean; className?: string; } const RequestPresentationMode = observer((props: Props) => { const { document, className } = props; const userStore = useStore('userStore'); - // React.useEffect(() => { - // return () => { - // if (props.adminOnly && userStore.current?.hasElevatedAccess) { - // document.setPresenting(false); - // } - // }; - // }, [props.adminOnly]); - if (props.adminOnly && !userStore.current?.hasElevatedAccess) { + const groupStore = useStore('studentGroupStore'); + if (document.isDummy) { return null; } - if (document.isDummy) { + if (groupStore.managedStudentGroups.length === 0 || !userStore.current) { return null; } return ( - + ))} + + ); }); diff --git a/src/models/DocumentRoot.ts b/src/models/DocumentRoot.ts index 72d052832..c15fbd06f 100644 --- a/src/models/DocumentRoot.ts +++ b/src/models/DocumentRoot.ts @@ -18,6 +18,7 @@ export abstract class TypeMeta { this.pagePosition = pagePosition || 0; } abstract get defaultData(): TypeDataMapping[T]; + abstract get props(): Record; } class DocumentRoot { diff --git a/src/models/StudentGroup.ts b/src/models/StudentGroup.ts index 2089dc4a8..c4ae2f3f1 100644 --- a/src/models/StudentGroup.ts +++ b/src/models/StudentGroup.ts @@ -1,5 +1,5 @@ import { action, computed, observable } from 'mobx'; -import { StudentGroup as StudentGroupProps } from '@tdev-api/studentGroup'; +import { DocumentPresentation, StudentGroup as StudentGroupProps } from '@tdev-api/studentGroup'; import { StudentGroupStore } from '@tdev-stores/StudentGroupStore'; import { formatDateTime } from '@tdev-models/helpers/date'; import User from '@tdev-models/User'; @@ -17,7 +17,8 @@ class StudentGroup { @observable accessor parentId: string | null; @observable accessor isEditing: boolean = false; - @observable accessor canStreamUpdates: boolean; + @observable accessor canPresent: boolean; + @observable.ref accessor presentedDocument: DocumentPresentation | null; readonly _pristine: { name: string; description: string }; @@ -34,7 +35,8 @@ class StudentGroup { }; this.name = props.name; this.description = props.description; - this.canStreamUpdates = props.canStreamUpdates; + this.canPresent = !!props.canPresent; + this.presentedDocument = props.presentedDocument ?? null; this.userIds.replace(props.userIds); this.adminIds.replace(props.adminIds); @@ -127,11 +129,20 @@ class StudentGroup { } @action - setCanStreamUpdates(canStreamUpdates: boolean) { - if (this.canStreamUpdates === canStreamUpdates) { + setCanPresent(canPresent: boolean) { + if (this.canPresent === canPresent || !this.isGroupAdmin) { return; } - this.canStreamUpdates = canStreamUpdates; + this.canPresent = canPresent; + this.save(); + } + + @action + setPresentedDocument(presentedDocument: DocumentPresentation | null) { + if (this.presentedDocument === presentedDocument || !this.isGroupAdmin) { + return; + } + this.presentedDocument = presentedDocument; this.save(); } @@ -147,7 +158,8 @@ class StudentGroup { name: this.name, description: this.description, parentId: this.parentId, - canStreamUpdates: this.canStreamUpdates + canPresent: this.canPresent, + presentedDocument: this.presentedDocument }; } diff --git a/src/models/documents/DynamicDocumentRoots/ContainerMeta.ts b/src/models/documents/DynamicDocumentRoots/ContainerMeta.ts index a2b890e75..2ee36b3a1 100644 --- a/src/models/documents/DynamicDocumentRoots/ContainerMeta.ts +++ b/src/models/documents/DynamicDocumentRoots/ContainerMeta.ts @@ -6,14 +6,21 @@ interface Options { description?: string; } +export interface MetaInit { + type: Type; + options?: Options; +} + export class ContainerMeta extends TypeMeta { readonly type: T; readonly description?: string; + readonly props: Partial<{ type: T; options: Options }>; - constructor(type: T, options?: Options) { - super(type, options?.access); - this.type = type; - this.description = options?.description; + constructor(props: MetaInit) { + super(props.type, props.options?.access); + this.type = props.type; + this.description = props.options?.description; + this.props = props; } get name(): string { diff --git a/src/models/documents/DynamicDocumentRoots/index.ts b/src/models/documents/DynamicDocumentRoots/index.ts index da808568c..013b68acb 100644 --- a/src/models/documents/DynamicDocumentRoots/index.ts +++ b/src/models/documents/DynamicDocumentRoots/index.ts @@ -23,10 +23,12 @@ export interface MetaInit { export class ModelMeta extends TypeMeta<'dynamic_document_roots'> { readonly type = 'dynamic_document_roots'; readonly containerType: Type; + readonly props: Partial>; constructor(props: MetaInit) { super('dynamic_document_roots', props.readonly ? Access.RO_User : undefined); this.containerType = props.type; + this.props = props; } get defaultData(): TypeDataMapping['dynamic_document_roots'] { @@ -82,7 +84,7 @@ class DynamicDocumentRoots extends iDocument<'dynami get defaultContainerMeta(): ContainerMeta { const component = this.store.root.componentStore.getComponent(this.containerType); if (!component) { - return new ContainerMeta(this.containerType); + return new ContainerMeta({ type: this.containerType }); } return component.defaultMeta; } @@ -97,7 +99,7 @@ class DynamicDocumentRoots extends iDocument<'dynami return; } this.store.root.documentRootStore - .create(id, new ContainerMeta(this.containerType), { + .create(id, new ContainerMeta({ type: this.containerType }), { access: Access.None_DocumentRoot, sharedAccess: Access.RO_DocumentRoot }) diff --git a/src/models/documents/iCode/index.ts b/src/models/documents/iCode/index.ts index 22fa21ac7..83b95b5da 100644 --- a/src/models/documents/iCode/index.ts +++ b/src/models/documents/iCode/index.ts @@ -23,6 +23,10 @@ interface Version { pasted?: boolean; } +export interface CodePostUpdateMeta { + action?: 'runCode'; +} + class iCode extends iDocument implements iPresentable { @observable accessor code: string; @observable accessor _initialVersionsLoaded: boolean = false; @@ -50,7 +54,7 @@ class iCode extends iDocument implements iPres return; } this._isPresenting = isPresenting ?? false; - this.save(); + this.saveNow(); } @computed @@ -90,7 +94,7 @@ class iCode extends iDocument implements iPres { id: this.id, data: this.data, - updatedAt: this.updatedAt.toISOString() + updatedAt: this.updatedAt } ); } @@ -166,9 +170,13 @@ class iCode extends iDocument implements iPres } else if (this._isPresenting) { this._isPresenting = false; } - this.setCode(data.code); + if ('code' in data) { + this.setCode(data.code); + } } else { - this.code = data.code; + if ('code' in data) { + this.code = data.code; + } if (data.isPresenting) { this._isPresenting = data.isPresenting; } else if (this._isPresenting) { @@ -180,6 +188,26 @@ class iCode extends iDocument implements iPres } } + @action + postUpdate(meta?: CodePostUpdateMeta) { + if (!meta) { + return; + } + if (meta.action === 'runCode') { + this.runCode(); + } + } + + @action + triggerRemoteAction(data: CodePostUpdateMeta) { + if (!this.isPresenting || !this.canEdit || !this.canExecute) { + return; + } + // this.store.root.socketStore.streamUpdate( + + // ) + } + @computed get codeLines() { return this.code.split('\n').length; diff --git a/src/models/iDocument.ts b/src/models/iDocument.ts index 39f8f89bc..f650aebc3 100644 --- a/src/models/iDocument.ts +++ b/src/models/iDocument.ts @@ -121,6 +121,10 @@ abstract class iDocument { abstract setData(data: TypeDataMapping[Type], from: Source, updatedAt?: Date): void; + postUpdate>(meta?: T) { + // Implementation for post-update logic + } + @computed get derivedData() { return this.sideEffects.reduce((acc, se) => { diff --git a/src/stores/DocumentStore.ts b/src/stores/DocumentStore.ts index 6ec06c9e3..448ce0372 100644 --- a/src/stores/DocumentStore.ts +++ b/src/stores/DocumentStore.ts @@ -344,11 +344,12 @@ class DocumentStore extends iStore<`delete-${string}`> { return; } model.setData(change.data as any, Source.API, updatedAt); + model.postUpdate(change.meta); } else if ('isPresenting' in change.data) { // TODO: document in PR, that 'isPresenting' is a special property that is only used // for presentable documents. // probably the document was not loaded yet - try to load it from the api - this.apiLoadDocument(change.id); + this.apiLoadDocument(change.id, change.meta); } } @@ -362,7 +363,7 @@ class DocumentStore extends iStore<`delete-${string}`> { }); @action - _apiLoadDocument(id: string) { + _apiLoadDocument(id: string, meta?: Record) { return this.withAbortController(`load-${id}`, (sig) => { return apiFind(id, sig.signal); }) @@ -390,6 +391,10 @@ class DocumentStore extends iStore<`delete-${string}`> { ); } const model = this.addToStore(data.document); + if (meta) { + model?.postUpdate(meta); + } + return model; }) .catch((err) => { diff --git a/src/stores/SocketDataStore.ts b/src/stores/SocketDataStore.ts index d953cd066..a0ab1b983 100644 --- a/src/stores/SocketDataStore.ts +++ b/src/stores/SocketDataStore.ts @@ -205,8 +205,8 @@ export class SocketDataStore extends iStore<'ping'> { * in the payload, which usually is a documentRootId) */ @action - streamUpdate(roomId: string, payload: ChangedDocument) { - this.socket?.emit(IoClientEvent.STREAM_UPDATE, { ...payload, roomId }); + streamUpdate>(roomId: string, payload: ChangedDocument, meta?: T) { + this.socket?.emit(IoClientEvent.STREAM_UPDATE, { ...payload, roomId, meta }); } @action From bdbebd6c04a1d03e97f39feb9c737b47138b3383 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Wed, 22 Jul 2026 23:10:38 +0200 Subject: [PATCH 05/18] refactor model meta --- .../tdev/brython-code/models/ScriptMeta.ts | 2 +- packages/tdev/excalidoc/model/ModelMeta.ts | 2 +- .../tdev/netpbm-graphic/model/ModelMeta.ts | 4 +--- .../tdev/page-read-check/model/ModelMeta.ts | 4 +--- .../tdev/pyodide-code/models/ModelMeta.ts | 2 +- .../models/SimpleChat/ModelMeta.ts | 2 +- .../models/TextMessage/ModelMeta.ts | 4 +--- src/api/document.ts | 19 ------------------- src/components/MdxPage/index.tsx | 2 -- .../shared/RequestPresentationMode/index.tsx | 1 - src/models/DocumentRoot.ts | 15 +++++++++++---- src/models/documents/CmsText.ts | 2 +- src/models/documents/Code.ts | 5 ++--- .../DynamicDocumentRoots/ContainerMeta.ts | 10 ++++------ .../documents/DynamicDocumentRoots/index.ts | 6 +++--- .../documents/FileSystem/iFileSystem.ts | 2 +- src/models/documents/ProgressState/index.ts | 2 +- src/models/documents/QuillV2/index.ts | 2 +- src/models/documents/Restricted.ts | 2 +- src/models/documents/Solution.ts | 2 +- src/models/documents/String.ts | 2 +- src/models/documents/TaskState.ts | 2 +- src/models/documents/_scaffold.ts | 2 +- src/models/documents/iCode/iCodeMeta.ts | 4 ++-- src/models/documents/iCode/index.ts | 12 +++--------- src/stores/StudentGroupStore.ts | 15 ++++++++++++--- 26 files changed, 53 insertions(+), 74 deletions(-) diff --git a/packages/tdev/brython-code/models/ScriptMeta.ts b/packages/tdev/brython-code/models/ScriptMeta.ts index 6814218e9..07bf23057 100644 --- a/packages/tdev/brython-code/models/ScriptMeta.ts +++ b/packages/tdev/brython-code/models/ScriptMeta.ts @@ -15,6 +15,6 @@ export const createModel: Factory = (data, store) => { export class ScriptMeta extends iScriptMeta<'script'> { constructor(props: Partial>) { - super(props, 'script'); + super('script', props); } } diff --git a/packages/tdev/excalidoc/model/ModelMeta.ts b/packages/tdev/excalidoc/model/ModelMeta.ts index 53a905158..cb5d46f68 100644 --- a/packages/tdev/excalidoc/model/ModelMeta.ts +++ b/packages/tdev/excalidoc/model/ModelMeta.ts @@ -18,7 +18,7 @@ export class ModelMeta extends TypeMeta<'excalidoc'> { readonly defaultImage: string; constructor(props: Partial) { - super('excalidoc', props.readonly ? Access.RO_User : undefined); + super('excalidoc', props); this.defaultElements = props.defaultElements || []; this.defaultFiles = props.defaultFiles || {}; this.defaultImage = props.defaultImage || ''; diff --git a/packages/tdev/netpbm-graphic/model/ModelMeta.ts b/packages/tdev/netpbm-graphic/model/ModelMeta.ts index e56383bba..b26ee9911 100644 --- a/packages/tdev/netpbm-graphic/model/ModelMeta.ts +++ b/packages/tdev/netpbm-graphic/model/ModelMeta.ts @@ -9,10 +9,9 @@ export class ModelMeta extends TypeMeta<'netpbm_graphic'> { readonly type = 'netpbm_graphic'; readonly readonly?: boolean; readonly default?: string; - readonly props: Partial; constructor(props: Partial) { - super('netpbm_graphic', props.readonly ? Access.RO_User : undefined); + super('netpbm_graphic', props); /** * the default data can be either provided as a string or as a child element. * If it is provided as a child element, the relevant data is extracted by the @@ -33,7 +32,6 @@ export class ModelMeta extends TypeMeta<'netpbm_graphic'> { * ``` * */ - this.props = props; this.readonly = props.readonly; this.default = props.default; } diff --git a/packages/tdev/page-read-check/model/ModelMeta.ts b/packages/tdev/page-read-check/model/ModelMeta.ts index ccdc093a2..ad9456888 100644 --- a/packages/tdev/page-read-check/model/ModelMeta.ts +++ b/packages/tdev/page-read-check/model/ModelMeta.ts @@ -10,12 +10,10 @@ export interface MetaInit { export class ModelMeta extends TypeMeta<'page_read_check'> { readonly type = 'page_read_check'; readonly minReadTime: number; - readonly props: Partial; constructor(props: Partial) { - super('page_read_check', props.readonly ? Access.RO_User : undefined); + super('page_read_check', props); this.minReadTime = props.minReadTime || 10; - this.props = props; } get defaultData(): TypeDataMapping['page_read_check'] { diff --git a/packages/tdev/pyodide-code/models/ModelMeta.ts b/packages/tdev/pyodide-code/models/ModelMeta.ts index 376d71ef7..727cf760b 100644 --- a/packages/tdev/pyodide-code/models/ModelMeta.ts +++ b/packages/tdev/pyodide-code/models/ModelMeta.ts @@ -6,6 +6,6 @@ export interface MetaInit { export class ModelMeta extends iCodeMeta<'pyodide_code'> { constructor(props: Partial) { - super({ lang: 'py', title: 'Python', ...props }, 'pyodide_code'); + super('pyodide_code', { lang: 'py', title: 'Python', ...props }); } } diff --git a/packages/tdev/text-message/models/SimpleChat/ModelMeta.ts b/packages/tdev/text-message/models/SimpleChat/ModelMeta.ts index e04b162a8..c6fce2865 100644 --- a/packages/tdev/text-message/models/SimpleChat/ModelMeta.ts +++ b/packages/tdev/text-message/models/SimpleChat/ModelMeta.ts @@ -11,7 +11,7 @@ export class ModelMeta extends ContainerMeta<'simple_chat'> { constructor(props: Partial) { super('simple_chat', { - access: props.readonly ? Access.RO_User : undefined, + ...props, description: 'Ein simpler Chat zum Senden und Empfangen von Textnachrichten.' }); this.defaultName = props.name || 'Simple Chat'; diff --git a/packages/tdev/text-message/models/TextMessage/ModelMeta.ts b/packages/tdev/text-message/models/TextMessage/ModelMeta.ts index 14bca7723..66a090050 100644 --- a/packages/tdev/text-message/models/TextMessage/ModelMeta.ts +++ b/packages/tdev/text-message/models/TextMessage/ModelMeta.ts @@ -7,11 +7,9 @@ export interface MetaInit { export class ModelMeta extends TypeMeta<'text_message'> { readonly type = 'text_message'; - readonly props: Partial; constructor(props: Partial) { - super('text_message', props.readonly ? Access.RO_User : undefined); - this.props = props; + super('text_message', props); } get defaultData(): TypeDataMapping['text_message'] { diff --git a/src/api/document.ts b/src/api/document.ts index 48be3f7b5..66edfe581 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -139,13 +139,6 @@ type KeysWithCode = { }[keyof Omit]; export type CodeType = KeysWithCode; - -type PresentableKeys = { - [K in keyof T]: 'isPresenting' extends keyof T[K] ? K : never; -}[keyof T]; - -export type PresentableType = PresentableKeys; - export interface ContainerTypeModelMapping { ['_container_placeholder_']: iDocumentContainer; // placeholder to avoid empty interface error } @@ -184,18 +177,6 @@ export type TaskableModelType = TaskableTypeModelMapping[TaskableType]; export type DocumentType = keyof TypeModelMapping; export type DocumentModelType = TypeModelMapping[DocumentType]; -export type PresentableModelType = TypeModelMapping[PresentableType]; - -export interface iPresentable { - id: string; - setPresenting(isPresenting?: boolean): void; - isPresenting: boolean; - isDummy: boolean; -} - -// ensure all PresentableModelTypes implement `iPresentable`: -type EnsureAllPresentable = T; -null as unknown as EnsureAllPresentable>; /** * Document types that can be edited by admins ON BEHALF OF other users. diff --git a/src/components/MdxPage/index.tsx b/src/components/MdxPage/index.tsx index a92d92b89..76051be13 100644 --- a/src/components/MdxPage/index.tsx +++ b/src/components/MdxPage/index.tsx @@ -10,10 +10,8 @@ interface Props { } export class PageMeta extends TypeMeta { - readonly props: Partial<{}>; constructor() { super('_page_' as DocumentType); - this.props = {}; } get defaultData() { return {}; diff --git a/src/components/shared/RequestPresentationMode/index.tsx b/src/components/shared/RequestPresentationMode/index.tsx index 825a4ed33..ce92e2dbd 100644 --- a/src/components/shared/RequestPresentationMode/index.tsx +++ b/src/components/shared/RequestPresentationMode/index.tsx @@ -6,7 +6,6 @@ import Button from '../Button'; import { mdiPresentationPlay, mdiTelevisionStop } from '@mdi/js'; import { Color } from '../Colors'; import { useStore } from '@tdev-hooks/useStore'; -import { iPresentable, PresentableModelType } from '@tdev-api/document'; import Popup from 'reactjs-popup'; import Card from '../Card'; import iDocument from '@tdev-models/iDocument'; diff --git a/src/models/DocumentRoot.ts b/src/models/DocumentRoot.ts index c15fbd06f..d87a6ef81 100644 --- a/src/models/DocumentRoot.ts +++ b/src/models/DocumentRoot.ts @@ -8,17 +8,24 @@ import { orderBy } from 'es-toolkit/array'; import { Hashery } from 'hashery'; export const MetaHasher = new Hashery({ cache: { enabled: true, maxSize: 500 } }); +interface BaseMetaProps { + access?: Access; + readonly?: boolean; + pagePosition?: number; +} + export abstract class TypeMeta { readonly pagePosition: number; + readonly props: BaseMetaProps; type: T; access?: Access; - constructor(type: T, access?: Access, pagePosition?: number) { + constructor(type: T, props: BaseMetaProps = {}) { this.type = type; - this.access = access; - this.pagePosition = pagePosition || 0; + this.props = props; + this.access = props.access ?? (props.readonly ? Access.RO_User : undefined); + this.pagePosition = props.pagePosition || 0; } abstract get defaultData(): TypeDataMapping[T]; - abstract get props(): Record; } class DocumentRoot { diff --git a/src/models/documents/CmsText.ts b/src/models/documents/CmsText.ts index 30fdae37a..96e0bc347 100644 --- a/src/models/documents/CmsText.ts +++ b/src/models/documents/CmsText.ts @@ -13,7 +13,7 @@ export class CmsTextMeta extends TypeMeta<'cms_text'> { readonly default: string; constructor(props: Partial) { - super('cms_text', undefined); + super('cms_text'); this.default = props.default ?? ''; } diff --git a/src/models/documents/Code.ts b/src/models/documents/Code.ts index 6c006e016..bdecb9887 100644 --- a/src/models/documents/Code.ts +++ b/src/models/documents/Code.ts @@ -1,13 +1,12 @@ -import { Document as DocumentProps, iPresentable } from '@tdev-api/document'; +import { Document as DocumentProps } from '@tdev-api/document'; import DocumentStore from '@tdev-stores/DocumentStore'; import { default as iScriptMeta } from './iCode/iCodeMeta'; import { Props as CodeEditorProps } from '@tdev-components/documents/CodeEditor'; import iCode from './iCode'; -import { action } from 'mobx'; export class CodeMeta extends iScriptMeta<'code'> { constructor(props: Partial>) { - super(props, 'code'); + super('code', props); } } diff --git a/src/models/documents/DynamicDocumentRoots/ContainerMeta.ts b/src/models/documents/DynamicDocumentRoots/ContainerMeta.ts index 2ee36b3a1..5ace8699e 100644 --- a/src/models/documents/DynamicDocumentRoots/ContainerMeta.ts +++ b/src/models/documents/DynamicDocumentRoots/ContainerMeta.ts @@ -14,13 +14,11 @@ export interface MetaInit { export class ContainerMeta extends TypeMeta { readonly type: T; readonly description?: string; - readonly props: Partial<{ type: T; options: Options }>; - constructor(props: MetaInit) { - super(props.type, props.options?.access); - this.type = props.type; - this.description = props.options?.description; - this.props = props; + constructor(type: T, options?: Options) { + super(type, options); + this.type = type; + this.description = options?.description; } get name(): string { diff --git a/src/models/documents/DynamicDocumentRoots/index.ts b/src/models/documents/DynamicDocumentRoots/index.ts index 013b68acb..97afc58fc 100644 --- a/src/models/documents/DynamicDocumentRoots/index.ts +++ b/src/models/documents/DynamicDocumentRoots/index.ts @@ -26,7 +26,7 @@ export class ModelMeta extends TypeMeta<'dynamic_doc readonly props: Partial>; constructor(props: MetaInit) { - super('dynamic_document_roots', props.readonly ? Access.RO_User : undefined); + super('dynamic_document_roots', props); this.containerType = props.type; this.props = props; } @@ -84,7 +84,7 @@ class DynamicDocumentRoots extends iDocument<'dynami get defaultContainerMeta(): ContainerMeta { const component = this.store.root.componentStore.getComponent(this.containerType); if (!component) { - return new ContainerMeta({ type: this.containerType }); + return new ContainerMeta(this.containerType); } return component.defaultMeta; } @@ -99,7 +99,7 @@ class DynamicDocumentRoots extends iDocument<'dynami return; } this.store.root.documentRootStore - .create(id, new ContainerMeta({ type: this.containerType }), { + .create(id, new ContainerMeta(this.containerType), { access: Access.None_DocumentRoot, sharedAccess: Access.RO_DocumentRoot }) diff --git a/src/models/documents/FileSystem/iFileSystem.ts b/src/models/documents/FileSystem/iFileSystem.ts index 2101e362b..a9129dade 100644 --- a/src/models/documents/FileSystem/iFileSystem.ts +++ b/src/models/documents/FileSystem/iFileSystem.ts @@ -22,7 +22,7 @@ export class iFSMeta extends TypeMeta { readonly readonly?: boolean; readonly name: string; constructor(type: T, props: Partial) { - super(type, props.readonly ? Access.RO_User : undefined); + super(type, props); this.readonly = props.readonly; this.name = props.name || `${DefaultName[type]} ${formatDateTime(new Date())}`; } diff --git a/src/models/documents/ProgressState/index.ts b/src/models/documents/ProgressState/index.ts index 294713c9b..7421bcf9f 100644 --- a/src/models/documents/ProgressState/index.ts +++ b/src/models/documents/ProgressState/index.ts @@ -35,7 +35,7 @@ export class ModelMeta extends TypeMeta<'progress_state'> { readonly keepPreviousStepsOpen: boolean; constructor(props: Partial) { - super('progress_state', props.readonly ? Access.RO_User : undefined, props.pagePosition); + super('progress_state', props); this.default = props.default ?? DEFAULT_PROGRESS; this.readonly = !!props.readonly; if (props.allOpen) { diff --git a/src/models/documents/QuillV2/index.ts b/src/models/documents/QuillV2/index.ts index 4eb7ba7d7..956c286fd 100644 --- a/src/models/documents/QuillV2/index.ts +++ b/src/models/documents/QuillV2/index.ts @@ -23,7 +23,7 @@ export class ModelMeta extends TypeMeta<'quill_v2'> { readonly default: string; constructor(props: Partial) { - super('quill_v2', props.readonly ? Access.RO_User : undefined); + super('quill_v2', props); this.default = `${props.default || ''}\n` || '\n'; this.toolbar = props.toolbar ? getToolbar(props.toolbar) diff --git a/src/models/documents/Restricted.ts b/src/models/documents/Restricted.ts index be1b4d48c..0b6bd577d 100644 --- a/src/models/documents/Restricted.ts +++ b/src/models/documents/Restricted.ts @@ -12,7 +12,7 @@ export class ModelMeta extends TypeMeta<'restricted'> { readonly type = 'restricted'; constructor(props: Partial) { - super('restricted', props.readonly ? Access.RO_User : undefined); + super('restricted', props); } get defaultData(): TypeDataMapping['restricted'] { diff --git a/src/models/documents/Solution.ts b/src/models/documents/Solution.ts index 774c7d1c0..6317525b7 100644 --- a/src/models/documents/Solution.ts +++ b/src/models/documents/Solution.ts @@ -13,7 +13,7 @@ export class ModelMeta extends TypeMeta<'solution'> { readonly type = 'solution'; constructor(props: Partial) { - super('solution', props.readonly ? Access.RO_User : undefined); + super('solution', props); } get defaultData(): TypeDataMapping['solution'] { diff --git a/src/models/documents/String.ts b/src/models/documents/String.ts index b365c940b..8d129ebf9 100644 --- a/src/models/documents/String.ts +++ b/src/models/documents/String.ts @@ -22,7 +22,7 @@ export class ModelMeta extends TypeMeta<'string'> { readonly checker: (val: string | undefined) => boolean; constructor(props: Partial) { - super('string', props.readonly ? Access.RO_User : undefined); + super('string', props); this.readonly = props.readonly; this.default = props.default; this.solution = props.solution; diff --git a/src/models/documents/TaskState.ts b/src/models/documents/TaskState.ts index ed3530112..c4898a1a2 100644 --- a/src/models/documents/TaskState.ts +++ b/src/models/documents/TaskState.ts @@ -28,7 +28,7 @@ export class TaskMeta extends TypeMeta<'task_state'> { readonly default: StateType; constructor(props: Partial) { - super('task_state', props.readonly ? Access.RO_User : undefined, props.pagePosition); + super('task_state', props); this.default = props.states && props.states.length > 0 ? props.states[0] : DEFAULT_TASK_STATES[0]; this.readonly = !!props.readonly; } diff --git a/src/models/documents/_scaffold.ts b/src/models/documents/_scaffold.ts index 6f0554873..9b89e99a9 100644 --- a/src/models/documents/_scaffold.ts +++ b/src/models/documents/_scaffold.ts @@ -20,7 +20,7 @@ export class ModelMeta extends TypeMeta<'task_state'> { readonly type = 'task_state'; constructor(props: Partial) { - super('task_state', props.readonly ? Access.RO_User : undefined); + super('task_state', props); } get defaultData(): TypeDataMapping['task_state'] { diff --git a/src/models/documents/iCode/iCodeMeta.ts b/src/models/documents/iCode/iCodeMeta.ts index 6bc7214c2..0adec34c4 100644 --- a/src/models/documents/iCode/iCodeMeta.ts +++ b/src/models/documents/iCode/iCodeMeta.ts @@ -32,8 +32,8 @@ class iCodeMeta extends TypeMeta { readonly theme?: string; readonly versioned: boolean; - constructor(props: Partial, type: T) { - super(type, props.readonly ? Access.RO_User : undefined); + constructor(type: T, props: Partial) { + super(type, props); this.initCode = props.code || ''; this.title = props.title || ''; this.lang = props.lang || 'markdown'; diff --git a/src/models/documents/iCode/index.ts b/src/models/documents/iCode/index.ts index 83b95b5da..04eb078e8 100644 --- a/src/models/documents/iCode/index.ts +++ b/src/models/documents/iCode/index.ts @@ -1,12 +1,6 @@ import { action, computed, observable } from 'mobx'; import iDocument, { Source } from '@tdev-models/iDocument'; -import { - Document as DocumentProps, - TypeDataMapping, - ScriptVersionData, - CodeType, - iPresentable -} from '@tdev-api/document'; +import { Document as DocumentProps, TypeDataMapping, ScriptVersionData, CodeType } from '@tdev-api/document'; import DocumentStore from '@tdev-stores/DocumentStore'; import { orderBy } from 'es-toolkit/array'; import { throttle } from 'es-toolkit/function'; @@ -27,7 +21,7 @@ export interface CodePostUpdateMeta { action?: 'runCode'; } -class iCode extends iDocument implements iPresentable { +class iCode extends iDocument { @observable accessor code: string; @observable accessor _initialVersionsLoaded: boolean = false; @observable accessor showRaw: boolean = false; @@ -316,7 +310,7 @@ class iCode extends iDocument implements iPres if (this.root?.type === this.type) { return this.root.meta as iCodeMeta; } - return new iCodeMeta({ code: '' }, this.type); + return new iCodeMeta(this.type, { code: '' }); } } diff --git a/src/stores/StudentGroupStore.ts b/src/stores/StudentGroupStore.ts index 288203658..28a155b62 100644 --- a/src/stores/StudentGroupStore.ts +++ b/src/stores/StudentGroupStore.ts @@ -16,6 +16,9 @@ import { import User from '../models/User'; import { orderBy } from 'es-toolkit/array'; +const NEEDED_REPLACEMENT_KEYS: (keyof ApiStudentGroup)[] = ['name', 'description']; +const UPDATEABLE_KEYS: (keyof ApiStudentGroup)[] = ['canPresent', 'presentedDocument']; + export class StudentGroupStore extends iStore<`members-${string}`> { readonly root: RootStore; studentGroups = observable.array([]); @@ -98,9 +101,9 @@ export class StudentGroupStore extends iStore<`members-${string}`> { if (!model) { return; } - const needsReplace = ( - ['name', 'description', 'canStreamUpdates'] as ('name' | 'description' | 'canStreamUpdates')[] - ).some((key) => data[key] !== undefined && data[key] !== model[key]); + const needsReplace = NEEDED_REPLACEMENT_KEYS.some( + (key) => data[key] !== undefined && data[key] !== model[key] + ); if (needsReplace) { return this.addToStore(new StudentGroup(data, this)); } @@ -108,6 +111,12 @@ export class StudentGroupStore extends iStore<`members-${string}`> { if (data.parentId !== undefined && data.parentId !== model.parentId) { model.setParentId(data.parentId); } + if (data.canPresent !== undefined && data.canPresent !== model.canPresent) { + model.setCanPresent(data.canPresent); + } + if (data.presentedDocument !== undefined && data.presentedDocument !== model.presentedDocument) { + model.setPresentedDocument(data.presentedDocument); + } if (Array.isArray(data.userIds)) { model.userIds.replace(data.userIds); } From be4455c9f3961a58ce1c95af5d142660a8cdd243 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Thu, 23 Jul 2026 10:33:30 +0200 Subject: [PATCH 06/18] update ui to use presentable student groups --- packages/tdev/brython-code/index.ts | 3 +- packages/tdev/pyodide-code/index.ts | 3 +- src/api/document.ts | 6 +- src/api/studentGroup.ts | 4 +- .../DocumentPresentationView/index.tsx | 34 +++++++ .../styles.module.scss | 0 .../PresentationModal/index.tsx | 4 +- src/components/PresentationPanel/index.tsx | 34 +++---- src/components/StudentGroup/index.tsx | 4 +- .../CodeEditor/Editor/Header/Content.tsx | 6 +- .../shared/RequestPresentationMode/index.tsx | 36 ++++++-- src/models/StudentGroup.ts | 22 +++-- src/models/documents/iCode/index.ts | 50 +--------- src/models/iDocument.ts | 5 + src/stores/DocumentStore.ts | 92 ++++++++----------- src/stores/StudentGroupStore.ts | 30 +++++- src/stores/ViewStores/index.ts | 23 ----- 17 files changed, 176 insertions(+), 180 deletions(-) create mode 100644 src/components/PresentationPanel/DocumentPresentationView/index.tsx create mode 100644 src/components/PresentationPanel/DocumentPresentationView/styles.module.scss diff --git a/packages/tdev/brython-code/index.ts b/packages/tdev/brython-code/index.ts index f1d3cb0b3..e696ffd0e 100644 --- a/packages/tdev/brython-code/index.ts +++ b/packages/tdev/brython-code/index.ts @@ -1,4 +1,3 @@ -import { Presentable } from '@tdev-api/document'; import Script from './models/Script'; /** * Notes @@ -7,7 +6,7 @@ import Script from './models/Script'; * - call brython with arguments: https://github.com/brython-dev/brython/issues/2421 * */ -export interface ScriptData extends Presentable { +export interface ScriptData { code: string; } diff --git a/packages/tdev/pyodide-code/index.ts b/packages/tdev/pyodide-code/index.ts index 6901d943c..880670449 100644 --- a/packages/tdev/pyodide-code/index.ts +++ b/packages/tdev/pyodide-code/index.ts @@ -1,7 +1,6 @@ import type PyodideStore from './stores/PyodideStore'; import PyodideCode from './models/PyodideCode'; -import { Presentable } from '@tdev-api/document'; -export interface PyodideData extends Presentable { +export interface PyodideData { code: string; } diff --git a/src/api/document.ts b/src/api/document.ts index 66edfe581..664783bc7 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -32,10 +32,6 @@ export enum Access { None_User = 'None_User' } -export interface Presentable { - isPresenting?: boolean; -} - export interface ScriptVersionData { code: string; pasted?: boolean; @@ -49,7 +45,7 @@ export interface QuillV2Data { delta: Delta; } -export interface CodeData extends Presentable { +export interface CodeData { code: string; } diff --git a/src/api/studentGroup.ts b/src/api/studentGroup.ts index c09e71fb2..64fe68b32 100644 --- a/src/api/studentGroup.ts +++ b/src/api/studentGroup.ts @@ -1,4 +1,4 @@ -import { Document, DocumentType } from '@tdev-api/document'; +import { type Access, type Document, type DocumentType } from '@tdev-api/document'; import api from './base'; import { AxiosPromise } from 'axios'; import { TypeMeta } from '@tdev-models/DocumentRoot'; @@ -6,6 +6,8 @@ import { TypeMeta } from '@tdev-models/DocumentRoot'; export interface DocumentPresentation { document: Document; meta: TypeMeta; + access: Access; + sharedAccess: Access; } export interface StudentGroup { diff --git a/src/components/PresentationPanel/DocumentPresentationView/index.tsx b/src/components/PresentationPanel/DocumentPresentationView/index.tsx new file mode 100644 index 000000000..d8bc8d634 --- /dev/null +++ b/src/components/PresentationPanel/DocumentPresentationView/index.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; +import { observer } from 'mobx-react-lite'; +import { useStore } from '@tdev-hooks/useStore'; +import StudentGroup from '@tdev-models/StudentGroup'; +import { CodeType } from '@tdev-api/document'; +import CodeEditorComponent from '@tdev-components/documents/CodeEditor'; +import iCode from '@tdev-models/documents/iCode'; + +interface Props { + group: StudentGroup; +} + +const DocumentPresentationView = observer((props: Props) => { + const componentStore = useStore('componentStore'); + const { group } = props; + if (!group.presentedDocument) { + return
Keine Präsentation
; + } + const docType = group.presentedDocument.type; + const EC = componentStore.editorComponent(docType as CodeType); + if (!EC) { + return
Kein Editor für Dokumenttyp {docType}
; + } + + return ( +
+ } isPresentation /> +
+ ); +}); + +export default DocumentPresentationView; diff --git a/src/components/PresentationPanel/DocumentPresentationView/styles.module.scss b/src/components/PresentationPanel/DocumentPresentationView/styles.module.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/PresentationPanel/PresentationModal/index.tsx b/src/components/PresentationPanel/PresentationModal/index.tsx index ad2764b2b..145fee922 100644 --- a/src/components/PresentationPanel/PresentationModal/index.tsx +++ b/src/components/PresentationPanel/PresentationModal/index.tsx @@ -9,13 +9,13 @@ import PresentationPanel from '..'; interface Props {} const PresentationModal = observer((props: Props) => { - const viewStore = useStore('viewStore'); + const documentStore = useStore('documentStore'); return ( 0} repositionOnResize closeOnDocumentClick={false} closeOnEscape={false} diff --git a/src/components/PresentationPanel/index.tsx b/src/components/PresentationPanel/index.tsx index 8509b8a69..b6cc413c1 100644 --- a/src/components/PresentationPanel/index.tsx +++ b/src/components/PresentationPanel/index.tsx @@ -8,36 +8,26 @@ import type iCode from '@tdev-models/documents/iCode'; import Button from '@tdev-components/shared/Button'; import { mdiClose } from '@mdi/js'; import CodeEditorComponent from '@tdev-components/documents/CodeEditor'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import DocumentPresentationView from './DocumentPresentationView'; interface Props {} const PresentationPanel = observer((props: Props) => { const groupStore = useStore('studentGroupStore'); - const documentStore = useStore('documentStore'); - const viewStore = useStore('viewStore'); - const componentStore = useStore('componentStore'); - const doc = viewStore.presentedDocument as iCode; - const EC = doc ? componentStore.editorComponent(doc.type as CodeType) : null; - React.useEffect(() => { - const rid = groupStore.managedStudentGroups[0]?.id; - if (rid && doc?.isPresenting && !doc.isDummy) { - console.log('join room', doc.documentRootId); - documentStore.root.socketStore.joinRoom(rid); - } - return () => { - if (rid) { - documentStore.root.socketStore.leaveRoom(rid); - } - }; - }, [doc?.id]); - if (!EC) { - return
Kein Editor für Dokumenttyp {doc?.type}
; - } return (
-
); }); diff --git a/src/components/StudentGroup/index.tsx b/src/components/StudentGroup/index.tsx index 5150ef0b6..870cc164e 100644 --- a/src/components/StudentGroup/index.tsx +++ b/src/components/StudentGroup/index.tsx @@ -162,10 +162,10 @@ const StudentGroup = observer((props: Props) => { icon={group.canPresent ? mdiToggleSwitch : mdiToggleSwitchOff} />
- {group.presentedDocument && ( + {group.presentedDocumentProps && (
-                                        {JSON.stringify(group.presentedDocument, null, 2)}
+                                        {JSON.stringify(group.presentedDocumentProps, null, 2)}
                                     
)} diff --git a/src/components/documents/CodeEditor/Editor/Header/Content.tsx b/src/components/documents/CodeEditor/Editor/Header/Content.tsx index 5a6139250..64c1991bf 100644 --- a/src/components/documents/CodeEditor/Editor/Header/Content.tsx +++ b/src/components/documents/CodeEditor/Editor/Header/Content.tsx @@ -39,11 +39,7 @@ const Content = observer((props: Props) => { <>
{code.title}
- + { if (groupStore.managedStudentGroups.length === 0 || !userStore.current) { return null; } + if (groupStore.presentedDocumentIds.has(document.id)) { + return ( + ))} + {groupStore.studentGroups + .filter((g) => !g.canPresent) + .map((g) => ( + + ))} ); diff --git a/src/models/DocumentRoot.ts b/src/models/DocumentRoot.ts index d87a6ef81..4410833b5 100644 --- a/src/models/DocumentRoot.ts +++ b/src/models/DocumentRoot.ts @@ -96,6 +96,7 @@ class DocumentRoot { @action setSharedAccess(access: Access) { + console.log('setSharedAccess', access, this._sharedAccess); if (this._sharedAccess === access) { return; } @@ -139,6 +140,11 @@ class DocumentRoot { return highestAccess(new Set([...this.permissions.map((p) => p.access), this.access])); } + @computed + get sharedPermission() { + return highestAccess(new Set([this.sharedAccess]), this.permission); + } + permissionsForUser(userId: string) { return [...this.store.usersPermissions(this.id, userId)]; } diff --git a/src/models/StudentGroup.ts b/src/models/StudentGroup.ts index 6adbc4c6c..047c50caa 100644 --- a/src/models/StudentGroup.ts +++ b/src/models/StudentGroup.ts @@ -18,7 +18,7 @@ class StudentGroup { @observable accessor parentId: string | null; @observable accessor isEditing: boolean = false; @observable accessor canPresent: boolean; - @observable.ref accessor presentedDocumentProps: DocumentPresentation | null; + @observable.ref accessor presentedDocumentProps: DocumentPresentation | null = null; readonly _pristine: { name: string; description: string }; @@ -36,7 +36,6 @@ class StudentGroup { this.name = props.name; this.description = props.description; this.canPresent = !!props.canPresent; - this.presentedDocumentProps = props.presentedDocument ?? null; this.userIds.replace(props.userIds); this.adminIds.replace(props.adminIds); @@ -44,6 +43,7 @@ class StudentGroup { this.updatedAt = new Date(props.updatedAt); this.createdAt = new Date(props.createdAt); + this.setPresentedDocumentProps(props.presentedDocument ?? null, true); } get fCreatedAt() { @@ -131,25 +131,34 @@ class StudentGroup { @action setCanPresent(canPresent: boolean, skipSave: boolean = false) { if (this.canPresent === canPresent || !this.isGroupAdmin) { - return; + return Promise.resolve(this); } this.canPresent = canPresent; if (!skipSave) { - this.save(); + return this.save(); } + return Promise.resolve(this); } @action setPresentedDocumentProps(props: DocumentPresentation | null, skipSave: boolean = false) { - if (this.presentedDocumentProps === props || !this.isGroupAdmin) { + if (!this.canPresent || this.presentedDocumentProps === props) { return; } this.presentedDocumentProps = props; + if (props) { + this.store.root.documentStore.addPresentedDocumentToStore(this); + } if (!skipSave) { this.save(); } } + @computed + get permissions() { + return this.store.root.permissionStore.groupPermissions.filter((p) => p.groupId === this.id); + } + @computed get presentedDocumentId() { return this.presentedDocumentProps?.document.id ?? null; diff --git a/src/models/documents/iCode/index.ts b/src/models/documents/iCode/index.ts index 1bfb07c01..b1e21e2f2 100644 --- a/src/models/documents/iCode/index.ts +++ b/src/models/documents/iCode/index.ts @@ -48,6 +48,7 @@ class iCode extends iDocument { @action setCode(code: string, action?: 'insert' | 'remove' | string) { if (this.isPasted && action === 'remove') { + this.streamUpdate(); return; } this.code = code; diff --git a/src/models/iDocument.ts b/src/models/iDocument.ts index 459053a20..05693cb29 100644 --- a/src/models/iDocument.ts +++ b/src/models/iDocument.ts @@ -3,7 +3,7 @@ import { Document as DocumentProps, TypeDataMapping, DocumentType } from '@tdev- import DocumentStore from '@tdev-stores/DocumentStore'; import _, { type DebouncedFunc } from 'es-toolkit/compat'; import { ApiState } from '@tdev-stores/iStore'; -import { NoneAccess, ROAccess, RWAccess } from './helpers/accessPolicy'; +import { highestAccess, NoneAccess, ROAccess, RWAccess } from './helpers/accessPolicy'; import type iSideEffect from './SideEffects/iSideEffect'; import { isDummyId, isTempId } from '@tdev-hooks/useDummyId'; @@ -207,10 +207,14 @@ abstract class iDocument { if (!this.root) { return true; } - if (!this.store.root.userStore.current) { + const userStore = this.store.root.userStore; + if (!userStore.current) { + return !NoneAccess.has(this.root._access); + } + if (this.authorId === userStore.current.id) { return !NoneAccess.has(this.root.permission); } - return this.root.hasReadAccess || this.root.hasAdminOrRWAccess; + return !NoneAccess.has(this.root.sharedAccess); } get author() { @@ -233,19 +237,26 @@ abstract class iDocument { @action save(onBeforeSave?: (() => Promise) | undefined) { const res = this.saveFn(onBeforeSave); - if (this.isPresenting) { - const now = new Date(); - this.presentingGroups.forEach((g) => { - this.store.root.socketStore.streamUpdate(g.id, { - id: this.id, - data: this.data, - updatedAt: now - }); - }); - } + this.streamUpdate(); return res; } + @action + streamUpdate() { + if (!this.isPresenting) { + return; + } + + const now = new Date(); + this.presentingGroups.forEach((g) => { + this.store.root.socketStore.streamUpdate(g.id, { + id: this.id, + data: this.data, + updatedAt: now + }); + }); + } + @action saveNow() { this.save(); diff --git a/src/stores/DocumentStore.ts b/src/stores/DocumentStore.ts index 6ad140dfd..6f2fa24fa 100644 --- a/src/stores/DocumentStore.ts +++ b/src/stores/DocumentStore.ts @@ -35,8 +35,6 @@ import DynamicDocumentRoots from '@tdev-models/documents/DynamicDocumentRoots'; import ProgressState from '@tdev-models/documents/ProgressState'; import TaskState from '@tdev-models/documents/TaskState'; import Code from '@tdev-models/documents/Code'; -import { throttle } from 'es-toolkit/function'; -import type { TypeMeta } from '@tdev-models/DocumentRoot'; import StudentGroup from '@tdev-models/StudentGroup'; import DocumentRoot, { MetaHasher } from '@tdev-models/DocumentRoot'; @@ -350,28 +348,22 @@ class DocumentStore extends iStore<`delete-${string}`> { } model.setData(change.data as any, Source.API, updatedAt); model.postUpdate(change.meta); - } else if (this.root.studentGroupStore.presentedDocumentIds.has(change.id)) { - // probably the document was not loaded yet - try to load it from the api - const presentedGroup = this.root.studentGroupStore.presentingStudentGroups.find( - (g) => g.presentedDocumentId === change.id - ); - this._addPresentedDocumentToStore(presentedGroup); } } @action - _addPresentedDocumentToStore(studentGroup?: StudentGroup) { - const presentedDoc = studentGroup?.presentedDocumentProps; + addPresentedDocumentToStore(studentGroup: StudentGroup) { + const presentedDoc = studentGroup.presentedDocumentProps; if (!presentedDoc) { return; } const rawDoc = presentedDoc.document; const rawMeta = presentedDoc.meta; - const model = this.find(presentedDoc.document.id); + const model = this.find(rawDoc.id); if (model) { return; } - const docRoot = this.root.documentRootStore.find(presentedDoc.document.documentRootId); + const docRoot = this.root.documentRootStore.find(rawDoc.documentRootId); const metaHash = MetaHasher.toHashSync(rawMeta); if (!docRoot || !docRoot?.isDummy || docRoot._metaHash !== metaHash) { this.root.documentRootStore.addDocumentRoot( @@ -387,6 +379,11 @@ class DocumentStore extends iStore<`delete-${string}`> { ) ); } + const documentRoot = this.root.documentRootStore.find(rawDoc.documentRootId); + if (!documentRoot) { + return; + } + this.addToStore(rawDoc); } diff --git a/src/stores/SocketDataStore.ts b/src/stores/SocketDataStore.ts index 33cf0b9c9..431065b75 100644 --- a/src/stores/SocketDataStore.ts +++ b/src/stores/SocketDataStore.ts @@ -290,6 +290,7 @@ export class SocketDataStore extends iStore<'ping'> { updateRecord({ type, record }: ChangedRecord) { switch (type) { case RecordType.DocumentRoot: + console.log('DocumentRoot update', record); this.root.documentRootStore.handleUpdate(record as DocumentRootUpdate); break; case RecordType.UserPermission: diff --git a/src/stores/StudentGroupStore.ts b/src/stores/StudentGroupStore.ts index 0ce920ef0..cc3ce79e5 100644 --- a/src/stores/StudentGroupStore.ts +++ b/src/stores/StudentGroupStore.ts @@ -124,25 +124,20 @@ export class StudentGroupStore extends iStore<`members-${string}`> { if (needsReplace) { return this.addToStore(new StudentGroup(data, this)); } - if (model && model.id) { - if (data.parentId !== undefined && data.parentId !== model.parentId) { - model.setParentId(data.parentId); - } - if (data.canPresent !== undefined && data.canPresent !== model.canPresent) { - model.setCanPresent(data.canPresent, true); - } - if ( - data.presentedDocument !== undefined && - data.presentedDocument !== model.presentedDocumentProps - ) { - model.setPresentedDocumentProps(data.presentedDocument, true); - } - if (Array.isArray(data.userIds)) { - model.userIds.replace(data.userIds); - } - if (Array.isArray(data.adminIds)) { - model.adminIds.replace(data.adminIds); - } + if (data.parentId !== undefined && data.parentId !== model.parentId) { + model.setParentId(data.parentId); + } + if (data.canPresent !== undefined && data.canPresent !== model.canPresent) { + model.setCanPresent(data.canPresent, true); + } + if (data.presentedDocument !== undefined && data.presentedDocument !== model.presentedDocumentProps) { + model.setPresentedDocumentProps(data.presentedDocument, true); + } + if (Array.isArray(data.userIds)) { + model.userIds.replace(data.userIds); + } + if (Array.isArray(data.adminIds)) { + model.adminIds.replace(data.adminIds); } } From 9e14b3980923a845ade5155c1807f4b6c5abcd67 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Fri, 24 Jul 2026 00:11:51 +0200 Subject: [PATCH 09/18] add visual hints --- .../GroupAccessSelector.tsx | 16 ++-- .../AccessSelector/SharedAccessSelector.tsx | 31 ++++++++ .../PermissionsPanel/AccessSelector/index.tsx | 74 +++++++++++++------ .../AccessSelector/styles.module.scss | 10 +++ .../DocumentPresentationView/index.tsx | 46 +++--------- 5 files changed, 110 insertions(+), 67 deletions(-) rename src/components/PermissionsPanel/{GroupPermission => AccessSelector}/GroupAccessSelector.tsx (66%) create mode 100644 src/components/PermissionsPanel/AccessSelector/SharedAccessSelector.tsx diff --git a/src/components/PermissionsPanel/GroupPermission/GroupAccessSelector.tsx b/src/components/PermissionsPanel/AccessSelector/GroupAccessSelector.tsx similarity index 66% rename from src/components/PermissionsPanel/GroupPermission/GroupAccessSelector.tsx rename to src/components/PermissionsPanel/AccessSelector/GroupAccessSelector.tsx index 866c7d8f3..84e13b3c5 100644 --- a/src/components/PermissionsPanel/GroupPermission/GroupAccessSelector.tsx +++ b/src/components/PermissionsPanel/AccessSelector/GroupAccessSelector.tsx @@ -2,7 +2,7 @@ import React from 'react'; import clsx from 'clsx'; import { observer } from 'mobx-react-lite'; import { useStore } from '@tdev-hooks/useStore'; -import AccessSelector from '../AccessSelector'; +import AccessSelector from '.'; import { Access } from '@tdev-api/document'; import StudentGroup from '@tdev-models/StudentGroup'; @@ -15,11 +15,15 @@ interface Props { const GroupAccessSelector = observer((props: Props) => { const { group } = props; const permissionStore = useStore('permissionStore'); + const groupPermission = permissionStore + .groupPermissionsByDocumentRoot(group.presentedDocument?.documentRootId) + .find((p) => p.groupId === group.id)?.access; return (
{ const currentPermission = group.presentedDocument!.root!.groupPermissions.find( (gp) => gp.groupId === group.id @@ -29,14 +33,6 @@ const GroupAccessSelector = observer((props: Props) => { } else { permissionStore.createGroupPermission(group.presentedDocument!.root!, group, access); } - documentRoots.forEach((dr) => { - const currentPermission = dr.groupPermissions.find((gp) => gp.groupId === group.id); - if (currentPermission) { - currentPermission.setAccess(access); - } else { - permissionStore.createGroupPermission(dr, group, access); - } - }); }} mark={props.mark} /> diff --git a/src/components/PermissionsPanel/AccessSelector/SharedAccessSelector.tsx b/src/components/PermissionsPanel/AccessSelector/SharedAccessSelector.tsx new file mode 100644 index 000000000..cc2a29922 --- /dev/null +++ b/src/components/PermissionsPanel/AccessSelector/SharedAccessSelector.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import clsx from 'clsx'; +import { observer } from 'mobx-react-lite'; +import { useStore } from '@tdev-hooks/useStore'; +import AccessSelector from '.'; +import { Access, DocumentType } from '@tdev-api/document'; +import DocumentRoot from '@tdev-models/DocumentRoot'; + +interface Props { + documentRoot: DocumentRoot; + maxAccess?: Access; + className?: string; +} + +const SharedAccessSelector = observer((props: Props) => { + const { documentRoot } = props; + + return ( + { + documentRoot.setSharedAccess(access); + documentRoot.save(); + }} + maxAccess={props.maxAccess} + /> + ); +}); + +export default SharedAccessSelector; diff --git a/src/components/PermissionsPanel/AccessSelector/index.tsx b/src/components/PermissionsPanel/AccessSelector/index.tsx index 0c7f22ddb..0365c1000 100644 --- a/src/components/PermissionsPanel/AccessSelector/index.tsx +++ b/src/components/PermissionsPanel/AccessSelector/index.tsx @@ -18,39 +18,69 @@ export const AccessNames: { [key in Access]: string } = { [Access.None_DocumentRoot]: 'None' }; +export const AccessLevels = new Map([ + [Access.None_DocumentRoot, 0], + [Access.RO_DocumentRoot, 1], + [Access.RW_DocumentRoot, 2], + [Access.None_StudentGroup, 0], + [Access.RO_StudentGroup, 1], + [Access.RW_StudentGroup, 2], + [Access.None_User, 0], + [Access.RO_User, 1], + [Access.RW_User, 2] +]); + interface Props { onChange: (access: Access) => void; accessTypes: Access[]; access?: Access; className?: string; + maxAccess?: Access; mark?: Set; } +const buttonColorClasses = (level: Access, access: Access, maxAccess?: Access) => { + const res = access === level ? ['button--primary'] : ['button--secondary']; + if (!maxAccess) { + return res; + } + const maxAccessLevel = maxAccess ? AccessLevels.get(maxAccess)! : 3; + const disabled = maxAccessLevel < AccessLevels.get(level)!; + if (disabled) { + res.push(styles.unaffected); + } + return res; +}; + const AccessSelector = observer((props: Props) => { + const maxAccessLevel = props.maxAccess ? AccessLevels.get(props.maxAccess)! : 3; + return (
- {props.accessTypes.map((acc) => ( - - ))} + {props.accessTypes.map((acc) => { + return ( + + ); + })}
); }); diff --git a/src/components/PermissionsPanel/AccessSelector/styles.module.scss b/src/components/PermissionsPanel/AccessSelector/styles.module.scss index fa1ea67f2..7b34e8a5c 100644 --- a/src/components/PermissionsPanel/AccessSelector/styles.module.scss +++ b/src/components/PermissionsPanel/AccessSelector/styles.module.scss @@ -1,6 +1,16 @@ .selector { .button { position: relative; + &.unaffected { + &:global(.button--primary) { + --ifm-color-primary-dark: var(--ifm-color-primary-darkest); + --ifm-color-primary: var(--ifm-color-primary-darker); + } + &:global(.button--secondary) { + --ifm-color-secondary-dark: var(--ifm-color-secondary-darkest); + --ifm-color-secondary: var(--ifm-color-secondary-darker); + } + } .mark { position: absolute; top: -6px; diff --git a/src/components/PresentationPanel/DocumentPresentationView/index.tsx b/src/components/PresentationPanel/DocumentPresentationView/index.tsx index 9a5642bdc..f2b6112be 100644 --- a/src/components/PresentationPanel/DocumentPresentationView/index.tsx +++ b/src/components/PresentationPanel/DocumentPresentationView/index.tsx @@ -9,6 +9,8 @@ import CodeEditorComponent from '@tdev-components/documents/CodeEditor'; import iCode from '@tdev-models/documents/iCode'; import AccessSelector, { AccessNames } from '@tdev-components/PermissionsPanel/AccessSelector'; import Badge from '@tdev-components/shared/Badge'; +import GroupAccessSelector from '@tdev-components/PermissionsPanel/AccessSelector/GroupAccessSelector'; +import SharedAccessSelector from '@tdev-components/PermissionsPanel/AccessSelector/SharedAccessSelector'; interface Props { group: StudentGroup; @@ -35,43 +37,17 @@ const DocumentPresentationView = observer((props: Props) => {
{userStore.current?.hasElevatedAccess && (
- { - group.presentedDocument!.root?.setSharedAccess(access); - group.presentedDocument!.root?.save(); - }} - /> - - { - const currentPermission = group.presentedDocument!.root!.groupPermissions.find( - (gp) => gp.groupId === group.id - ); - if (currentPermission) { - currentPermission.setAccess(access); - } else { - permissionStore.createGroupPermission( - group.presentedDocument!.root!, - group, - access - ); - } - }} - /> + group +
)} +
+ shared + +
} isPresentation />
); From 2bb9e49e9296351950615d52ac9768c9de380579 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Fri, 24 Jul 2026 12:24:01 +0200 Subject: [PATCH 10/18] add user selector --- .../AccessSelector/GroupAccessSelector.tsx | 2 +- .../AccessSelector/SharedAccessSelector.tsx | 2 + .../PermissionsPanel/AccessSelector/index.tsx | 14 +++- src/components/PermissionsPanel/index.tsx | 2 +- .../DocumentPresentationView/index.tsx | 75 +++++++++++++++---- .../styles.module.scss | 22 ++++++ src/components/PresentationPanel/index.tsx | 2 +- .../PresentationPanel/styles.module.scss | 5 ++ src/components/User/BadgeSelector/index.tsx | 50 +++++++++++++ .../User/BadgeSelector/styles.module.scss | 40 ++++++++++ .../AddDynamicDocumentRoot/index.tsx | 2 +- .../shared/RequestPresentationMode/index.tsx | 6 +- src/models/StudentGroup.ts | 39 ++++++++-- src/stores/PermissionStore.ts | 10 +-- src/stores/StudentGroupStore.ts | 2 +- 15 files changed, 234 insertions(+), 39 deletions(-) create mode 100644 src/components/User/BadgeSelector/index.tsx create mode 100644 src/components/User/BadgeSelector/styles.module.scss diff --git a/src/components/PermissionsPanel/AccessSelector/GroupAccessSelector.tsx b/src/components/PermissionsPanel/AccessSelector/GroupAccessSelector.tsx index 84e13b3c5..c9a4f0e1c 100644 --- a/src/components/PermissionsPanel/AccessSelector/GroupAccessSelector.tsx +++ b/src/components/PermissionsPanel/AccessSelector/GroupAccessSelector.tsx @@ -8,7 +8,7 @@ import StudentGroup from '@tdev-models/StudentGroup'; interface Props { group: StudentGroup; - mark?: Set; + mark?: Access | Access[] | Set; className?: string; } diff --git a/src/components/PermissionsPanel/AccessSelector/SharedAccessSelector.tsx b/src/components/PermissionsPanel/AccessSelector/SharedAccessSelector.tsx index cc2a29922..8d7076d3b 100644 --- a/src/components/PermissionsPanel/AccessSelector/SharedAccessSelector.tsx +++ b/src/components/PermissionsPanel/AccessSelector/SharedAccessSelector.tsx @@ -10,6 +10,7 @@ interface Props { documentRoot: DocumentRoot; maxAccess?: Access; className?: string; + mark?: Access | Access[] | Set; } const SharedAccessSelector = observer((props: Props) => { @@ -24,6 +25,7 @@ const SharedAccessSelector = observer((props: Props) => { documentRoot.save(); }} maxAccess={props.maxAccess} + mark={props.mark} /> ); }); diff --git a/src/components/PermissionsPanel/AccessSelector/index.tsx b/src/components/PermissionsPanel/AccessSelector/index.tsx index 0365c1000..688fb9e7e 100644 --- a/src/components/PermissionsPanel/AccessSelector/index.tsx +++ b/src/components/PermissionsPanel/AccessSelector/index.tsx @@ -36,7 +36,7 @@ interface Props { access?: Access; className?: string; maxAccess?: Access; - mark?: Set; + mark?: Access | Access[] | Set; } const buttonColorClasses = (level: Access, access: Access, maxAccess?: Access) => { @@ -53,7 +53,15 @@ const buttonColorClasses = (level: Access, access: Access, maxAccess?: Access) = }; const AccessSelector = observer((props: Props) => { - const maxAccessLevel = props.maxAccess ? AccessLevels.get(props.maxAccess)! : 3; + const marked = React.useMemo(() => { + if (Array.isArray(props.mark)) { + return new Set(props.mark); + } + if (props.mark instanceof Set) { + return props.mark; + } + return new Set([props.mark]); + }, [props.mark]); return (
@@ -69,7 +77,7 @@ const AccessSelector = observer((props: Props) => { )} onClick={() => props.onChange(acc)} > - {props.mark?.has(acc) ? ( + {marked.has(acc) ? ( { keepTooltipInside="#__docusaurus" modal={isMobileView} onOpen={action(() => { - documentRoots.forEach((dr) => permissionStore.loadPermissions(dr)); + documentRoots.forEach((dr) => permissionStore.loadPermissions(dr.id)); setIsOpen(true); })} onClose={() => setIsOpen(false)} diff --git a/src/components/PresentationPanel/DocumentPresentationView/index.tsx b/src/components/PresentationPanel/DocumentPresentationView/index.tsx index f2b6112be..5ee79bae0 100644 --- a/src/components/PresentationPanel/DocumentPresentationView/index.tsx +++ b/src/components/PresentationPanel/DocumentPresentationView/index.tsx @@ -11,6 +11,11 @@ import AccessSelector, { AccessNames } from '@tdev-components/PermissionsPanel/A import Badge from '@tdev-components/shared/Badge'; import GroupAccessSelector from '@tdev-components/PermissionsPanel/AccessSelector/GroupAccessSelector'; import SharedAccessSelector from '@tdev-components/PermissionsPanel/AccessSelector/SharedAccessSelector'; +import { asStudentGroupAccess } from '@tdev-models/helpers/accessPolicy'; +import BadgeSelector from '@tdev-components/User/BadgeSelector'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import Card from '@tdev-components/shared/Card'; interface Props { group: StudentGroup; @@ -29,27 +34,65 @@ const DocumentPresentationView = observer((props: Props) => { if (!EC) { return
Kein Editor für Dokumenttyp {docType}
; } + const rootId = group.presentedDocument.documentRootId; + const userPermissions = permissionStore.userPermissionsByDocumentRoot(rootId); const groupPermission = permissionStore - .groupPermissionsByDocumentRoot(group.presentedDocument?.documentRootId) + .groupPermissionsByDocumentRoot(rootId) .find((p) => p.groupId === group.id)?.access; + if (!group.adminIds.has(userStore.current?.id ?? ' ')) { + return ( +
+ } isPresentation /> +
+ ); + } + return ( -
- {userStore.current?.hasElevatedAccess && ( -
- group - + + +
+ } isPresentation />
- )} -
- shared - -
- } isPresentation /> -
+ + + +
+
+ Gruppe + +
+
+ Geteilt + +
+
+
+ {group.students.map((s) => ( + { + const all = await Promise.all( + userPermissions.map((p) => { + return permissionStore.deleteUserPermission(p); + }) + ); + await permissionStore.createUserPermission(rootId, user, Access.RW_User); + }} + selected={userPermissions.some((p) => p.userId === s.id)} + /> + ))} +
+
+
+ ); }); diff --git a/src/components/PresentationPanel/DocumentPresentationView/styles.module.scss b/src/components/PresentationPanel/DocumentPresentationView/styles.module.scss index e69de29bb..f4e77c54f 100644 --- a/src/components/PresentationPanel/DocumentPresentationView/styles.module.scss +++ b/src/components/PresentationPanel/DocumentPresentationView/styles.module.scss @@ -0,0 +1,22 @@ +.documentPresentationView { +} +.accessPanels { + display: flex; + justify-content: flex-start; + gap: 2rem; + flex-wrap: wrap; + .panel { + display: flex; + gap: 0.5rem; + } +} +.studentSelector { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.tabs { + --ifm-tabs-padding-vertical: 0.2em; + --ifm-tabs-padding-horizontal: 0.4em; +} diff --git a/src/components/PresentationPanel/index.tsx b/src/components/PresentationPanel/index.tsx index b6cc413c1..929102ee3 100644 --- a/src/components/PresentationPanel/index.tsx +++ b/src/components/PresentationPanel/index.tsx @@ -19,7 +19,7 @@ const PresentationPanel = observer((props: Props) => { return (
- + {groupStore.presentingStudentGroups.map((g, idx) => { return ( diff --git a/src/components/PresentationPanel/styles.module.scss b/src/components/PresentationPanel/styles.module.scss index b86ea97fe..b1c05508d 100644 --- a/src/components/PresentationPanel/styles.module.scss +++ b/src/components/PresentationPanel/styles.module.scss @@ -2,3 +2,8 @@ width: 75vw; height: 100%; } + +.tabs { + --ifm-tabs-padding-vertical: 0.2em; + --ifm-tabs-padding-horizontal: 0.4em; +} diff --git a/src/components/User/BadgeSelector/index.tsx b/src/components/User/BadgeSelector/index.tsx new file mode 100644 index 000000000..2ffdc1aaa --- /dev/null +++ b/src/components/User/BadgeSelector/index.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; +import { observer } from 'mobx-react-lite'; +import { useStore } from '@tdev-hooks/useStore'; +import User from '@tdev-models/User'; + +interface Props { + user: User; + onClick?: (user: User) => void; + selected?: boolean; + className?: string; +} + +const BadgeSelector = observer((props: Props) => { + const userStore = useStore('userStore'); + const svgId = React.useId(); + const { user } = props; + /** + * M 6,50 A 44,44 0 0 1 94,50 + * M 10,50 A 40,40 0 0 1 90,50 + * M 14,50 A 36,36 0 0 1 86,50 + * M 18,50 A 32,36 0 0 1 82,50 + * M 22,50 A 28,36 0 0 1 78,50 + */ + + return ( +
{ + e.stopPropagation(); + props.onClick?.(user); + }} + > + {user.firstName.charAt(0)} + {user.lastName.charAt(0)} + + + + + {user.firstName} + + + +
+ ); +}); + +export default BadgeSelector; diff --git a/src/components/User/BadgeSelector/styles.module.scss b/src/components/User/BadgeSelector/styles.module.scss new file mode 100644 index 000000000..734d8dedf --- /dev/null +++ b/src/components/User/BadgeSelector/styles.module.scss @@ -0,0 +1,40 @@ +.badgeSelector { + position: relative; + --tdev-user-badge-border-color: var(--ifm-color-primary); + --tdev-user-badge-border-color-darker: var(--ifm-color-primary-dark); + --tdev-user-badge-background-color: var(--ifm-color-secondary); + --tdev-user-badge-background-color-darker: var(--ifm-color-secondary-dark); + aspect-ratio: 1 / 1; + width: 4rem; + font-weight: var(--ifm-font-weight-bold); + border-radius: 50%; + overflow: hidden; + border: 2px solid var(--tdev-user-badge-border-color); + background-color: var(--tdev-user-badge-background-color); + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + &:hover { + border-color: var(--tdev-user-badge-border-color-darker); + background-color: var(--tdev-user-badge-background-color-darker); + } + &.selected { + --tdev-user-badge-border-color: var(--ifm-color-warning); + --tdev-user-badge-border-color-darker: var(--ifm-color-warning-dark); + // --tdev-user-badge-background-color: var(--ifm-color-secondary); + // --tdev-user-badge-background-color-darker: var(--ifm-color-secondary-dark); + } + svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + text { + font-size: 16px; + font-weight: 600; + fill: var(--ifm-color-emphasis-700); + } + } +} diff --git a/src/components/documents/DynamicDocumentRoots/AddDynamicDocumentRoot/index.tsx b/src/components/documents/DynamicDocumentRoots/AddDynamicDocumentRoot/index.tsx index 6cb7c862c..2c693a50b 100644 --- a/src/components/documents/DynamicDocumentRoots/AddDynamicDocumentRoot/index.tsx +++ b/src/components/documents/DynamicDocumentRoots/AddDynamicDocumentRoot/index.tsx @@ -20,7 +20,7 @@ const AddDynamicDocumentRoot = observer((props: Props) => { if (!dynamicDocumentRoot.root || !user?.hasElevatedAccess) { return; } - permissionStore.loadPermissions(dynamicDocumentRoot.root); + permissionStore.loadPermissions(dynamicDocumentRoot.documentRootId); }, [dynamicDocumentRoot?.root, user?.hasElevatedAccess]); if (!user || !user.hasElevatedAccess) { return null; diff --git a/src/components/shared/RequestPresentationMode/index.tsx b/src/components/shared/RequestPresentationMode/index.tsx index a558f85e8..4ddacfcc7 100644 --- a/src/components/shared/RequestPresentationMode/index.tsx +++ b/src/components/shared/RequestPresentationMode/index.tsx @@ -39,7 +39,7 @@ const RequestPresentationMode = observer((props: Props) => { onClick={() => { groupStore.presentingStudentGroups.forEach((g) => { if (g.presentedDocument?.id === document.id) { - g.setPresentedDocumentProps(null); + g.apiSetPresentedDocumentProps(null); } }); }} @@ -71,7 +71,7 @@ const RequestPresentationMode = observer((props: Props) => { if (!document.root) { return; } - g.setPresentedDocumentProps({ + g.apiSetPresentedDocumentProps({ document: document.props, meta: document.root.meta, access: document.root._access, @@ -95,7 +95,7 @@ const RequestPresentationMode = observer((props: Props) => { } g.setCanPresent(true).then( action((updated) => { - updated.setPresentedDocumentProps({ + updated.apiSetPresentedDocumentProps({ document: document.props, meta: root.meta, access: root._access, diff --git a/src/models/StudentGroup.ts b/src/models/StudentGroup.ts index 047c50caa..8fd9c8728 100644 --- a/src/models/StudentGroup.ts +++ b/src/models/StudentGroup.ts @@ -4,6 +4,7 @@ import { StudentGroupStore } from '@tdev-stores/StudentGroupStore'; import { formatDateTime } from '@tdev-models/helpers/date'; import User from '@tdev-models/User'; import _ from 'es-toolkit/compat'; +import { orderBy } from 'es-toolkit/array'; class StudentGroup { readonly store: StudentGroupStore; @@ -43,7 +44,7 @@ class StudentGroup { this.updatedAt = new Date(props.updatedAt); this.createdAt = new Date(props.createdAt); - this.setPresentedDocumentProps(props.presentedDocument ?? null, true); + this.setPresentedDocumentProps(props.presentedDocument ?? null); } get fCreatedAt() { @@ -56,8 +57,10 @@ class StudentGroup { @computed get students() { - return this.store.root.userStore.users.filter( - (u) => this.userIds.has(u.id) && !this.adminIds.has(u.id) + return orderBy( + this.store.root.userStore.users.filter((u) => this.userIds.has(u.id) && !this.adminIds.has(u.id)), + ['firstName', 'lastName'], + ['asc', 'asc'] ); } @@ -73,7 +76,7 @@ class StudentGroup { @computed get children() { - return _.orderBy( + return orderBy( this.store.studentGroups.filter((g) => g.parentId === this.id), ['name'], ['asc'] @@ -140,18 +143,40 @@ class StudentGroup { return Promise.resolve(this); } + /** + * sets the props only locally without saving to the server + */ @action - setPresentedDocumentProps(props: DocumentPresentation | null, skipSave: boolean = false) { + setPresentedDocumentProps(props: DocumentPresentation | null) { if (!this.canPresent || this.presentedDocumentProps === props) { return; } this.presentedDocumentProps = props; if (props) { this.store.root.documentStore.addPresentedDocumentToStore(this); + this.store.root.permissionStore.loadPermissions(props.document.documentRootId); } - if (!skipSave) { - this.save(); + } + + @action + apiSetPresentedDocumentProps(props: DocumentPresentation | null) { + if (!this.canPresent || this.presentedDocumentProps === props) { + return; } + const current = this.presentedDocumentProps; + this.setPresentedDocumentProps(props); + if (current && !props) { + Promise.all( + this.store.root.permissionStore + .userPermissionsByDocumentRoot(current.document.documentRootId) + .map((p) => { + return this.store.root.permissionStore.deleteUserPermission(p); + }) + ).catch((err) => { + console.error('Error deleting user permissions for presented document', err); + }); + } + this.save(); } @computed diff --git a/src/stores/PermissionStore.ts b/src/stores/PermissionStore.ts index 4cad8c2cb..a4868b830 100644 --- a/src/stores/PermissionStore.ts +++ b/src/stores/PermissionStore.ts @@ -239,12 +239,12 @@ class PermissionStore extends iStore<`update-${string}`> { } @action - loadPermissions(documentRoot: DocumentRoot) { - if (this.permissionsLoadedForDocumentRootIds.has(documentRoot.id)) { + loadPermissions(documentRootId: string) { + if (this.permissionsLoadedForDocumentRootIds.has(documentRootId)) { return Promise.resolve(); } - this.withAbortController(`load-permissions-${documentRoot.id}`, async (signal) => { - return permissionsFor(documentRoot.id, signal.signal).then( + this.withAbortController(`load-permissions-${documentRootId}`, async (signal) => { + return permissionsFor(documentRootId, signal.signal).then( action(({ data }) => { const docRootId = data.id; data.userPermissions.forEach((p) => { @@ -255,7 +255,7 @@ class PermissionStore extends iStore<`update-${string}`> { new GroupPermission({ ...p, documentRootId: docRootId }, this) ); }); - this.permissionsLoadedForDocumentRootIds.add(documentRoot.id); + this.permissionsLoadedForDocumentRootIds.add(documentRootId); }) ); }); diff --git a/src/stores/StudentGroupStore.ts b/src/stores/StudentGroupStore.ts index cc3ce79e5..529be53ed 100644 --- a/src/stores/StudentGroupStore.ts +++ b/src/stores/StudentGroupStore.ts @@ -131,7 +131,7 @@ export class StudentGroupStore extends iStore<`members-${string}`> { model.setCanPresent(data.canPresent, true); } if (data.presentedDocument !== undefined && data.presentedDocument !== model.presentedDocumentProps) { - model.setPresentedDocumentProps(data.presentedDocument, true); + model.setPresentedDocumentProps(data.presentedDocument ?? null); } if (Array.isArray(data.userIds)) { model.userIds.replace(data.userIds); From be3522a79aa9806e3b6b817e0e841f789e080318 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Fri, 24 Jul 2026 13:18:45 +0200 Subject: [PATCH 11/18] add better selection mode --- .../CodeEditor/Editor/Header/Content.tsx | 4 +- .../Editor/Header/styles.module.scss | 7 +- .../GroupSelector/index.tsx | 67 +++++++++++++++++++ .../GroupSelector/styles.module.scss | 4 ++ .../shared/RequestPresentationMode/index.tsx | 62 +++++------------ src/models/Page.ts | 30 +++++++++ 6 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 src/components/shared/RequestPresentationMode/GroupSelector/index.tsx create mode 100644 src/components/shared/RequestPresentationMode/GroupSelector/styles.module.scss diff --git a/src/components/documents/CodeEditor/Editor/Header/Content.tsx b/src/components/documents/CodeEditor/Editor/Header/Content.tsx index 64c1991bf..6c49ad6f2 100644 --- a/src/components/documents/CodeEditor/Editor/Header/Content.tsx +++ b/src/components/documents/CodeEditor/Editor/Header/Content.tsx @@ -39,11 +39,11 @@ const Content = observer((props: Props) => { <>
{code.title}
- + {notifyUnpersisted && ( ; + color?: string; + noOutline?: boolean; +} + +const GroupSelector = observer((props: Props) => { + const { group, document } = props; + + return ( +
+ + {group.canPresent && ( +
+ ); +}); + +export default GroupSelector; diff --git a/src/components/shared/RequestPresentationMode/GroupSelector/styles.module.scss b/src/components/shared/RequestPresentationMode/GroupSelector/styles.module.scss new file mode 100644 index 000000000..333b165c1 --- /dev/null +++ b/src/components/shared/RequestPresentationMode/GroupSelector/styles.module.scss @@ -0,0 +1,4 @@ +.activeGroups { + display: flex; + justify-content: space-between; +} diff --git a/src/components/shared/RequestPresentationMode/index.tsx b/src/components/shared/RequestPresentationMode/index.tsx index 4ddacfcc7..4b0535e97 100644 --- a/src/components/shared/RequestPresentationMode/index.tsx +++ b/src/components/shared/RequestPresentationMode/index.tsx @@ -3,13 +3,14 @@ import clsx from 'clsx'; import styles from './styles.module.scss'; import { observer } from 'mobx-react-lite'; import Button from '../Button'; -import { mdiPresentationPlay, mdiTelevisionStop } from '@mdi/js'; +import { mdiClose, mdiPresentationPlay, mdiTelevisionStop } from '@mdi/js'; import { Color } from '../Colors'; import { useStore } from '@tdev-hooks/useStore'; import Popup from 'reactjs-popup'; import Card from '../Card'; import iDocument from '@tdev-models/iDocument'; import { action } from 'mobx'; +import GroupSelector from './GroupSelector'; interface Props { document: iDocument; @@ -20,8 +21,10 @@ interface Props { const RequestPresentationMode = observer((props: Props) => { const { document, className } = props; + const pageStore = useStore('pageStore'); const userStore = useStore('userStore'); const groupStore = useStore('studentGroupStore'); + const [focus, setFocus] = React.useState(false); if (document.isDummy) { return null; } @@ -52,7 +55,7 @@ const RequestPresentationMode = observer((props: Props) => { trigger={ - ))} + {groupStore.presentableStudentGroups + .filter((g) => pageStore.current?.relevantStudentGroupIds.has(g.id) ?? false) + .map((g) => ( + + ))} + {groupStore.presentableStudentGroups + .filter((g) => !(pageStore.current?.relevantStudentGroupIds.has(g.id) ?? false)) + .map((g) => ( + + ))} {groupStore.studentGroups .filter((g) => !g.canPresent) .map((g) => ( - + ))} diff --git a/src/models/Page.ts b/src/models/Page.ts index f4e6f8365..0a39ad807 100644 --- a/src/models/Page.ts +++ b/src/models/Page.ts @@ -140,6 +140,36 @@ export default class Page { return this._primaryViewedStudentGroupName ?? this.store.currentStudentGroupName; } + @computed + get relevantStudentGroups() { + const group = this.store.root.studentGroupStore.findByName(this.store.currentStudentGroupName); + if (!group) { + return []; + } + const groups: StudentGroup[] = []; + const addGroupAndChildren = (g: StudentGroup) => { + groups.push(g); + g.children.forEach((child) => addGroupAndChildren(child)); + }; + addGroupAndChildren(group); + return groups; + } + + @computed + get relevantStudentGroupIds(): Set { + const group = this.store.root.studentGroupStore.findByName(this.store.currentStudentGroupName); + if (!group) { + return new Set(); + } + const groups: StudentGroup[] = []; + const addGroupAndChildren = (g: StudentGroup) => { + groups.push(g); + g.children.forEach((child) => addGroupAndChildren(child)); + }; + addGroupAndChildren(group); + return new Set(groups.map((g) => g.id)); + } + @action setPrimaryViewedStudentGroupName(name?: string) { this._primaryViewedStudentGroupName = name; From 0f6d185bc0f605e08044ba49d64203a01774cac4 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Fri, 24 Jul 2026 14:59:24 +0200 Subject: [PATCH 12/18] visual improvements --- .../DocumentPresentationView/index.tsx | 23 ++++++++++++++++--- .../styles.module.scss | 16 ++++++++++++- src/components/PresentationPanel/index.tsx | 18 +++++++++++---- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/components/PresentationPanel/DocumentPresentationView/index.tsx b/src/components/PresentationPanel/DocumentPresentationView/index.tsx index 5ee79bae0..a0c3912ac 100644 --- a/src/components/PresentationPanel/DocumentPresentationView/index.tsx +++ b/src/components/PresentationPanel/DocumentPresentationView/index.tsx @@ -7,8 +7,6 @@ import StudentGroup from '@tdev-models/StudentGroup'; import { Access, CodeType } from '@tdev-api/document'; import CodeEditorComponent from '@tdev-components/documents/CodeEditor'; import iCode from '@tdev-models/documents/iCode'; -import AccessSelector, { AccessNames } from '@tdev-components/PermissionsPanel/AccessSelector'; -import Badge from '@tdev-components/shared/Badge'; import GroupAccessSelector from '@tdev-components/PermissionsPanel/AccessSelector/GroupAccessSelector'; import SharedAccessSelector from '@tdev-components/PermissionsPanel/AccessSelector/SharedAccessSelector'; import { asStudentGroupAccess } from '@tdev-models/helpers/accessPolicy'; @@ -16,6 +14,9 @@ import BadgeSelector from '@tdev-components/User/BadgeSelector'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; import Card from '@tdev-components/shared/Card'; +import Badge from '@tdev-components/shared/Badge'; +import Button from '@tdev-components/shared/Button'; +import { mdiClose } from '@mdi/js'; interface Props { group: StudentGroup; @@ -56,7 +57,22 @@ const DocumentPresentationView = observer((props: Props) => {
- + + {group.name} +
+ } + > +

Berechtigungen

Gruppe @@ -73,6 +89,7 @@ const DocumentPresentationView = observer((props: Props) => { />
+

Fokus

{group.students.map((s) => ( { const groupStore = useStore('studentGroupStore'); + if (groupStore.presentingStudentGroups.length === 0) { + return Keine Präsentation aktiv; + } + + if (groupStore.presentingStudentGroups.length === 1) { + return ( +
+ +
+ ); + } + return (
From 80bb36727204fec08af9f5af58e5f5e9948fb173 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Sat, 25 Jul 2026 23:36:14 +0200 Subject: [PATCH 13/18] fix colors --- src/components/User/BadgeSelector/styles.module.scss | 6 +++--- src/models/StudentGroup.ts | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/User/BadgeSelector/styles.module.scss b/src/components/User/BadgeSelector/styles.module.scss index 734d8dedf..3ab20a74a 100644 --- a/src/components/User/BadgeSelector/styles.module.scss +++ b/src/components/User/BadgeSelector/styles.module.scss @@ -2,8 +2,8 @@ position: relative; --tdev-user-badge-border-color: var(--ifm-color-primary); --tdev-user-badge-border-color-darker: var(--ifm-color-primary-dark); - --tdev-user-badge-background-color: var(--ifm-color-secondary); - --tdev-user-badge-background-color-darker: var(--ifm-color-secondary-dark); + --tdev-user-badge-background-color: var(--ifm-background-surface-color); + --tdev-user-badge-background-color-darker: var(--ifm-background-color); aspect-ratio: 1 / 1; width: 4rem; font-weight: var(--ifm-font-weight-bold); @@ -33,7 +33,7 @@ overflow: visible; text { font-size: 16px; - font-weight: 600; + font-weight: 800; fill: var(--ifm-color-emphasis-700); } } diff --git a/src/models/StudentGroup.ts b/src/models/StudentGroup.ts index 8fd9c8728..174f7578b 100644 --- a/src/models/StudentGroup.ts +++ b/src/models/StudentGroup.ts @@ -154,7 +154,6 @@ class StudentGroup { this.presentedDocumentProps = props; if (props) { this.store.root.documentStore.addPresentedDocumentToStore(this); - this.store.root.permissionStore.loadPermissions(props.document.documentRootId); } } From d267541d539d55098ffa26a2e891debc290b223c Mon Sep 17 00:00:00 2001 From: bh0fer Date: Sun, 26 Jul 2026 18:30:29 +0200 Subject: [PATCH 14/18] fix ssr issues --- .../PresentationPanel/PresentationModal/index.tsx | 11 ++++++++++- src/components/PresentationPanel/index.tsx | 6 ++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/PresentationPanel/PresentationModal/index.tsx b/src/components/PresentationPanel/PresentationModal/index.tsx index 145fee922..c2bfdfc15 100644 --- a/src/components/PresentationPanel/PresentationModal/index.tsx +++ b/src/components/PresentationPanel/PresentationModal/index.tsx @@ -1,16 +1,25 @@ import React from 'react'; import clsx from 'clsx'; import styles from './styles.module.scss'; +import { useClientLib } from '@tdev-hooks/useClientLib'; import { observer } from 'mobx-react-lite'; import { useStore } from '@tdev-hooks/useStore'; import Popup from 'reactjs-popup'; -import PresentationPanel from '..'; +import type { default as PresentationPanelLib } from '..'; interface Props {} const PresentationModal = observer((props: Props) => { const documentStore = useStore('documentStore'); + const PresentationPanel = useClientLib( + () => import('@tdev-components/PresentationPanel').then((d) => d.default), + '@tdev-components/PresentationPanel' + ); + if (!PresentationPanel) { + return null; + } + return ( { const groupStore = useStore('studentGroupStore'); + const isBrowser = useIsBrowser(); + if (!isBrowser) { + return Aktuell nicht verfügbar; + } + if (groupStore.presentingStudentGroups.length === 0) { return Keine Präsentation aktiv; } From c5dbdeb0e9902826dd1c5ce77071ca7aa842df4b Mon Sep 17 00:00:00 2001 From: bh0fer Date: Sun, 26 Jul 2026 20:05:18 +0200 Subject: [PATCH 15/18] fix cyclic refs --- src/models/DocumentRoot.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/models/DocumentRoot.ts b/src/models/DocumentRoot.ts index 4410833b5..813eb5150 100644 --- a/src/models/DocumentRoot.ts +++ b/src/models/DocumentRoot.ts @@ -6,7 +6,25 @@ import { highestAccess, NoneAccess, ROAccess, RWAccess } from './helpers/accessP import { isDummyId } from '@tdev-hooks/useDummyId'; import { orderBy } from 'es-toolkit/array'; import { Hashery } from 'hashery'; -export const MetaHasher = new Hashery({ cache: { enabled: true, maxSize: 500 } }); + +/** + * removes react specific props from the object: + * - children + * - props + * - ref + */ +const sanitizedProps = (props: any) => { + const { children, props: _p, ref, ...rest } = props; + return rest; +}; + +export const MetaHasher = new Hashery({ + cache: { enabled: true, maxSize: 500 }, + stringify: (obj) => { + const rest = sanitizedProps(obj); + return JSON.stringify(rest); + } +}); interface BaseMetaProps { access?: Access; @@ -20,8 +38,8 @@ export abstract class TypeMeta { type: T; access?: Access; constructor(type: T, props: BaseMetaProps = {}) { + this.props = sanitizedProps(props); this.type = type; - this.props = props; this.access = props.access ?? (props.readonly ? Access.RO_User : undefined); this.pagePosition = props.pagePosition || 0; } From 67cb14a5d267ac362ccc39df65ae9ca628ab3303 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Tue, 28 Jul 2026 15:16:46 +0200 Subject: [PATCH 16/18] fix cyclic meta props --- src/hooks/useDocumentRoot.ts | 2 +- src/models/DocumentRoot.ts | 32 +++++++++++++-------- src/models/documents/ProgressState/index.ts | 2 +- src/models/documents/Restricted.ts | 2 +- src/models/documents/Solution.ts | 2 +- src/models/documents/String.ts | 2 +- src/models/documents/TaskState.ts | 2 +- src/stores/DocumentStore.ts | 2 +- 8 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/hooks/useDocumentRoot.ts b/src/hooks/useDocumentRoot.ts index 904e3cc03..15315a089 100644 --- a/src/hooks/useDocumentRoot.ts +++ b/src/hooks/useDocumentRoot.ts @@ -115,7 +115,7 @@ export const useDocumentRoot = ( if (!rootDoc || !rootDoc.isLoaded) { return; } - const hash = MetaHasher.toHashSync(meta); + const hash = MetaHasher.toHashSync(meta.props); if (hash === rootDoc._metaHash) { return; } diff --git a/src/models/DocumentRoot.ts b/src/models/DocumentRoot.ts index 813eb5150..d4fc6440a 100644 --- a/src/models/DocumentRoot.ts +++ b/src/models/DocumentRoot.ts @@ -13,17 +13,25 @@ import { Hashery } from 'hashery'; * - props * - ref */ -const sanitizedProps = (props: any) => { - const { children, props: _p, ref, ...rest } = props; - return rest; -}; +// const sanitizedProps = (props: any) => { +// if (props.props) { +// console.log('sanitizedProps called with props:', props, props.props); +// } +// const { +// children, +// // ref, +// // className, +// // style, +// suppressContentEditableWarning, +// suppressHydrationWarning, +// dangerouslySetInnerHTML, +// ...rest +// } = props; +// return rest; +// }; export const MetaHasher = new Hashery({ - cache: { enabled: true, maxSize: 500 }, - stringify: (obj) => { - const rest = sanitizedProps(obj); - return JSON.stringify(rest); - } + cache: { enabled: true, maxSize: 500 } }); interface BaseMetaProps { @@ -37,11 +45,11 @@ export abstract class TypeMeta { readonly props: BaseMetaProps; type: T; access?: Access; - constructor(type: T, props: BaseMetaProps = {}) { - this.props = sanitizedProps(props); + constructor(type: T, props: BaseMetaProps = {}, useMinimalProps: boolean = false) { this.type = type; this.access = props.access ?? (props.readonly ? Access.RO_User : undefined); this.pagePosition = props.pagePosition || 0; + this.props = useMinimalProps ? { access: this.access, pagePosition: this.pagePosition } : props; } abstract get defaultData(): TypeDataMapping[T]; } @@ -67,7 +75,7 @@ class DocumentRoot { constructor(props: DocumentRootProps, meta: TypeMeta, store: DocumentRootStore, isDummy?: boolean) { this.store = store; this.meta = meta; - this._metaHash = MetaHasher.toHashSync(meta); + this._metaHash = MetaHasher.toHashSync(meta.props); this.id = props.id; this._access = props.access; this._sharedAccess = props.sharedAccess; diff --git a/src/models/documents/ProgressState/index.ts b/src/models/documents/ProgressState/index.ts index 7421bcf9f..5cdb1f153 100644 --- a/src/models/documents/ProgressState/index.ts +++ b/src/models/documents/ProgressState/index.ts @@ -35,7 +35,7 @@ export class ModelMeta extends TypeMeta<'progress_state'> { readonly keepPreviousStepsOpen: boolean; constructor(props: Partial) { - super('progress_state', props); + super('progress_state', props, true); this.default = props.default ?? DEFAULT_PROGRESS; this.readonly = !!props.readonly; if (props.allOpen) { diff --git a/src/models/documents/Restricted.ts b/src/models/documents/Restricted.ts index 0b6bd577d..c976f03d5 100644 --- a/src/models/documents/Restricted.ts +++ b/src/models/documents/Restricted.ts @@ -12,7 +12,7 @@ export class ModelMeta extends TypeMeta<'restricted'> { readonly type = 'restricted'; constructor(props: Partial) { - super('restricted', props); + super('restricted', props, true); } get defaultData(): TypeDataMapping['restricted'] { diff --git a/src/models/documents/Solution.ts b/src/models/documents/Solution.ts index 6317525b7..af138ba92 100644 --- a/src/models/documents/Solution.ts +++ b/src/models/documents/Solution.ts @@ -13,7 +13,7 @@ export class ModelMeta extends TypeMeta<'solution'> { readonly type = 'solution'; constructor(props: Partial) { - super('solution', props); + super('solution', props, true); } get defaultData(): TypeDataMapping['solution'] { diff --git a/src/models/documents/String.ts b/src/models/documents/String.ts index 8d129ebf9..440c8a98f 100644 --- a/src/models/documents/String.ts +++ b/src/models/documents/String.ts @@ -22,7 +22,7 @@ export class ModelMeta extends TypeMeta<'string'> { readonly checker: (val: string | undefined) => boolean; constructor(props: Partial) { - super('string', props); + super('string', props, true); this.readonly = props.readonly; this.default = props.default; this.solution = props.solution; diff --git a/src/models/documents/TaskState.ts b/src/models/documents/TaskState.ts index c4898a1a2..1df45534a 100644 --- a/src/models/documents/TaskState.ts +++ b/src/models/documents/TaskState.ts @@ -28,7 +28,7 @@ export class TaskMeta extends TypeMeta<'task_state'> { readonly default: StateType; constructor(props: Partial) { - super('task_state', props); + super('task_state', props, true); this.default = props.states && props.states.length > 0 ? props.states[0] : DEFAULT_TASK_STATES[0]; this.readonly = !!props.readonly; } diff --git a/src/stores/DocumentStore.ts b/src/stores/DocumentStore.ts index 6f2fa24fa..3dc955527 100644 --- a/src/stores/DocumentStore.ts +++ b/src/stores/DocumentStore.ts @@ -364,7 +364,7 @@ class DocumentStore extends iStore<`delete-${string}`> { return; } const docRoot = this.root.documentRootStore.find(rawDoc.documentRootId); - const metaHash = MetaHasher.toHashSync(rawMeta); + const metaHash = MetaHasher.toHashSync(rawMeta.props); if (!docRoot || !docRoot?.isDummy || docRoot._metaHash !== metaHash) { this.root.documentRootStore.addDocumentRoot( new DocumentRoot( From 0f99a6b35fbf17c0dfc3b17c0f62c9fae5c6c7e0 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Tue, 28 Jul 2026 15:43:41 +0200 Subject: [PATCH 17/18] set default permissions on open and close --- .../DocumentPresentationView/index.tsx | 122 +++++++++--------- .../styles.module.scss | 18 +-- src/components/PresentationPanel/index.tsx | 2 + .../documents/CodeEditor/Editor/index.tsx | 1 - .../shared/RequestPresentationMode/index.tsx | 4 +- src/models/StudentGroup.ts | 39 +++++- 6 files changed, 110 insertions(+), 76 deletions(-) diff --git a/src/components/PresentationPanel/DocumentPresentationView/index.tsx b/src/components/PresentationPanel/DocumentPresentationView/index.tsx index a0c3912ac..dcbfbf839 100644 --- a/src/components/PresentationPanel/DocumentPresentationView/index.tsx +++ b/src/components/PresentationPanel/DocumentPresentationView/index.tsx @@ -16,7 +16,7 @@ import TabItem from '@theme/TabItem'; import Card from '@tdev-components/shared/Card'; import Badge from '@tdev-components/shared/Badge'; import Button from '@tdev-components/shared/Button'; -import { mdiClose } from '@mdi/js'; +import { mdiClose, mdiProjectorScreenOffOutline } from '@mdi/js'; interface Props { group: StudentGroup; @@ -50,66 +50,70 @@ const DocumentPresentationView = observer((props: Props) => { } return ( - - -
- } isPresentation /> -
-
- - - {group.name} -
- } - > -

Berechtigungen

-
-
- Gruppe - +
+ + +
+ } + isPresentation + /> +
+
+ + +

+ Gruppe {group.name} +

+

Berechtigungen

+
+
+ Gruppe + +
+
+ Geteilt + +
-
- Geteilt - +

Fokus

+
+ {group.students.map((s) => ( + { + const all = await Promise.all( + userPermissions.map((p) => { + return permissionStore.deleteUserPermission(p); + }) + ); + await permissionStore.createUserPermission( + rootId, + user, + Access.RW_User + ); + }} + selected={userPermissions.some((p) => p.userId === s.id)} + /> + ))}
-
-

Fokus

-
- {group.students.map((s) => ( - { - const all = await Promise.all( - userPermissions.map((p) => { - return permissionStore.deleteUserPermission(p); - }) - ); - await permissionStore.createUserPermission(rootId, user, Access.RW_User); - }} - selected={userPermissions.some((p) => p.userId === s.id)} - /> - ))} -
-
-
-
+ + + +
); }); diff --git a/src/components/PresentationPanel/DocumentPresentationView/styles.module.scss b/src/components/PresentationPanel/DocumentPresentationView/styles.module.scss index fdaacad4f..59503d263 100644 --- a/src/components/PresentationPanel/DocumentPresentationView/styles.module.scss +++ b/src/components/PresentationPanel/DocumentPresentationView/styles.module.scss @@ -4,15 +4,6 @@ .admin { overflow-y: auto; } - .adminHeader { - display: flex; - align-items: center; - justify-content: space-between; - } - // display: flex; - // flex-direction: column; - // align-items: flex-start; - // gap: 1rem; } .accessPanels { display: flex; @@ -34,3 +25,12 @@ --ifm-tabs-padding-vertical: 0.2em; --ifm-tabs-padding-horizontal: 0.4em; } + +.presentationView { + position: relative; + .closePresentationButton { + position: absolute; + top: 0.5rem; + right: 0; + } +} diff --git a/src/components/PresentationPanel/index.tsx b/src/components/PresentationPanel/index.tsx index f4090e8b2..ece5c08ff 100644 --- a/src/components/PresentationPanel/index.tsx +++ b/src/components/PresentationPanel/index.tsx @@ -8,6 +8,8 @@ import TabItem from '@theme/TabItem'; import DocumentPresentationView from './DocumentPresentationView'; import Alert from '@tdev-components/shared/Alert'; import useIsBrowser from '@docusaurus/useIsBrowser'; +import Button from '@tdev-components/shared/Button'; +import { mdiProjectorScreenOffOutline } from '@mdi/js'; interface Props {} diff --git a/src/components/documents/CodeEditor/Editor/index.tsx b/src/components/documents/CodeEditor/Editor/index.tsx index 5cfccd549..e52427824 100644 --- a/src/components/documents/CodeEditor/Editor/index.tsx +++ b/src/components/documents/CodeEditor/Editor/index.tsx @@ -21,7 +21,6 @@ const Editor = observer((props: Props) => { const componentStore = useStore('componentStore'); const userStore = useStore('userStore'); const EC = componentStore.editorComponent(code.type); - console.log('Editor Access', code.canDisplay, code.isDummy, userStore.isUserSwitched); if (!code.canDisplay && !code.isDummy) { if (!userStore.isUserSwitched) { return ( diff --git a/src/components/shared/RequestPresentationMode/index.tsx b/src/components/shared/RequestPresentationMode/index.tsx index 4b0535e97..7c05fb5d0 100644 --- a/src/components/shared/RequestPresentationMode/index.tsx +++ b/src/components/shared/RequestPresentationMode/index.tsx @@ -3,7 +3,7 @@ import clsx from 'clsx'; import styles from './styles.module.scss'; import { observer } from 'mobx-react-lite'; import Button from '../Button'; -import { mdiClose, mdiPresentationPlay, mdiTelevisionStop } from '@mdi/js'; +import { mdiClose, mdiPresentationPlay, mdiProjectorScreenOffOutline, mdiTelevisionStop } from '@mdi/js'; import { Color } from '../Colors'; import { useStore } from '@tdev-hooks/useStore'; import Popup from 'reactjs-popup'; @@ -38,7 +38,7 @@ const RequestPresentationMode = observer((props: Props) => { color={props.color || 'blue'} size={props.size} title={'Präsentation beenden'} - icon={mdiTelevisionStop} + icon={mdiProjectorScreenOffOutline} onClick={() => { groupStore.presentingStudentGroups.forEach((g) => { if (g.presentedDocument?.id === document.id) { diff --git a/src/models/StudentGroup.ts b/src/models/StudentGroup.ts index 174f7578b..512956c66 100644 --- a/src/models/StudentGroup.ts +++ b/src/models/StudentGroup.ts @@ -5,6 +5,7 @@ import { formatDateTime } from '@tdev-models/helpers/date'; import User from '@tdev-models/User'; import _ from 'es-toolkit/compat'; import { orderBy } from 'es-toolkit/array'; +import { Access } from '@tdev-api/document'; class StudentGroup { readonly store: StudentGroupStore; @@ -164,14 +165,42 @@ class StudentGroup { } const current = this.presentedDocumentProps; this.setPresentedDocumentProps(props); - if (current && !props) { - Promise.all( - this.store.root.permissionStore + if (props) { + const permission = this.store.root.permissionStore + .groupPermissionsByDocumentRoot(props.document.documentRootId) + .find((p) => p.groupId === this.id); + const docRoot = this.store.root.documentRootStore.find(props.document.documentRootId); + if (!docRoot) { + console.error( + 'Document root not found for presented document', + props.document.documentRootId + ); + return; + } + docRoot.setSharedAccess(Access.RW_DocumentRoot); + docRoot.save(); + if (permission) { + permission.setAccess(Access.RO_StudentGroup); + } else { + this.store.root.permissionStore.createGroupPermission(docRoot, this, Access.RO_StudentGroup); + } + } else if (current) { + const currentDocRoot = this.store.root.documentRootStore.find(current.document.documentRootId); + currentDocRoot?.setSharedAccess(Access.None_DocumentRoot); + Promise.all([ + ...this.store.root.permissionStore .userPermissionsByDocumentRoot(current.document.documentRootId) .map((p) => { return this.store.root.permissionStore.deleteUserPermission(p); - }) - ).catch((err) => { + }), + ...this.store.root.permissionStore + .groupPermissionsByDocumentRoot(current.document.documentRootId) + .filter((p) => p.groupId === this.id) + .map((p) => { + return this.store.root.permissionStore.deleteGroupPermission(p); + }), + currentDocRoot ? currentDocRoot.save() : Promise.resolve() + ]).catch((err) => { console.error('Error deleting user permissions for presented document', err); }); } From 827e092372ed5f2031b6536420467c173e408b5a Mon Sep 17 00:00:00 2001 From: bh0fer Date: Wed, 29 Jul 2026 16:46:15 +0200 Subject: [PATCH 18/18] cleanup permission controls --- .../AccessSelector/GroupAccessSelector.tsx | 6 +- .../AccessSelector/RootAccessSelector.tsx | 32 +++++++++ .../AccessSelector/SharedAccessSelector.tsx | 1 - .../GroupPermission/AccessPanel.tsx | 6 +- src/components/PermissionsPanel/index.tsx | 2 - .../DocumentPresentationView/index.tsx | 67 +++++++++++++------ .../styles.module.scss | 3 + src/components/User/BadgeSelector/index.tsx | 54 +++++++++------ .../User/BadgeSelector/styles.module.scss | 8 +++ src/models/DocumentRoot.ts | 17 +++-- src/models/StudentGroup.ts | 39 ++++++++--- src/stores/DocumentRootStore.ts | 5 +- src/stores/PermissionStore.ts | 30 ++++++++- 13 files changed, 204 insertions(+), 66 deletions(-) create mode 100644 src/components/PermissionsPanel/AccessSelector/RootAccessSelector.tsx diff --git a/src/components/PermissionsPanel/AccessSelector/GroupAccessSelector.tsx b/src/components/PermissionsPanel/AccessSelector/GroupAccessSelector.tsx index c9a4f0e1c..97cc815ef 100644 --- a/src/components/PermissionsPanel/AccessSelector/GroupAccessSelector.tsx +++ b/src/components/PermissionsPanel/AccessSelector/GroupAccessSelector.tsx @@ -31,7 +31,11 @@ const GroupAccessSelector = observer((props: Props) => { if (currentPermission) { currentPermission.setAccess(access); } else { - permissionStore.createGroupPermission(group.presentedDocument!.root!, group, access); + permissionStore.createGroupPermission( + group.presentedDocumentProps?.document.documentRootId!, + group, + access + ); } }} mark={props.mark} diff --git a/src/components/PermissionsPanel/AccessSelector/RootAccessSelector.tsx b/src/components/PermissionsPanel/AccessSelector/RootAccessSelector.tsx new file mode 100644 index 000000000..75d5d41c1 --- /dev/null +++ b/src/components/PermissionsPanel/AccessSelector/RootAccessSelector.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import clsx from 'clsx'; +import { observer } from 'mobx-react-lite'; +import { useStore } from '@tdev-hooks/useStore'; +import AccessSelector from '.'; +import { Access, DocumentType } from '@tdev-api/document'; +import DocumentRoot from '@tdev-models/DocumentRoot'; + +interface Props { + documentRoot: DocumentRoot; + maxAccess?: Access; + className?: string; + mark?: Access | Access[] | Set; +} + +const RootAccessSelector = observer((props: Props) => { + const { documentRoot } = props; + + return ( + { + documentRoot.setRootAccess(access); + }} + maxAccess={props.maxAccess} + mark={props.mark} + /> + ); +}); + +export default RootAccessSelector; diff --git a/src/components/PermissionsPanel/AccessSelector/SharedAccessSelector.tsx b/src/components/PermissionsPanel/AccessSelector/SharedAccessSelector.tsx index 8d7076d3b..a00b07f71 100644 --- a/src/components/PermissionsPanel/AccessSelector/SharedAccessSelector.tsx +++ b/src/components/PermissionsPanel/AccessSelector/SharedAccessSelector.tsx @@ -22,7 +22,6 @@ const SharedAccessSelector = observer((props: Props) => { access={documentRoot.sharedAccess} onChange={(access) => { documentRoot.setSharedAccess(access); - documentRoot.save(); }} maxAccess={props.maxAccess} mark={props.mark} diff --git a/src/components/PermissionsPanel/GroupPermission/AccessPanel.tsx b/src/components/PermissionsPanel/GroupPermission/AccessPanel.tsx index 2d4726411..1b801575a 100644 --- a/src/components/PermissionsPanel/GroupPermission/AccessPanel.tsx +++ b/src/components/PermissionsPanel/GroupPermission/AccessPanel.tsx @@ -98,7 +98,11 @@ const AccessPanel = observer((props: Props) => { if (currentPermission) { currentPermission.setAccess(access); } else { - permissionStore.createGroupPermission(dr, group, access); + permissionStore.createGroupPermission( + dr.id, + group, + access + ); } }); }} diff --git a/src/components/PermissionsPanel/index.tsx b/src/components/PermissionsPanel/index.tsx index f5e9576ec..4a07475df 100644 --- a/src/components/PermissionsPanel/index.tsx +++ b/src/components/PermissionsPanel/index.tsx @@ -151,7 +151,6 @@ const PermissionsPanel = observer((props: Props) => { onChange={(access) => { documentRoots.forEach((dr) => { dr.setRootAccess(access); - dr.save(); }); }} /> @@ -172,7 +171,6 @@ const PermissionsPanel = observer((props: Props) => { onChange={(access) => { documentRoots.forEach((dr) => { dr.setSharedAccess(access); - dr.save(); }); }} /> diff --git a/src/components/PresentationPanel/DocumentPresentationView/index.tsx b/src/components/PresentationPanel/DocumentPresentationView/index.tsx index dcbfbf839..28f0d2bd0 100644 --- a/src/components/PresentationPanel/DocumentPresentationView/index.tsx +++ b/src/components/PresentationPanel/DocumentPresentationView/index.tsx @@ -17,6 +17,7 @@ import Card from '@tdev-components/shared/Card'; import Badge from '@tdev-components/shared/Badge'; import Button from '@tdev-components/shared/Button'; import { mdiClose, mdiProjectorScreenOffOutline } from '@mdi/js'; +import RootAccessSelector from '@tdev-components/PermissionsPanel/AccessSelector/RootAccessSelector'; interface Props { group: StudentGroup; @@ -36,7 +37,6 @@ const DocumentPresentationView = observer((props: Props) => { return
Kein Editor für Dokumenttyp {docType}
; } const rootId = group.presentedDocument.documentRootId; - const userPermissions = permissionStore.userPermissionsByDocumentRoot(rootId); const groupPermission = permissionStore .groupPermissionsByDocumentRoot(rootId) .find((p) => p.groupId === group.id)?.access; @@ -57,6 +57,7 @@ const DocumentPresentationView = observer((props: Props) => { } isPresentation + className={clsx(group.presentedDocument.canEdit && styles.focus)} />
@@ -74,33 +75,57 @@ const DocumentPresentationView = observer((props: Props) => { mark={asStudentGroupAccess(group.presentedDocument.root!.access)} />
-
- Geteilt - +
+
+ Root + +
+
+ Geteilt + +

Fokus

- {group.students.map((s) => ( + {group.users.map((s) => ( { - const all = await Promise.all( - userPermissions.map((p) => { - return permissionStore.deleteUserPermission(p); - }) - ); - await permissionStore.createUserPermission( - rootId, - user, - Access.RW_User - ); + onClick={async (user, clearCurrent) => { + if (clearCurrent) { + await Promise.all( + permissionStore + .userPermissionsByDocumentRoot(rootId) + .filter( + (u) => + group.userIds.has(u.userId) && + !group.adminIds.has(u.userId) + ) + .map((p) => { + return permissionStore.deleteUserPermission(p); + }) + ); + } + const currentPermission = permissionStore + .userPermissionsByDocumentRoot(rootId) + .find((p) => p.userId === user.id); + if (currentPermission) { + await permissionStore.deleteUserPermission(currentPermission); + } else { + await permissionStore.createUserPermission( + rootId, + user, + Access.RW_User + ); + } }} - selected={userPermissions.some((p) => p.userId === s.id)} + selected={permissionStore + .userPermissionsByDocumentRoot(rootId) + .some((p) => p.userId === s.id)} /> ))}
@@ -111,7 +136,7 @@ const DocumentPresentationView = observer((props: Props) => { className={clsx(styles.closePresentationButton)} icon={mdiProjectorScreenOffOutline} noOutline - onClick={() => group.setPresentedDocumentProps(null)} + onClick={() => group.apiSetPresentedDocumentProps(null)} />
); diff --git a/src/components/PresentationPanel/DocumentPresentationView/styles.module.scss b/src/components/PresentationPanel/DocumentPresentationView/styles.module.scss index 59503d263..ebec6cc4a 100644 --- a/src/components/PresentationPanel/DocumentPresentationView/styles.module.scss +++ b/src/components/PresentationPanel/DocumentPresentationView/styles.module.scss @@ -5,6 +5,9 @@ overflow-y: auto; } } +.focus { + box-shadow: 0 0 0 2px var(--ifm-color-primary); +} .accessPanels { display: flex; justify-content: flex-start; diff --git a/src/components/User/BadgeSelector/index.tsx b/src/components/User/BadgeSelector/index.tsx index 2ffdc1aaa..92c816ae2 100644 --- a/src/components/User/BadgeSelector/index.tsx +++ b/src/components/User/BadgeSelector/index.tsx @@ -4,10 +4,13 @@ import styles from './styles.module.scss'; import { observer } from 'mobx-react-lite'; import { useStore } from '@tdev-hooks/useStore'; import User from '@tdev-models/User'; +import Button from '@tdev-components/shared/Button'; +import { mdiMinusCircleOutline, mdiPlusCircleOutline } from '@mdi/js'; +import { SIZE_XS } from '@tdev-components/shared/iconSizes'; interface Props { user: User; - onClick?: (user: User) => void; + onClick?: (user: User, clearCurrent: boolean) => void; selected?: boolean; className?: string; } @@ -25,24 +28,37 @@ const BadgeSelector = observer((props: Props) => { */ return ( -
{ - e.stopPropagation(); - props.onClick?.(user); - }} - > - {user.firstName.charAt(0)} - {user.lastName.charAt(0)} - - - - - {user.firstName} - - - +
+
{ + e.stopPropagation(); + props.onClick?.(user, true); + }} + > + {user.firstName.charAt(0)} + {user.lastName.charAt(0)} + + + + + {user.firstName} + + + +
+
); }); diff --git a/src/components/User/BadgeSelector/styles.module.scss b/src/components/User/BadgeSelector/styles.module.scss index 3ab20a74a..b3d78cd96 100644 --- a/src/components/User/BadgeSelector/styles.module.scss +++ b/src/components/User/BadgeSelector/styles.module.scss @@ -38,3 +38,11 @@ } } } +.badgeSelectorContainer { + position: relative; + .addButton { + position: absolute; + bottom: -6px; + right: -12px; + } +} diff --git a/src/models/DocumentRoot.ts b/src/models/DocumentRoot.ts index d4fc6440a..d2e5eb214 100644 --- a/src/models/DocumentRoot.ts +++ b/src/models/DocumentRoot.ts @@ -109,11 +109,15 @@ class DocumentRoot { } @action - setRootAccess(access: Access) { + setRootAccess(access: Access, skipSave: boolean = false) { if (this._access === access) { - return; + return Promise.resolve(); } this._access = access; + if (skipSave) { + return Promise.resolve(); + } + return this.save(); } get sharedAccess() { @@ -121,12 +125,15 @@ class DocumentRoot { } @action - setSharedAccess(access: Access) { - console.log('setSharedAccess', access, this._sharedAccess); + setSharedAccess(access: Access, skipSave: boolean = false) { if (this._sharedAccess === access) { - return; + return Promise.resolve(); } this._sharedAccess = access; + if (skipSave) { + return Promise.resolve(); + } + return this.save(); } get loadStatus() { diff --git a/src/models/StudentGroup.ts b/src/models/StudentGroup.ts index 512956c66..c58786b77 100644 --- a/src/models/StudentGroup.ts +++ b/src/models/StudentGroup.ts @@ -70,6 +70,14 @@ class StudentGroup { return this.store.root.userStore.users.filter((u) => this.adminIds.has(u.id)); } + /** + * all users - both students and admins - in the group + */ + @computed + get users() { + return [...this.admins, ...this.students]; + } + @computed get searchTerm() { return `${this.name} ${this.description}`; @@ -166,6 +174,7 @@ class StudentGroup { const current = this.presentedDocumentProps; this.setPresentedDocumentProps(props); if (props) { + const rootId = props.document.documentRootId; const permission = this.store.root.permissionStore .groupPermissionsByDocumentRoot(props.document.documentRootId) .find((p) => p.groupId === this.id); @@ -178,18 +187,30 @@ class StudentGroup { return; } docRoot.setSharedAccess(Access.RW_DocumentRoot); - docRoot.save(); - if (permission) { - permission.setAccess(Access.RO_StudentGroup); - } else { - this.store.root.permissionStore.createGroupPermission(docRoot, this, Access.RO_StudentGroup); - } + this.store.root.permissionStore.createOrUpdateGroupPermission( + rootId, + this, + Access.RO_StudentGroup + ); + const adminPermissions = Promise.all( + this.admins.map((admin) => { + this.store.root.permissionStore.createOrUpdateUserPermission( + rootId, + admin, + Access.RW_User + ); + }) + ).catch((err) => { + console.error('Error creating admin permissions for presented document', err); + }); } else if (current) { const currentDocRoot = this.store.root.documentRootStore.find(current.document.documentRootId); - currentDocRoot?.setSharedAccess(Access.None_DocumentRoot); Promise.all([ + currentDocRoot?.setRootAccess(Access.RW_DocumentRoot), + currentDocRoot?.setSharedAccess(Access.None_DocumentRoot), ...this.store.root.permissionStore .userPermissionsByDocumentRoot(current.document.documentRootId) + .filter((p) => p.userId && this.userIds.has(p.userId)) .map((p) => { return this.store.root.permissionStore.deleteUserPermission(p); }), @@ -198,13 +219,11 @@ class StudentGroup { .filter((p) => p.groupId === this.id) .map((p) => { return this.store.root.permissionStore.deleteGroupPermission(p); - }), - currentDocRoot ? currentDocRoot.save() : Promise.resolve() + }) ]).catch((err) => { console.error('Error deleting user permissions for presented document', err); }); } - this.save(); } @computed diff --git a/src/stores/DocumentRootStore.ts b/src/stores/DocumentRootStore.ts index 0dd342649..9d500e39b 100644 --- a/src/stores/DocumentRootStore.ts +++ b/src/stores/DocumentRootStore.ts @@ -356,18 +356,17 @@ export class DocumentRootStore extends iStore { const old = model.permission; let needsReload = false; if (access !== model.rootAccess) { - model.setRootAccess(access); + model.setRootAccess(access, true); const current = model.permission; needsReload = NoneAccess.has(old) && !NoneAccess.has(current); } if (sharedAccess !== model.sharedAccess) { needsReload = needsReload || (NoneAccess.has(model.sharedAccess) && !NoneAccess.has(sharedAccess)); - model.setSharedAccess(sharedAccess); + model.setSharedAccess(sharedAccess, true); } if (needsReload) { this.reload(model); - console.log('reload model', model.id); } } } diff --git a/src/stores/PermissionStore.ts b/src/stores/PermissionStore.ts index a4868b830..b16cbc49d 100644 --- a/src/stores/PermissionStore.ts +++ b/src/stores/PermissionStore.ts @@ -161,13 +161,37 @@ class PermissionStore extends iStore<`update-${string}`> { } @action - createGroupPermission(documentRoot: DocumentRoot, group: StudentGroup, access: Access) { - this.withAbortController(`create-${documentRoot.id}-${group.id}`, async (signal) => { + createOrUpdateUserPermission(documentRootId: string, user: User, access: Access) { + const existingPermission = this.userPermissionsByDocumentRoot(documentRootId).find( + (p) => p.userId === user.id + ); + if (existingPermission) { + return existingPermission.setAccess(access); + } else { + return this.createUserPermission(documentRootId, user, access); + } + } + + @action + createOrUpdateGroupPermission(documentRootId: string, group: StudentGroup, access: Access) { + const existingPermission = this.groupPermissionsByDocumentRoot(documentRootId).find( + (p) => p.groupId === group.id + ); + if (existingPermission) { + return existingPermission.setAccess(access); + } else { + return this.createGroupPermission(documentRootId, group, access); + } + } + + @action + createGroupPermission(documentRootId: string, group: StudentGroup, access: Access) { + this.withAbortController(`create-${documentRootId}-${group.id}`, async (signal) => { return createGroupPermissionApi( { groupId: group.id, access: access, - documentRootId: documentRoot.id + documentRootId: documentRootId }, signal.signal ).then(({ data }) => {