feat: show real HF username and avatar for connected users
Browse files- Fetch /api/auth/status on mount to get HF user info (name, avatar)
- Replace random names with actual HF fullName and profile picture
- Update collaboration cursor to display avatar next to username
- Update top bar Chip with avatar component
- Sync awareness when user data arrives after initial render
- Use deterministic color from username hash instead of random
Made-with: Cursor
frontend/src/App.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { UndoManager } from "yjs";
|
|
| 4 |
import type * as Y from "yjs";
|
| 5 |
import { ThemeProvider } from "@mui/material/styles";
|
| 6 |
import {
|
|
|
|
| 7 |
Box,
|
| 8 |
Chip,
|
| 9 |
IconButton,
|
|
@@ -44,20 +45,41 @@ const COLORS = [
|
|
| 44 |
"#70CFF8", "#94FADB", "#B9F18D", "#C4B5FD",
|
| 45 |
];
|
| 46 |
|
| 47 |
-
function
|
| 48 |
-
|
|
|
|
|
|
|
| 49 |
}
|
| 50 |
|
| 51 |
-
function
|
| 52 |
const names = ["Alice", "Bob", "Carol", "Dave", "Eve", "Frank", "Grace", "Heidi"];
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
}
|
| 55 |
|
| 56 |
export default function App() {
|
| 57 |
-
const [user] = useState
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
const docName = "default";
|
| 63 |
|
|
@@ -250,6 +272,11 @@ export default function App() {
|
|
| 250 |
</IconButton>
|
| 251 |
</Tooltip>
|
| 252 |
<Chip
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
label={user.name}
|
| 254 |
size="small"
|
| 255 |
sx={{
|
|
|
|
| 4 |
import type * as Y from "yjs";
|
| 5 |
import { ThemeProvider } from "@mui/material/styles";
|
| 6 |
import {
|
| 7 |
+
Avatar,
|
| 8 |
Box,
|
| 9 |
Chip,
|
| 10 |
IconButton,
|
|
|
|
| 45 |
"#70CFF8", "#94FADB", "#B9F18D", "#C4B5FD",
|
| 46 |
];
|
| 47 |
|
| 48 |
+
function colorFromName(name: string): string {
|
| 49 |
+
let hash = 0;
|
| 50 |
+
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0;
|
| 51 |
+
return COLORS[Math.abs(hash) % COLORS.length];
|
| 52 |
}
|
| 53 |
|
| 54 |
+
function randomFallbackUser(): CollabUser {
|
| 55 |
const names = ["Alice", "Bob", "Carol", "Dave", "Eve", "Frank", "Grace", "Heidi"];
|
| 56 |
+
const name = names[Math.floor(Math.random() * names.length)];
|
| 57 |
+
return { name, color: colorFromName(name) };
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export interface CollabUser {
|
| 61 |
+
name: string;
|
| 62 |
+
color: string;
|
| 63 |
+
avatarUrl?: string;
|
| 64 |
}
|
| 65 |
|
| 66 |
export default function App() {
|
| 67 |
+
const [user, setUser] = useState<CollabUser>(randomFallbackUser);
|
| 68 |
+
|
| 69 |
+
useEffect(() => {
|
| 70 |
+
fetch("/api/auth/status", { credentials: "include" })
|
| 71 |
+
.then((r) => r.json())
|
| 72 |
+
.then((data) => {
|
| 73 |
+
if (data.authenticated && data.user) {
|
| 74 |
+
setUser({
|
| 75 |
+
name: data.user.fullName || data.user.name,
|
| 76 |
+
color: colorFromName(data.user.name),
|
| 77 |
+
avatarUrl: data.user.avatarUrl || undefined,
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
})
|
| 81 |
+
.catch(() => {});
|
| 82 |
+
}, []);
|
| 83 |
|
| 84 |
const docName = "default";
|
| 85 |
|
|
|
|
| 272 |
</IconButton>
|
| 273 |
</Tooltip>
|
| 274 |
<Chip
|
| 275 |
+
avatar={
|
| 276 |
+
user.avatarUrl
|
| 277 |
+
? <Avatar src={user.avatarUrl} sx={{ width: 18, height: 18 }} />
|
| 278 |
+
: undefined
|
| 279 |
+
}
|
| 280 |
label={user.name}
|
| 281 |
size="small"
|
| 282 |
sx={{
|
frontend/src/components/CommentsSidebar.tsx
CHANGED
|
@@ -16,7 +16,7 @@ import type { CommentStore, CommentData } from "../editor/comments";
|
|
| 16 |
interface CommentsSidebarProps {
|
| 17 |
editor: Editor | null;
|
| 18 |
commentStore: CommentStore | null;
|
| 19 |
-
user: { name: string; color: string };
|
| 20 |
editorContainer: HTMLElement | null;
|
| 21 |
}
|
| 22 |
|
|
|
|
| 16 |
interface CommentsSidebarProps {
|
| 17 |
editor: Editor | null;
|
| 18 |
commentStore: CommentStore | null;
|
| 19 |
+
user: { name: string; color: string; avatarUrl?: string };
|
| 20 |
editorContainer: HTMLElement | null;
|
| 21 |
}
|
| 22 |
|
frontend/src/editor/Editor.tsx
CHANGED
|
@@ -41,7 +41,7 @@ const lowlight = createLowlight(all);
|
|
| 41 |
|
| 42 |
interface EditorProps {
|
| 43 |
docName: string;
|
| 44 |
-
user: { name: string; color: string };
|
| 45 |
editorRef: MutableRefObject<TiptapEditor | null>;
|
| 46 |
onCommentStoreReady: (store: CommentStore) => void;
|
| 47 |
onFrontmatterStoreReady: (store: FrontmatterStore) => void;
|
|
@@ -239,6 +239,11 @@ export function Editor({
|
|
| 239 |
};
|
| 240 |
}, [editor, provider, ydoc, frontmatterStore]);
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
useEffect(() => {
|
| 243 |
if (!editor) return;
|
| 244 |
if (!editor.storage.citation) {
|
|
|
|
| 41 |
|
| 42 |
interface EditorProps {
|
| 43 |
docName: string;
|
| 44 |
+
user: { name: string; color: string; avatarUrl?: string };
|
| 45 |
editorRef: MutableRefObject<TiptapEditor | null>;
|
| 46 |
onCommentStoreReady: (store: CommentStore) => void;
|
| 47 |
onFrontmatterStoreReady: (store: FrontmatterStore) => void;
|
|
|
|
| 239 |
};
|
| 240 |
}, [editor, provider, ydoc, frontmatterStore]);
|
| 241 |
|
| 242 |
+
useEffect(() => {
|
| 243 |
+
if (!editor) return;
|
| 244 |
+
(editor.commands as any).updateUser(user);
|
| 245 |
+
}, [editor, user]);
|
| 246 |
+
|
| 247 |
useEffect(() => {
|
| 248 |
if (!editor) return;
|
| 249 |
if (!editor.storage.citation) {
|
frontend/src/editor/extensions/collaboration-cursor-v3.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
| 1 |
import { Extension } from "@tiptap/core";
|
| 2 |
import { yCursorPlugin, defaultCursorBuilder, defaultSelectionBuilder } from "@tiptap/y-tiptap";
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
export interface CollaborationCursorOptions {
|
| 5 |
provider: any;
|
| 6 |
-
user:
|
| 7 |
-
render?: (user:
|
| 8 |
selectionRender?: typeof defaultSelectionBuilder;
|
| 9 |
}
|
| 10 |
|
|
@@ -22,16 +28,24 @@ export const CollaborationCursorV3 = Extension.create<CollaborationCursorOptions
|
|
| 22 |
addOptions() {
|
| 23 |
return {
|
| 24 |
provider: null,
|
| 25 |
-
user: { name: "Anonymous", color: "#aaaaaa" },
|
| 26 |
-
render: (user:
|
| 27 |
const cursor = document.createElement("span");
|
| 28 |
cursor.classList.add("collaboration-cursor__caret");
|
| 29 |
cursor.setAttribute("style", `border-color: ${user.color}`);
|
| 30 |
const label = document.createElement("div");
|
| 31 |
label.classList.add("collaboration-cursor__label");
|
| 32 |
label.setAttribute("style", `background-color: ${user.color}`);
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
return cursor;
|
| 36 |
},
|
| 37 |
selectionRender: defaultSelectionBuilder,
|
|
|
|
| 1 |
import { Extension } from "@tiptap/core";
|
| 2 |
import { yCursorPlugin, defaultCursorBuilder, defaultSelectionBuilder } from "@tiptap/y-tiptap";
|
| 3 |
|
| 4 |
+
export interface CursorUser {
|
| 5 |
+
name: string;
|
| 6 |
+
color: string;
|
| 7 |
+
avatarUrl?: string;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
export interface CollaborationCursorOptions {
|
| 11 |
provider: any;
|
| 12 |
+
user: CursorUser;
|
| 13 |
+
render?: (user: CursorUser) => HTMLElement;
|
| 14 |
selectionRender?: typeof defaultSelectionBuilder;
|
| 15 |
}
|
| 16 |
|
|
|
|
| 28 |
addOptions() {
|
| 29 |
return {
|
| 30 |
provider: null,
|
| 31 |
+
user: { name: "Anonymous", color: "#aaaaaa" } as CursorUser,
|
| 32 |
+
render: (user: CursorUser) => {
|
| 33 |
const cursor = document.createElement("span");
|
| 34 |
cursor.classList.add("collaboration-cursor__caret");
|
| 35 |
cursor.setAttribute("style", `border-color: ${user.color}`);
|
| 36 |
const label = document.createElement("div");
|
| 37 |
label.classList.add("collaboration-cursor__label");
|
| 38 |
label.setAttribute("style", `background-color: ${user.color}`);
|
| 39 |
+
|
| 40 |
+
if (user.avatarUrl) {
|
| 41 |
+
const avatar = document.createElement("img");
|
| 42 |
+
avatar.src = user.avatarUrl;
|
| 43 |
+
avatar.classList.add("collaboration-cursor__avatar");
|
| 44 |
+
label.appendChild(avatar);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
label.appendChild(document.createTextNode(user.name));
|
| 48 |
+
cursor.appendChild(label);
|
| 49 |
return cursor;
|
| 50 |
},
|
| 51 |
selectionRender: defaultSelectionBuilder,
|
frontend/src/styles/editing.css
CHANGED
|
@@ -114,6 +114,9 @@
|
|
| 114 |
position: absolute;
|
| 115 |
top: -1.4em;
|
| 116 |
left: -1px;
|
|
|
|
|
|
|
|
|
|
| 117 |
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
| 118 |
font-size: 0.65rem;
|
| 119 |
font-weight: 600;
|
|
@@ -125,6 +128,14 @@
|
|
| 125 |
pointer-events: none;
|
| 126 |
}
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
/* Placeholder */
|
| 129 |
.tiptap p.is-editor-empty:first-child::before {
|
| 130 |
content: attr(data-placeholder);
|
|
|
|
| 114 |
position: absolute;
|
| 115 |
top: -1.4em;
|
| 116 |
left: -1px;
|
| 117 |
+
display: flex;
|
| 118 |
+
align-items: center;
|
| 119 |
+
gap: 3px;
|
| 120 |
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
| 121 |
font-size: 0.65rem;
|
| 122 |
font-weight: 600;
|
|
|
|
| 128 |
pointer-events: none;
|
| 129 |
}
|
| 130 |
|
| 131 |
+
.collaboration-cursor__avatar {
|
| 132 |
+
width: 14px;
|
| 133 |
+
height: 14px;
|
| 134 |
+
border-radius: 50%;
|
| 135 |
+
object-fit: cover;
|
| 136 |
+
flex-shrink: 0;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
/* Placeholder */
|
| 140 |
.tiptap p.is-editor-empty:first-child::before {
|
| 141 |
content: attr(data-placeholder);
|