Merge pull request #195 from learnhouse/feat/collaboration-server

Init Collaborative Edition
This commit is contained in:
Badr B 2024-04-07 18:14:31 +02:00 committed by GitHub
commit 28c7def56a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 5882 additions and 14273 deletions

View file

@ -18,11 +18,9 @@ class AIEnabledFeatures(BaseModel):
class AIConfig(BaseModel):
enabled : bool = True
enabled: bool = True
limits: AILimitsSettings = AILimitsSettings()
embeddings: Literal[
"text-embedding-ada-002",
] = "text-embedding-ada-002"
embeddings: Literal["text-embedding-ada-002",] = "text-embedding-ada-002"
ai_model: Literal["gpt-3.5-turbo", "gpt-4-1106-preview"] = "gpt-3.5-turbo"
features: AIEnabledFeatures = AIEnabledFeatures()
@ -44,13 +42,15 @@ class GeneralConfig(BaseModel):
color: str = ""
limits: LimitSettings = LimitSettings()
users: OrgUserConfig = OrgUserConfig()
collaboration: bool = False
active: bool = True
class OrganizationConfigBase(SQLModel):
GeneralConfig: GeneralConfig
AIConfig: AIConfig
class OrganizationConfig(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
org_id: int = Field(
@ -60,7 +60,3 @@ class OrganizationConfig(SQLModel, table=True):
config: dict = Field(default={}, sa_column=Column(JSON))
creation_date: Optional[str]
update_date: Optional[str]

View file

@ -80,7 +80,7 @@ async def get_image_block(
if block:
block = BlockRead.model_validate(block)
block = BlockRead.from_orm(block)
return block
else:

View file

@ -1,14 +1,16 @@
from datetime import datetime
import json
from uuid import uuid4
from fastapi import HTTPException, Request
from sqlalchemy import desc
from sqlmodel import Session, select
from src.db.install import Install, InstallRead
from src.db.organization_config import AIEnabledFeatures, AILimitsSettings, LimitSettings, OrgUserConfig, OrganizationConfig, OrganizationConfigBase, GeneralConfig, AIConfig
from src.db.organizations import Organization, OrganizationCreate
from src.db.roles import Permission, Rights, Role, RoleTypeEnum
from src.db.user_organizations import UserOrganization
from src.db.users import User, UserCreate, UserRead
from config.config import get_learnhouse_config
from config.config import get_learnhouse_config
from src.security.security import security_hash_password
@ -312,6 +314,53 @@ async def install_create_organization(
db_session.commit()
db_session.refresh(org)
# Org Config
org_config = OrganizationConfigBase(
GeneralConfig=GeneralConfig(
color="#000000",
limits=LimitSettings(
limits_enabled=False,
max_users=0,
max_storage=0,
max_staff=0,
),
collaboration=False,
users=OrgUserConfig(
signup_mechanism="open",
),
active=True,
),
AIConfig=AIConfig(
enabled=True,
limits=AILimitsSettings(
limits_enabled=False,
max_asks=0,
),
embeddings="text-embedding-ada-002",
ai_model="gpt-3.5-turbo",
features=AIEnabledFeatures(
editor=True,
activity_ask=True,
course_ask=True,
global_ai_ask=True,
),
),
)
org_config = json.loads(org_config.json())
# OrgSettings
org_settings = OrganizationConfig(
org_id=int(org.id if org.id else 0),
config=org_config,
creation_date=str(datetime.now()),
update_date=str(datetime.now()),
)
db_session.add(org_settings)
db_session.commit()
db_session.refresh(org_settings)
return org

View file

@ -158,6 +158,7 @@ async def create_org(
max_storage=0,
max_staff=0,
),
collaboration=False,
users=OrgUserConfig(
signup_mechanism="open",
),

View file

@ -0,0 +1 @@
node_modules

View file

@ -0,0 +1,34 @@
# use the official Bun image
# see all versions at https://hub.docker.com/r/oven/bun/tags
FROM oven/bun:1 as base
WORKDIR /usr/src/app
# install dependencies into temp directory
# this will cache them and speed up future builds
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json /temp/dev/
RUN cd /temp/dev && bun install
# install with --production (exclude devDependencies)
RUN mkdir -p /temp/prod
COPY package.json pnpm-lock.yaml /temp/prod/
RUN cd /temp/prod && bun install --production
# copy node_modules from temp directory
# then copy all (non-ignored) project files into the image
FROM base AS prerelease
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
# copy production dependencies and source code into final image
FROM base AS release
COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /usr/src/app/app.ts .
COPY --from=prerelease /usr/src/app/package.json .
# run the app
USER bun
EXPOSE 1998/tcp
ENTRYPOINT [ "bun", "run", "app.ts" ]

View file

@ -0,0 +1,9 @@
import { Hocuspocus } from "@hocuspocus/server";
// Configure the server …
const server = new Hocuspocus({
port: 1998,
});
// … and run it!
server.listen();

View file

@ -0,0 +1,20 @@
{
"name": "collaboration",
"version": "1.0.0",
"description": "",
"main": "app.ts",
"scripts": {
"dev": "bun app.ts",
"start": "bun app.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@hocuspocus/server": "^2.11.3",
"bun": "^1.0.36",
"typescript": "5.4.3",
"y-protocols": "^1.0.6",
"yjs": "^13.6.14"
}
}

192
apps/collaboration/pnpm-lock.yaml generated Normal file
View file

@ -0,0 +1,192 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
'@hocuspocus/server':
specifier: ^2.11.3
version: 2.11.3(y-protocols@1.0.6)(yjs@13.6.14)
bun:
specifier: ^1.0.36
version: 1.1.1
typescript:
specifier: 5.4.3
version: 5.4.3
y-protocols:
specifier: ^1.0.6
version: 1.0.6(yjs@13.6.14)
yjs:
specifier: ^13.6.14
version: 13.6.14
packages:
/@hocuspocus/common@2.11.3:
resolution: {integrity: sha512-w3UZpW6ZVYIHPEFzZJV3yn1d3EZaXf2m2zU53pwj0AyTBmVD7kB9ZiD6twc9A7NNB1dkqD8c58PbD42+pnNiKQ==}
dependencies:
lib0: 0.2.93
dev: false
/@hocuspocus/server@2.11.3(y-protocols@1.0.6)(yjs@13.6.14):
resolution: {integrity: sha512-1Vdy4RtJcpffs5I4Ey3M8ulu2f6AbpSDmK4YFG8k3O4EJT7HDSO3Ib5STiRBxlr2LncJeVa2ikwlvwQotsWqew==}
peerDependencies:
y-protocols: ^1.0.6
yjs: ^13.6.8
dependencies:
'@hocuspocus/common': 2.11.3
async-lock: 1.4.1
kleur: 4.1.5
lib0: 0.2.93
uuid: 9.0.1
ws: 8.16.0
y-protocols: 1.0.6(yjs@13.6.14)
yjs: 13.6.14
transitivePeerDependencies:
- bufferutil
- utf-8-validate
dev: false
/@oven/bun-darwin-aarch64@1.1.1:
resolution: {integrity: sha512-RDs5ZMSkcurj4YqPtkcKGYUA46/LDcw7tQ0a4hBI/mtjpYySYmIIYkSeeotl9IJMNcG+ZsHpRc4b7ROFRYhxEw==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@oven/bun-darwin-x64-baseline@1.1.1:
resolution: {integrity: sha512-sJKZqgT9JSbxTPLULHdcYiKy+F4x2gq114FxDwEqn3YVZnBqSO0X9GCqWOa1CNqUaxGvJnNgn+HDkIQlnXVLiA==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@oven/bun-darwin-x64@1.1.1:
resolution: {integrity: sha512-RiRbhu9htOML4+81AfHIvjgdVU3jsn+EiyvwuUv5j91vgGrZLkNXebGZXt2eGDDutGzHqvQJqW6sxQ+UNJQi7w==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@oven/bun-linux-aarch64@1.1.1:
resolution: {integrity: sha512-9twn92P90pAwyvC6PzcWv/3a2B2/01TzdCwslWNaI0LdQ3b+sJR4IvdXG1yQI3N2Ne/ticM7eww2eWma4I0LRQ==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@oven/bun-linux-x64-baseline@1.1.1:
resolution: {integrity: sha512-2nXg32DLs0xaZH5GafJ16UqrDr4XGRXTeyZW3PNhplaFY0m3fRDXCqDsXmTvsQoGO/FEtMrEmJSWXbLa7u0B4A==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@oven/bun-linux-x64@1.1.1:
resolution: {integrity: sha512-2JPkRTCSXe5w9JvMucx7fgN77yQK+XZ+fY7WlEsZnAR4PjEGImZA12nGNbnxEHM3TmOEivy2PP00nAXeu9LViA==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@oven/bun-windows-x64-baseline@1.1.1:
resolution: {integrity: sha512-3q/THmrP1yA8/YTJoS29Et5a+AxP2jGX96cYHlOZEjoTj/FBNFSuuPVvvFEpjrRkQ8Oz9iNE/C6ltna8WKSUxQ==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@oven/bun-windows-x64@1.1.1:
resolution: {integrity: sha512-oolhIph8Kah6K/7kPUjcqgc2N5lS6RD4yruwrG2QYhxcYWTh7m36Ngp709l8+trhLLaUyTnvr4MvuiKPl1cRjQ==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
dev: false
/bun@1.1.1:
resolution: {integrity: sha512-gV90TkJgHvI50X9BoKQ3zVpPEY6YP0vqOww2uZmsOyckZSRlcFYWhXZwFj6PV8KCFINYs8VZ65m59U2RuFYfWw==}
cpu: [arm64, x64]
os: [darwin, linux, win32]
hasBin: true
requiresBuild: true
optionalDependencies:
'@oven/bun-darwin-aarch64': 1.1.1
'@oven/bun-darwin-x64': 1.1.1
'@oven/bun-darwin-x64-baseline': 1.1.1
'@oven/bun-linux-aarch64': 1.1.1
'@oven/bun-linux-x64': 1.1.1
'@oven/bun-linux-x64-baseline': 1.1.1
'@oven/bun-windows-x64': 1.1.1
'@oven/bun-windows-x64-baseline': 1.1.1
dev: false
/isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
dev: false
/kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
dev: false
/lib0@0.2.93:
resolution: {integrity: sha512-M5IKsiFJYulS+8Eal8f+zAqf5ckm1vffW0fFDxfgxJ+uiVopvDdd3PxJmz0GsVi3YNO7QCFSq0nAsiDmNhLj9Q==}
engines: {node: '>=16'}
hasBin: true
dependencies:
isomorphic.js: 0.2.5
dev: false
/typescript@5.4.3:
resolution: {integrity: sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==}
engines: {node: '>=14.17'}
hasBin: true
dev: false
/uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
dev: false
/ws@8.16.0:
resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
dev: false
/y-protocols@1.0.6(yjs@13.6.14):
resolution: {integrity: sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
peerDependencies:
yjs: ^13.0.0
dependencies:
lib0: 0.2.93
yjs: 13.6.14
dev: false
/yjs@13.6.14:
resolution: {integrity: sha512-D+7KcUr0j+vBCUSKXXEWfA+bG4UQBviAwP3gYBhkstkgwy5+8diOPMx0iqLIOxNo/HxaREUimZRxqHGAHCL2BQ==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
dependencies:
lib0: 0.2.93
dev: false

View file

@ -1,5 +1,5 @@
import { default as React } from 'react'
import EditorWrapper from '@components/Objects/Editor/EditorWrapper'
import dynamic from 'next/dynamic'
import { getCourseMetadataWithAuthHeader } from '@services/courses/courses'
import { cookies } from 'next/headers'
import { Metadata } from 'next'
@ -9,6 +9,8 @@ import { getOrganizationContextInfoWithId } from '@services/organizations/orgs'
import SessionProvider from '@components/Contexts/SessionContext'
import EditorOptionsProvider from '@components/Contexts/Editor/EditorContext'
import AIEditorProvider from '@components/Contexts/AI/AIEditorContext'
const EditorWrapper = dynamic(() => import('@components/Objects/Editor/EditorWrapper'), { ssr: false })
type MetadataProps = {
params: { orgslug: string; courseid: string; activityid: string }
@ -49,7 +51,7 @@ const EditActivity = async (params: any) => {
access_token ? access_token : null
)
const org = await getOrganizationContextInfoWithId(courseInfo.org_id, {
revalidate: 1800,
revalidate: 180,
tags: ['organizations'],
})

View file

@ -9,7 +9,6 @@ import ImageBlock from '@components/Objects/Editor/Extensions/Image/ImageBlock'
import VideoBlock from '@components/Objects/Editor/Extensions/Video/VideoBlock'
import MathEquationBlock from '@components/Objects/Editor/Extensions/MathEquation/MathEquationBlock'
import PDFBlock from '@components/Objects/Editor/Extensions/PDF/PDFBlock'
import { OrderedList } from '@tiptap/extension-ordered-list'
import QuizBlock from '@components/Objects/Editor/Extensions/Quiz/QuizBlock'
// Lowlight
@ -83,7 +82,6 @@ function Canva(props: Editor) {
controls: true,
modestBranding: true,
}),
OrderedList.configure(),
CodeBlockLowlight.configure({
lowlight,
}),

View file

@ -0,0 +1,67 @@
import React, { useEffect, useState } from 'react'
import UserAvatar from '../UserAvatar'
import { useSession } from '@components/Contexts/SessionContext'
import { getUserAvatarMediaDirectory } from '@services/media/media';
import { getCollaborationServerUrl } from '@services/config/config';
import { useOrg } from '@components/Contexts/OrgContext';
type ActiveAvatarsProps = {
mouseMovements: any;
userRandomColor: string;
}
function ActiveAvatars(props: ActiveAvatarsProps) {
const session = useSession() as any;
const org = useOrg() as any;
const [activeUsers, setActiveUsers] = useState({} as any);
/* Collaboration Features */
const collab = getCollaborationServerUrl()
const isCollabEnabledOnThisOrg = org?.config.config.GeneralConfig.collaboration && collab
// Get users from the mouseMovements object
useEffect(() => {
const users: any = {};
Object.keys(props.mouseMovements).forEach((key) => {
users[props.mouseMovements[key].user.user_uuid] = props.mouseMovements[key].user;
});
// Remove the current user from the list
delete users[session.user.user_uuid];
setActiveUsers(users);
}
, [props.mouseMovements, session.user, org]);
return (
<div className=''>
<div className='flex -space-x-2 transition-all ease-linear'>
{isCollabEnabledOnThisOrg && Object.keys(activeUsers).map((key) => (
<div className='flex' style={{ position: 'relative' }} key={key}>
<UserAvatar
key={key}
width={40}
border="border-4"
rounded="rounded-full"
avatar_url={getUserAvatarMediaDirectory(activeUsers[key].user_uuid, activeUsers[key].avatar_image) as string}
/>
<div className="h-2 w-2 rounded-full" style={{ position: 'absolute', bottom: -5, right: 16, backgroundColor: props.mouseMovements[key].color }} />
</div>
))}
{session.isAuthenticated && (
<div className='z-50'>
<UserAvatar
width={40}
border="border-4"
rounded="rounded-full"
/>
</div>
)}
</div>
</div>
)
}
export default ActiveAvatars

View file

@ -15,7 +15,7 @@ import {
useAIEditorDispatch,
} from '@components/Contexts/AI/AIEditorContext'
// extensions
// Extensions
import InfoCallout from './Extensions/Callout/Info/InfoCallout'
import WarningCallout from './Extensions/Callout/Warning/WarningCallout'
import ImageBlock from './Extensions/Image/ImageBlock'
@ -28,7 +28,7 @@ import QuizBlock from './Extensions/Quiz/QuizBlock'
import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
import Link from 'next/link'
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
import { OrderedList } from '@tiptap/extension-ordered-list'
// Lowlight
import { common, createLowlight } from 'lowlight'
@ -45,14 +45,22 @@ import { useSession } from '@components/Contexts/SessionContext'
import AIEditorToolkit from './AI/AIEditorToolkit'
import useGetAIFeatures from '@components/AI/Hooks/useGetAIFeatures'
import UserAvatar from '../UserAvatar'
import randomColor from 'randomcolor'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import ActiveAvatars from './ActiveAvatars'
interface Editor {
content: string
ydoc: any
provider: any
activity: any
course: any
org: any
session: any
ydoc: any
hocuspocusProvider: any,
isCollabEnabledOnThisOrg: boolean
userRandomColor: string
mouseMovements: any
setContent: (content: string) => void
}
@ -63,10 +71,12 @@ function Editor(props: Editor) {
const is_ai_feature_enabled = useGetAIFeatures({ feature: 'editor' })
const [isButtonAvailable, setIsButtonAvailable] = React.useState(false)
React.useEffect(() => {
if (is_ai_feature_enabled) {
setIsButtonAvailable(true)
}
}, [is_ai_feature_enabled])
// remove course_ from course_uuid
@ -89,7 +99,7 @@ function Editor(props: Editor) {
extensions: [
StarterKit.configure({
// The Collaboration extension comes with its own history handling
// history: false,
history: props.isCollabEnabledOnThisOrg ? false : undefined,
}),
InfoCallout.configure({
editable: true,
@ -121,26 +131,29 @@ function Editor(props: Editor) {
controls: true,
modestBranding: true,
}),
OrderedList.configure(),
CodeBlockLowlight.configure({
lowlight,
}),
// Register the document with Tiptap
// Collaboration.configure({
// document: props.ydoc,
// }),
// Register the collaboration cursor extension
// CollaborationCursor.configure({
// provider: props.provider,
// user: {
// name: auth.userInfo.username,
// color: "#f783ac",
// },
// }),
// Add Collaboration and CollaborationCursor only if isCollabEnabledOnThisOrg is true
...(props.isCollabEnabledOnThisOrg ? [
Collaboration.configure({
document: props.hocuspocusProvider?.document,
}),
CollaborationCursor.configure({
provider: props.hocuspocusProvider,
user: {
name: props.session.user.first_name + ' ' + props.session.user.last_name,
color: props.userRandomColor,
},
}),
] : []),
],
content: props.content,
// If collab is enabled the onSynced callback ensures initial content is set only once using editor.setContent(), preventing repetitive content insertion on editor syncs.
content: props.isCollabEnabledOnThisOrg ? null : props.content,
})
return (
@ -257,14 +270,7 @@ function Editor(props: Editor) {
/>
<EditorUserProfileWrapper>
{!session.isAuthenticated && <span>Loading</span>}
{session.isAuthenticated && (
<UserAvatar
width={40}
border="border-4"
rounded="rounded-full"
/>
)}
<ActiveAvatars userRandomColor={props.userRandomColor} mouseMovements={props.mouseMovements} />
</EditorUserProfileWrapper>
</EditorUsersSection>
</EditorTop>

View file

@ -1,11 +1,20 @@
'use client'
import { default as React } from 'react'
import * as Y from 'yjs'
import { default as React, useEffect, useRef, useState } from 'react'
import Editor from './Editor'
import { updateActivity } from '@services/courses/activities'
import { toast } from 'react-hot-toast'
import Toast from '@components/StyledElements/Toast/Toast'
import { OrgProvider } from '@components/Contexts/OrgContext'
import { useSession } from '@components/Contexts/SessionContext'
// Collaboration
import { HocuspocusProvider } from '@hocuspocus/provider'
import * as Y from 'yjs'
import { IndexeddbPersistence } from 'y-indexeddb'
import { getCollaborationServerUrl } from '@services/config/config'
import randomColor from 'randomcolor'
import MouseMovements from './MouseMovements'
import { v4 as uuidv4 } from 'uuid';
interface EditorWrapperProps {
content: string
@ -15,23 +24,60 @@ interface EditorWrapperProps {
}
function EditorWrapper(props: EditorWrapperProps): JSX.Element {
// A new Y document
const ydoc = new Y.Doc()
const [providerState, setProviderState] = React.useState<any>({})
const [ydocState, setYdocState] = React.useState<any>({})
const [isLoading, setIsLoading] = React.useState(true)
const session = useSession() as any
// Define provider in the state
const [provider, setProvider] = React.useState<HocuspocusProvider | null>(null);
const [thisPageColor, setThisPageColor] = useState(randomColor({ luminosity: 'light' }) as string)
let uuid = uuidv4();
const [onlinePageInstanceID, setOnlinePageInstanceID] = useState(uuid as string)
/* Collaboration Features */
const collab = getCollaborationServerUrl()
const isCollabEnabledOnThisOrg = props.org.config.config.GeneralConfig.collaboration && collab
const doc = new Y.Doc()
// mouse movement
const [mouseMovements, setMouseMovements] = useState({} as any);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const debouncedSetMouseMovements = (newMovements: any) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setMouseMovements(newMovements);
}, 10);
};
// Store the Y document in the browser
new IndexeddbPersistence(props.activity.activity_uuid, doc)
document.addEventListener("mousemove", (event) => {
// Share any information you like
provider?.setAwarenessField("userMouseMovement", {
user: session.user,
mouseX: event.clientX,
mouseY: event.clientY,
color: thisPageColor,
onlineInstanceID: onlinePageInstanceID
});
});
function createRTCProvider() {
// const provider = new WebrtcProvider(props.activity.activity_id, ydoc);
// setYdocState(ydoc);
// setProviderState(provider);
setIsLoading(false)
}
async function setContent(content: any) {
let activity = props.activity
activity.content = content
provider?.setAwarenessField("savings_states", {
[session.user.user_uuid]: {
status: 'action_save',
timestamp: new Date().toISOString(),
user: session.user
}
});
toast.promise(updateActivity(activity, activity.activity_uuid), {
loading: 'Saving...',
success: <b>Activity saved!</b>,
@ -39,28 +85,114 @@ function EditorWrapper(props: EditorWrapperProps): JSX.Element {
})
}
if (isLoading) {
createRTCProvider()
return <div>Loading...</div>
} else {
// Create a ref to store the last save timestamp of each user
const lastSaveTimestampRef = useRef({}) as any;
useEffect(() => {
// Check if provider is not already set
if (!provider) {
const newProvider = new HocuspocusProvider({
url: collab,
name: props.activity.activity_uuid,
document: doc,
// TODO(alpha code): This whole block of code should be improved to something more efficient and less hacky
onConnect: () => {
// Set the online page instance ID
setOnlinePageInstanceID(uuidv4());
// Set the user color
setThisPageColor(randomColor({ luminosity: 'light' }) as string);
},
onAwarenessUpdate: ({ states }) => {
const usersStates = states;
/* Showing user mouse movement */
usersStates.forEach((userState: any) => {
if (userState.userMouseMovement) {
const userMouseMovement = userState.userMouseMovement;
// Update the mouse movements state
debouncedSetMouseMovements((prevMovements: any) => {
return {
...prevMovements,
[userMouseMovement.user.user_uuid]: {
user: userMouseMovement.user,
mouseX: userMouseMovement.mouseX,
mouseY: userMouseMovement.mouseY,
color: userMouseMovement.color,
onlinePageInstanceID: userMouseMovement.onlineInstanceID
},
};
}
);
}
});
/* Notifiying if a user has saved course content */
usersStates.forEach((userState: any) => {
if (userState.savings_states) {
const savingsState = userState.savings_states
// Check if a user has saved the document
Object.keys(savingsState).forEach(user => {
const userObj = savingsState[user].user;
const status = savingsState[user].status;
const timestamp = savingsState[user].timestamp;
// Get the current timestamp
const currentTimestamp = new Date().getTime();
// If the user has saved the document and the timestamp is close to the current timestamp, show the toast
if (status === 'action_save' && Math.abs(currentTimestamp - new Date(timestamp).getTime()) < 10) { // 5000 milliseconds = 5 seconds
// Update the last save timestamp for this user
lastSaveTimestampRef.current[user] = timestamp;
toast.success(`${userObj.first_name} ${userObj.last_name} has saved the document`);
}
});
}
})
},
});
// Set the new provider
setProvider(newProvider);
}
}, []);
{
return (
<>
<Toast></Toast>
<MouseMovements org={props.org} movements={mouseMovements} onlinePageInstanceID={onlinePageInstanceID} />
<OrgProvider orgslug={props.org.slug}>
<Editor
{!session.isLoading && (<Editor
org={props.org}
course={props.course}
activity={props.activity}
content={props.content}
setContent={setContent}
provider={providerState}
ydoc={ydocState}
></Editor>
;
session={session}
ydoc={doc}
hocuspocusProvider={provider}
isCollabEnabledOnThisOrg={isCollabEnabledOnThisOrg}
userRandomColor={thisPageColor}
mouseMovements={mouseMovements}
></Editor>)}
</OrgProvider>
</>
)
}
}
export default EditorWrapper

View file

@ -0,0 +1,80 @@
import { getCollaborationServerUrl } from "@services/config/config";
import { motion } from "framer-motion";
import React, { useEffect } from 'react'
interface User {
user_uuid: string;
first_name: string;
last_name: string;
}
interface Movement {
user: User;
mouseX: number;
mouseY: number;
color: string;
onlinePageInstanceID: string;
}
interface MouseMovementsProps {
movements: Record<string, Movement>;
onlinePageInstanceID: string;
org ?: any;
}
function MouseMovements({ movements, onlinePageInstanceID, org }: MouseMovementsProps): JSX.Element {
/* Collaboration config */
const collab = getCollaborationServerUrl()
const isCollabEnabledOnThisOrg = org?.config.config.GeneralConfig.collaboration && collab
useEffect(() => {
}
, [movements, org]);
return (
<div>
{isCollabEnabledOnThisOrg && Object.keys(movements).map((key) => (
movements[key].onlinePageInstanceID !== onlinePageInstanceID && (<motion.div
key={key}
className="flex -space-x-2"
style={{
position: "fixed",
zIndex: 10000,
}}
initial={{ x: 0, y: 0 }}
animate={{ x: movements[key].mouseX, y: movements[key].mouseY }}
transition={{
type: "spring",
damping: 30,
mass: 0.8,
stiffness: 350,
}}
>
<CursorSvg color={movements[key].color} />
<div
style={{ backgroundColor: movements[key].color }}
className={`px-3 h-fit py-0.5 rounded-full font-bold text-[11px] shadow-sm text-black`}>{movements[key].user.first_name} {movements[key].user.last_name}</div>
</motion.div>)
))}
</div>
);
}
function CursorSvg({ color }: { color: string }) {
return (
<svg width="32" height="44" viewBox="0 0 24 36" fill="none">
<path
fill={color}
d="M5.65376 12.3673H5.46026L5.31717 12.4976L0.500002 16.8829L0.500002 1.19841L11.7841 12.3673H5.65376Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default MouseMovements;

View file

@ -38,7 +38,6 @@ function UserAvatar(props: UserAvatarProps) {
return predefinedAvatar
} else {
if (props.avatar_url) {
console.log('avatar_url', props.avatar_url)
return props.avatar_url
} else {
if (session.user.avatar_image) {

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@
"lint:fix": "eslint --fix ."
},
"dependencies": {
"@hocuspocus/provider": "^2.11.3",
"@radix-ui/colors": "^0.1.9",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
@ -17,15 +18,19 @@
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@sentry/browser": "^7.109.0",
"@sentry/nextjs": "^7.109.0",
"@stitches/react": "^1.2.8",
"@tiptap/core": "^2.2.4",
"@tiptap/extension-code-block-lowlight": "^2.2.4",
"@tiptap/extension-collaboration": "^2.2.4",
"@tiptap/extension-collaboration-cursor": "^2.2.4",
"@tiptap/extension-youtube": "^2.2.4",
"@tiptap/html": "^2.2.4",
"@tiptap/pm": "^2.2.4",
"@tiptap/react": "^2.2.4",
"@tiptap/starter-kit": "^2.2.4",
"@types/randomcolor": "^0.5.9",
"avvvatars-react": "^0.4.2",
"dayjs": "^1.11.10",
"formik": "^2.4.5",
@ -33,6 +38,8 @@
"lowlight": "^3.1.0",
"lucide-react": "^0.363.0",
"next": "14.1.4",
"prosemirror-state": "^1.4.3",
"randomcolor": "^0.6.2",
"re-resizable": "^6.9.11",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
@ -48,6 +55,7 @@
"tailwind-scrollbar": "^3.1.0",
"uuid": "^9.0.1",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.3",
"y-webrtc": "^10.3.0",
"yjs": "^13.6.14"
},

5183
apps/web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,8 @@ export const LEARNHOUSE_HTTP_PROTOCOL =
const LEARNHOUSE_API_URL = `${process.env.NEXT_PUBLIC_LEARNHOUSE_API_URL}`
export const LEARNHOUSE_BACKEND_URL = `${process.env.NEXT_PUBLIC_LEARNHOUSE_BACKEND_URL}`
export const LEARNHOUSE_DOMAIN = process.env.NEXT_PUBLIC_LEARNHOUSE_DOMAIN
export const LEARNHOUSE_COLLABORATION_WS_URL =
process.env.NEXT_PUBLIC_LEARNHOUSE_COLLABORATION_WS_URL
export const getAPIUrl = () => LEARNHOUSE_API_URL
export const getBackendUrl = () => LEARNHOUSE_BACKEND_URL
@ -35,3 +37,7 @@ export const getOrgFromUri = () => {
export const getDefaultOrg = () => {
return process.env.NEXT_PUBLIC_LEARNHOUSE_DEFAULT_ORG
}
export const getCollaborationServerUrl = () => {
return `${LEARNHOUSE_COLLABORATION_WS_URL}`
}