diff --git a/apps/api/migrations/versions/adb944cc8bec_add_users_details_and_profile.py b/apps/api/migrations/versions/adb944cc8bec_add_users_details_and_profile.py new file mode 100644 index 00000000..269e6c29 --- /dev/null +++ b/apps/api/migrations/versions/adb944cc8bec_add_users_details_and_profile.py @@ -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 ### diff --git a/apps/api/src/db/users.py b/apps/api/src/db/users.py index d7412e30..9985c177 100644 --- a/apps/api/src/db/users.py +++ b/apps/api/src/db/users.py @@ -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): diff --git a/apps/api/src/routers/users.py b/apps/api/src/routers/users.py index 98cb25aa..b78b5fc0 100644 --- a/apps/api/src/routers/users.py +++ b/apps/api/src/routers/users.py @@ -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, + ) diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index f7405e3d..192df13a 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -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 ## diff --git a/apps/api/src/services/users/users.py b/apps/api/src/services/users/users.py index c23f6df7..5c19b786 100644 --- a/apps/api/src/services/users/users.py +++ b/apps/api/src/services/users/users.py @@ -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: diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx index 9bda5fee..85b80af9 100644 --- a/apps/web/app/global-error.tsx +++ b/apps/web/app/global-error.tsx @@ -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 ( - {/* `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. */} - +

Something went wrong!

+ ); diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx index c8f2ea19..716a5e1a 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx @@ -22,6 +22,7 @@ import CourseActionsMobile from '@components/Objects/Courses/CourseActions/Cours const CourseClient = (props: any) => { const [learnings, setLearnings] = useState([]) + 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) => {

About

-
-

{course.about}

+
+

{course.about}

{learnings.length > 0 && learnings[0]?.text !== 'null' && ( @@ -164,117 +165,113 @@ const CourseClient = (props: any) => {

Course Lessons

{course.chapters.map((chapter: any) => { + const isExpanded = expandedChapters[chapter.chapter_uuid] ?? true; // Default to expanded return (
-
+
setExpandedChapters(prev => ({ + ...prev, + [chapter.chapter_uuid]: !isExpanded + }))} + >

{chapter.name}

-

- {chapter.activities.length} Activities -

+
+

+ {chapter.activities.length} Activities +

+ + + +
-
- {chapter.activities.map((activity: any) => { - return ( -
-

-
-
- {activity.activity_type === - 'TYPE_DYNAMIC' && ( -
- -
- )} - {activity.activity_type === 'TYPE_VIDEO' && ( -
-
- )} - {activity.activity_type === - 'TYPE_DOCUMENT' && ( -
- -
- )} - {activity.activity_type === - 'TYPE_ASSIGNMENT' && ( -
- -
- )} -
- -

{activity.name}

- -
- {activity.activity_type === - 'TYPE_DYNAMIC' && ( -
- -
-

Page

- -
- -
- )} - {activity.activity_type === 'TYPE_VIDEO' && ( -
- -
-

Video

- +
+
+ {chapter.activities.map((activity: any) => { + return ( +
+

+
+
+ {activity.activity_type === + 'TYPE_DYNAMIC' && ( +
+
- -
- )} - {activity.activity_type === - 'TYPE_DOCUMENT' && ( + )} + {activity.activity_type === 'TYPE_VIDEO' && ( +
+
+ )} + {activity.activity_type === + 'TYPE_DOCUMENT' && ( +
+ +
+ )} + {activity.activity_type === + 'TYPE_ASSIGNMENT' && ( +
+ +
+ )} +
+ +

{activity.name}

+ +
+ {activity.activity_type === + 'TYPE_DYNAMIC' && ( +
+ +
+

Page

+ +
+ +
+ )} + {activity.activity_type === 'TYPE_VIDEO' && (
{ prefetch={false} >
-

Document

- -
- -
- )} - {activity.activity_type === - 'TYPE_ASSIGNMENT' && ( -
- -
-

Assignment

+

Video

)} + {activity.activity_type === + 'TYPE_DOCUMENT' && ( +
+ +
+

Document

+ +
+ +
+ )} + {activity.activity_type === + 'TYPE_ASSIGNMENT' && ( +
+ +
+

Assignment

+ +
+ +
+ )} +
-
- ) - })} + ) + })} +
) diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/user/[username]/UserProfileClient.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/user/[username]/UserProfileClient.tsx new file mode 100644 index 00000000..cda99126 --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/user/[username]/UserProfileClient.tsx @@ -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 ( +
+
+ + {image.caption + {image.caption && ( +

{image.caption}

+ )} +
+
+ ); +}; + +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([]); + 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 + } + + return ( +
+ {/* Banner */} +
+ {/* Optional banner content */} +
+ + {/* Profile Content */} +
+ {/* Avatar Positioned on the banner */} +
+
+ +
+
+ + {/* Affiliation Logos */} +
+ {profile.sections?.map((section: any) => ( + section.type === 'affiliation' && section.affiliations?.map((affiliation: any, index: number) => ( + affiliation.logoUrl && ( +
+ {affiliation.name} +
+ ) + )) + ))} +
+ + {/* Profile Content with right padding to avoid overlap */} +
+
+ {/* Left column with details - aligned with avatar */} +
+ {/* Name */} +

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

+ + {/* Details */} +
+ {userData.details && Object.values(userData.details).map((detail: any) => ( +
+
+ +
+ {detail.text} +
+ ))} +
+
+ + {/* Right column with about and related content */} +
+
+

About

+ {userData.bio ? ( +

{userData.bio}

+ ) : ( +

No biography provided

+ )} +
+ + {/* Profile sections from profile builder */} + {profile.sections && profile.sections.length > 0 && ( +
+ {profile.sections.map((section: any, index: number) => ( +
+

{section.title}

+ + {/* Add Image Gallery section */} + {section.type === 'image-gallery' && ( +
+ {section.images.map((image: any, imageIndex: number) => ( +
setSelectedImage(image)} + > + {image.caption + {image.caption && ( +
+

{image.caption}

+
+ )} +
+ ))} +
+ )} + + {section.type === 'text' && ( +
{section.content}
+ )} + + {section.type === 'links' && ( +
+ {section.links.map((link: any, linkIndex: number) => ( + + + {link.title} + + ))} +
+ )} + + {section.type === 'skills' && ( +
+ {section.skills.map((skill: any, skillIndex: number) => ( + + {skill.name} + {skill.level && ` • ${skill.level}`} + + ))} +
+ )} + + {section.type === 'experience' && ( +
+ {section.experiences.map((exp: any, expIndex: number) => ( +
+

{exp.title}

+

{exp.organization}

+

+ {exp.startDate} - {exp.current ? 'Present' : exp.endDate} +

+ {exp.description && ( +

{exp.description}

+ )} +
+ ))} +
+ )} + + {section.type === 'education' && ( +
+ {section.education.map((edu: any, eduIndex: number) => ( +
+

{edu.institution}

+

{edu.degree} in {edu.field}

+

+ {edu.startDate} - {edu.current ? 'Present' : edu.endDate} +

+ {edu.description && ( +

{edu.description}

+ )} +
+ ))} +
+ )} + + {section.type === 'affiliation' && ( +
+ {section.affiliations.map((affiliation: any, affIndex: number) => ( +
+
+ {affiliation.logoUrl && ( + {affiliation.name} + )} +
+

{affiliation.name}

+ {affiliation.description && ( +

{affiliation.description}

+ )} +
+
+
+ ))} +
+ )} + + {section.type === 'courses' && ( +
+ {isLoadingCourses ? ( +
+
+
+ ) : userCourses.length > 0 ? ( +
+ {userCourses.map((course) => ( +
+ +
+ ))} +
+ ) : ( +
+ No courses found +
+ )} +
+ )} +
+ ))} +
+ )} +
+
+
+
+ + {/* Image Modal */} + {selectedImage && ( + setSelectedImage(null)} + /> + )} +
+ ) +} + +export default UserProfileClient \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/user/[username]/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/user/[username]/page.tsx new file mode 100644 index 00000000..eb44a420 --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/user/[username]/page.tsx @@ -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; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +} + +export async function generateMetadata({ params }: UserPageProps): Promise { + 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 ( +
+ +
+ ) + } catch (error) { + console.error('Error fetching user data:', error) + return ( +
+
+

Error loading user profile

+
+
+ ) + } +} + +export default UserPage \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx index b3bda556..fc83647c 100644 --- a/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx @@ -75,8 +75,8 @@ function OrgPage(props: { params: Promise }) { }, [params.subpage, params]) return ( -
-
+
+
@@ -99,12 +99,13 @@ function OrgPage(props: { params: Promise }) { ))}
-
+
{params.subpage == 'general' ? : ''} {params.subpage == 'previews' ? : ''} diff --git a/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx index d98bc0a5..b72d733c 100644 --- a/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx @@ -69,7 +69,7 @@ function PaymentsPage(props: { params: Promise }) { return (
-
+
@@ -102,7 +102,7 @@ function PaymentsPage(props: { params: Promise }) { />
-
+
}) { - 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 +}) => ( +
+ {items.map((item) => ( + +
+
+ +
{item.label}
+
+
+ + ))} +
+) + +function SettingsPage({ params }: { params: Promise }) { + const { subpage, orgslug } = use(params); + const session = useLHSession() as Session; useEffect(() => {}, [session]) + const CurrentComponent = navigationItems.find(item => item.id === subpage)?.component; + return ( -
-
+
+
+ />
Account Settings
-
- -
-
- -
General
-
-
- - -
-
- -
Password
-
-
- -
+
-
+
- {params.subpage == 'general' ? : ''} - {params.subpage == 'security' ? : ''} + {CurrentComponent && }
) diff --git a/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx index 79001a41..71131cf8 100644 --- a/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx @@ -154,7 +154,7 @@ function UsersSettingsPage(props: { params: Promise }) { 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' ? : ''} {params.subpage == 'signups' ? : ''} diff --git a/apps/web/components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding.tsx b/apps/web/components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding.tsx index 49a39b95..034a19f0 100644 --- a/apps/web/components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding.tsx +++ b/apps/web/components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding.tsx @@ -1400,7 +1400,7 @@ const PeopleSectionEditor: React.FC<{
{section.people.map((person, index) => ( -
+
+
+ + { + const newPeople = [...section.people] + newPeople[index] = { ...person, username: e.target.value } + onChange({ ...section, people: newPeople }) + }} + placeholder="@username" + /> +
+
@@ -1480,7 +1493,8 @@ const PeopleSectionEditor: React.FC<{ user_uuid: '', name: '', description: '', - image_url: '' + image_url: '', + username: '' } onChange({ ...section, diff --git a/apps/web/components/Dashboard/Pages/Org/OrgEditLanding/landing_types.ts b/apps/web/components/Dashboard/Pages/Org/OrgEditLanding/landing_types.ts index 8a8f6c29..3324cdcb 100644 --- a/apps/web/components/Dashboard/Pages/Org/OrgEditLanding/landing_types.ts +++ b/apps/web/components/Dashboard/Pages/Org/OrgEditLanding/landing_types.ts @@ -40,6 +40,7 @@ export interface LandingUsers { name: string; description: string; image_url: string; + username?: string; } export interface LandingPeople { diff --git a/apps/web/components/Dashboard/Pages/UserAccount/UserEditGeneral/UserEditGeneral.tsx b/apps/web/components/Dashboard/Pages/UserAccount/UserEditGeneral/UserEditGeneral.tsx index d5301384..8d08bf07 100644 --- a/apps/web/components/Dashboard/Pages/UserAccount/UserEditGeneral/UserEditGeneral.tsx +++ b/apps/web/components/Dashboard/Pages/UserAccount/UserEditGeneral/UserEditGeneral.tsx @@ -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 ; +}; + +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) => { + 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) => { + 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 ( +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ ); +}); + +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) => void; + errors: any; + touched: any; + isSubmitting: boolean; + profilePicture: { + error: string | undefined; + success: string; + isLoading: boolean; + localAvatar: File | null; + handleFileChange: (event: any) => Promise; + }; +}) => { + // 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 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 ( +
+
+
+

+ Account Settings +

+

+ Manage your personal information and preferences +

+
+ +
+ {/* Profile Information Section */} +
+
+ + + {touched.email && errors.email && ( +

{errors.email}

+ )} + {values.email !== values.email && ( +
+ + You will be logged out after changing your email +
+ )} +
+ +
+ + + {touched.username && errors.username && ( +

{errors.username}

+ )} +
+ +
+ + + {touched.first_name && errors.first_name && ( +

{errors.first_name}

+ )} +
+ +
+ + + {touched.last_name && errors.last_name && ( +

{errors.last_name}

+ )} +
+ +
+ +