diff --git a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx index 11799bb5..51672575 100644 --- a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx +++ b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx @@ -31,6 +31,7 @@ import Table from '@tiptap/extension-table' import TableHeader from '@tiptap/extension-table-header' import TableRow from '@tiptap/extension-table-row' import TableCell from '@tiptap/extension-table-cell' +import UserBlock from '@components/Objects/Editor/Extensions/Users/UserBlock' interface Editor { content: string @@ -104,6 +105,10 @@ function Canva(props: Editor) { editable: isEditable, activity: props.activity, }), + UserBlock.configure({ + editable: isEditable, + activity: props.activity, + }), Table.configure({ resizable: true, }), diff --git a/apps/web/components/Objects/Editor/Editor.tsx b/apps/web/components/Objects/Editor/Editor.tsx index 56a8e0de..51422ce1 100644 --- a/apps/web/components/Objects/Editor/Editor.tsx +++ b/apps/web/components/Objects/Editor/Editor.tsx @@ -53,6 +53,7 @@ import Badges from './Extensions/Badges/Badges' import Buttons from './Extensions/Buttons/Buttons' import { useMediaQuery } from 'usehooks-ts' import UserAvatar from '../UserAvatar' +import UserBlock from './Extensions/Users/UserBlock' interface Editor { content: string @@ -140,6 +141,10 @@ function Editor(props: Editor) { editable: true, activity: props.activity, }), + UserBlock.configure({ + editable: true, + activity: props.activity, + }), Table.configure({ resizable: true, }), diff --git a/apps/web/components/Objects/Editor/Extensions/Users/UserBlock.ts b/apps/web/components/Objects/Editor/Extensions/Users/UserBlock.ts new file mode 100644 index 00000000..1d840e4d --- /dev/null +++ b/apps/web/components/Objects/Editor/Extensions/Users/UserBlock.ts @@ -0,0 +1,35 @@ +import { mergeAttributes, Node } from '@tiptap/core' +import { ReactNodeViewRenderer } from '@tiptap/react' + +import UserBlockComponent from './UserBlockComponent' + +export default Node.create({ + name: 'blockUser', + group: 'block', + + atom: true, + + addAttributes() { + return { + user_id: { + default: '', + }, + } + }, + + parseHTML() { + return [ + { + tag: 'block-user', + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['block-user', mergeAttributes(HTMLAttributes), 0] + }, + + addNodeView() { + return ReactNodeViewRenderer(UserBlockComponent) + }, +}) diff --git a/apps/web/components/Objects/Editor/Extensions/Users/UserBlockComponent.tsx b/apps/web/components/Objects/Editor/Extensions/Users/UserBlockComponent.tsx new file mode 100644 index 00000000..02bb598d --- /dev/null +++ b/apps/web/components/Objects/Editor/Extensions/Users/UserBlockComponent.tsx @@ -0,0 +1,279 @@ +import { NodeViewWrapper } from '@tiptap/react' +import React, { useEffect, useState } from 'react' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import { getUserByUsername, getUser } from '@services/users/users' +import { Input } from "@components/ui/input" +import { Button } from "@components/ui/button" +import { Label } from "@components/ui/label" +import { + Loader2, + User, + ExternalLink, + Briefcase, + GraduationCap, + MapPin, + Building2, + Globe, + Laptop2, + Award, + BookOpen, + Link, + Users, + Calendar, + Lightbulb +} from 'lucide-react' +import { Badge } from "@components/ui/badge" +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@components/ui/hover-card" +import { useRouter } from 'next/navigation' +import UserAvatar from '@components/Objects/UserAvatar' +import { useEditorProvider } from '@components/Contexts/Editor/EditorContext' +import { getUserAvatarMediaDirectory } from '@services/media/media' + +type UserData = { + id: string + user_uuid: string + first_name: string + last_name: string + username: string + bio?: string + avatar_image?: string + details?: { + [key: string]: { + id: string + label: string + icon: string + text: string + } + } +} + +const AVAILABLE_ICONS = { + 'briefcase': Briefcase, + 'graduation-cap': GraduationCap, + 'map-pin': MapPin, + 'building-2': Building2, + 'speciality': Lightbulb, + 'globe': Globe, + 'laptop-2': Laptop2, + 'award': Award, + 'book-open': BookOpen, + 'link': Link, + 'users': Users, + 'calendar': Calendar, +} as const; + +const IconComponent = ({ iconName }: { iconName: string }) => { + const IconElement = AVAILABLE_ICONS[iconName as keyof typeof AVAILABLE_ICONS] + if (!IconElement) return + return +} + +function UserBlockComponent(props: any) { + const session = useLHSession() as any + const access_token = session?.data?.tokens?.access_token + const editorState = useEditorProvider() as any + const isEditable = editorState.isEditable + const router = useRouter() + + const [username, setUsername] = useState('') + const [userData, setUserData] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (props.node.attrs.user_id) { + fetchUserById(props.node.attrs.user_id) + } + }, [props.node.attrs.user_id]) + + const fetchUserById = async (userId: string) => { + setIsLoading(true) + setError(null) + try { + const data = await getUser(userId) + if (!data) { + throw new Error('User not found') + } + setUserData(data) + setUsername(data.username) + } catch (err: any) { + console.error('Error fetching user by ID:', err) + setError(err.detail || 'User not found') + // Clear the invalid user_id from the node attributes + props.updateAttributes({ + user_id: null + }) + } finally { + setIsLoading(false) + } + } + + const fetchUserByUsername = async (username: string) => { + setIsLoading(true) + setError(null) + try { + const data = await getUserByUsername(username) + if (!data) { + throw new Error('User not found') + } + setUserData(data) + props.updateAttributes({ + user_id: data.id + }) + } catch (err: any) { + console.error('Error fetching user by username:', err) + setError(err.detail || 'User not found') + } finally { + setIsLoading(false) + } + } + + const handleUsernameSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!username.trim()) return + await fetchUserByUsername(username) + } + + if (isEditable && !userData) { + return ( + +
+
+
+ +
+ setUsername(e.target.value)} + placeholder="Enter username" + className="flex-1" + /> + +
+ {error && ( +

{error}

+ )} +
+
+
+
+ ) + } + + if (isLoading) { + return ( + +
+ +
+
+ ) + } + + if (error) { + return ( + +
+ {error} +
+
+ ) + } + + if (!userData) { + return ( + +
+
+ + No user selected +
+
+
+ ) + } + + return ( + +
+ {/* Header with Avatar and Name */} +
+ {/* Background gradient */} +
+ + {/* Content */} +
+
+ {/* Avatar */} +
+
+ +
+
+ + {/* Name, Bio, and Button */} +
+
+
+

+ {userData.first_name} {userData.last_name} +

+ {userData.username && ( + + @{userData.username} + + )} +
+ +
+ {userData.bio && ( +

+ {userData.bio} +

+ )} +
+
+
+
+ + {/* Details */} + {userData.details && Object.values(userData.details).length > 0 && ( +
+ {Object.values(userData.details).map((detail) => ( +
+ +
+ {detail.label} + {detail.text} +
+
+ ))} +
+ )} +
+ + ) +} + +export default UserBlockComponent \ No newline at end of file diff --git a/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx b/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx index e1107e63..d79a547b 100644 --- a/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx +++ b/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx @@ -28,6 +28,7 @@ import { Table, Tag, Tags, + User, Video, } from 'lucide-react' import { SiYoutube } from '@icons-pack/react-simple-icons' @@ -299,6 +300,13 @@ export const ToolbarButtons = ({ editor, props }: any) => { + + editor.chain().focus().insertContent({ type: 'blockUser' }).run()} + > + + + ) } diff --git a/apps/web/services/users/users.ts b/apps/web/services/users/users.ts index e63a4e06..4b0c5fee 100644 --- a/apps/web/services/users/users.ts +++ b/apps/web/services/users/users.ts @@ -7,19 +7,19 @@ import { getResponseMetadata, } from '@services/utils/ts/requests' -export async function getUser(user_id: string) { +export async function getUser(user_id: string, access_token?: string) { const result = await fetch( `${getAPIUrl()}users/id/${user_id}`, - RequestBody('GET', null, null) + access_token ? RequestBodyWithAuthHeader('GET', null, null, access_token) : RequestBody('GET', null, null) ) const res = await errorHandling(result) return res } -export async function getUserByUsername(username: string) { +export async function getUserByUsername(username: string, access_token?: string) { const result = await fetch( `${getAPIUrl()}users/username/${username}`, - RequestBody('GET', null, null) + access_token ? RequestBodyWithAuthHeader('GET', null, null, access_token) : RequestBody('GET', null, null) ) const res = await errorHandling(result) return res