Merge pull request #468 from learnhouse/feat/advanced-profiles

Advanced Profiles
This commit is contained in:
Badr B. 2025-04-06 12:14:44 +02:00 committed by GitHub
commit 32a59f0ffc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 4198 additions and 3896 deletions

View file

@ -0,0 +1,33 @@
"""Add Users details and Profile
Revision ID: adb944cc8bec
Revises: 4a88b680263c
Create Date: 2025-03-29 16:31:38.797525
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa # noqa: F401
import sqlmodel # noqa: F401
# revision identifiers, used by Alembic.
revision: str = 'adb944cc8bec'
down_revision: Union[str, None] = '4a88b680263c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('details', sa.JSON(), nullable=True))
op.add_column('user', sa.Column('profile', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'profile')
op.drop_column('user', 'details')
# ### end Alembic commands ###

View file

@ -1,6 +1,7 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from sqlalchemy import JSON, Column
from src.db.roles import RoleRead from src.db.roles import RoleRead
@ -12,7 +13,8 @@ class UserBase(SQLModel):
email: EmailStr email: EmailStr
avatar_image: Optional[str] = "" avatar_image: Optional[str] = ""
bio: Optional[str] = "" bio: Optional[str] = ""
details: Optional[dict] = Field(default={}, sa_column=Column(JSON))
profile: Optional[dict] = Field(default={}, sa_column=Column(JSON))
class UserCreate(UserBase): class UserCreate(UserBase):
first_name: str = "" first_name: str = ""
@ -27,6 +29,8 @@ class UserUpdate(UserBase):
email: str email: str
avatar_image: Optional[str] = "" avatar_image: Optional[str] = ""
bio: Optional[str] = "" bio: Optional[str] = ""
details: Optional[dict] = {}
profile: Optional[dict] = {}
class UserUpdatePassword(SQLModel): class UserUpdatePassword(SQLModel):

View file

@ -1,4 +1,4 @@
from typing import Literal from typing import Literal, List
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from pydantic import EmailStr from pydantic import EmailStr
from sqlmodel import Session from sqlmodel import Session
@ -9,6 +9,7 @@ from src.services.users.password_reset import (
from src.services.orgs.orgs import get_org_join_mechanism from src.services.orgs.orgs import get_org_join_mechanism
from src.security.auth import get_current_user from src.security.auth import get_current_user
from src.core.events.database import get_db_session from src.core.events.database import get_db_session
from src.db.courses.courses import CourseRead
from src.db.users import ( from src.db.users import (
PublicUser, PublicUser,
@ -28,10 +29,12 @@ from src.services.users.users import (
get_user_session, get_user_session,
read_user_by_id, read_user_by_id,
read_user_by_uuid, read_user_by_uuid,
read_user_by_username,
update_user, update_user,
update_user_avatar, update_user_avatar,
update_user_password, update_user_password,
) )
from src.services.courses.courses import get_user_courses
router = APIRouter() router = APIRouter()
@ -170,6 +173,20 @@ async def api_get_user_by_uuid(
return await read_user_by_uuid(request, db_session, current_user, user_uuid) return await read_user_by_uuid(request, db_session, current_user, user_uuid)
@router.get("/username/{username}", response_model=UserRead, tags=["users"])
async def api_get_user_by_username(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
username: str,
) -> UserRead:
"""
Get User by Username
"""
return await read_user_by_username(request, db_session, current_user, username)
@router.put("/{user_id}", response_model=UserRead, tags=["users"]) @router.put("/{user_id}", response_model=UserRead, tags=["users"])
async def api_update_user( async def api_update_user(
*, *,
@ -262,3 +279,26 @@ async def api_delete_user(
Delete User Delete User
""" """
return await delete_user_by_id(request, db_session, current_user, user_id) return await delete_user_by_id(request, db_session, current_user, user_id)
@router.get("/{user_id}/courses", response_model=List[CourseRead], tags=["users"])
async def api_get_user_courses(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
user_id: int,
page: int = 1,
limit: int = 10,
) -> List[CourseRead]:
"""
Get courses made or contributed by a user.
"""
return await get_user_courses(
request=request,
current_user=current_user,
user_id=user_id,
db_session=db_session,
page=page,
limit=limit,
)

View file

@ -638,7 +638,88 @@ async def delete_course(
return {"detail": "Course deleted"} return {"detail": "Course deleted"}
async def get_user_courses(
request: Request,
current_user: PublicUser | AnonymousUser,
user_id: int,
db_session: Session,
page: int = 1,
limit: int = 10,
) -> List[CourseRead]:
# Verify user is not anonymous
await authorization_verify_if_user_is_anon(current_user.id)
# Get all resource authors for the user
statement = select(ResourceAuthor).where(
and_(
ResourceAuthor.user_id == user_id,
ResourceAuthor.authorship_status == ResourceAuthorshipStatusEnum.ACTIVE
)
)
resource_authors = db_session.exec(statement).all()
# Extract course UUIDs from resource authors
course_uuids = [author.resource_uuid for author in resource_authors]
if not course_uuids:
return []
# Get courses with the extracted UUIDs
statement = select(Course).where(Course.course_uuid.in_(course_uuids))
# Apply pagination
statement = statement.offset((page - 1) * limit).limit(limit)
courses = db_session.exec(statement).all()
# Convert to CourseRead objects
result = []
for course in courses:
# Get authors for the course
authors_statement = select(ResourceAuthor).where(
ResourceAuthor.resource_uuid == course.course_uuid
)
authors = db_session.exec(authors_statement).all()
# Convert authors to AuthorWithRole objects
authors_with_role = []
for author in authors:
# Get user for the author
user_statement = select(User).where(User.id == author.user_id)
user = db_session.exec(user_statement).first()
if user:
authors_with_role.append(
AuthorWithRole(
user=UserRead.model_validate(user),
authorship=author.authorship,
authorship_status=author.authorship_status,
creation_date=author.creation_date,
update_date=author.update_date,
)
)
# Create CourseRead object
course_read = CourseRead(
id=course.id,
org_id=course.org_id,
name=course.name,
description=course.description,
about=course.about,
learnings=course.learnings,
tags=course.tags,
thumbnail_image=course.thumbnail_image,
public=course.public,
open_to_contributors=course.open_to_contributors,
course_uuid=course.course_uuid,
creation_date=course.creation_date,
update_date=course.update_date,
authors=authors_with_role,
)
result.append(course_read)
return result
## 🔒 RBAC Utils ## ## 🔒 RBAC Utils ##

View file

@ -153,13 +153,13 @@ async def create_user_with_invite(
user = await create_user(request, db_session, current_user, user_object, org_id) user = await create_user(request, db_session, current_user, user_object, org_id)
# Check if invite code contains UserGroup # Check if invite code contains UserGroup
if inviteCode.get("usergroup_id"): if inviteCode.get("usergroup_id"): # type: ignore
# Add user to UserGroup # Add user to UserGroup
await add_users_to_usergroup( await add_users_to_usergroup(
request, request,
db_session, db_session,
InternalUser(id=0), InternalUser(id=0),
int(inviteCode.get("usergroup_id")), # Convert to int since usergroup_id is expected to be int int(inviteCode.get("usergroup_id")), # type: ignore / Convert to int since usergroup_id is expected to be int
str(user.id), str(user.id),
) )
@ -416,8 +416,30 @@ async def read_user_by_uuid(
detail="User does not exist", detail="User does not exist",
) )
# RBAC check
await rbac_check(request, current_user, "read", user.user_uuid, db_session)
user = UserRead.model_validate(user)
return user
async def read_user_by_username(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
username: str,
):
# Get user
statement = select(User).where(User.username == username)
user = db_session.exec(statement).first()
if not user:
raise HTTPException(
status_code=400,
detail="User does not exist",
)
user = UserRead.model_validate(user) user = UserRead.model_validate(user)
@ -563,7 +585,7 @@ async def rbac_check(
user_uuid: str, user_uuid: str,
db_session: Session, db_session: Session,
): ):
if action == "create": if action == "create" or action == "read":
if current_user.id == 0: # if user is anonymous if current_user.id == 0: # if user is anonymous
return True return True
else: else:

View file

@ -1,22 +1,17 @@
"use client"; "use client";
import * as Sentry from "@sentry/nextjs"; export default function GlobalError({
import NextError from "next/error"; error,
import { useEffect } from "react"; reset,
}: {
export default function GlobalError({ error }: { error: Error & { digest?: string } }) { error: Error & { digest?: string };
useEffect(() => { reset: () => void;
Sentry.captureException(error); }) {
}, [error]);
return ( return (
<html> <html>
<body> <body>
{/* `NextError` is the default Next.js error page component. Its type <h2>Something went wrong!</h2>
definition requires a `statusCode` prop. However, since the App Router <button onClick={() => reset()}>Try again</button>
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
</body> </body>
</html> </html>
); );

View file

@ -22,6 +22,7 @@ import CourseActionsMobile from '@components/Objects/Courses/CourseActions/Cours
const CourseClient = (props: any) => { const CourseClient = (props: any) => {
const [learnings, setLearnings] = useState<any>([]) const [learnings, setLearnings] = useState<any>([])
const [expandedChapters, setExpandedChapters] = useState<{[key: string]: boolean}>({})
const courseuuid = props.courseuuid const courseuuid = props.courseuuid
const orgslug = props.orgslug const orgslug = props.orgslug
const course = props.course const course = props.course
@ -112,8 +113,8 @@ const CourseClient = (props: any) => {
<div className="flex flex-col md:flex-row md:space-x-10 space-y-6 md:space-y-0 pt-10"> <div className="flex flex-col md:flex-row md:space-x-10 space-y-6 md:space-y-0 pt-10">
<div className="course_metadata_left w-full md:basis-3/4 space-y-2"> <div className="course_metadata_left w-full md:basis-3/4 space-y-2">
<h2 className="py-3 text-2xl font-bold">About</h2> <h2 className="py-3 text-2xl font-bold">About</h2>
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden"> <div className="">
<p className="py-5 px-5 whitespace-pre-wrap">{course.about}</p> <p className="py-5 whitespace-pre-wrap">{course.about}</p>
</div> </div>
{learnings.length > 0 && learnings[0]?.text !== 'null' && ( {learnings.length > 0 && learnings[0]?.text !== 'null' && (
@ -164,14 +165,32 @@ const CourseClient = (props: any) => {
<h2 className="py-3 text-xl md:text-2xl font-bold">Course Lessons</h2> <h2 className="py-3 text-xl md:text-2xl font-bold">Course Lessons</h2>
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden"> <div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
{course.chapters.map((chapter: any) => { {course.chapters.map((chapter: any) => {
const isExpanded = expandedChapters[chapter.chapter_uuid] ?? true; // Default to expanded
return ( return (
<div key={chapter.chapter_uuid || `chapter-${chapter.name}`} className=""> <div key={chapter.chapter_uuid || `chapter-${chapter.name}`} className="">
<div className="flex text-lg py-4 px-4 outline outline-1 outline-neutral-200/40 font-bold bg-neutral-50 text-neutral-600 items-center"> <div
className="flex text-lg py-4 px-4 outline outline-1 outline-neutral-200/40 font-bold bg-neutral-50 text-neutral-600 items-center cursor-pointer hover:bg-neutral-100 transition-colors"
onClick={() => setExpandedChapters(prev => ({
...prev,
[chapter.chapter_uuid]: !isExpanded
}))}
>
<h3 className="grow mr-3 break-words">{chapter.name}</h3> <h3 className="grow mr-3 break-words">{chapter.name}</h3>
<div className="flex items-center space-x-3">
<p className="text-sm font-normal text-neutral-400 px-3 py-[2px] outline-1 outline outline-neutral-200 rounded-full whitespace-nowrap shrink-0"> <p className="text-sm font-normal text-neutral-400 px-3 py-[2px] outline-1 outline outline-neutral-200 rounded-full whitespace-nowrap shrink-0">
{chapter.activities.length} Activities {chapter.activities.length} Activities
</p> </p>
<svg
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div> </div>
</div>
<div className={`py-3 transition-all duration-200 ${isExpanded ? 'block' : 'hidden'}`}>
<div className="py-3"> <div className="py-3">
{chapter.activities.map((activity: any) => { {chapter.activities.map((activity: any) => {
return ( return (
@ -324,6 +343,7 @@ const CourseClient = (props: any) => {
})} })}
</div> </div>
</div> </div>
</div>
) )
})} })}
</div> </div>

View file

@ -0,0 +1,350 @@
'use client';
import React from 'react'
import UserAvatar from '@components/Objects/UserAvatar'
import {
Briefcase,
Building2,
MapPin,
Globe,
Link as LinkIcon,
GraduationCap,
Award,
BookOpen,
Laptop2,
Users,
Calendar,
Lightbulb,
X,
ExternalLink
} from 'lucide-react'
import { getUserAvatarMediaDirectory } from '@services/media/media'
import { getCoursesByUser } from '@services/users/users'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { Button } from "@components/ui/button"
import CourseThumbnailLanding from '@components/Objects/Thumbnails/CourseThumbnailLanding'
interface UserProfileClientProps {
userData: any;
profile: any;
}
const ICON_MAP = {
'briefcase': Briefcase,
'graduation-cap': GraduationCap,
'map-pin': MapPin,
'building-2': Building2,
'speciality': Lightbulb,
'globe': Globe,
'laptop-2': Laptop2,
'award': Award,
'book-open': BookOpen,
'link': LinkIcon,
'users': Users,
'calendar': Calendar,
} as const
// Add Modal component
const ImageModal: React.FC<{
image: { url: string; caption?: string };
onClose: () => void;
}> = ({ image, onClose }) => {
return (
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
<div className="relative max-w-4xl w-full">
<button
onClick={onClose}
className="absolute -top-10 right-0 text-white hover:text-gray-300 transition-colors"
>
<X className="w-6 h-6" />
</button>
<img
src={image.url}
alt={image.caption || ''}
className="w-full h-auto rounded-lg"
/>
{image.caption && (
<p className="mt-4 text-white text-center text-lg">{image.caption}</p>
)}
</div>
</div>
);
};
function UserProfileClient({ userData, profile }: UserProfileClientProps) {
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
const [selectedImage, setSelectedImage] = React.useState<{ url: string; caption?: string } | null>(null);
const [userCourses, setUserCourses] = React.useState<any[]>([]);
const [isLoadingCourses, setIsLoadingCourses] = React.useState(false);
React.useEffect(() => {
const fetchUserCourses = async () => {
if (userData.id && access_token) {
try {
setIsLoadingCourses(true);
const coursesData = await getCoursesByUser(userData.id, access_token);
if (coursesData.data) {
setUserCourses(coursesData.data);
}
} catch (error) {
console.error('Error fetching user courses:', error);
} finally {
setIsLoadingCourses(false);
}
}
};
fetchUserCourses();
}, [userData.id, access_token]);
const IconComponent = ({ iconName }: { iconName: string }) => {
const IconElement = ICON_MAP[iconName as keyof typeof ICON_MAP]
if (!IconElement) return null
return <IconElement className="w-4 h-4 text-gray-600" />
}
return (
<div className="container mx-auto py-8">
{/* Banner */}
<div className="h-48 w-full bg-gray-100 rounded-t-xl mb-0 relative overflow-hidden">
{/* Optional banner content */}
</div>
{/* Profile Content */}
<div className="bg-white rounded-b-xl nice-shadow p-8 relative">
{/* Avatar Positioned on the banner */}
<div className="absolute -top-24 left-8">
<div className="rounded-xl overflow-hidden shadow-lg border-4 border-white">
<UserAvatar
width={150}
avatar_url={userData.avatar_image ? getUserAvatarMediaDirectory(userData.user_uuid, userData.avatar_image) : ''}
predefined_avatar={userData.avatar_image ? undefined : 'empty'}
userId={userData.id}
showProfilePopup
rounded="rounded-xl"
/>
</div>
</div>
{/* Affiliation Logos */}
<div className="absolute -top-12 right-8 flex items-center gap-4">
{profile.sections?.map((section: any) => (
section.type === 'affiliation' && section.affiliations?.map((affiliation: any, index: number) => (
affiliation.logoUrl && (
<div key={index} className="bg-white rounded-lg p-2 shadow-lg border-2 border-white">
<img
src={affiliation.logoUrl}
alt={affiliation.name}
className="w-16 h-16 object-contain"
title={affiliation.name}
/>
</div>
)
))
))}
</div>
{/* Profile Content with right padding to avoid overlap */}
<div className="mt-20 md:mt-14">
<div className="flex flex-col md:flex-row gap-12">
{/* Left column with details - aligned with avatar */}
<div className="w-full md:w-1/6 pl-2">
{/* Name */}
<h1 className="text-[32px] font-bold mb-8">
{userData.first_name} {userData.last_name}
</h1>
{/* Details */}
<div className="flex flex-col space-y-3">
{userData.details && Object.values(userData.details).map((detail: any) => (
<div key={detail.id} className="flex items-center gap-4">
<div className="flex-shrink-0">
<IconComponent iconName={detail.icon} />
</div>
<span className="text-gray-700 text-[15px] font-medium">{detail.text}</span>
</div>
))}
</div>
</div>
{/* Right column with about and related content */}
<div className="w-full md:w-4/6">
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">About</h2>
{userData.bio ? (
<p className="text-gray-700">{userData.bio}</p>
) : (
<p className="text-gray-500 italic">No biography provided</p>
)}
</div>
{/* Profile sections from profile builder */}
{profile.sections && profile.sections.length > 0 && (
<div>
{profile.sections.map((section: any, index: number) => (
<div key={index} className="mb-8">
<h2 className="text-xl font-semibold mb-4">{section.title}</h2>
{/* Add Image Gallery section */}
{section.type === 'image-gallery' && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{section.images.map((image: any, imageIndex: number) => (
<div
key={imageIndex}
className="relative group cursor-pointer"
onClick={() => setSelectedImage(image)}
>
<img
src={image.url}
alt={image.caption || ''}
className="w-full h-48 object-cover rounded-lg"
/>
{image.caption && (
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center p-4">
<p className="text-white text-center text-sm">{image.caption}</p>
</div>
)}
</div>
))}
</div>
)}
{section.type === 'text' && (
<div className="prose max-w-none">{section.content}</div>
)}
{section.type === 'links' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{section.links.map((link: any, linkIndex: number) => (
<a
key={linkIndex}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
>
<LinkIcon className="w-4 h-4" />
<span>{link.title}</span>
</a>
))}
</div>
)}
{section.type === 'skills' && (
<div className="flex flex-wrap gap-2">
{section.skills.map((skill: any, skillIndex: number) => (
<span
key={skillIndex}
className="px-3 py-1 bg-gray-100 rounded-full text-sm"
>
{skill.name}
{skill.level && `${skill.level}`}
</span>
))}
</div>
)}
{section.type === 'experience' && (
<div className="space-y-4">
{section.experiences.map((exp: any, expIndex: number) => (
<div key={expIndex} className="border-l-2 border-gray-200 pl-4">
<h3 className="font-medium">{exp.title}</h3>
<p className="text-gray-600">{exp.organization}</p>
<p className="text-sm text-gray-500">
{exp.startDate} - {exp.current ? 'Present' : exp.endDate}
</p>
{exp.description && (
<p className="mt-2 text-gray-700">{exp.description}</p>
)}
</div>
))}
</div>
)}
{section.type === 'education' && (
<div className="space-y-4">
{section.education.map((edu: any, eduIndex: number) => (
<div key={eduIndex} className="border-l-2 border-gray-200 pl-4">
<h3 className="font-medium">{edu.institution}</h3>
<p className="text-gray-600">{edu.degree} in {edu.field}</p>
<p className="text-sm text-gray-500">
{edu.startDate} - {edu.current ? 'Present' : edu.endDate}
</p>
{edu.description && (
<p className="mt-2 text-gray-700">{edu.description}</p>
)}
</div>
))}
</div>
)}
{section.type === 'affiliation' && (
<div className="space-y-4">
{section.affiliations.map((affiliation: any, affIndex: number) => (
<div key={affIndex} className="border-l-2 border-gray-200 pl-4">
<div className="flex items-start gap-4">
{affiliation.logoUrl && (
<img
src={affiliation.logoUrl}
alt={affiliation.name}
className="w-12 h-12 object-contain"
/>
)}
<div>
<h3 className="font-medium">{affiliation.name}</h3>
{affiliation.description && (
<p className="mt-2 text-gray-700">{affiliation.description}</p>
)}
</div>
</div>
</div>
))}
</div>
)}
{section.type === 'courses' && (
<div>
{isLoadingCourses ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
</div>
) : userCourses.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 auto-rows-fr">
{userCourses.map((course) => (
<div key={course.id} className="flex">
<CourseThumbnailLanding
course={course}
orgslug={userData.org_slug || course.org_slug}
/>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">
No courses found
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
{/* Image Modal */}
{selectedImage && (
<ImageModal
image={selectedImage}
onClose={() => setSelectedImage(null)}
/>
)}
</div>
)
}
export default UserProfileClient

View file

@ -0,0 +1,67 @@
import React from 'react'
import { getUserByUsername } from '@services/users/users'
import { getServerSession } from 'next-auth'
import { nextAuthOptions } from 'app/auth/options'
import { Metadata } from 'next'
import UserProfileClient from './UserProfileClient'
interface UserPageParams {
username: string;
orgslug: string;
}
interface UserPageProps {
params: Promise<UserPageParams>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export async function generateMetadata({ params }: UserPageProps): Promise<Metadata> {
try {
const resolvedParams = await params
const userData = await getUserByUsername(resolvedParams.username)
return {
title: `${userData.first_name} ${userData.last_name} | Profile`,
description: userData.bio || `Profile page of ${userData.first_name} ${userData.last_name}`,
}
} catch (error) {
return {
title: 'User Profile',
}
}
}
async function UserPage({ params }: UserPageProps) {
const resolvedParams = await params;
const { username } = resolvedParams;
try {
// Fetch user data by username
const userData = await getUserByUsername(username);
const profile = userData.profile ? (
typeof userData.profile === 'string' ? JSON.parse(userData.profile) : userData.profile
) : { sections: [] };
return (
<div>
<UserProfileClient
userData={userData}
profile={profile}
/>
</div>
)
} catch (error) {
console.error('Error fetching user data:', error)
return (
<div className="container mx-auto py-8">
<div className="bg-white rounded-xl nice-shadow p-6">
<p className="text-red-600">Error loading user profile</p>
</div>
</div>
)
}
}
export default UserPage

View file

@ -75,8 +75,8 @@ function OrgPage(props: { params: Promise<OrgParams> }) {
}, [params.subpage, params]) }, [params.subpage, params])
return ( return (
<div className="h-full w-full bg-[#f8f8f8]"> <div className="h-full w-full bg-[#f8f8f8] flex flex-col">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] nice-shadow"> <div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] nice-shadow flex-shrink-0">
<BreadCrumbs type="org"></BreadCrumbs> <BreadCrumbs type="org"></BreadCrumbs>
<div className="my-2 py-2"> <div className="my-2 py-2">
<div className="w-100 flex flex-col space-y-1"> <div className="w-100 flex flex-col space-y-1">
@ -99,12 +99,13 @@ function OrgPage(props: { params: Promise<OrgParams> }) {
))} ))}
</div> </div>
</div> </div>
<div className="h-6"></div> <div className="h-6 flex-shrink-0"></div>
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }} transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
className="flex-1 overflow-y-auto"
> >
{params.subpage == 'general' ? <OrgEditGeneral /> : ''} {params.subpage == 'general' ? <OrgEditGeneral /> : ''}
{params.subpage == 'previews' ? <OrgEditImages /> : ''} {params.subpage == 'previews' ? <OrgEditImages /> : ''}

View file

@ -69,7 +69,7 @@ function PaymentsPage(props: { params: Promise<PaymentsParams> }) {
return ( return (
<div className="h-screen w-full bg-[#f8f8f8] flex flex-col"> <div className="h-screen w-full bg-[#f8f8f8] flex flex-col">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 nice-shadow"> <div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 nice-shadow flex-shrink-0">
<BreadCrumbs type="payments" /> <BreadCrumbs type="payments" />
<div className="my-2 py-2"> <div className="my-2 py-2">
<div className="w-100 flex flex-col space-y-1"> <div className="w-100 flex flex-col space-y-1">
@ -102,7 +102,7 @@ function PaymentsPage(props: { params: Promise<PaymentsParams> }) {
/> />
</div> </div>
</div> </div>
<div className="h-6"></div> <div className="h-6 flex-shrink-0"></div>
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}

View file

@ -5,84 +5,119 @@ import UserEditGeneral from '@components/Dashboard/Pages/UserAccount/UserEditGen
import UserEditPassword from '@components/Dashboard/Pages/UserAccount/UserEditPassword/UserEditPassword' import UserEditPassword from '@components/Dashboard/Pages/UserAccount/UserEditPassword/UserEditPassword'
import Link from 'next/link' import Link from 'next/link'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
import { Info, Lock } from 'lucide-react' import { Info, Lock, LucideIcon, User } from 'lucide-react'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs' import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import UserProfile from '@components/Dashboard/Pages/UserAccount/UserProfile/UserProfile';
interface User {
username: string;
// Add other user properties as needed
}
interface Session {
user?: User;
// Add other session properties as needed
}
export type SettingsParams = { export type SettingsParams = {
subpage: string subpage: string
orgslug: string orgslug: string
} }
function SettingsPage(props: { params: Promise<SettingsParams> }) { type NavigationItem = {
const params = use(props.params); id: string
const session = useLHSession() as any label: string
icon: LucideIcon
component: React.ComponentType
}
const navigationItems: NavigationItem[] = [
{
id: 'general',
label: 'General',
icon: Info,
component: UserEditGeneral
},
{
id: 'profile',
label: 'Profile',
icon: User,
component: UserProfile
},
{
id: 'security',
label: 'Password',
icon: Lock,
component: UserEditPassword
},
]
const SettingsNavigation = ({
items,
currentPage,
orgslug
}: {
items: NavigationItem[]
currentPage: string
orgslug: string
}) => (
<div className="flex space-x-5 font-black text-sm">
{items.map((item) => (
<Link
key={item.id}
href={getUriWithOrg(orgslug, `/dash/user-account/settings/${item.id}`)}
>
<div
className={`py-2 w-fit text-center border-black transition-all ease-linear ${
currentPage === item.id ? 'border-b-4' : 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<item.icon size={16} />
<div>{item.label}</div>
</div>
</div>
</Link>
))}
</div>
)
function SettingsPage({ params }: { params: Promise<SettingsParams> }) {
const { subpage, orgslug } = use(params);
const session = useLHSession() as Session;
useEffect(() => {}, [session]) useEffect(() => {}, [session])
const CurrentComponent = navigationItems.find(item => item.id === subpage)?.component;
return ( return (
<div className="h-full w-full bg-[#f8f8f8]"> <div className="h-full w-full bg-[#f8f8f8] flex flex-col">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]"> <div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 nice-shadow flex-shrink-0">
<BreadCrumbs <BreadCrumbs
type="user" type="user"
last_breadcrumb={session?.user?.username} last_breadcrumb={session?.user?.username}
></BreadCrumbs> />
<div className="my-2 tracking-tighter"> <div className="my-2 tracking-tighter">
<div className="w-100 flex justify-between"> <div className="w-100 flex justify-between">
<div className="pt-3 flex font-bold text-4xl">Account Settings</div> <div className="pt-3 flex font-bold text-4xl">Account Settings</div>
</div> </div>
</div> </div>
<div className="flex space-x-5 font-black text-sm"> <SettingsNavigation
<Link items={navigationItems}
href={ currentPage={subpage}
getUriWithOrg(params.orgslug, '') + orgslug={orgslug}
`/dash/user-account/settings/general` />
}
>
<div
className={`py-2 w-fit text-center border-black transition-all ease-linear ${
params.subpage.toString() === 'general'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<Info size={16} />
<div>General</div>
</div> </div>
</div> <div className="h-6 flex-shrink-0" />
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/user-account/settings/security`
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${
params.subpage.toString() === 'security'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<Lock size={16} />
<div>Password</div>
</div>
</div>
</Link>
</div>
</div>
<div className="h-6"></div>
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }} transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
className="h-full overflow-y-auto" className="flex-1 overflow-y-auto"
> >
{params.subpage == 'general' ? <UserEditGeneral /> : ''} {CurrentComponent && <CurrentComponent />}
{params.subpage == 'security' ? <UserEditPassword /> : ''}
</motion.div> </motion.div>
</div> </div>
) )

View file

@ -154,7 +154,7 @@ function UsersSettingsPage(props: { params: Promise<SettingsParams> }) {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }} transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
className="h-full overflow-y-auto" className="flex-1 overflow-y-auto"
> >
{params.subpage == 'users' ? <OrgUsers /> : ''} {params.subpage == 'users' ? <OrgUsers /> : ''}
{params.subpage == 'signups' ? <OrgAccess /> : ''} {params.subpage == 'signups' ? <OrgAccess /> : ''}

View file

@ -1400,7 +1400,7 @@ const PeopleSectionEditor: React.FC<{
<Label>People</Label> <Label>People</Label>
<div className="space-y-4 mt-2"> <div className="space-y-4 mt-2">
{section.people.map((person, index) => ( {section.people.map((person, index) => (
<div key={index} className="grid grid-cols-[1fr_1fr_1fr_auto] gap-4 p-4 border rounded-lg"> <div key={index} className="grid grid-cols-[1fr_1fr_1fr_1fr_auto] gap-4 p-4 border rounded-lg">
<div className="space-y-2"> <div className="space-y-2">
<Label>Name</Label> <Label>Name</Label>
<Input <Input
@ -1414,6 +1414,19 @@ const PeopleSectionEditor: React.FC<{
/> />
</div> </div>
<div className="space-y-2">
<Label>Username</Label>
<Input
value={person.username || ''}
onChange={(e) => {
const newPeople = [...section.people]
newPeople[index] = { ...person, username: e.target.value }
onChange({ ...section, people: newPeople })
}}
placeholder="@username"
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Image</Label> <Label>Image</Label>
<div className="space-y-2"> <div className="space-y-2">
@ -1480,7 +1493,8 @@ const PeopleSectionEditor: React.FC<{
user_uuid: '', user_uuid: '',
name: '', name: '',
description: '', description: '',
image_url: '' image_url: '',
username: ''
} }
onChange({ onChange({
...section, ...section,

View file

@ -40,6 +40,7 @@ export interface LandingUsers {
name: string; name: string;
description: string; description: string;
image_url: string; image_url: string;
username?: string;
} }
export interface LandingPeople { export interface LandingPeople {

View file

@ -1,6 +1,7 @@
'use client'; 'use client';
import { updateProfile } from '@services/settings/profile' import { updateProfile } from '@services/settings/profile'
import React, { useEffect } from 'react' import { getUser } from '@services/users/users'
import React, { useEffect, useState, useCallback, useMemo } from 'react'
import { Formik, Form } from 'formik' import { Formik, Form } from 'formik'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import { import {
@ -10,7 +11,19 @@ import {
Info, Info,
UploadCloud, UploadCloud,
AlertTriangle, AlertTriangle,
LogOut LogOut,
Briefcase,
GraduationCap,
MapPin,
Building2,
Globe,
Laptop2,
Award,
BookOpen,
Link,
Users,
Calendar,
Lightbulb
} from 'lucide-react' } from 'lucide-react'
import UserAvatar from '@components/Objects/UserAvatar' import UserAvatar from '@components/Objects/UserAvatar'
import { updateUserAvatar } from '@services/users/users' import { updateUserAvatar } from '@services/users/users'
@ -20,19 +33,48 @@ import { Input } from "@components/ui/input"
import { Textarea } from "@components/ui/textarea" import { Textarea } from "@components/ui/textarea"
import { Button } from "@components/ui/button" import { Button } from "@components/ui/button"
import { Label } from "@components/ui/label" import { Label } from "@components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/ui/select"
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
import { getUriWithoutOrg } from '@services/config/config'; import { getUriWithoutOrg } from '@services/config/config';
import { useDebounce } from '@/hooks/useDebounce';
const SUPPORTED_FILES = constructAcceptValue(['image']) const SUPPORTED_FILES = constructAcceptValue(['image'])
const validationSchema = Yup.object().shape({ const AVAILABLE_ICONS = [
email: Yup.string().email('Invalid email').required('Email is required'), { name: 'briefcase', label: 'Briefcase', component: Briefcase },
username: Yup.string().required('Username is required'), { name: 'graduation-cap', label: 'Education', component: GraduationCap },
first_name: Yup.string().required('First name is required'), { name: 'map-pin', label: 'Location', component: MapPin },
last_name: Yup.string().required('Last name is required'), { name: 'building-2', label: 'Organization', component: Building2 },
bio: Yup.string().max(400, 'Bio must be 400 characters or less'), { name: 'speciality', label: 'Speciality', component: Lightbulb },
}) { name: 'globe', label: 'Website', component: Globe },
{ name: 'laptop-2', label: 'Tech', component: Laptop2 },
{ name: 'award', label: 'Achievement', component: Award },
{ name: 'book-open', label: 'Book', component: BookOpen },
{ name: 'link', label: 'Link', component: Link },
{ name: 'users', label: 'Community', component: Users },
{ name: 'calendar', label: 'Calendar', component: Calendar },
] as const;
const IconComponent = ({ iconName }: { iconName: string }) => {
const iconConfig = AVAILABLE_ICONS.find(i => i.name === iconName);
if (!iconConfig) return null;
const IconElement = iconConfig.component;
return <IconElement className="w-4 h-4" />;
};
interface DetailItem {
id: string;
label: string;
icon: string;
text: string;
}
interface FormValues { interface FormValues {
username: string; username: string;
@ -40,87 +82,214 @@ interface FormValues {
last_name: string; last_name: string;
email: string; email: string;
bio: string; bio: string;
details: {
[key: string]: DetailItem;
};
} }
function UserEditGeneral() { const DETAIL_TEMPLATES = {
const session = useLHSession() as any; general: [
const access_token = session?.data?.tokens?.access_token; { id: 'title', label: 'Title', icon: 'briefcase', text: '' },
const [localAvatar, setLocalAvatar] = React.useState(null) as any { id: 'affiliation', label: 'Affiliation', icon: 'building-2', text: '' },
const [isLoading, setIsLoading] = React.useState(false) as any { id: 'location', label: 'Location', icon: 'map-pin', text: '' },
const [error, setError] = React.useState() as any { id: 'website', label: 'Website', icon: 'globe', text: '' },
const [success, setSuccess] = React.useState('') as any { id: 'linkedin', label: 'LinkedIn', icon: 'link', text: '' }
],
academic: [
{ id: 'institution', label: 'Institution', icon: 'building-2', text: '' },
{ id: 'department', label: 'Department', icon: 'graduation-cap', text: '' },
{ id: 'research', label: 'Research Area', icon: 'book-open', text: '' },
{ id: 'academic-title', label: 'Academic Title', icon: 'award', text: '' }
],
professional: [
{ id: 'company', label: 'Company', icon: 'building-2', text: '' },
{ id: 'industry', label: 'Industry', icon: 'briefcase', text: '' },
{ id: 'expertise', label: 'Expertise', icon: 'laptop-2', text: '' },
{ id: 'community', label: 'Community', icon: 'users', text: '' }
]
} as const;
const handleFileChange = async (event: any) => { const validationSchema = Yup.object().shape({
const file = event.target.files[0] email: Yup.string().email('Invalid email').required('Email is required'),
setLocalAvatar(file) username: Yup.string().required('Username is required'),
setIsLoading(true) first_name: Yup.string().required('First name is required'),
const res = await updateUserAvatar(session.data.user_uuid, file, access_token) last_name: Yup.string().required('Last name is required'),
// wait for 1 second to show loading animation bio: Yup.string().max(400, 'Bio must be 400 characters or less'),
await new Promise((r) => setTimeout(r, 1500)) details: Yup.object().shape({})
if (res.success === false) { });
setError(res.HTTPmessage)
} else { // Memoized detail card component for better performance
setIsLoading(false) const DetailCard = React.memo(({
setError('') id,
setSuccess('Avatar Updated') detail,
} onUpdate,
onRemove,
onLabelChange
}: {
id: string;
detail: DetailItem;
onUpdate: (id: string, field: keyof DetailItem, value: string) => void;
onRemove: (id: string) => void;
onLabelChange: (id: string, newLabel: string) => void;
}) => {
// Add local state for label input
const [localLabel, setLocalLabel] = useState(detail.label);
// Debounce the label change handler
const debouncedLabelChange = useDebounce((newLabel: string) => {
if (newLabel !== detail.label) {
onLabelChange(id, newLabel);
} }
}, 500);
const handleEmailChange = async (newEmail: string) => { // Memoize handlers to prevent unnecessary re-renders
toast.success('Profile Updated Successfully', { duration: 4000 }) const handleLabelChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newLabel = e.target.value;
setLocalLabel(newLabel);
debouncedLabelChange(newLabel);
}, [debouncedLabelChange]);
// Show message about logging in with new email const handleIconChange = useCallback((value: string) => {
toast((t: any) => ( onUpdate(id, 'icon', value);
<div className="flex items-center gap-2"> }, [id, onUpdate]);
<span>Please login again with your new email: {newEmail}</span>
</div>
), {
duration: 4000,
icon: '📧'
})
// Wait for 4 seconds before signing out const handleTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
await new Promise(resolve => setTimeout(resolve, 4000)) onUpdate(id, 'text', e.target.value);
signOut({ redirect: true, callbackUrl: getUriWithoutOrg('/') }) }, [id, onUpdate]);
}
useEffect(() => { }, [session, session.data]) const handleRemove = useCallback(() => {
onRemove(id);
}, [id, onRemove]);
// Update local label when prop changes
useEffect(() => {
setLocalLabel(detail.label);
}, [detail.label]);
return ( return (
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow"> <div className="space-y-2 p-4 border rounded-lg bg-white shadow-sm">
{session.data.user && ( <div className="flex justify-between items-center mb-3">
<Formik<FormValues> <Input
enableReinitialize value={localLabel}
initialValues={{ onChange={handleLabelChange}
username: session.data.user.username, placeholder="Enter label (e.g., Title, Location)"
first_name: session.data.user.first_name, className="max-w-[200px]"
last_name: session.data.user.last_name, />
email: session.data.user.email, <Button
bio: session.data.user.bio || '', type="button"
}} variant="ghost"
validationSchema={validationSchema} size="sm"
onSubmit={(values, { setSubmitting }) => { className="text-red-500 hover:text-red-700"
const isEmailChanged = values.email !== session.data.user.email onClick={handleRemove}
const loadingToast = toast.loading('Updating profile...')
setTimeout(() => {
setSubmitting(false)
updateProfile(values, session.data.user.id, access_token)
.then(() => {
toast.dismiss(loadingToast)
if (isEmailChanged) {
handleEmailChange(values.email)
} else {
toast.success('Profile Updated Successfully')
}
})
.catch(() => {
toast.error('Failed to update profile', { id: loadingToast })
})
}, 400)
}}
> >
{({ isSubmitting, values, handleChange, errors, touched }) => ( Remove
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Icon</Label>
<Select
value={detail.icon}
onValueChange={handleIconChange}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select icon">
{detail.icon && (
<div className="flex items-center gap-2">
<IconComponent iconName={detail.icon} />
<span>
{AVAILABLE_ICONS.find(i => i.name === detail.icon)?.label}
</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{AVAILABLE_ICONS.map((icon) => (
<SelectItem key={icon.name} value={icon.name}>
<div className="flex items-center gap-2">
<icon.component className="w-4 h-4" />
<span>{icon.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Text</Label>
<Input
value={detail.text}
onChange={handleTextChange}
placeholder="Enter detail text"
/>
</div>
</div>
</div>
);
});
DetailCard.displayName = 'DetailCard';
// Form component to handle the details section
const UserEditForm = ({
values,
setFieldValue,
handleChange,
errors,
touched,
isSubmitting,
profilePicture
}: {
values: FormValues;
setFieldValue: (field: string, value: any) => void;
handleChange: (e: React.ChangeEvent<any>) => void;
errors: any;
touched: any;
isSubmitting: boolean;
profilePicture: {
error: string | undefined;
success: string;
isLoading: boolean;
localAvatar: File | null;
handleFileChange: (event: any) => Promise<void>;
};
}) => {
// Memoize template handlers
const templateHandlers = useMemo(() =>
Object.entries(DETAIL_TEMPLATES).reduce((acc, [key, template]) => ({
...acc,
[key]: () => {
const currentIds = new Set(Object.keys(values.details));
const newDetails = { ...values.details };
template.forEach((item) => {
if (!currentIds.has(item.id)) {
newDetails[item.id] = { ...item };
}
});
setFieldValue('details', newDetails);
}
}), {} as Record<string, () => void>)
, [values.details, setFieldValue]);
// Memoize detail handlers
const detailHandlers = useMemo(() => ({
handleDetailUpdate: (id: string, field: keyof DetailItem, value: string) => {
const newDetails = { ...values.details };
newDetails[id] = { ...newDetails[id], [field]: value };
setFieldValue('details', newDetails);
},
handleDetailRemove: (id: string) => {
const newDetails = { ...values.details };
delete newDetails[id];
setFieldValue('details', newDetails);
}
}), [values.details, setFieldValue]);
return (
<Form> <Form>
<div className="flex flex-col gap-0"> <div className="flex flex-col gap-0">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 mx-3 my-3 rounded-md"> <div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 mx-3 my-3 rounded-md">
@ -148,7 +317,7 @@ function UserEditGeneral() {
{touched.email && errors.email && ( {touched.email && errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email}</p> <p className="text-red-500 text-sm mt-1">{errors.email}</p>
)} )}
{values.email !== session.data.user.email && ( {values.email !== values.email && (
<div className="flex items-center space-x-2 mt-2 text-amber-600 bg-amber-50 p-2 rounded-md"> <div className="flex items-center space-x-2 mt-2 text-amber-600 bg-amber-50 p-2 rounded-md">
<AlertTriangle size={16} /> <AlertTriangle size={16} />
<span className="text-sm">You will be logged out after changing your email</span> <span className="text-sm">You will be logged out after changing your email</span>
@ -218,6 +387,99 @@ function UserEditGeneral() {
<p className="text-red-500 text-sm mt-1">{errors.bio}</p> <p className="text-red-500 text-sm mt-1">{errors.bio}</p>
)} )}
</div> </div>
<div className="space-y-4">
<div className="flex flex-col gap-3">
<div className="flex justify-between items-center">
<Label>Additional Details</Label>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => {
setFieldValue('details', {});
}}
>
Clear All
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newDetails = { ...values.details };
const id = `detail-${Date.now()}`;
newDetails[id] = {
id,
label: 'New Detail',
icon: '',
text: ''
};
setFieldValue('details', newDetails);
}}
>
Add Detail
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(DETAIL_TEMPLATES).map(([key, template]) => (
<Button
key={key}
type="button"
variant="secondary"
size="sm"
className="flex items-center gap-2"
onClick={() => {
const currentIds = new Set(Object.keys(values.details));
const newDetails = { ...values.details };
template.forEach((item) => {
if (!currentIds.has(item.id)) {
newDetails[item.id] = { ...item };
}
});
setFieldValue('details', newDetails);
}}
>
{key === 'general' && <Briefcase className="w-4 h-4" />}
{key === 'academic' && <GraduationCap className="w-4 h-4" />}
{key === 'professional' && <Building2 className="w-4 h-4" />}
Add {key.charAt(0).toUpperCase() + key.slice(1)}
</Button>
))}
</div>
</div>
<div className="space-y-3">
{Object.entries(values.details).map(([id, detail]) => (
<DetailCard
key={id}
id={id}
detail={detail}
onUpdate={(id, field, value) => {
const newDetails = { ...values.details };
newDetails[id] = { ...newDetails[id], [field]: value };
setFieldValue('details', newDetails);
}}
onRemove={(id) => {
const newDetails = { ...values.details };
delete newDetails[id];
setFieldValue('details', newDetails);
}}
onLabelChange={(id, newLabel) => {
const newDetails = { ...values.details };
newDetails[id] = { ...newDetails[id], label: newLabel };
setFieldValue('details', newDetails);
}}
/>
))}
</div>
</div>
</div> </div>
{/* Profile Picture Section */} {/* Profile Picture Section */}
@ -225,28 +487,28 @@ function UserEditGeneral() {
<div className="bg-gray-50/50 p-6 rounded-lg nice-shadow h-full"> <div className="bg-gray-50/50 p-6 rounded-lg nice-shadow h-full">
<div className="flex flex-col items-center space-y-6"> <div className="flex flex-col items-center space-y-6">
<Label className="font-bold">Profile Picture</Label> <Label className="font-bold">Profile Picture</Label>
{error && ( {profilePicture.error && (
<div className="flex items-center bg-red-200 rounded-md text-red-950 px-4 py-2 text-sm"> <div className="flex items-center bg-red-200 rounded-md text-red-950 px-4 py-2 text-sm">
<FileWarning size={16} className="mr-2" /> <FileWarning size={16} className="mr-2" />
<span className="font-semibold first-letter:uppercase">{error}</span> <span className="font-semibold first-letter:uppercase">{profilePicture.error}</span>
</div> </div>
)} )}
{success && ( {profilePicture.success && (
<div className="flex items-center bg-green-200 rounded-md text-green-950 px-4 py-2 text-sm"> <div className="flex items-center bg-green-200 rounded-md text-green-950 px-4 py-2 text-sm">
<Check size={16} className="mr-2" /> <Check size={16} className="mr-2" />
<span className="font-semibold first-letter:uppercase">{success}</span> <span className="font-semibold first-letter:uppercase">{profilePicture.success}</span>
</div> </div>
)} )}
{localAvatar ? ( {profilePicture.localAvatar ? (
<UserAvatar <UserAvatar
border="border-8" border="border-8"
width={120} width={120}
avatar_url={URL.createObjectURL(localAvatar)} avatar_url={URL.createObjectURL(profilePicture.localAvatar)}
/> />
) : ( ) : (
<UserAvatar border="border-8" width={120} /> <UserAvatar border="border-8" width={120} />
)} )}
{isLoading ? ( {profilePicture.isLoading ? (
<div className="font-bold animate-pulse antialiased bg-green-200 text-gray text-sm rounded-md px-4 py-2 flex items-center"> <div className="font-bold animate-pulse antialiased bg-green-200 text-gray text-sm rounded-md px-4 py-2 flex items-center">
<ArrowBigUpDash size={16} className="mr-2" /> <ArrowBigUpDash size={16} className="mr-2" />
<span>Uploading</span> <span>Uploading</span>
@ -258,7 +520,7 @@ function UserEditGeneral() {
id="fileInput" id="fileInput"
accept={SUPPORTED_FILES} accept={SUPPORTED_FILES}
className="hidden" className="hidden"
onChange={handleFileChange} onChange={profilePicture.handleFileChange}
/> />
<Button <Button
type="button" type="button"
@ -291,11 +553,129 @@ function UserEditGeneral() {
</div> </div>
</div> </div>
</Form> </Form>
);
};
function UserEditGeneral() {
const session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token;
const [localAvatar, setLocalAvatar] = React.useState(null) as any
const [isLoading, setIsLoading] = React.useState(false) as any
const [error, setError] = React.useState() as any
const [success, setSuccess] = React.useState('') as any
const [userData, setUserData] = useState<any>(null);
useEffect(() => {
const fetchUserData = async () => {
if (session?.data?.user?.id) {
try {
const data = await getUser(session.data.user.id, access_token);
setUserData(data);
} catch (error) {
console.error('Error fetching user data:', error);
setError('Failed to load user data');
}
}
};
fetchUserData();
}, [session?.data?.user?.id]);
const handleFileChange = async (event: any) => {
const file = event.target.files[0]
setLocalAvatar(file)
setIsLoading(true)
const res = await updateUserAvatar(session.data.user_uuid, file, access_token)
// wait for 1 second to show loading animation
await new Promise((r) => setTimeout(r, 1500))
if (res.success === false) {
setError(res.HTTPmessage)
} else {
setIsLoading(false)
setError('')
setSuccess('Avatar Updated')
}
}
const handleEmailChange = async (newEmail: string) => {
toast.success('Profile Updated Successfully', { duration: 4000 })
// Show message about logging in with new email
toast((t: any) => (
<div className="flex items-center gap-2">
<span>Please login again with your new email: {newEmail}</span>
</div>
), {
duration: 4000,
icon: '📧'
})
// Wait for 4 seconds before signing out
await new Promise(resolve => setTimeout(resolve, 4000))
signOut({ redirect: true, callbackUrl: getUriWithoutOrg('/') })
}
if (!userData) {
return (
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow p-8">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
</div>
</div>
);
}
return (
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow">
<Formik<FormValues>
enableReinitialize
initialValues={{
username: userData.username,
first_name: userData.first_name,
last_name: userData.last_name,
email: userData.email,
bio: userData.bio || '',
details: userData.details || {},
}}
validationSchema={validationSchema}
onSubmit={(values, { setSubmitting }) => {
const isEmailChanged = values.email !== userData.email
const loadingToast = toast.loading('Updating profile...')
setTimeout(() => {
setSubmitting(false)
updateProfile(values, userData.id, access_token)
.then(() => {
toast.dismiss(loadingToast)
if (isEmailChanged) {
handleEmailChange(values.email)
} else {
toast.success('Profile Updated Successfully')
}
// Refresh user data after successful update
getUser(userData.id, access_token).then(setUserData);
})
.catch(() => {
toast.error('Failed to update profile', { id: loadingToast })
})
}, 400)
}}
>
{(formikProps) => (
<UserEditForm
{...formikProps}
profilePicture={{
error,
success,
isLoading,
localAvatar,
handleFileChange
}}
/>
)} )}
</Formik> </Formik>
)}
</div> </div>
) );
} }
export default UserEditGeneral export default UserEditGeneral

View file

@ -0,0 +1,12 @@
import React from 'react'
import UserProfileBuilder from './UserProfileBuilder'
function UserProfile() {
return (
<div>
<UserProfileBuilder />
</div>
)
}
export default UserProfile

View file

@ -7,6 +7,7 @@ import useSWR from 'swr'
import { getOrgCourses } from '@services/courses/courses' import { getOrgCourses } from '@services/courses/courses'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import CourseThumbnailLanding from '@components/Objects/Thumbnails/CourseThumbnailLanding' import CourseThumbnailLanding from '@components/Objects/Thumbnails/CourseThumbnailLanding'
import UserAvatar from '@components/Objects/UserAvatar'
interface LandingCustomProps { interface LandingCustomProps {
landing: { landing: {
@ -183,11 +184,21 @@ function LandingCustom({ landing, orgslug }: LandingCustomProps) {
{section.people.map((person, index) => ( {section.people.map((person, index) => (
<div key={index} className="w-[140px] flex flex-col items-center"> <div key={index} className="w-[140px] flex flex-col items-center">
<div className="w-24 h-24 mb-4"> <div className="w-24 h-24 mb-4">
{person.username ? (
<UserAvatar
username={person.username}
width={96}
rounded="rounded-full"
border="border-4"
showProfilePopup
/>
) : (
<img <img
src={person.image_url} src={person.image_url}
alt={person.name} alt={person.name}
className="w-full h-full rounded-full object-cover border-4 border-white nice-shadow" className="w-full h-full rounded-full object-cover border-4 border-white nice-shadow"
/> />
)}
</div> </div>
<h3 className="text-lg font-semibold text-center text-gray-900">{person.name}</h3> <h3 className="text-lg font-semibold text-center text-gray-900">{person.name}</h3>
<p className="text-sm text-center text-gray-600 mt-1">{person.description}</p> <p className="text-sm text-center text-gray-600 mt-1">{person.description}</p>

View file

@ -31,6 +31,7 @@ import Table from '@tiptap/extension-table'
import TableHeader from '@tiptap/extension-table-header' import TableHeader from '@tiptap/extension-table-header'
import TableRow from '@tiptap/extension-table-row' import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell' import TableCell from '@tiptap/extension-table-cell'
import UserBlock from '@components/Objects/Editor/Extensions/Users/UserBlock'
interface Editor { interface Editor {
content: string content: string
@ -104,6 +105,10 @@ function Canva(props: Editor) {
editable: isEditable, editable: isEditable,
activity: props.activity, activity: props.activity,
}), }),
UserBlock.configure({
editable: isEditable,
activity: props.activity,
}),
Table.configure({ Table.configure({
resizable: true, resizable: true,
}), }),

View file

@ -18,6 +18,7 @@ import { useContributorStatus } from '../../../../hooks/useContributorStatus'
interface Author { interface Author {
user: { user: {
id: string
user_uuid: string user_uuid: string
avatar_image: string avatar_image: string
first_name: string first_name: string
@ -66,6 +67,8 @@ const AuthorInfo = ({ author, isMobile }: { author: Author, isMobile: boolean })
avatar_url={author.user.avatar_image ? getUserAvatarMediaDirectory(author.user.user_uuid, author.user.avatar_image) : ''} avatar_url={author.user.avatar_image ? getUserAvatarMediaDirectory(author.user.user_uuid, author.user.avatar_image) : ''}
predefined_avatar={author.user.avatar_image ? undefined : 'empty'} predefined_avatar={author.user.avatar_image ? undefined : 'empty'}
width={isMobile ? 60 : 100} width={isMobile ? 60 : 100}
showProfilePopup={true}
userId={author.user.user_uuid}
/> />
<div className="md:-space-y-2"> <div className="md:-space-y-2">
<div className="text-[12px] text-neutral-400 font-semibold">Author</div> <div className="text-[12px] text-neutral-400 font-semibold">Author</div>
@ -115,6 +118,8 @@ const MultipleAuthors = ({ authors, isMobile }: { authors: Author[], isMobile: b
avatar_url={author.user.avatar_image ? getUserAvatarMediaDirectory(author.user.user_uuid, author.user.avatar_image) : ''} avatar_url={author.user.avatar_image ? getUserAvatarMediaDirectory(author.user.user_uuid, author.user.avatar_image) : ''}
predefined_avatar={author.user.avatar_image ? undefined : 'empty'} predefined_avatar={author.user.avatar_image ? undefined : 'empty'}
width={avatarSize} width={avatarSize}
showProfilePopup={true}
userId={author.user.id}
/> />
</div> </div>
</div> </div>

View file

@ -53,6 +53,7 @@ import Badges from './Extensions/Badges/Badges'
import Buttons from './Extensions/Buttons/Buttons' import Buttons from './Extensions/Buttons/Buttons'
import { useMediaQuery } from 'usehooks-ts' import { useMediaQuery } from 'usehooks-ts'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import UserBlock from './Extensions/Users/UserBlock'
interface Editor { interface Editor {
content: string content: string
@ -140,6 +141,10 @@ function Editor(props: Editor) {
editable: true, editable: true,
activity: props.activity, activity: props.activity,
}), }),
UserBlock.configure({
editable: true,
activity: props.activity,
}),
Table.configure({ Table.configure({
resizable: true, resizable: true,
}), }),

View file

@ -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)
},
})

View file

@ -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 <User className="w-4 h-4 text-gray-600" />
return <IconElement className="w-4 h-4 text-gray-600" />
}
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<UserData | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<NodeViewWrapper className="block-user">
<div className="bg-gray-50 rounded-lg p-6 border border-dashed border-gray-200">
<form onSubmit={handleUsernameSubmit} className="space-y-4">
<div>
<Label htmlFor="username">Username</Label>
<div className="flex gap-2 mt-2">
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username"
className="flex-1"
/>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Load User'
)}
</Button>
</div>
{error && (
<p className="text-sm text-red-500 mt-2">{error}</p>
)}
</div>
</form>
</div>
</NodeViewWrapper>
)
}
if (isLoading) {
return (
<NodeViewWrapper className="block-user">
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
</NodeViewWrapper>
)
}
if (error) {
return (
<NodeViewWrapper className="block-user">
<div className="bg-red-50 text-red-500 p-4 rounded-lg">
{error}
</div>
</NodeViewWrapper>
)
}
if (!userData) {
return (
<NodeViewWrapper className="block-user">
<div className="bg-gray-50 rounded-lg p-6 border border-dashed border-gray-200">
<div className="flex items-center gap-2 text-gray-500">
<User className="w-5 h-5" />
<span>No user selected</span>
</div>
</div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper className="block-user">
<div className="bg-white rounded-lg nice-shadow overflow-hidden">
{/* Header with Avatar and Name */}
<div className="relative">
{/* Background gradient */}
<div className="absolute inset-0 bg-gradient-to-b from-gray-100/30 to-transparent h-28 rounded-t-lg" />
{/* Content */}
<div className="relative px-5 pt-5 pb-4">
<div className="flex items-start gap-4">
{/* Avatar */}
<div className="flex-shrink-0">
<div className="rounded-full">
<UserAvatar
width={80}
avatar_url={userData.avatar_image ? getUserAvatarMediaDirectory(userData.user_uuid, userData.avatar_image) : ''}
predefined_avatar={userData.avatar_image ? undefined : 'empty'}
userId={userData.id}
showProfilePopup
rounded="rounded-full"
/>
</div>
</div>
{/* Name, Bio, and Button */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<h4 className="font-semibold text-gray-900 truncate">
{userData.first_name} {userData.last_name}
</h4>
{userData.username && (
<Badge variant="outline" className="text-xs font-normal text-gray-500 px-2 truncate">
@{userData.username}
</Badge>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-600 hover:text-gray-900 flex-shrink-0"
onClick={() => userData.username && router.push(`/user/${userData.username}`)}
>
<ExternalLink className="w-4 h-4" />
</Button>
</div>
{userData.bio && (
<p className="text-sm text-gray-500 mt-1.5 line-clamp-4 leading-normal">
{userData.bio}
</p>
)}
</div>
</div>
</div>
</div>
{/* Details */}
{userData.details && Object.values(userData.details).length > 0 && (
<div className="px-5 pb-4 space-y-2.5 border-t border-gray-100 pt-3.5">
{Object.values(userData.details).map((detail) => (
<div key={detail.id} className="flex items-center gap-2.5">
<IconComponent iconName={detail.icon} />
<div className="flex flex-col">
<span className="text-xs text-gray-500">{detail.label}</span>
<span className="text-sm text-gray-700">{detail.text}</span>
</div>
</div>
))}
</div>
)}
</div>
</NodeViewWrapper>
)
}
export default UserBlockComponent

View file

@ -28,6 +28,7 @@ import {
Table, Table,
Tag, Tag,
Tags, Tags,
User,
Video, Video,
} from 'lucide-react' } from 'lucide-react'
import { SiYoutube } from '@icons-pack/react-simple-icons' import { SiYoutube } from '@icons-pack/react-simple-icons'
@ -299,6 +300,13 @@ export const ToolbarButtons = ({ editor, props }: any) => {
<MousePointerClick size={15} /> <MousePointerClick size={15} />
</ToolBtn> </ToolBtn>
</ToolTip> </ToolTip>
<ToolTip content={'User'}>
<ToolBtn
onClick={() => editor.chain().focus().insertContent({ type: 'blockUser' }).run()}
>
<User size={15} />
</ToolBtn>
</ToolTip>
</ToolButtonsWrapper> </ToolButtonsWrapper>
) )
} }

View file

@ -35,7 +35,30 @@ export const SearchBar: React.FC<SearchBarProps> = ({ orgslug, className = '', i
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null); const searchRef = useRef<HTMLDivElement>(null);
const session = useLHSession() as any; const session = useLHSession() as any;
const debouncedSearch = useDebounce(searchQuery, 300);
const debouncedSearchFunction = useDebounce(async (query: string) => {
if (query.trim().length === 0) {
setCourses([]);
return;
}
setIsLoading(true);
try {
const results = await searchOrgCourses(
orgslug,
query,
1,
5,
null,
session?.data?.tokens?.access_token
);
setCourses(results);
} catch (error) {
console.error('Error searching courses:', error);
setCourses([]);
}
setIsLoading(false);
}, 300);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@ -49,31 +72,8 @@ export const SearchBar: React.FC<SearchBarProps> = ({ orgslug, className = '', i
}, []); }, []);
useEffect(() => { useEffect(() => {
const fetchCourses = async () => { debouncedSearchFunction(searchQuery);
if (debouncedSearch.trim().length === 0) { }, [searchQuery, debouncedSearchFunction]);
setCourses([]);
return;
}
setIsLoading(true);
try {
const results = await searchOrgCourses(
orgslug,
debouncedSearch,
1,
5,
null,
session?.data?.tokens?.access_token
);
setCourses(results);
} catch (error) {
console.error('Error searching courses:', error);
setCourses([]);
}
setIsLoading(false);
};
fetchCourses();
}, [debouncedSearch, orgslug, session?.data?.tokens?.access_token]);
const handleSearchFocus = () => { const handleSearchFocus = () => {
if (searchQuery.trim().length > 0) { if (searchQuery.trim().length > 0) {

View file

@ -1,8 +1,10 @@
import React from 'react' import React, { useEffect, useState } from 'react'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { getUserAvatarMediaDirectory } from '@services/media/media' import { getUserAvatarMediaDirectory } from '@services/media/media'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import UserProfilePopup from './UserProfilePopup'
import { getUserByUsername } from '@services/users/users'
type UserAvatarProps = { type UserAvatarProps = {
width?: number width?: number
@ -13,11 +15,30 @@ type UserAvatarProps = {
borderColor?: string borderColor?: string
predefined_avatar?: 'ai' | 'empty' predefined_avatar?: 'ai' | 'empty'
backgroundColor?: 'bg-white' | 'bg-gray-100' backgroundColor?: 'bg-white' | 'bg-gray-100'
showProfilePopup?: boolean
userId?: string
username?: string
} }
function UserAvatar(props: UserAvatarProps) { function UserAvatar(props: UserAvatarProps) {
const session = useLHSession() as any const session = useLHSession() as any
const params = useParams() as any const params = useParams() as any
const [userData, setUserData] = useState<any>(null)
useEffect(() => {
const fetchUserByUsername = async () => {
if (props.username) {
try {
const data = await getUserByUsername(props.username)
setUserData(data)
} catch (error) {
console.error('Error fetching user by username:', error)
}
}
}
fetchUserByUsername()
}, [props.username])
const isExternalUrl = (url: string): boolean => { const isExternalUrl = (url: string): boolean => {
return url.startsWith('http://') || url.startsWith('https://') return url.startsWith('http://') || url.startsWith('https://')
@ -54,7 +75,18 @@ function UserAvatar(props: UserAvatarProps) {
return props.avatar_url return props.avatar_url
} }
// If user has an avatar in session // If we have user data from username fetch
if (userData?.avatar_image) {
const avatarUrl = userData.avatar_image
// If it's an external URL (e.g., from Google, Facebook, etc.), use it directly
if (isExternalUrl(avatarUrl)) {
return avatarUrl
}
// Otherwise, get the local avatar URL
return getUserAvatarMediaDirectory(userData.user_uuid, avatarUrl)
}
// If user has an avatar in session (only if session exists)
if (session?.data?.user?.avatar_image) { if (session?.data?.user?.avatar_image) {
const avatarUrl = session.data.user.avatar_image const avatarUrl = session.data.user.avatar_image
// If it's an external URL (e.g., from Google, Facebook, etc.), use it directly // If it's an external URL (e.g., from Google, Facebook, etc.), use it directly
@ -69,7 +101,7 @@ function UserAvatar(props: UserAvatarProps) {
return getUriWithOrg(params.orgslug, '/empty_avatar.png') return getUriWithOrg(params.orgslug, '/empty_avatar.png')
} }
return ( const avatarImage = (
<img <img
alt="User Avatar" alt="User Avatar"
width={props.width ?? 50} width={props.width ?? 50}
@ -88,6 +120,16 @@ function UserAvatar(props: UserAvatarProps) {
`} `}
/> />
) )
if (props.showProfilePopup && (props.userId || (userData?.id))) {
return (
<UserProfilePopup userId={props.userId || userData?.id}>
{avatarImage}
</UserProfilePopup>
)
}
return avatarImage
} }
export default UserAvatar export default UserAvatar

View file

@ -0,0 +1,159 @@
import React, { useEffect, useState } from 'react'
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { MapPin, Building2, Globe, Briefcase, GraduationCap, Link, Users, Calendar, Lightbulb, Loader2, ExternalLink } from 'lucide-react'
import { getUser } from '@services/users/users'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useRouter } from 'next/navigation'
type UserProfilePopupProps = {
children: React.ReactNode
userId: string
}
type UserData = {
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 ICON_MAP = {
'briefcase': Briefcase,
'graduation-cap': GraduationCap,
'map-pin': MapPin,
'building-2': Building2,
'speciality': Lightbulb,
'globe': Globe,
'link': Link,
'users': Users,
'calendar': Calendar,
} as const
const UserProfilePopup = ({ children, userId }: UserProfilePopupProps) => {
const session = useLHSession() as any
const router = useRouter()
const [userData, setUserData] = useState<UserData | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchUserData = async () => {
if (!userId) return
setIsLoading(true)
setError(null)
try {
const data = await getUser(userId, session?.data?.tokens?.access_token)
setUserData(data)
} catch (err) {
setError('Failed to load user data')
console.error('Error fetching user data:', err)
} finally {
setIsLoading(false)
}
}
fetchUserData()
}, [userId, session?.data?.tokens?.access_token])
const IconComponent = ({ iconName }: { iconName: string }) => {
const IconElement = ICON_MAP[iconName as keyof typeof ICON_MAP]
if (!IconElement) return null
return <IconElement className="w-4 h-4 text-gray-500" />
}
return (
<HoverCard openDelay={100} closeDelay={150}>
<HoverCardTrigger asChild>
{children}
</HoverCardTrigger>
<HoverCardContent className="w-96 bg-white/95 backdrop-blur-md p-0 nice-shadow">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : error ? (
<div className="text-sm text-red-500 p-4">{error}</div>
) : userData ? (
<div>
{/* Header with Avatar and Name */}
<div className="relative">
{/* Background gradient */}
<div className="absolute inset-0 bg-gradient-to-b from-gray-100/30 to-transparent h-28 rounded-t-lg" />
{/* Content */}
<div className="relative px-5 pt-5 pb-4">
<div className="flex items-start gap-4">
{/* Avatar */}
<div className="flex-shrink-0">
<div className="rounded-full">
{children}
</div>
</div>
{/* Name, Bio, and Button */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<h4 className="font-semibold text-gray-900 truncate">
{userData.first_name} {userData.last_name}
</h4>
{userData.username && (
<Badge variant="outline" className="text-xs font-normal text-gray-500 px-2 truncate">
@{userData.username}
</Badge>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-600 hover:text-gray-900 flex-shrink-0"
onClick={() => userData.username && router.push(`/user/${userData.username}`)}
>
<ExternalLink className="w-4 h-4" />
</Button>
</div>
{userData.bio && (
<p className="text-sm text-gray-500 mt-1.5 line-clamp-4 leading-normal">
{userData.bio}
</p>
)}
</div>
</div>
</div>
</div>
{/* Details */}
{userData.details && Object.values(userData.details).length > 0 && (
<div className="px-5 pb-4 space-y-2.5 border-t border-gray-100 pt-3.5">
{Object.values(userData.details).map((detail) => (
<div key={detail.id} className="flex items-center gap-2.5">
<IconComponent iconName={detail.icon} />
<div className="flex flex-col">
<span className="text-xs text-gray-500">{detail.label}</span>
<span className="text-sm text-gray-700">{detail.text}</span>
</div>
</div>
))}
</div>
)}
</div>
) : null}
</HoverCardContent>
</HoverCard>
)
}
export default UserProfilePopup

View file

@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none hover:cursor-pointer cursor-pointer [&_svg]:size-4 [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {

View file

@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View file

@ -1,17 +1,26 @@
import { useState, useEffect } from 'react'; import { useEffect, useRef } from 'react';
export function useDebounce<T>(value: T, delay: number): T { export function useDebounce<T extends (...args: any[]) => any>(
const [debouncedValue, setDebouncedValue] = useState<T>(value); callback: T,
delay: number
): T {
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
useEffect(() => { useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => { return () => {
clearTimeout(handler); if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
}; };
}, [value, delay]); }, []);
return debouncedValue; return ((...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
}) as T;
} }

View file

@ -1,9 +1,9 @@
export async function register() { export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') { if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config'); // Node.js specific instrumentation
} }
if (process.env.NEXT_RUNTIME === 'edge') { if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config'); // Edge runtime specific instrumentation
} }
} }

View file

@ -17,46 +17,3 @@ const nextConfig = {
} }
module.exports = nextConfig module.exports = nextConfig
// Injected content via Sentry wizard below
const { withSentryConfig } = require("@sentry/nextjs");
module.exports = withSentryConfig(
module.exports,
{
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options
org: "learnhouse",
project: "learnhouse-web",
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: "/monitoring",
// Hides source maps from generated client bundles
hideSourceMaps: true,
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
// See the following for more information:
// https://docs.sentry.io/product/crons/
// https://vercel.com/docs/cron-jobs
automaticVercelMonitors: true,
}
);

View file

@ -20,6 +20,7 @@
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-form": "^0.0.3", "@radix-ui/react-form": "^0.0.3",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.1.6",
@ -29,21 +30,19 @@
"@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@sentry/nextjs": "^9.5.0",
"@sentry/utils": "^8.55.0",
"@stitches/react": "^1.2.8", "@stitches/react": "^1.2.8",
"@tanstack/react-table": "^8.21.2", "@tanstack/react-table": "^8.21.2",
"@tiptap/core": "^2.11.5", "@tiptap/core": "^2.11.6",
"@tiptap/extension-code-block-lowlight": "^2.11.5", "@tiptap/extension-code-block-lowlight": "^2.11.6",
"@tiptap/extension-table": "^2.11.5", "@tiptap/extension-table": "^2.11.6",
"@tiptap/extension-table-cell": "^2.11.5", "@tiptap/extension-table-cell": "^2.11.6",
"@tiptap/extension-table-header": "^2.11.5", "@tiptap/extension-table-header": "^2.11.6",
"@tiptap/extension-table-row": "^2.11.5", "@tiptap/extension-table-row": "^2.11.6",
"@tiptap/extension-youtube": "^2.11.5", "@tiptap/extension-youtube": "^2.11.6",
"@tiptap/html": "^2.11.5", "@tiptap/html": "^2.11.6",
"@tiptap/pm": "^2.11.5", "@tiptap/pm": "^2.11.6",
"@tiptap/react": "^2.11.5", "@tiptap/react": "^2.11.6",
"@tiptap/starter-kit": "^2.11.5", "@tiptap/starter-kit": "^2.11.6",
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
"@types/randomcolor": "^0.5.9", "@types/randomcolor": "^0.5.9",
"avvvatars-react": "^0.4.2", "avvvatars-react": "^0.4.2",
@ -52,15 +51,15 @@
"currency-codes": "^2.2.0", "currency-codes": "^2.2.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"emblor": "^1.4.7", "emblor": "^1.4.8",
"formik": "^2.4.6", "formik": "^2.4.6",
"framer-motion": "^12.4.12", "framer-motion": "^12.6.2",
"get-youtube-id": "^1.0.1", "get-youtube-id": "^1.0.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.21", "katex": "^0.16.21",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"next": "15.2.3", "next": "15.2.4",
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"nextjs-toploader": "^1.6.12", "nextjs-toploader": "^1.6.12",
"prosemirror-state": "^1.4.3", "prosemirror-state": "^1.4.3",
@ -75,7 +74,7 @@
"react-youtube": "^10.1.0", "react-youtube": "^10.1.0",
"require-in-the-middle": "^7.5.2", "require-in-the-middle": "^7.5.2",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"styled-components": "^6.1.15", "styled-components": "^6.1.16",
"swr": "^2.3.3", "swr": "^2.3.3",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@ -85,7 +84,7 @@
"yup": "^1.6.1" "yup": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.12", "@tailwindcss/postcss": "^4.0.17",
"@types/node": "20.12.2", "@types/node": "20.12.2",
"@types/react": "19.0.10", "@types/react": "19.0.10",
"@types/react-dom": "19.0.4", "@types/react-dom": "19.0.4",
@ -97,7 +96,7 @@
"eslint-config-next": "15.2.1", "eslint-config-next": "15.2.1",
"eslint-plugin-unused-imports": "^3.2.0", "eslint-plugin-unused-imports": "^3.2.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"tailwindcss": "^4.0.12", "tailwindcss": "^4.0.17",
"typescript": "5.4.4" "typescript": "5.4.4"
}, },
"pnpm": { "pnpm": {

3954
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,31 +0,0 @@
// This file configures the initialization of Sentry on the client.
// The config you add here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs'
Sentry.init({
dsn: process.env.SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.5,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
replaysOnErrorSampleRate: 1.0,
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
enabled: process.env.NODE_ENV != 'development',
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
integrations: [
Sentry.replayIntegration({
// Additional Replay configuration goes in here, for example:
maskAllText: true,
blockAllMedia: true,
}),
],
})

View file

@ -1,18 +0,0 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs'
Sentry.init({
dsn: process.env.SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.5,
enabled: process.env.NODE_ENV != 'development',
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
})

View file

@ -1,4 +0,0 @@
defaults.url=https://sentry.io/
defaults.org=learnhouse
defaults.project=learnhouse-web
cli.executable=node_modules/@sentry/cli/bin/sentry-cli

View file

@ -1,20 +0,0 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs'
Sentry.init({
dsn: process.env.SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.5,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
enabled: process.env.NODE_ENV != 'development',
// Uncomment the line below to enable Spotlight (https://spotlightjs.com)
// spotlight: process.env.NODE_ENV === 'development',
})

View file

@ -2,6 +2,7 @@ import { getAPIUrl } from '@services/config/config'
import { import {
RequestBodyWithAuthHeader, RequestBodyWithAuthHeader,
errorHandling, errorHandling,
getResponseMetadata,
} from '@services/utils/ts/requests' } from '@services/utils/ts/requests'
/* /*
@ -18,6 +19,6 @@ export async function updateProfile(
`${getAPIUrl()}users/` + user_id, `${getAPIUrl()}users/` + user_id,
RequestBodyWithAuthHeader('PUT', data, null, access_token) RequestBodyWithAuthHeader('PUT', data, null, access_token)
) )
const res = await errorHandling(result) const res = await getResponseMetadata(result)
return res return res
} }

View file

@ -2,19 +2,37 @@ import { getAPIUrl } from '@services/config/config'
import { import {
RequestBody, RequestBody,
RequestBodyFormWithAuthHeader, RequestBodyFormWithAuthHeader,
RequestBodyWithAuthHeader,
errorHandling, errorHandling,
getResponseMetadata, getResponseMetadata,
} from '@services/utils/ts/requests' } 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( const result = await fetch(
`${getAPIUrl()}users/user_id/${user_id}`, `${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) const res = await errorHandling(result)
return res return res
} }
export async function getUserByUsername(username: string, access_token?: string) {
const result = await fetch(
`${getAPIUrl()}users/username/${username}`,
access_token ? RequestBodyWithAuthHeader('GET', null, null, access_token) : RequestBody('GET', null, null)
)
const res = await errorHandling(result)
return res
}
export async function getCoursesByUser(user_id: string, access_token?: string) {
const result = await fetch(
`${getAPIUrl()}users/${user_id}/courses`,
access_token ? RequestBodyWithAuthHeader('GET', null, null, access_token) : RequestBody('GET', null, null)
)
const res = await getResponseMetadata(result)
return res
}
export async function updateUserAvatar( export async function updateUserAvatar(
user_uuid: any, user_uuid: any,
avatar_file: any, avatar_file: any,