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 pydantic import BaseModel, EmailStr
from sqlmodel import Field, SQLModel
from sqlalchemy import JSON, Column
from src.db.roles import RoleRead
@ -12,7 +13,8 @@ class UserBase(SQLModel):
email: EmailStr
avatar_image: 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):
first_name: str = ""
@ -27,6 +29,8 @@ class UserUpdate(UserBase):
email: str
avatar_image: Optional[str] = ""
bio: Optional[str] = ""
details: Optional[dict] = {}
profile: Optional[dict] = {}
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 pydantic import EmailStr
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.security.auth import get_current_user
from src.core.events.database import get_db_session
from src.db.courses.courses import CourseRead
from src.db.users import (
PublicUser,
@ -28,10 +29,12 @@ from src.services.users.users import (
get_user_session,
read_user_by_id,
read_user_by_uuid,
read_user_by_username,
update_user,
update_user_avatar,
update_user_password,
)
from src.services.courses.courses import get_user_courses
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)
@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"])
async def api_update_user(
*,
@ -262,3 +279,26 @@ async def api_delete_user(
Delete User
"""
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"}
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 ##

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)
# Check if invite code contains UserGroup
if inviteCode.get("usergroup_id"):
if inviteCode.get("usergroup_id"): # type: ignore
# Add user to UserGroup
await add_users_to_usergroup(
request,
db_session,
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),
)
@ -416,8 +416,30 @@ async def read_user_by_uuid(
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)
@ -563,7 +585,7 @@ async def rbac_check(
user_uuid: str,
db_session: Session,
):
if action == "create":
if action == "create" or action == "read":
if current_user.id == 0: # if user is anonymous
return True
else:

View file

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

View file

@ -22,6 +22,7 @@ import CourseActionsMobile from '@components/Objects/Courses/CourseActions/Cours
const CourseClient = (props: any) => {
const [learnings, setLearnings] = useState<any>([])
const [expandedChapters, setExpandedChapters] = useState<{[key: string]: boolean}>({})
const courseuuid = props.courseuuid
const orgslug = props.orgslug
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="course_metadata_left w-full md:basis-3/4 space-y-2">
<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">
<p className="py-5 px-5 whitespace-pre-wrap">{course.about}</p>
<div className="">
<p className="py-5 whitespace-pre-wrap">{course.about}</p>
</div>
{learnings.length > 0 && learnings[0]?.text !== 'null' && (
@ -164,117 +165,113 @@ const CourseClient = (props: any) => {
<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">
{course.chapters.map((chapter: any) => {
const isExpanded = expandedChapters[chapter.chapter_uuid] ?? true; // Default to expanded
return (
<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>
<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
</p>
<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">
{chapter.activities.length} Activities
</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 className="py-3">
{chapter.activities.map((activity: any) => {
return (
<div key={activity.activity_uuid} className="activity-container">
<p className="flex text-md"></p>
<div className="flex space-x-1 py-2 px-4 items-center">
<div className="courseicon items-center flex space-x-2 text-neutral-400">
{activity.activity_type ===
'TYPE_DYNAMIC' && (
<div className="bg-gray-100 px-2 py-2 rounded-full">
<Sparkles
className="text-gray-400"
size={13}
/>
</div>
)}
{activity.activity_type === 'TYPE_VIDEO' && (
<div className="bg-gray-100 px-2 py-2 rounded-full">
<Video
className="text-gray-400"
size={13}
/>
</div>
)}
{activity.activity_type ===
'TYPE_DOCUMENT' && (
<div className="bg-gray-100 px-2 py-2 rounded-full">
<File
className="text-gray-400"
size={13}
/>
</div>
)}
{activity.activity_type ===
'TYPE_ASSIGNMENT' && (
<div className="bg-gray-100 px-2 py-2 rounded-full">
<Backpack
className="text-gray-400"
size={13}
/>
</div>
)}
</div>
<Link
className="flex font-semibold grow pl-2 text-neutral-500"
href={
getUriWithOrg(orgslug, '') +
`/course/${courseuuid}/activity/${activity.activity_uuid.replace(
'activity_',
''
)}`
}
rel="noopener noreferrer"
prefetch={false}
>
<p>{activity.name}</p>
</Link>
<div className="flex ">
{activity.activity_type ===
'TYPE_DYNAMIC' && (
<div>
<Link
className="flex grow pl-2 text-gray-500"
href={
getUriWithOrg(orgslug, '') +
`/course/${courseuuid}/activity/${activity.activity_uuid.replace(
'activity_',
''
)}`
}
rel="noopener noreferrer"
prefetch={false}
>
<div className="text-xs bg-gray-100 text-gray-400 font-bold px-2 py-1 rounded-full flex space-x-1 items-center">
<p>Page</p>
<ArrowRight size={13} />
</div>
</Link>
</div>
)}
{activity.activity_type === 'TYPE_VIDEO' && (
<div>
<Link
className="flex grow pl-2 text-gray-500"
href={
getUriWithOrg(orgslug, '') +
`/course/${courseuuid}/activity/${activity.activity_uuid.replace(
'activity_',
''
)}`
}
rel="noopener noreferrer"
prefetch={false}
>
<div className="text-xs bg-gray-100 text-gray-400 font-bold px-2 py-1 rounded-full flex space-x-1 items-center">
<p>Video</p>
<ArrowRight size={13} />
<div className={`py-3 transition-all duration-200 ${isExpanded ? 'block' : 'hidden'}`}>
<div className="py-3">
{chapter.activities.map((activity: any) => {
return (
<div key={activity.activity_uuid} className="activity-container">
<p className="flex text-md"></p>
<div className="flex space-x-1 py-2 px-4 items-center">
<div className="courseicon items-center flex space-x-2 text-neutral-400">
{activity.activity_type ===
'TYPE_DYNAMIC' && (
<div className="bg-gray-100 px-2 py-2 rounded-full">
<Sparkles
className="text-gray-400"
size={13}
/>
</div>
</Link>
</div>
)}
{activity.activity_type ===
'TYPE_DOCUMENT' && (
)}
{activity.activity_type === 'TYPE_VIDEO' && (
<div className="bg-gray-100 px-2 py-2 rounded-full">
<Video
className="text-gray-400"
size={13}
/>
</div>
)}
{activity.activity_type ===
'TYPE_DOCUMENT' && (
<div className="bg-gray-100 px-2 py-2 rounded-full">
<File
className="text-gray-400"
size={13}
/>
</div>
)}
{activity.activity_type ===
'TYPE_ASSIGNMENT' && (
<div className="bg-gray-100 px-2 py-2 rounded-full">
<Backpack
className="text-gray-400"
size={13}
/>
</div>
)}
</div>
<Link
className="flex font-semibold grow pl-2 text-neutral-500"
href={
getUriWithOrg(orgslug, '') +
`/course/${courseuuid}/activity/${activity.activity_uuid.replace(
'activity_',
''
)}`
}
rel="noopener noreferrer"
prefetch={false}
>
<p>{activity.name}</p>
</Link>
<div className="flex ">
{activity.activity_type ===
'TYPE_DYNAMIC' && (
<div>
<Link
className="flex grow pl-2 text-gray-500"
href={
getUriWithOrg(orgslug, '') +
`/course/${courseuuid}/activity/${activity.activity_uuid.replace(
'activity_',
''
)}`
}
rel="noopener noreferrer"
prefetch={false}
>
<div className="text-xs bg-gray-100 text-gray-400 font-bold px-2 py-1 rounded-full flex space-x-1 items-center">
<p>Page</p>
<ArrowRight size={13} />
</div>
</Link>
</div>
)}
{activity.activity_type === 'TYPE_VIDEO' && (
<div>
<Link
className="flex grow pl-2 text-gray-500"
@ -289,39 +286,62 @@ const CourseClient = (props: any) => {
prefetch={false}
>
<div className="text-xs bg-gray-100 text-gray-400 font-bold px-2 py-1 rounded-full flex space-x-1 items-center">
<p>Document</p>
<ArrowRight size={13} />
</div>
</Link>
</div>
)}
{activity.activity_type ===
'TYPE_ASSIGNMENT' && (
<div>
<Link
className="flex grow pl-2 text-gray-500"
href={
getUriWithOrg(orgslug, '') +
`/course/${courseuuid}/activity/${activity.activity_uuid.replace(
'activity_',
''
)}`
}
rel="noopener noreferrer"
prefetch={false}
>
<div className="text-xs bg-gray-100 text-gray-400 font-bold px-2 py-1 rounded-full flex space-x-1 items-center">
<p>Assignment</p>
<p>Video</p>
<ArrowRight size={13} />
</div>
</Link>
</div>
)}
{activity.activity_type ===
'TYPE_DOCUMENT' && (
<div>
<Link
className="flex grow pl-2 text-gray-500"
href={
getUriWithOrg(orgslug, '') +
`/course/${courseuuid}/activity/${activity.activity_uuid.replace(
'activity_',
''
)}`
}
rel="noopener noreferrer"
prefetch={false}
>
<div className="text-xs bg-gray-100 text-gray-400 font-bold px-2 py-1 rounded-full flex space-x-1 items-center">
<p>Document</p>
<ArrowRight size={13} />
</div>
</Link>
</div>
)}
{activity.activity_type ===
'TYPE_ASSIGNMENT' && (
<div>
<Link
className="flex grow pl-2 text-gray-500"
href={
getUriWithOrg(orgslug, '') +
`/course/${courseuuid}/activity/${activity.activity_uuid.replace(
'activity_',
''
)}`
}
rel="noopener noreferrer"
prefetch={false}
>
<div className="text-xs bg-gray-100 text-gray-400 font-bold px-2 py-1 rounded-full flex space-x-1 items-center">
<p>Assignment</p>
<ArrowRight size={13} />
</div>
</Link>
</div>
)}
</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])
return (
<div className="h-full w-full bg-[#f8f8f8]">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] nice-shadow">
<div className="h-full w-full bg-[#f8f8f8] flex flex-col">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] nice-shadow flex-shrink-0">
<BreadCrumbs type="org"></BreadCrumbs>
<div className="my-2 py-2">
<div className="w-100 flex flex-col space-y-1">
@ -99,12 +99,13 @@ function OrgPage(props: { params: Promise<OrgParams> }) {
))}
</div>
</div>
<div className="h-6"></div>
<div className="h-6 flex-shrink-0"></div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
className="flex-1 overflow-y-auto"
>
{params.subpage == 'general' ? <OrgEditGeneral /> : ''}
{params.subpage == 'previews' ? <OrgEditImages /> : ''}

View file

@ -69,7 +69,7 @@ function PaymentsPage(props: { params: Promise<PaymentsParams> }) {
return (
<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" />
<div className="my-2 py-2">
<div className="w-100 flex flex-col space-y-1">
@ -102,7 +102,7 @@ function PaymentsPage(props: { params: Promise<PaymentsParams> }) {
/>
</div>
</div>
<div className="h-6"></div>
<div className="h-6 flex-shrink-0"></div>
<motion.div
initial={{ opacity: 0 }}
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 Link from 'next/link'
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 { 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 = {
subpage: string
orgslug: string
}
function SettingsPage(props: { params: Promise<SettingsParams> }) {
const params = use(props.params);
const session = useLHSession() as any
type NavigationItem = {
id: string
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])
const CurrentComponent = navigationItems.find(item => item.id === subpage)?.component;
return (
<div className="h-full w-full bg-[#f8f8f8]">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]">
<div className="h-full w-full bg-[#f8f8f8] flex flex-col">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 nice-shadow flex-shrink-0">
<BreadCrumbs
type="user"
last_breadcrumb={session?.user?.username}
></BreadCrumbs>
/>
<div className="my-2 tracking-tighter">
<div className="w-100 flex justify-between">
<div className="pt-3 flex font-bold text-4xl">Account Settings</div>
</div>
</div>
<div className="flex space-x-5 font-black text-sm">
<Link
href={
getUriWithOrg(params.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>
</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>
<SettingsNavigation
items={navigationItems}
currentPage={subpage}
orgslug={orgslug}
/>
</div>
<div className="h-6"></div>
<div className="h-6 flex-shrink-0" />
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
className="h-full overflow-y-auto"
className="flex-1 overflow-y-auto"
>
{params.subpage == 'general' ? <UserEditGeneral /> : ''}
{params.subpage == 'security' ? <UserEditPassword /> : ''}
{CurrentComponent && <CurrentComponent />}
</motion.div>
</div>
)

View file

@ -154,7 +154,7 @@ function UsersSettingsPage(props: { params: Promise<SettingsParams> }) {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
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 == 'signups' ? <OrgAccess /> : ''}

View file

@ -1400,7 +1400,7 @@ const PeopleSectionEditor: React.FC<{
<Label>People</Label>
<div className="space-y-4 mt-2">
{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">
<Label>Name</Label>
<Input
@ -1414,6 +1414,19 @@ const PeopleSectionEditor: React.FC<{
/>
</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">
<Label>Image</Label>
<div className="space-y-2">
@ -1480,7 +1493,8 @@ const PeopleSectionEditor: React.FC<{
user_uuid: '',
name: '',
description: '',
image_url: ''
image_url: '',
username: ''
}
onChange({
...section,

View file

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

View file

@ -1,6 +1,7 @@
'use client';
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 { useLHSession } from '@components/Contexts/LHSessionContext'
import {
@ -10,7 +11,19 @@ import {
Info,
UploadCloud,
AlertTriangle,
LogOut
LogOut,
Briefcase,
GraduationCap,
MapPin,
Building2,
Globe,
Laptop2,
Award,
BookOpen,
Link,
Users,
Calendar,
Lightbulb
} from 'lucide-react'
import UserAvatar from '@components/Objects/UserAvatar'
import { updateUserAvatar } from '@services/users/users'
@ -20,19 +33,48 @@ import { Input } from "@components/ui/input"
import { Textarea } from "@components/ui/textarea"
import { Button } from "@components/ui/button"
import { Label } from "@components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/ui/select"
import { toast } from 'react-hot-toast'
import { signOut } from 'next-auth/react'
import { getUriWithoutOrg } from '@services/config/config';
import { useDebounce } from '@/hooks/useDebounce';
const SUPPORTED_FILES = constructAcceptValue(['image'])
const validationSchema = Yup.object().shape({
email: Yup.string().email('Invalid email').required('Email is required'),
username: Yup.string().required('Username is required'),
first_name: Yup.string().required('First name is required'),
last_name: Yup.string().required('Last name is required'),
bio: Yup.string().max(400, 'Bio must be 400 characters or less'),
})
const AVAILABLE_ICONS = [
{ name: 'briefcase', label: 'Briefcase', component: Briefcase },
{ name: 'graduation-cap', label: 'Education', component: GraduationCap },
{ name: 'map-pin', label: 'Location', component: MapPin },
{ name: 'building-2', label: 'Organization', component: Building2 },
{ 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 {
username: string;
@ -40,8 +82,480 @@ interface FormValues {
last_name: string;
email: string;
bio: string;
details: {
[key: string]: DetailItem;
};
}
const DETAIL_TEMPLATES = {
general: [
{ id: 'title', label: 'Title', icon: 'briefcase', text: '' },
{ id: 'affiliation', label: 'Affiliation', icon: 'building-2', text: '' },
{ id: 'location', label: 'Location', icon: 'map-pin', text: '' },
{ id: 'website', label: 'Website', icon: 'globe', text: '' },
{ 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 validationSchema = Yup.object().shape({
email: Yup.string().email('Invalid email').required('Email is required'),
username: Yup.string().required('Username is required'),
first_name: Yup.string().required('First name is required'),
last_name: Yup.string().required('Last name is required'),
bio: Yup.string().max(400, 'Bio must be 400 characters or less'),
details: Yup.object().shape({})
});
// Memoized detail card component for better performance
const DetailCard = React.memo(({
id,
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);
// Memoize handlers to prevent unnecessary re-renders
const handleLabelChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newLabel = e.target.value;
setLocalLabel(newLabel);
debouncedLabelChange(newLabel);
}, [debouncedLabelChange]);
const handleIconChange = useCallback((value: string) => {
onUpdate(id, 'icon', value);
}, [id, onUpdate]);
const handleTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onUpdate(id, 'text', e.target.value);
}, [id, onUpdate]);
const handleRemove = useCallback(() => {
onRemove(id);
}, [id, onRemove]);
// Update local label when prop changes
useEffect(() => {
setLocalLabel(detail.label);
}, [detail.label]);
return (
<div className="space-y-2 p-4 border rounded-lg bg-white shadow-sm">
<div className="flex justify-between items-center mb-3">
<Input
value={localLabel}
onChange={handleLabelChange}
placeholder="Enter label (e.g., Title, Location)"
className="max-w-[200px]"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700"
onClick={handleRemove}
>
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>
<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">
<h1 className="font-bold text-xl text-gray-800">
Account Settings
</h1>
<h2 className="text-gray-500 text-md">
Manage your personal information and preferences
</h2>
</div>
<div className="flex flex-col lg:flex-row mt-0 mx-5 my-5 gap-8">
{/* Profile Information Section */}
<div className="flex-1 min-w-0 space-y-4">
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
value={values.email}
onChange={handleChange}
placeholder="Your email address"
/>
{touched.email && errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
)}
{values.email !== values.email && (
<div className="flex items-center space-x-2 mt-2 text-amber-600 bg-amber-50 p-2 rounded-md">
<AlertTriangle size={16} />
<span className="text-sm">You will be logged out after changing your email</span>
</div>
)}
</div>
<div>
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
value={values.username}
onChange={handleChange}
placeholder="Your username"
/>
{touched.username && errors.username && (
<p className="text-red-500 text-sm mt-1">{errors.username}</p>
)}
</div>
<div>
<Label htmlFor="first_name">First Name</Label>
<Input
id="first_name"
name="first_name"
value={values.first_name}
onChange={handleChange}
placeholder="Your first name"
/>
{touched.first_name && errors.first_name && (
<p className="text-red-500 text-sm mt-1">{errors.first_name}</p>
)}
</div>
<div>
<Label htmlFor="last_name">Last Name</Label>
<Input
id="last_name"
name="last_name"
value={values.last_name}
onChange={handleChange}
placeholder="Your last name"
/>
{touched.last_name && errors.last_name && (
<p className="text-red-500 text-sm mt-1">{errors.last_name}</p>
)}
</div>
<div>
<Label htmlFor="bio">
Bio
<span className="text-gray-500 text-sm ml-2">
({400 - (values.bio?.length || 0)} characters left)
</span>
</Label>
<Textarea
id="bio"
name="bio"
value={values.bio}
onChange={handleChange}
placeholder="Tell us about yourself"
className="min-h-[150px]"
maxLength={400}
/>
{touched.bio && errors.bio && (
<p className="text-red-500 text-sm mt-1">{errors.bio}</p>
)}
</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>
{/* Profile Picture Section */}
<div className="lg:w-80 w-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">
<Label className="font-bold">Profile Picture</Label>
{profilePicture.error && (
<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" />
<span className="font-semibold first-letter:uppercase">{profilePicture.error}</span>
</div>
)}
{profilePicture.success && (
<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" />
<span className="font-semibold first-letter:uppercase">{profilePicture.success}</span>
</div>
)}
{profilePicture.localAvatar ? (
<UserAvatar
border="border-8"
width={120}
avatar_url={URL.createObjectURL(profilePicture.localAvatar)}
/>
) : (
<UserAvatar border="border-8" width={120} />
)}
{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">
<ArrowBigUpDash size={16} className="mr-2" />
<span>Uploading</span>
</div>
) : (
<>
<input
type="file"
id="fileInput"
accept={SUPPORTED_FILES}
className="hidden"
onChange={profilePicture.handleFileChange}
/>
<Button
type="button"
variant="outline"
onClick={() => document.getElementById('fileInput')?.click()}
className="w-full"
>
<UploadCloud size={16} className="mr-2" />
Change Avatar
</Button>
</>
)}
<div className="flex items-center text-xs text-gray-500">
<Info size={13} className="mr-2" />
<p>Recommended size 100x100</p>
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-row-reverse mt-0 mx-5 mb-5">
<Button
type="submit"
disabled={isSubmitting}
className="bg-black text-white hover:bg-black/90"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</Form>
);
};
function UserEditGeneral() {
const session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token;
@ -49,6 +563,23 @@ function UserEditGeneral() {
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]
@ -84,218 +615,67 @@ function UserEditGeneral() {
signOut({ redirect: true, callbackUrl: getUriWithoutOrg('/') })
}
useEffect(() => { }, [session, session.data])
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">
{session.data.user && (
<Formik<FormValues>
enableReinitialize
initialValues={{
username: session.data.user.username,
first_name: session.data.user.first_name,
last_name: session.data.user.last_name,
email: session.data.user.email,
bio: session.data.user.bio || '',
}}
validationSchema={validationSchema}
onSubmit={(values, { setSubmitting }) => {
const isEmailChanged = values.email !== session.data.user.email
const loadingToast = toast.loading('Updating profile...')
<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, 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 }) => (
<Form>
<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">
<h1 className="font-bold text-xl text-gray-800">
Account Settings
</h1>
<h2 className="text-gray-500 text-md">
Manage your personal information and preferences
</h2>
</div>
<div className="flex flex-col lg:flex-row mt-0 mx-5 my-5 gap-8">
{/* Profile Information Section */}
<div className="flex-1 min-w-0 space-y-4">
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
value={values.email}
onChange={handleChange}
placeholder="Your email address"
/>
{touched.email && errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
)}
{values.email !== session.data.user.email && (
<div className="flex items-center space-x-2 mt-2 text-amber-600 bg-amber-50 p-2 rounded-md">
<AlertTriangle size={16} />
<span className="text-sm">You will be logged out after changing your email</span>
</div>
)}
</div>
<div>
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
value={values.username}
onChange={handleChange}
placeholder="Your username"
/>
{touched.username && errors.username && (
<p className="text-red-500 text-sm mt-1">{errors.username}</p>
)}
</div>
<div>
<Label htmlFor="first_name">First Name</Label>
<Input
id="first_name"
name="first_name"
value={values.first_name}
onChange={handleChange}
placeholder="Your first name"
/>
{touched.first_name && errors.first_name && (
<p className="text-red-500 text-sm mt-1">{errors.first_name}</p>
)}
</div>
<div>
<Label htmlFor="last_name">Last Name</Label>
<Input
id="last_name"
name="last_name"
value={values.last_name}
onChange={handleChange}
placeholder="Your last name"
/>
{touched.last_name && errors.last_name && (
<p className="text-red-500 text-sm mt-1">{errors.last_name}</p>
)}
</div>
<div>
<Label htmlFor="bio">
Bio
<span className="text-gray-500 text-sm ml-2">
({400 - (values.bio?.length || 0)} characters left)
</span>
</Label>
<Textarea
id="bio"
name="bio"
value={values.bio}
onChange={handleChange}
placeholder="Tell us about yourself"
className="min-h-[150px]"
maxLength={400}
/>
{touched.bio && errors.bio && (
<p className="text-red-500 text-sm mt-1">{errors.bio}</p>
)}
</div>
</div>
{/* Profile Picture Section */}
<div className="lg:w-80 w-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">
<Label className="font-bold">Profile Picture</Label>
{error && (
<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" />
<span className="font-semibold first-letter:uppercase">{error}</span>
</div>
)}
{success && (
<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" />
<span className="font-semibold first-letter:uppercase">{success}</span>
</div>
)}
{localAvatar ? (
<UserAvatar
border="border-8"
width={120}
avatar_url={URL.createObjectURL(localAvatar)}
/>
) : (
<UserAvatar border="border-8" width={120} />
)}
{isLoading ? (
<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" />
<span>Uploading</span>
</div>
) : (
<>
<input
type="file"
id="fileInput"
accept={SUPPORTED_FILES}
className="hidden"
onChange={handleFileChange}
/>
<Button
type="button"
variant="outline"
onClick={() => document.getElementById('fileInput')?.click()}
className="w-full"
>
<UploadCloud size={16} className="mr-2" />
Change Avatar
</Button>
</>
)}
<div className="flex items-center text-xs text-gray-500">
<Info size={13} className="mr-2" />
<p>Recommended size 100x100</p>
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-row-reverse mt-0 mx-5 mb-5">
<Button
type="submit"
disabled={isSubmitting}
className="bg-black text-white hover:bg-black/90"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</Form>
)}
</Formik>
)}
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>
</div>
)
);
}
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 { useLHSession } from '@components/Contexts/LHSessionContext'
import CourseThumbnailLanding from '@components/Objects/Thumbnails/CourseThumbnailLanding'
import UserAvatar from '@components/Objects/UserAvatar'
interface LandingCustomProps {
landing: {
@ -183,11 +184,21 @@ function LandingCustom({ landing, orgslug }: LandingCustomProps) {
{section.people.map((person, index) => (
<div key={index} className="w-[140px] flex flex-col items-center">
<div className="w-24 h-24 mb-4">
<img
src={person.image_url}
alt={person.name}
className="w-full h-full rounded-full object-cover border-4 border-white nice-shadow"
/>
{person.username ? (
<UserAvatar
username={person.username}
width={96}
rounded="rounded-full"
border="border-4"
showProfilePopup
/>
) : (
<img
src={person.image_url}
alt={person.name}
className="w-full h-full rounded-full object-cover border-4 border-white nice-shadow"
/>
)}
</div>
<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>

View file

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

View file

@ -18,6 +18,7 @@ import { useContributorStatus } from '../../../../hooks/useContributorStatus'
interface Author {
user: {
id: string
user_uuid: string
avatar_image: 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) : ''}
predefined_avatar={author.user.avatar_image ? undefined : 'empty'}
width={isMobile ? 60 : 100}
showProfilePopup={true}
userId={author.user.user_uuid}
/>
<div className="md:-space-y-2">
<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) : ''}
predefined_avatar={author.user.avatar_image ? undefined : 'empty'}
width={avatarSize}
showProfilePopup={true}
userId={author.user.id}
/>
</div>
</div>

View file

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

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,
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) => {
<MousePointerClick size={15} />
</ToolBtn>
</ToolTip>
<ToolTip content={'User'}>
<ToolBtn
onClick={() => editor.chain().focus().insertContent({ type: 'blockUser' }).run()}
>
<User size={15} />
</ToolBtn>
</ToolTip>
</ToolButtonsWrapper>
)
}

View file

@ -35,7 +35,30 @@ export const SearchBar: React.FC<SearchBarProps> = ({ orgslug, className = '', i
const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
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(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -49,31 +72,8 @@ export const SearchBar: React.FC<SearchBarProps> = ({ orgslug, className = '', i
}, []);
useEffect(() => {
const fetchCourses = async () => {
if (debouncedSearch.trim().length === 0) {
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]);
debouncedSearchFunction(searchQuery);
}, [searchQuery, debouncedSearchFunction]);
const handleSearchFocus = () => {
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 { useParams } from 'next/navigation'
import { getUserAvatarMediaDirectory } from '@services/media/media'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import UserProfilePopup from './UserProfilePopup'
import { getUserByUsername } from '@services/users/users'
type UserAvatarProps = {
width?: number
@ -13,11 +15,30 @@ type UserAvatarProps = {
borderColor?: string
predefined_avatar?: 'ai' | 'empty'
backgroundColor?: 'bg-white' | 'bg-gray-100'
showProfilePopup?: boolean
userId?: string
username?: string
}
function UserAvatar(props: UserAvatarProps) {
const session = useLHSession() 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 => {
return url.startsWith('http://') || url.startsWith('https://')
@ -54,7 +75,18 @@ function UserAvatar(props: UserAvatarProps) {
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) {
const avatarUrl = session.data.user.avatar_image
// 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 (
const avatarImage = (
<img
alt="User Avatar"
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

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"
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: {
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 {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
export function useDebounce<T extends (...args: any[]) => any>(
callback: T,
delay: number
): T {
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
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() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
// Node.js specific instrumentation
}
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
// 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-dropdown-menu": "^2.1.6",
"@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-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",
@ -29,21 +30,19 @@
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@sentry/nextjs": "^9.5.0",
"@sentry/utils": "^8.55.0",
"@stitches/react": "^1.2.8",
"@tanstack/react-table": "^8.21.2",
"@tiptap/core": "^2.11.5",
"@tiptap/extension-code-block-lowlight": "^2.11.5",
"@tiptap/extension-table": "^2.11.5",
"@tiptap/extension-table-cell": "^2.11.5",
"@tiptap/extension-table-header": "^2.11.5",
"@tiptap/extension-table-row": "^2.11.5",
"@tiptap/extension-youtube": "^2.11.5",
"@tiptap/html": "^2.11.5",
"@tiptap/pm": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"@tiptap/core": "^2.11.6",
"@tiptap/extension-code-block-lowlight": "^2.11.6",
"@tiptap/extension-table": "^2.11.6",
"@tiptap/extension-table-cell": "^2.11.6",
"@tiptap/extension-table-header": "^2.11.6",
"@tiptap/extension-table-row": "^2.11.6",
"@tiptap/extension-youtube": "^2.11.6",
"@tiptap/html": "^2.11.6",
"@tiptap/pm": "^2.11.6",
"@tiptap/react": "^2.11.6",
"@tiptap/starter-kit": "^2.11.6",
"@types/dompurify": "^3.2.0",
"@types/randomcolor": "^0.5.9",
"avvvatars-react": "^0.4.2",
@ -52,15 +51,15 @@
"currency-codes": "^2.2.0",
"dayjs": "^1.11.13",
"dompurify": "^3.2.4",
"emblor": "^1.4.7",
"emblor": "^1.4.8",
"formik": "^2.4.6",
"framer-motion": "^12.4.12",
"framer-motion": "^12.6.2",
"get-youtube-id": "^1.0.1",
"highlight.js": "^11.11.1",
"katex": "^0.16.21",
"lowlight": "^3.3.0",
"lucide-react": "^0.453.0",
"next": "15.2.3",
"next": "15.2.4",
"next-auth": "^4.24.11",
"nextjs-toploader": "^1.6.12",
"prosemirror-state": "^1.4.3",
@ -75,7 +74,7 @@
"react-youtube": "^10.1.0",
"require-in-the-middle": "^7.5.2",
"sharp": "^0.33.5",
"styled-components": "^6.1.15",
"styled-components": "^6.1.16",
"swr": "^2.3.3",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
@ -85,7 +84,7 @@
"yup": "^1.6.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.12",
"@tailwindcss/postcss": "^4.0.17",
"@types/node": "20.12.2",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
@ -97,7 +96,7 @@
"eslint-config-next": "15.2.1",
"eslint-plugin-unused-imports": "^3.2.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.12",
"tailwindcss": "^4.0.17",
"typescript": "5.4.4"
},
"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 {
RequestBodyWithAuthHeader,
errorHandling,
getResponseMetadata,
} from '@services/utils/ts/requests'
/*
@ -18,6 +19,6 @@ export async function updateProfile(
`${getAPIUrl()}users/` + user_id,
RequestBodyWithAuthHeader('PUT', data, null, access_token)
)
const res = await errorHandling(result)
const res = await getResponseMetadata(result)
return res
}

View file

@ -2,19 +2,37 @@ import { getAPIUrl } from '@services/config/config'
import {
RequestBody,
RequestBodyFormWithAuthHeader,
RequestBodyWithAuthHeader,
errorHandling,
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/user_id/${user_id}`,
RequestBody('GET', null, null)
`${getAPIUrl()}users/id/${user_id}`,
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, 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(
user_uuid: any,
avatar_file: any,