diff --git a/apps/api/src/routers/users.py b/apps/api/src/routers/users.py index 98cb25aa..b7b6780b 100644 --- a/apps/api/src/routers/users.py +++ b/apps/api/src/routers/users.py @@ -28,6 +28,7 @@ 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, @@ -170,6 +171,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( *, diff --git a/apps/api/src/services/users/users.py b/apps/api/src/services/users/users.py index c23f6df7..c4b2a420 100644 --- a/apps/api/src/services/users/users.py +++ b/apps/api/src/services/users/users.py @@ -424,6 +424,30 @@ async def read_user_by_uuid( 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", + ) + + # RBAC check + await rbac_check(request, current_user, "read", user.user_uuid, db_session) + + user = UserRead.model_validate(user) + + return user + + async def get_user_session( request: Request, db_session: Session, 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..da5e676b --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/user/[username]/UserProfileClient.tsx @@ -0,0 +1,234 @@ +'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 +} from 'lucide-react' +import { getUserAvatarMediaDirectory } from '@services/media/media' + +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 + +function UserProfileClient({ userData, profile }: UserProfileClientProps) { + 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}

+ + {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}

+ )} +
+
+
+ ))} +
+ )} +
+ ))} +
+ )} +
+
+
+
+
+ ) +} + +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..2ce0430d --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/user/[username]/page.tsx @@ -0,0 +1,81 @@ +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 session = await getServerSession(nextAuthOptions) + const access_token = session?.tokens?.access_token + const resolvedParams = await params + + if (!access_token) { + return { + title: 'User Profile', + } + } + + const userData = await getUserByUsername(resolvedParams.username, access_token) + 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 { + // Get access token from server session + const session = await getServerSession(nextAuthOptions) + const access_token = session?.tokens?.access_token + + if (!access_token) { + throw new Error('No access token available') + } + + // Fetch user data by username + const userData = await getUserByUsername(username, access_token); + 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/user-account/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/user-account/settings/[subpage]/page.tsx index ae7caf06..2252e57f 100644 --- a/apps/web/app/orgs/[orgslug]/dash/user-account/settings/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/user-account/settings/[subpage]/page.tsx @@ -39,18 +39,18 @@ const navigationItems: NavigationItem[] = [ icon: Info, component: UserEditGeneral }, + { + id: 'profile', + label: 'Profile', + icon: User, + component: UserProfile + }, { id: 'security', label: 'Password', icon: Lock, component: UserEditPassword }, - { - id: 'profile', - label: 'Profile', - icon: User, - component: UserProfile - } ] const SettingsNavigation = ({ 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/UserProfile/UserProfile.tsx b/apps/web/components/Dashboard/Pages/UserAccount/UserProfile/UserProfile.tsx index 6b7590d5..05dc6fc0 100644 --- a/apps/web/components/Dashboard/Pages/UserAccount/UserProfile/UserProfile.tsx +++ b/apps/web/components/Dashboard/Pages/UserAccount/UserProfile/UserProfile.tsx @@ -1,8 +1,11 @@ import React from 'react' +import UserProfileBuilder from './UserProfileBuilder' function UserProfile() { return ( -
UserProfile
+
+ +
) } diff --git a/apps/web/components/Dashboard/Pages/UserAccount/UserProfile/UserProfileBuilder.tsx b/apps/web/components/Dashboard/Pages/UserAccount/UserProfile/UserProfileBuilder.tsx new file mode 100644 index 00000000..abd2be19 --- /dev/null +++ b/apps/web/components/Dashboard/Pages/UserAccount/UserProfile/UserProfileBuilder.tsx @@ -0,0 +1,1288 @@ +import React from 'react' +import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd' +import { Plus, Trash2, GripVertical, ImageIcon, Link as LinkIcon, Award, ArrowRight, Edit, TextIcon, Briefcase, GraduationCap, Upload, MapPin } from 'lucide-react' +import { Input } from "@components/ui/input" +import { Textarea } from "@components/ui/textarea" +import { Label } from "@components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@components/ui/select" +import { Button } from "@components/ui/button" +import { useLHSession } from '@components/Contexts/LHSessionContext' +import { updateProfile } from '@services/settings/profile' +import { getUser } from '@services/users/users' +import { toast } from 'react-hot-toast' +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@components/ui/tabs" + +// Define section types and their configurations +const SECTION_TYPES = { + 'image-gallery': { + icon: ImageIcon, + label: 'Image Gallery', + description: 'Add a collection of images' + }, + 'text': { + icon: TextIcon, + label: 'Text', + description: 'Add formatted text content' + }, + 'links': { + icon: LinkIcon, + label: 'Links', + description: 'Add social or professional links' + }, + 'skills': { + icon: Award, + label: 'Skills', + description: 'Showcase your skills and expertise' + }, + 'experience': { + icon: Briefcase, + label: 'Experience', + description: 'Add work or project experience' + }, + 'education': { + icon: GraduationCap, + label: 'Education', + description: 'Add educational background' + }, + 'affiliation': { + icon: MapPin, + label: 'Affiliation', + description: 'Add organizational affiliations' + } +} as const + +// Type definitions +interface ProfileImage { + url: string; + caption?: string; +} + +interface ProfileLink { + title: string; + url: string; + icon?: string; +} + +interface ProfileSkill { + name: string; + level?: 'beginner' | 'intermediate' | 'advanced' | 'expert'; + category?: string; +} + +interface ProfileExperience { + title: string; + organization: string; + startDate: string; + endDate?: string; + current: boolean; + description: string; +} + +interface ProfileEducation { + institution: string; + degree: string; + field: string; + startDate: string; + endDate?: string; + current: boolean; + description?: string; +} + +interface ProfileAffiliation { + name: string; + description: string; + logoUrl: string; +} + +interface BaseSection { + id: string; + type: keyof typeof SECTION_TYPES; + title: string; +} + +interface ImageGallerySection extends BaseSection { + type: 'image-gallery'; + images: ProfileImage[]; +} + +interface TextSection extends BaseSection { + type: 'text'; + content: string; +} + +interface LinksSection extends BaseSection { + type: 'links'; + links: ProfileLink[]; +} + +interface SkillsSection extends BaseSection { + type: 'skills'; + skills: ProfileSkill[]; +} + +interface ExperienceSection extends BaseSection { + type: 'experience'; + experiences: ProfileExperience[]; +} + +interface EducationSection extends BaseSection { + type: 'education'; + education: ProfileEducation[]; +} + +interface AffiliationSection extends BaseSection { + type: 'affiliation'; + affiliations: ProfileAffiliation[]; +} + +type ProfileSection = + | ImageGallerySection + | TextSection + | LinksSection + | SkillsSection + | ExperienceSection + | EducationSection + | AffiliationSection; + +interface ProfileData { + sections: ProfileSection[]; +} + +const UserProfileBuilder = () => { + const session = useLHSession() as any + const access_token = session?.data?.tokens?.access_token + const [profileData, setProfileData] = React.useState({ + sections: [] + }) + const [selectedSection, setSelectedSection] = React.useState(null) + const [isSaving, setIsSaving] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(true) + + // Initialize profile data from user data + React.useEffect(() => { + const fetchUserData = async () => { + if (session?.data?.user?.id && access_token) { + try { + setIsLoading(true) + const userData = await getUser(session.data.user.id, access_token) + + if (userData.profile) { + try { + const profileSections = typeof userData.profile === 'string' + ? JSON.parse(userData.profile).sections + : userData.profile.sections; + + setProfileData({ + sections: profileSections || [] + }); + } catch (error) { + console.error('Error parsing profile data:', error); + setProfileData({ sections: [] }); + } + } + } catch (error) { + console.error('Error fetching user data:', error); + toast.error('Failed to load profile data'); + } finally { + setIsLoading(false) + } + } + }; + + fetchUserData(); + }, [session?.data?.user?.id, access_token]) + + const createEmptySection = (type: keyof typeof SECTION_TYPES): ProfileSection => { + const baseSection = { + id: `section-${Date.now()}`, + type, + title: `New ${SECTION_TYPES[type].label} Section` + } + + switch (type) { + case 'image-gallery': + return { + ...baseSection, + type: 'image-gallery', + images: [] + } + case 'text': + return { + ...baseSection, + type: 'text', + content: '' + } + case 'links': + return { + ...baseSection, + type: 'links', + links: [] + } + case 'skills': + return { + ...baseSection, + type: 'skills', + skills: [] + } + case 'experience': + return { + ...baseSection, + type: 'experience', + experiences: [] + } + case 'education': + return { + ...baseSection, + type: 'education', + education: [] + } + case 'affiliation': + return { + ...baseSection, + type: 'affiliation', + affiliations: [] + } + } + } + + const addSection = (type: keyof typeof SECTION_TYPES) => { + const newSection = createEmptySection(type) + setProfileData(prev => ({ + ...prev, + sections: [...prev.sections, newSection] + })) + setSelectedSection(profileData.sections.length) + } + + const updateSection = (index: number, updatedSection: ProfileSection) => { + const newSections = [...profileData.sections] + newSections[index] = updatedSection + setProfileData(prev => ({ + ...prev, + sections: newSections + })) + } + + const deleteSection = (index: number) => { + setProfileData(prev => ({ + ...prev, + sections: prev.sections.filter((_, i) => i !== index) + })) + setSelectedSection(null) + } + + const onDragEnd = (result: any) => { + if (!result.destination) return + + const items = Array.from(profileData.sections) + const [reorderedItem] = items.splice(result.source.index, 1) + items.splice(result.destination.index, 0, reorderedItem) + + setProfileData(prev => ({ + ...prev, + sections: items + })) + setSelectedSection(result.destination.index) + } + + const handleSave = async () => { + setIsSaving(true) + const loadingToast = toast.loading('Saving profile...') + + try { + // Get fresh user data before update + const userData = await getUser(session.data.user.id, access_token) + + // Update only the profile field + userData.profile = profileData + + const res = await updateProfile(userData, userData.id, access_token) + + if (res.status === 200) { + toast.success('Profile updated successfully', { id: loadingToast }) + } else { + throw new Error('Failed to update profile') + } + } catch (error) { + console.error('Error updating profile:', error) + toast.error('Error updating profile', { id: loadingToast }) + } finally { + setIsSaving(false) + } + } + + if (isLoading) { + return ( +
+
+
+
+
+ ) + } + + return ( +
+
+ {/* Header */} +
+
+

Profile Builder
BETA

+

Customize your professional profile

+
+ +
+ + {/* Main Content */} +
+ {/* Sections Panel */} +
+

Sections

+ + + {(provided) => ( +
+ {profileData.sections.map((section, index) => ( + + {(provided, snapshot) => ( +
setSelectedSection(index)} + className={`p-4 bg-white/80 backdrop-blur-xs rounded-lg cursor-pointer border ${ + selectedSection === index + ? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500/20 shadow-xs' + : 'border-gray-200 hover:border-gray-300 hover:bg-gray-50/50 hover:shadow-xs' + } ${snapshot.isDragging ? 'shadow-lg ring-2 ring-blue-500/20 rotate-2' : ''}`} + > +
+
+
+ +
+
+ {React.createElement(SECTION_TYPES[section.type].icon, { + size: 16 + })} +
+ + {section.title} + +
+
+ + +
+
+
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+ +
+ +
+
+ + {/* Editor Panel */} +
+ {selectedSection !== null ? ( + updateSection(selectedSection, updatedSection as ProfileSection)} + /> + ) : ( +
+ Select a section to edit or add a new one +
+ )} +
+
+
+
+ ) +} + +interface SectionEditorProps { + section: ProfileSection; + onChange: (section: ProfileSection) => void; +} + +const SectionEditor: React.FC = ({ section, onChange }) => { + switch (section.type) { + case 'image-gallery': + return + case 'text': + return + case 'links': + return + case 'skills': + return + case 'experience': + return + case 'education': + return + case 'affiliation': + return + default: + return
Unknown section type
+ } +} + +const ImageGalleryEditor: React.FC<{ + section: ImageGallerySection; + onChange: (section: ImageGallerySection) => void; +}> = ({ section, onChange }) => { + return ( +
+
+ +

Image Gallery

+
+ +
+ {/* Title */} +
+ + onChange({ ...section, title: e.target.value })} + placeholder="Enter section title" + /> +
+ + {/* Images */} +
+ +
+ {section.images.map((image, index) => ( +
+
+ + { + const newImages = [...section.images] + newImages[index] = { ...image, url: e.target.value } + onChange({ ...section, images: newImages }) + }} + placeholder="Enter image URL" + /> +
+
+ + { + const newImages = [...section.images] + newImages[index] = { ...image, caption: e.target.value } + onChange({ ...section, images: newImages }) + }} + placeholder="Image caption" + /> +
+
+ + +
+ {image.url && ( +
+ {image.caption +
+ )} +
+ ))} + +
+
+
+
+ ) +} + +const TextEditor: React.FC<{ + section: TextSection; + onChange: (section: TextSection) => void; +}> = ({ section, onChange }) => { + return ( +
+
+ +

Text Content

+
+ +
+ {/* Title */} +
+ + onChange({ ...section, title: e.target.value })} + placeholder="Enter section title" + /> +
+ + {/* Content */} +
+ +