diff --git a/packages/modules.editor/src/hooks/useCollaborators.ts b/packages/modules.editor/src/hooks/useCollaborators.ts new file mode 100644 index 000000000..d9ec81bab --- /dev/null +++ b/packages/modules.editor/src/hooks/useCollaborators.ts @@ -0,0 +1,10 @@ +import { useCollaboratorsStore } from '../store/collaboratorsStore'; + +export const useCollaborators = () => { + const collaborators = useCollaboratorsStore((s) => s.collaborators); + const setCollaborators = useCollaboratorsStore((s) => s.setCollaborators); + const setCollaboratorsIfChanged = useCollaboratorsStore((s) => s.setCollaborators); + const reset = useCollaboratorsStore((s) => s.reset); + + return { collaborators, setCollaborators, setCollaboratorsIfChanged, reset }; +}; diff --git a/packages/modules.editor/src/hooks/useYjsStore.ts b/packages/modules.editor/src/hooks/useYjsStore.ts index dad6f7f7c..ab96e5d89 100644 --- a/packages/modules.editor/src/hooks/useYjsStore.ts +++ b/packages/modules.editor/src/hooks/useYjsStore.ts @@ -13,6 +13,8 @@ import { type onAuthenticationFailedParameters, } from '@hocuspocus/provider'; import { generateUserColor } from '../utils/userColor'; +import { useCollaborators } from './useCollaborators'; +import { TCollaborator } from '../types'; type UseYjsStoreArgs = { hostUrl: string; @@ -60,6 +62,9 @@ export function useYjsStore({ return { provider, ydoc }; }); + const { awareness } = provider; + const { setCollaboratorsIfChanged, reset } = useCollaborators(); + /* ========================================================== * 2. Readonly state * ========================================================== */ @@ -70,9 +75,10 @@ export function useYjsStore({ * ========================================================== */ const { data: currentUser } = useCurrentUser(); const userData = useMemo(() => { + const id = currentUser?.id; const name = currentUser?.display_name || currentUser?.username || 'Участник'; const idForColor = currentUser?.id?.toString() ?? 'anonymous'; - return { name, color: generateUserColor(idForColor) }; + return { id, name, color: generateUserColor(idForColor) }; }, [currentUser?.id, currentUser?.display_name, currentUser?.username]); /* ========================================================== @@ -96,8 +102,8 @@ export function useYjsStore({ }, 0); // Awareness - if (provider.awareness) { - provider.awareness.setLocalStateField('user', userData); + if (awareness) { + awareness.setLocalStateField('user', userData); } // Auth events @@ -129,7 +135,26 @@ export function useYjsStore({ // ignore } }; - }, [provider, userData]); + }, [provider, awareness, userData]); + + useEffect(() => { + if (!awareness) return; + + const handleSyncUsersFromAwareness = () => { + const awarenessUsers: TCollaborator[] = [...awareness.getStates()] + .map((arr) => ({ id: arr[1].user?.id, userName: arr[1].user.name })) + .filter((user) => user.id); + setCollaboratorsIfChanged(awarenessUsers); + }; + + awareness.on('update', handleSyncUsersFromAwareness); + handleSyncUsersFromAwareness(); + + return () => { + awareness.off('update', handleSyncUsersFromAwareness); + reset(); + }; + }, [awareness, setCollaboratorsIfChanged, reset]); /* ========================================================== * 6. Editor — extensions в deps: при загрузке currentUser diff --git a/packages/modules.editor/src/index.ts b/packages/modules.editor/src/index.ts index 2671c25ea..a5e4adb9e 100644 --- a/packages/modules.editor/src/index.ts +++ b/packages/modules.editor/src/index.ts @@ -1,6 +1,7 @@ export { Editor } from './ui/Editor'; export { useInterfaceStore } from './store/interfaceStore'; export { useYjsContext } from './hooks/useYjsContext'; +export { useCollaborators } from './hooks/useCollaborators'; export { isUrl, isImageUrl } from './utils/isUrl'; export { normalizeTokens } from './utils/normalizeTokens'; diff --git a/packages/modules.editor/src/store/collaboratorsStore.ts b/packages/modules.editor/src/store/collaboratorsStore.ts new file mode 100644 index 000000000..65c99b3d6 --- /dev/null +++ b/packages/modules.editor/src/store/collaboratorsStore.ts @@ -0,0 +1,21 @@ +import { create } from 'zustand'; +import type { TCollaborator } from '../types'; + +type TCollaboratorsStore = { + collaborators: TCollaborator[]; + setCollaborators: (collaborators: TCollaborator[]) => void; + setCollaboratorsIfChanged: (collaborators: TCollaborator[]) => void; + reset: () => void; +}; + +export const useCollaboratorsStore = create()((set, get) => ({ + collaborators: [], + setCollaborators: (collaborators) => set({ collaborators }), + setCollaboratorsIfChanged: (collaborators) => { + const s = get(); + if (JSON.stringify(s.collaborators) !== JSON.stringify(collaborators)) { + set({ collaborators }); + } + }, + reset: () => set({ collaborators: [] }), +})); diff --git a/packages/modules.editor/src/types/index.ts b/packages/modules.editor/src/types/index.ts index 4f34b5326..50604ec75 100644 --- a/packages/modules.editor/src/types/index.ts +++ b/packages/modules.editor/src/types/index.ts @@ -1,3 +1,8 @@ export type { TextFormatTypeT } from './bubbleMenu'; export type { BlockTypeT } from './blockMenu'; export type { ActiveBlockT } from './activeBlock'; + +export type TCollaborator = { + id: number; + userName: string; +}; diff --git a/packages/modules.editor/src/ui/Editor.tsx b/packages/modules.editor/src/ui/Editor.tsx index 0d4ff248f..f13c1931f 100644 --- a/packages/modules.editor/src/ui/Editor.tsx +++ b/packages/modules.editor/src/ui/Editor.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ import { YjsProvider } from '../providers/YjsProvider'; import { TiptapEditor } from './components/TiptapEditor'; import { useParams } from '@tanstack/react-router'; @@ -11,6 +10,14 @@ import { import { StorageItemT } from 'common.types'; import { LoadingScreen } from 'common.ui'; +type TEditorWithData = { + storageItem: StorageItemT; +}; + +type TEditor = { + storageItem?: StorageItemT; +}; + const EditorWithoutData = () => { const { classroomId, noteId, materialId } = useParams({ strict: false }); @@ -63,7 +70,7 @@ const EditorWithoutData = () => { ); }; -const EditorWithData = ({ storageItem }: { storageItem: StorageItemT }) => { +const EditorWithData = ({ storageItem }: TEditorWithData) => { return ( @@ -71,7 +78,7 @@ const EditorWithData = ({ storageItem }: { storageItem: StorageItemT }) => { ); }; -export const Editor = ({ storageItem }: { storageItem?: StorageItemT }) => { +export const Editor = ({ storageItem }: TEditor) => { if (storageItem) { return ; } diff --git a/packages/pages.notes/package.json b/packages/pages.notes/package.json index bf1592a3c..d079c9b3b 100644 --- a/packages/pages.notes/package.json +++ b/packages/pages.notes/package.json @@ -12,11 +12,14 @@ }, "dependencies": { "@tanstack/react-router": "^1.166.6", + "@xipkg/avatar": "3.3.0", "@xipkg/utils": "^1.8.0", "@xipkg/button": "4.1.0", "@xipkg/icons": "3.0.20", "@xipkg/form": "4.2.2", "@xipkg/input": "2.2.14", + "@xipkg/popover": "^2.1.0", + "common.env": "workspace:*", "common.services": "workspace:*", "common.ui": "workspace:*", "modules.editor": "workspace:*" diff --git a/packages/pages.notes/src/types.ts b/packages/pages.notes/src/types.ts new file mode 100644 index 000000000..fedeedcbc --- /dev/null +++ b/packages/pages.notes/src/types.ts @@ -0,0 +1,6 @@ +export type TCollaborator = { + id: number; + userName: string; + avatarUrl?: string; + initial?: string; +}; diff --git a/packages/pages.notes/src/ui/CollaboratorAvatars.tsx b/packages/pages.notes/src/ui/CollaboratorAvatars.tsx new file mode 100644 index 000000000..726891926 --- /dev/null +++ b/packages/pages.notes/src/ui/CollaboratorAvatars.tsx @@ -0,0 +1,57 @@ +import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from '@xipkg/avatar'; +import { Popover, PopoverContent, PopoverTrigger } from '@xipkg/popover'; +import { TCollaborator } from '../types'; + +type TCollaboratorAvatars = { + collaborators: TCollaborator[]; + currentUserId: number; +}; + +const MAX_VISIBLE_AVATARS = 3; + +export const CollaboratorAvatars = ({ collaborators, currentUserId }: TCollaboratorAvatars) => { + const overflowCount = Math.max(0, collaborators.length - MAX_VISIBLE_AVATARS); + const visibleCollaborators = collaborators.slice(0, MAX_VISIBLE_AVATARS); + + return ( + + + + + +
+

Участники в заметке

+ {collaborators.map((collaborator) => ( +
+ + {collaborator.avatarUrl && } + {collaborator.initial} + + + {collaborator.id === currentUserId ? 'Вы' : collaborator.userName} + +
+ ))} +
+
+
+ ); +}; diff --git a/packages/pages.notes/src/ui/Header.tsx b/packages/pages.notes/src/ui/Header.tsx index 25054bbeb..46a24b34a 100644 --- a/packages/pages.notes/src/ui/Header.tsx +++ b/packages/pages.notes/src/ui/Header.tsx @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ +import { useMemo } from 'react'; import { useParams, useRouter } from '@tanstack/react-router'; import { useCurrentUser, @@ -10,9 +11,13 @@ import { Skeleton } from 'common.ui'; import { EditableTitle } from './EditableTitle'; import { Button } from '@xipkg/button'; import { ArrowLeft } from '@xipkg/icons'; +import { useCollaborators } from 'modules.editor'; +import { CollaboratorAvatars } from './CollaboratorAvatars'; +import { getAvatarUrlByUserId } from '../utils'; export const Header = () => { const { classroomId, noteId, materialId } = useParams({ strict: false }); + const { collaborators } = useCollaborators(); const router = useRouter(); const { data: user } = useCurrentUser(); @@ -54,6 +59,16 @@ export const Header = () => { } }; + const collaboratorsWithAvatars = useMemo( + () => + collaborators.map((collaborator) => ({ + ...collaborator, + avatarUrl: getAvatarUrlByUserId(collaborator.id), + initial: collaborator.userName.charAt(0).toUpperCase(), + })), + [collaborators], + ); + return (
@@ -73,6 +88,7 @@ export const Header = () => { )}
+
diff --git a/packages/pages.notes/src/utils.ts b/packages/pages.notes/src/utils.ts new file mode 100644 index 000000000..937a53c52 --- /dev/null +++ b/packages/pages.notes/src/utils.ts @@ -0,0 +1,5 @@ +import { env } from 'common.env'; + +export const getAvatarUrlByUserId = (id: number): string | undefined => { + return `${env.VITE_SERVER_URL_BACKEND}/files/users/${id}/avatar.webp`; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c65e3ccfe..f84ad74a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4872,6 +4872,9 @@ importers: '@tanstack/react-router': specifier: ^1.166.6 version: 1.170.15(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@xipkg/avatar': + specifier: 3.3.0 + version: 3.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@xipkg/button': specifier: 4.1.0 version: 4.1.0(@types/react@19.2.17)(react@19.2.7) @@ -4884,9 +4887,15 @@ importers: '@xipkg/input': specifier: 2.2.14 version: 2.2.14(react@19.2.7) + '@xipkg/popover': + specifier: ^2.1.0 + version: 2.1.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@xipkg/utils': specifier: ^1.8.0 version: 1.8.0(react@19.2.7) + common.env: + specifier: workspace:* + version: link:../common.env common.services: specifier: workspace:* version: link:../common.services