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

1
.npmrc Normal file
View file

@ -0,0 +1 @@
shared-workspace-lockfile=false

View file

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

View file

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

View file

@ -1,14 +1,16 @@
from datetime import datetime from datetime import datetime
import json
from uuid import uuid4 from uuid import uuid4
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from sqlalchemy import desc from sqlalchemy import desc
from sqlmodel import Session, select from sqlmodel import Session, select
from src.db.install import Install, InstallRead 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.organizations import Organization, OrganizationCreate
from src.db.roles import Permission, Rights, Role, RoleTypeEnum from src.db.roles import Permission, Rights, Role, RoleTypeEnum
from src.db.user_organizations import UserOrganization from src.db.user_organizations import UserOrganization
from src.db.users import User, UserCreate, UserRead 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 from src.security.security import security_hash_password
@ -312,6 +314,53 @@ async def install_create_organization(
db_session.commit() db_session.commit()
db_session.refresh(org) 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 return org

View file

@ -158,6 +158,7 @@ async def create_org(
max_storage=0, max_storage=0,
max_staff=0, max_staff=0,
), ),
collaboration=False,
users=OrgUserConfig( users=OrgUserConfig(
signup_mechanism="open", 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 { default as React } from 'react'
import EditorWrapper from '@components/Objects/Editor/EditorWrapper' import dynamic from 'next/dynamic'
import { getCourseMetadataWithAuthHeader } from '@services/courses/courses' import { getCourseMetadataWithAuthHeader } from '@services/courses/courses'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { Metadata } from 'next' import { Metadata } from 'next'
@ -9,6 +9,8 @@ import { getOrganizationContextInfoWithId } from '@services/organizations/orgs'
import SessionProvider from '@components/Contexts/SessionContext' import SessionProvider from '@components/Contexts/SessionContext'
import EditorOptionsProvider from '@components/Contexts/Editor/EditorContext' import EditorOptionsProvider from '@components/Contexts/Editor/EditorContext'
import AIEditorProvider from '@components/Contexts/AI/AIEditorContext' import AIEditorProvider from '@components/Contexts/AI/AIEditorContext'
const EditorWrapper = dynamic(() => import('@components/Objects/Editor/EditorWrapper'), { ssr: false })
type MetadataProps = { type MetadataProps = {
params: { orgslug: string; courseid: string; activityid: string } params: { orgslug: string; courseid: string; activityid: string }
@ -49,7 +51,7 @@ const EditActivity = async (params: any) => {
access_token ? access_token : null access_token ? access_token : null
) )
const org = await getOrganizationContextInfoWithId(courseInfo.org_id, { const org = await getOrganizationContextInfoWithId(courseInfo.org_id, {
revalidate: 1800, revalidate: 180,
tags: ['organizations'], 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 VideoBlock from '@components/Objects/Editor/Extensions/Video/VideoBlock'
import MathEquationBlock from '@components/Objects/Editor/Extensions/MathEquation/MathEquationBlock' import MathEquationBlock from '@components/Objects/Editor/Extensions/MathEquation/MathEquationBlock'
import PDFBlock from '@components/Objects/Editor/Extensions/PDF/PDFBlock' 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' import QuizBlock from '@components/Objects/Editor/Extensions/Quiz/QuizBlock'
// Lowlight // Lowlight
@ -83,7 +82,6 @@ function Canva(props: Editor) {
controls: true, controls: true,
modestBranding: true, modestBranding: true,
}), }),
OrderedList.configure(),
CodeBlockLowlight.configure({ CodeBlockLowlight.configure({
lowlight, 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, useAIEditorDispatch,
} from '@components/Contexts/AI/AIEditorContext' } from '@components/Contexts/AI/AIEditorContext'
// extensions // Extensions
import InfoCallout from './Extensions/Callout/Info/InfoCallout' import InfoCallout from './Extensions/Callout/Info/InfoCallout'
import WarningCallout from './Extensions/Callout/Warning/WarningCallout' import WarningCallout from './Extensions/Callout/Warning/WarningCallout'
import ImageBlock from './Extensions/Image/ImageBlock' import ImageBlock from './Extensions/Image/ImageBlock'
@ -28,7 +28,7 @@ import QuizBlock from './Extensions/Quiz/QuizBlock'
import ToolTip from '@components/StyledElements/Tooltip/Tooltip' import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
import Link from 'next/link' import Link from 'next/link'
import { getCourseThumbnailMediaDirectory } from '@services/media/media' import { getCourseThumbnailMediaDirectory } from '@services/media/media'
import { OrderedList } from '@tiptap/extension-ordered-list'
// Lowlight // Lowlight
import { common, createLowlight } from 'lowlight' import { common, createLowlight } from 'lowlight'
@ -45,14 +45,22 @@ import { useSession } from '@components/Contexts/SessionContext'
import AIEditorToolkit from './AI/AIEditorToolkit' import AIEditorToolkit from './AI/AIEditorToolkit'
import useGetAIFeatures from '@components/AI/Hooks/useGetAIFeatures' import useGetAIFeatures from '@components/AI/Hooks/useGetAIFeatures'
import UserAvatar from '../UserAvatar' 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 { interface Editor {
content: string content: string
ydoc: any
provider: any
activity: any activity: any
course: any course: any
org: any org: any
session: any
ydoc: any
hocuspocusProvider: any,
isCollabEnabledOnThisOrg: boolean
userRandomColor: string
mouseMovements: any
setContent: (content: string) => void setContent: (content: string) => void
} }
@ -63,10 +71,12 @@ function Editor(props: Editor) {
const is_ai_feature_enabled = useGetAIFeatures({ feature: 'editor' }) const is_ai_feature_enabled = useGetAIFeatures({ feature: 'editor' })
const [isButtonAvailable, setIsButtonAvailable] = React.useState(false) const [isButtonAvailable, setIsButtonAvailable] = React.useState(false)
React.useEffect(() => { React.useEffect(() => {
if (is_ai_feature_enabled) { if (is_ai_feature_enabled) {
setIsButtonAvailable(true) setIsButtonAvailable(true)
} }
}, [is_ai_feature_enabled]) }, [is_ai_feature_enabled])
// remove course_ from course_uuid // remove course_ from course_uuid
@ -89,7 +99,7 @@ function Editor(props: Editor) {
extensions: [ extensions: [
StarterKit.configure({ StarterKit.configure({
// The Collaboration extension comes with its own history handling // The Collaboration extension comes with its own history handling
// history: false, history: props.isCollabEnabledOnThisOrg ? false : undefined,
}), }),
InfoCallout.configure({ InfoCallout.configure({
editable: true, editable: true,
@ -121,26 +131,29 @@ function Editor(props: Editor) {
controls: true, controls: true,
modestBranding: true, modestBranding: true,
}), }),
OrderedList.configure(),
CodeBlockLowlight.configure({ CodeBlockLowlight.configure({
lowlight, lowlight,
}), }),
// Register the document with Tiptap // Add Collaboration and CollaborationCursor only if isCollabEnabledOnThisOrg is true
// Collaboration.configure({ ...(props.isCollabEnabledOnThisOrg ? [
// document: props.ydoc, Collaboration.configure({
// }), document: props.hocuspocusProvider?.document,
// Register the collaboration cursor extension
// CollaborationCursor.configure({ }),
// provider: props.provider,
// user: { CollaborationCursor.configure({
// name: auth.userInfo.username, provider: props.hocuspocusProvider,
// color: "#f783ac", 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 ( return (
@ -257,14 +270,7 @@ function Editor(props: Editor) {
/> />
<EditorUserProfileWrapper> <EditorUserProfileWrapper>
{!session.isAuthenticated && <span>Loading</span>} <ActiveAvatars userRandomColor={props.userRandomColor} mouseMovements={props.mouseMovements} />
{session.isAuthenticated && (
<UserAvatar
width={40}
border="border-4"
rounded="rounded-full"
/>
)}
</EditorUserProfileWrapper> </EditorUserProfileWrapper>
</EditorUsersSection> </EditorUsersSection>
</EditorTop> </EditorTop>

View file

@ -1,11 +1,20 @@
'use client' 'use client'
import { default as React } from 'react' import { default as React, useEffect, useRef, useState } from 'react'
import * as Y from 'yjs'
import Editor from './Editor' import Editor from './Editor'
import { updateActivity } from '@services/courses/activities' import { updateActivity } from '@services/courses/activities'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import Toast from '@components/StyledElements/Toast/Toast' import Toast from '@components/StyledElements/Toast/Toast'
import { OrgProvider } from '@components/Contexts/OrgContext' 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 { interface EditorWrapperProps {
content: string content: string
@ -15,23 +24,60 @@ interface EditorWrapperProps {
} }
function EditorWrapper(props: EditorWrapperProps): JSX.Element { function EditorWrapper(props: EditorWrapperProps): JSX.Element {
// A new Y document const session = useSession() as any
const ydoc = new Y.Doc() // Define provider in the state
const [providerState, setProviderState] = React.useState<any>({}) const [provider, setProvider] = React.useState<HocuspocusProvider | null>(null);
const [ydocState, setYdocState] = React.useState<any>({}) const [thisPageColor, setThisPageColor] = useState(randomColor({ luminosity: 'light' }) as string)
const [isLoading, setIsLoading] = React.useState(true) 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) { async function setContent(content: any) {
let activity = props.activity let activity = props.activity
activity.content = content 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), { toast.promise(updateActivity(activity, activity.activity_uuid), {
loading: 'Saving...', loading: 'Saving...',
success: <b>Activity saved!</b>, 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 ( return (
<> <>
<Toast></Toast> <Toast></Toast>
<MouseMovements org={props.org} movements={mouseMovements} onlinePageInstanceID={onlinePageInstanceID} />
<OrgProvider orgslug={props.org.slug}> <OrgProvider orgslug={props.org.slug}>
<Editor {!session.isLoading && (<Editor
org={props.org} org={props.org}
course={props.course} course={props.course}
activity={props.activity} activity={props.activity}
content={props.content} content={props.content}
setContent={setContent} setContent={setContent}
provider={providerState} session={session}
ydoc={ydocState} ydoc={doc}
></Editor> hocuspocusProvider={provider}
; isCollabEnabledOnThisOrg={isCollabEnabledOnThisOrg}
userRandomColor={thisPageColor}
mouseMovements={mouseMovements}
></Editor>)}
</OrgProvider> </OrgProvider>
</> </>
) )
} }
} }
export default EditorWrapper 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 return predefinedAvatar
} else { } else {
if (props.avatar_url) { if (props.avatar_url) {
console.log('avatar_url', props.avatar_url)
return props.avatar_url return props.avatar_url
} else { } else {
if (session.user.avatar_image) { 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 ." "lint:fix": "eslint --fix ."
}, },
"dependencies": { "dependencies": {
"@hocuspocus/provider": "^2.11.3",
"@radix-ui/colors": "^0.1.9", "@radix-ui/colors": "^0.1.9",
"@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
@ -17,15 +18,19 @@
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@sentry/browser": "^7.109.0",
"@sentry/nextjs": "^7.109.0", "@sentry/nextjs": "^7.109.0",
"@stitches/react": "^1.2.8", "@stitches/react": "^1.2.8",
"@tiptap/core": "^2.2.4",
"@tiptap/extension-code-block-lowlight": "^2.2.4", "@tiptap/extension-code-block-lowlight": "^2.2.4",
"@tiptap/extension-collaboration": "^2.2.4", "@tiptap/extension-collaboration": "^2.2.4",
"@tiptap/extension-collaboration-cursor": "^2.2.4", "@tiptap/extension-collaboration-cursor": "^2.2.4",
"@tiptap/extension-youtube": "^2.2.4", "@tiptap/extension-youtube": "^2.2.4",
"@tiptap/html": "^2.2.4", "@tiptap/html": "^2.2.4",
"@tiptap/pm": "^2.2.4",
"@tiptap/react": "^2.2.4", "@tiptap/react": "^2.2.4",
"@tiptap/starter-kit": "^2.2.4", "@tiptap/starter-kit": "^2.2.4",
"@types/randomcolor": "^0.5.9",
"avvvatars-react": "^0.4.2", "avvvatars-react": "^0.4.2",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"formik": "^2.4.5", "formik": "^2.4.5",
@ -33,6 +38,8 @@
"lowlight": "^3.1.0", "lowlight": "^3.1.0",
"lucide-react": "^0.363.0", "lucide-react": "^0.363.0",
"next": "14.1.4", "next": "14.1.4",
"prosemirror-state": "^1.4.3",
"randomcolor": "^0.6.2",
"re-resizable": "^6.9.11", "re-resizable": "^6.9.11",
"react": "^18.2.0", "react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
@ -48,6 +55,7 @@
"tailwind-scrollbar": "^3.1.0", "tailwind-scrollbar": "^3.1.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"y-indexeddb": "^9.0.12", "y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.3",
"y-webrtc": "^10.3.0", "y-webrtc": "^10.3.0",
"yjs": "^13.6.14" "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}` 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_BACKEND_URL = `${process.env.NEXT_PUBLIC_LEARNHOUSE_BACKEND_URL}`
export const LEARNHOUSE_DOMAIN = process.env.NEXT_PUBLIC_LEARNHOUSE_DOMAIN 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 getAPIUrl = () => LEARNHOUSE_API_URL
export const getBackendUrl = () => LEARNHOUSE_BACKEND_URL export const getBackendUrl = () => LEARNHOUSE_BACKEND_URL
@ -35,3 +37,7 @@ export const getOrgFromUri = () => {
export const getDefaultOrg = () => { export const getDefaultOrg = () => {
return process.env.NEXT_PUBLIC_LEARNHOUSE_DEFAULT_ORG return process.env.NEXT_PUBLIC_LEARNHOUSE_DEFAULT_ORG
} }
export const getCollaborationServerUrl = () => {
return `${LEARNHOUSE_COLLABORATION_WS_URL}`
}

View file

@ -17,6 +17,10 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
collaboration:
build: apps/collaboration/.
ports:
- "1998:1998"
db: db:
image: postgres:16-alpine image: postgres:16-alpine
restart: always restart: always

View file

@ -2,6 +2,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"start" : "turbo run start",
"dev": "turbo run dev", "dev": "turbo run dev",
"lint": "turbo run lint", "lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"" "format": "prettier --write \"**/*.{ts,tsx,md}\""

4524
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,9 @@
"outputs": [".next/**", "!.next/cache/**"] "outputs": [".next/**", "!.next/cache/**"]
}, },
"lint": {}, "lint": {},
"start": {
"dependsOn": ["^build"]
},
"dev": { "dev": {
"cache": false, "cache": false,
"persistent": true "persistent": true