diff --git a/packages/tdev/brython-code/models/Script.ts b/packages/tdev/brython-code/models/Script.ts index 6196b0dac..13412a1bd 100644 --- a/packages/tdev/brython-code/models/Script.ts +++ b/packages/tdev/brython-code/models/Script.ts @@ -78,13 +78,6 @@ export default class Script extends iCode<'script'> { return this.code.split('\n').length; } - @computed - get data(): TypeDataMapping['script'] { - return { - code: this.code - }; - } - @action toggleScriptExecution() { if (this.isExecuting) { 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 88edd817a..cb5d46f68 100644 --- a/packages/tdev/excalidoc/model/ModelMeta.ts +++ b/packages/tdev/excalidoc/model/ModelMeta.ts @@ -12,15 +12,17 @@ export interface MetaInit { export class ModelMeta extends TypeMeta<'excalidoc'> { readonly type = 'excalidoc'; + readonly props: Partial; readonly defaultElements: readonly ExcalidrawElement[]; readonly defaultFiles: BinaryFiles; 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 || ''; + this.props = props; } get defaultData(): TypeDataMapping['excalidoc'] { diff --git a/packages/tdev/netpbm-graphic/model/ModelMeta.ts b/packages/tdev/netpbm-graphic/model/ModelMeta.ts index c5ed98c87..b26ee9911 100644 --- a/packages/tdev/netpbm-graphic/model/ModelMeta.ts +++ b/packages/tdev/netpbm-graphic/model/ModelMeta.ts @@ -11,7 +11,7 @@ export class ModelMeta extends TypeMeta<'netpbm_graphic'> { readonly default?: string; 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 diff --git a/packages/tdev/page-read-check/model/ModelMeta.ts b/packages/tdev/page-read-check/model/ModelMeta.ts index 27421bbef..ad9456888 100644 --- a/packages/tdev/page-read-check/model/ModelMeta.ts +++ b/packages/tdev/page-read-check/model/ModelMeta.ts @@ -12,7 +12,7 @@ export class ModelMeta extends TypeMeta<'page_read_check'> { readonly minReadTime: number; constructor(props: Partial) { - super('page_read_check', props.readonly ? Access.RO_User : undefined); + super('page_read_check', props); this.minReadTime = props.minReadTime || 10; } 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/pyodide-code/models/PyodideCode.ts b/packages/tdev/pyodide-code/models/PyodideCode.ts index b1060fbfc..ede849038 100644 --- a/packages/tdev/pyodide-code/models/PyodideCode.ts +++ b/packages/tdev/pyodide-code/models/PyodideCode.ts @@ -98,17 +98,6 @@ class PyodideCode extends iCode<'pyodide_code'> { this.messages.push({ ...message }); } - @action - setData(data: TypeDataMapping['pyodide_code'], from: Source, updatedAt?: Date): void { - this.code = data.code; - if (from === Source.LOCAL) { - this.save(); - } - if (updatedAt) { - this.updatedAt = new Date(updatedAt); - } - } - @action runCode() { this.pyodideStore.run(this); @@ -119,12 +108,6 @@ class PyodideCode extends iCode<'pyodide_code'> { this.pyodideStore.recreatePyWorker(); } - get data(): TypeDataMapping['pyodide_code'] { - return { - code: this.code - }; - } - @computed get meta(): ModelMeta { if (this.root?.type === 'pyodide_code') { 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 19aeac146..66a090050 100644 --- a/packages/tdev/text-message/models/TextMessage/ModelMeta.ts +++ b/packages/tdev/text-message/models/TextMessage/ModelMeta.ts @@ -9,7 +9,7 @@ export class ModelMeta extends TypeMeta<'text_message'> { readonly type = 'text_message'; constructor(props: Partial) { - super('text_message', props.readonly ? Access.RO_User : undefined); + super('text_message', props); } get defaultData(): TypeDataMapping['text_message'] { diff --git a/src/api/IoEventTypes.ts b/src/api/IoEventTypes.ts index bf56b9c1c..9979427c7 100644 --- a/src/api/IoEventTypes.ts +++ b/src/api/IoEventTypes.ts @@ -45,13 +45,14 @@ export interface ChangedRecord { record: TypeRecordMap[T]; } -export interface ChangedDocument { +export interface ChangedDocument { id: string; - data: Object; - updatedAt: string; + data: Record; + updatedAt: Date; + meta?: T; } -export interface StreamedDocument extends ChangedDocument { +export interface StreamedDynamicDocument extends ChangedDocument { roomId: string; } @@ -137,7 +138,7 @@ export interface ClientToServerEvents { [IoClientEvent.JOIN_ROOM]: (roomId: string, callback: (joined: boolean) => void) => void; [IoClientEvent.LEAVE_ROOM]: (roomId: string, callback: (left: boolean) => void) => void; [IoClientEvent.ACTION]: (action: Action, callback: (ok: boolean) => void) => void; - [IoClientEvent.STREAM_UPDATE]: (payload: StreamedDocument) => void; + [IoClientEvent.STREAM_UPDATE]: (payload: StreamedDynamicDocument) => void; } export const RecordStoreMap: { [key in RecordType]: keyof typeof rootStore | `viewStore#${string}` } = { diff --git a/src/api/document.ts b/src/api/document.ts index 6c267fb33..664783bc7 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -135,7 +135,6 @@ type KeysWithCode = { }[keyof Omit]; export type CodeType = KeysWithCode; - export interface ContainerTypeModelMapping { ['_container_placeholder_']: iDocumentContainer; // placeholder to avoid empty interface error } @@ -203,7 +202,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/api/studentGroup.ts b/src/api/studentGroup.ts index ba257685a..64fe68b32 100644 --- a/src/api/studentGroup.ts +++ b/src/api/studentGroup.ts @@ -1,10 +1,21 @@ +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'; + +export interface DocumentPresentation { + document: Document; + meta: TypeMeta; + access: Access; + sharedAccess: Access; +} export interface StudentGroup { id: string; name: string; description: string; + canPresent: boolean; + presentedDocument: DocumentPresentation | null; userIds: string[]; adminIds: string[]; diff --git a/src/components/Admin/StudentGroupPanel/index.tsx b/src/components/Admin/StudentGroupPanel/index.tsx index 6fdcf8cc2..71735f7c0 100644 --- a/src/components/Admin/StudentGroupPanel/index.tsx +++ b/src/components/Admin/StudentGroupPanel/index.tsx @@ -10,6 +10,8 @@ import _ from 'es-toolkit/compat'; import { action } from 'mobx'; import Icon from '@mdi/react'; import TextInput from '@tdev-components/shared/TextInput'; +import DefinitionList from '@tdev-components/DefinitionList'; +import Badge from '@tdev-components/shared/Badge'; const StudentGroupPanel = observer(() => { const userStore = useStore('userStore'); @@ -20,6 +22,7 @@ const StudentGroupPanel = observer(() => { if (!current?.hasElevatedAccess) { return null; } + const presentableGroups = groupStore.managedStudentGroups.filter((g) => g.canPresent); return (
@@ -59,6 +62,34 @@ const StudentGroupPanel = observer(() => {
+
+ +
Präsentationsberechtigte Gruppen
+
+ 0 ? 'red' : 'lightBlue'}> + {presentableGroups.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/PermissionsPanel/AccessSelector/GroupAccessSelector.tsx b/src/components/PermissionsPanel/AccessSelector/GroupAccessSelector.tsx new file mode 100644 index 000000000..97cc815ef --- /dev/null +++ b/src/components/PermissionsPanel/AccessSelector/GroupAccessSelector.tsx @@ -0,0 +1,47 @@ +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 } from '@tdev-api/document'; +import StudentGroup from '@tdev-models/StudentGroup'; + +interface Props { + group: StudentGroup; + mark?: Access | Access[] | Set; + className?: string; +} + +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 + ); + if (currentPermission) { + currentPermission.setAccess(access); + } else { + permissionStore.createGroupPermission( + group.presentedDocumentProps?.document.documentRootId!, + group, + access + ); + } + }} + mark={props.mark} + /> +
+ ); +}); + +export default GroupAccessSelector; 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 new file mode 100644 index 000000000..a00b07f71 --- /dev/null +++ b/src/components/PermissionsPanel/AccessSelector/SharedAccessSelector.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 SharedAccessSelector = observer((props: Props) => { + const { documentRoot } = props; + + return ( + { + documentRoot.setSharedAccess(access); + }} + maxAccess={props.maxAccess} + mark={props.mark} + /> + ); +}); + +export default SharedAccessSelector; diff --git a/src/components/PermissionsPanel/AccessSelector/index.tsx b/src/components/PermissionsPanel/AccessSelector/index.tsx index cbbad0396..688fb9e7e 100644 --- a/src/components/PermissionsPanel/AccessSelector/index.tsx +++ b/src/components/PermissionsPanel/AccessSelector/index.tsx @@ -6,7 +6,7 @@ import { Access } from '@tdev-api/document'; import Icon from '@mdi/react'; import { mdiCircleSmall } from '@mdi/js'; -const AccessNames: { [key in Access]: string } = { +export const AccessNames: { [key in Access]: string } = { [Access.RO_User]: 'RO', [Access.RO_StudentGroup]: 'RO', [Access.RO_DocumentRoot]: 'RO', @@ -18,39 +18,77 @@ 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; - mark?: Set; + maxAccess?: Access; + mark?: Access | Access[] | 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 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 (
- {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/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 6d91fc1f3..4a07475df 100644 --- a/src/components/PermissionsPanel/index.tsx +++ b/src/components/PermissionsPanel/index.tsx @@ -109,7 +109,7 @@ const PermissionsPanel = observer((props: Props) => { keepTooltipInside="#__docusaurus" modal={isMobileView} onOpen={action(() => { - documentRoots.forEach((dr) => permissionStore.loadPermissions(dr)); + documentRoots.forEach((dr) => permissionStore.loadPermissions(dr.id)); setIsOpen(true); })} onClose={() => setIsOpen(false)} @@ -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 new file mode 100644 index 000000000..28f0d2bd0 --- /dev/null +++ b/src/components/PresentationPanel/DocumentPresentationView/index.tsx @@ -0,0 +1,145 @@ +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 { Access, CodeType } from '@tdev-api/document'; +import CodeEditorComponent from '@tdev-components/documents/CodeEditor'; +import iCode from '@tdev-models/documents/iCode'; +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'; +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; +} + +const DocumentPresentationView = observer((props: Props) => { + const componentStore = useStore('componentStore'); + const userStore = useStore('userStore'); + const permissionStore = useStore('permissionStore'); + 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}
; + } + const rootId = group.presentedDocument.documentRootId; + const groupPermission = permissionStore + .groupPermissionsByDocumentRoot(rootId) + .find((p) => p.groupId === group.id)?.access; + + if (!group.adminIds.has(userStore.current?.id ?? ' ')) { + return ( +
+ } isPresentation /> +
+ ); + } + + return ( +
+ + +
+ } + isPresentation + className={clsx(group.presentedDocument.canEdit && styles.focus)} + /> +
+
+ + +

+ Gruppe {group.name} +

+

Berechtigungen

+
+
+ Gruppe + +
+
+
+ Root + +
+
+ Geteilt + +
+
+
+

Fokus

+
+ {group.users.map((s) => ( + { + 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={permissionStore + .userPermissionsByDocumentRoot(rootId) + .some((p) => p.userId === s.id)} + /> + ))} +
+
+
+
+
+ ); +}); + +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..ebec6cc4a --- /dev/null +++ b/src/components/PresentationPanel/DocumentPresentationView/styles.module.scss @@ -0,0 +1,39 @@ +.adminCard { + max-height: 80vh; + overflow-y: hidden; + .admin { + overflow-y: auto; + } +} +.focus { + box-shadow: 0 0 0 2px var(--ifm-color-primary); +} +.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; +} + +.presentationView { + position: relative; + .closePresentationButton { + position: absolute; + top: 0.5rem; + right: 0; + } +} diff --git a/src/components/PresentationPanel/PresentationModal/index.tsx b/src/components/PresentationPanel/PresentationModal/index.tsx new file mode 100644 index 000000000..c2bfdfc15 --- /dev/null +++ b/src/components/PresentationPanel/PresentationModal/index.tsx @@ -0,0 +1,37 @@ +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 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 ( + 0} + 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..ece5c08ff --- /dev/null +++ b/src/components/PresentationPanel/index.tsx @@ -0,0 +1,51 @@ +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 Tabs from '@theme/Tabs'; +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 {} + +const PresentationPanel = observer((props: Props) => { + const groupStore = useStore('studentGroupStore'); + + const isBrowser = useIsBrowser(); + if (!isBrowser) { + return Aktuell nicht verfügbar; + } + + if (groupStore.presentingStudentGroups.length === 0) { + return Keine Präsentation aktiv; + } + + if (groupStore.presentingStudentGroups.length === 1) { + return ( +
+ +
+ ); + } + + return ( +
+ + {groupStore.presentingStudentGroups.map((g, idx) => { + 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..b1c05508d --- /dev/null +++ b/src/components/PresentationPanel/styles.module.scss @@ -0,0 +1,9 @@ +.presentationMode { + width: 75vw; + height: 100%; +} + +.tabs { + --ifm-tabs-padding-vertical: 0.2em; + --ifm-tabs-padding-horizontal: 0.4em; +} diff --git a/src/components/StudentGroup/index.tsx b/src/components/StudentGroup/index.tsx index bb4c76787..870cc164e 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,23 @@ const StudentGroup = observer((props: Props) => { {isAdmin && ( <> +
Präsentieren
+
+
+ {group.presentedDocumentProps && ( +
+
+                                        {JSON.stringify(group.presentedDocumentProps, null, 2)}
+                                    
+
+ )}
Export
diff --git a/src/components/User/BadgeSelector/index.tsx b/src/components/User/BadgeSelector/index.tsx new file mode 100644 index 000000000..92c816ae2 --- /dev/null +++ b/src/components/User/BadgeSelector/index.tsx @@ -0,0 +1,66 @@ +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'; +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, clearCurrent: boolean) => 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, true); + }} + > + {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..b3d78cd96 --- /dev/null +++ b/src/components/User/BadgeSelector/styles.module.scss @@ -0,0 +1,48 @@ +.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-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); + 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: 800; + fill: var(--ifm-color-emphasis-700); + } + } +} +.badgeSelectorContainer { + position: relative; + .addButton { + position: absolute; + bottom: -6px; + right: -12px; + } +} 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/documents/CodeEditor/Editor/EditorAce.tsx b/src/components/documents/CodeEditor/Editor/EditorAce.tsx index a80d8fb34..5cc856f19 100644 --- a/src/components/documents/CodeEditor/Editor/EditorAce.tsx +++ b/src/components/documents/CodeEditor/Editor/EditorAce.tsx @@ -86,7 +86,7 @@ const EditorAce = observer((props: Props) => { onChange={(value: string, e: { action: 'insert' | 'remove' }) => { code.setCode(value, e.action); }} - readOnly={code.meta.readonly || code.showRaw} + readOnly={!code.canEdit || code.showRaw} value={code.showRaw ? code.pristineCode : code.code} defaultValue={code?.code || '\n'} name={code.codeId} diff --git a/src/components/documents/CodeEditor/Editor/Header/Content.tsx b/src/components/documents/CodeEditor/Editor/Header/Content.tsx index de9f49791..6c49ad6f2 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,10 +39,11 @@ const Content = observer((props: Props) => { <>
{code.title}
+ {notifyUnpersisted && ( { code: iCode; @@ -17,7 +19,18 @@ interface Props { const Editor = observer((props: Props) => { const { code } = props; const componentStore = useStore('componentStore'); + const userStore = useStore('userStore'); const EC = componentStore.editorComponent(code.type); + if (!code.canDisplay && !code.isDummy) { + if (!userStore.isUserSwitched) { + return ( +
+ Warte auf die Berechtigung, um den Editor anzuzeigen. + +
+ ); + } + } return ( <> {EC?.Header ? ( 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 (
{ 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/RequestFullscreen/index.tsx b/src/components/shared/RequestFullscreen/index.tsx index 5e66eeb4c..ffa5999fd 100644 --- a/src/components/shared/RequestFullscreen/index.tsx +++ b/src/components/shared/RequestFullscreen/index.tsx @@ -32,7 +32,7 @@ const RequestFullscreen = observer((props: Props) => { 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/GroupSelector/index.tsx b/src/components/shared/RequestPresentationMode/GroupSelector/index.tsx new file mode 100644 index 000000000..1c764f483 --- /dev/null +++ b/src/components/shared/RequestPresentationMode/GroupSelector/index.tsx @@ -0,0 +1,67 @@ +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 Button from '@tdev-components/shared/Button'; +import { mdiClose } from '@mdi/js'; +import StudentGroup from '@tdev-models/StudentGroup'; +import iDocument from '@tdev-models/iDocument'; +import { DocumentType } from '@tdev-api/document'; +import { action } from 'mobx'; + +interface Props { + group: StudentGroup; + document: iDocument; + 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 new file mode 100644 index 000000000..7c05fb5d0 --- /dev/null +++ b/src/components/shared/RequestPresentationMode/index.tsx @@ -0,0 +1,91 @@ +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 { mdiClose, mdiPresentationPlay, mdiProjectorScreenOffOutline, 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; + size?: number; + color?: Color | string; + className?: string; +} + +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; + } + if (groupStore.managedStudentGroups.length === 0 || !userStore.current) { + return null; + } + if (groupStore.presentedDocumentIds.has(document.id)) { + return ( +