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 && (
+
+

+
+ )
+ ))
+ ))}
+
+
+ {/* 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) => (
+
+ ))}
+
+
+
+ {/* 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.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.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 && (
+
+

+
+ )}
+
+ ))}
+
+
+
+
+
+ )
+}
+
+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 */}
+
+
+
+
+
+ )
+}
+
+const LinksEditor: React.FC<{
+ section: LinksSection;
+ onChange: (section: LinksSection) => void;
+}> = ({ section, onChange }) => {
+ return (
+
+
+
+
Links
+
+
+
+ {/* Title */}
+
+
+ onChange({ ...section, title: e.target.value })}
+ placeholder="Enter section title"
+ />
+
+
+ {/* Links */}
+
+
+
+ {section.links.map((link, index) => (
+
+ {
+ const newLinks = [...section.links]
+ newLinks[index] = { ...link, title: e.target.value }
+ onChange({ ...section, links: newLinks })
+ }}
+ placeholder="Link title"
+ />
+ {
+ const newLinks = [...section.links]
+ newLinks[index] = { ...link, url: e.target.value }
+ onChange({ ...section, links: newLinks })
+ }}
+ placeholder="URL"
+ />
+
+
+ ))}
+
+
+
+
+
+ )
+}
+
+const SkillsEditor: React.FC<{
+ section: SkillsSection;
+ onChange: (section: SkillsSection) => void;
+}> = ({ section, onChange }) => {
+ return (
+
+
+
+
+ {/* Title */}
+
+
+ onChange({ ...section, title: e.target.value })}
+ placeholder="Enter section title"
+ />
+
+
+ {/* Skills */}
+
+
+
+ {section.skills.map((skill, index) => (
+
+ {
+ const newSkills = [...section.skills]
+ newSkills[index] = { ...skill, name: e.target.value }
+ onChange({ ...section, skills: newSkills })
+ }}
+ placeholder="Skill name"
+ />
+
+ {
+ const newSkills = [...section.skills]
+ newSkills[index] = { ...skill, category: e.target.value }
+ onChange({ ...section, skills: newSkills })
+ }}
+ placeholder="Category (optional)"
+ />
+
+
+ ))}
+
+
+
+
+
+ )
+}
+
+const ExperienceEditor: React.FC<{
+ section: ExperienceSection;
+ onChange: (section: ExperienceSection) => void;
+}> = ({ section, onChange }) => {
+ return (
+
+
+
+
Experience
+
+
+
+ {/* Title */}
+
+
+ onChange({ ...section, title: e.target.value })}
+ placeholder="Enter section title"
+ />
+
+
+ {/* Experiences */}
+
+
+
+ {section.experiences.map((experience, index) => (
+
+
+
+
+
+
+ {
+ const newExperiences = [...section.experiences]
+ newExperiences[index] = { ...experience, startDate: e.target.value }
+ onChange({ ...section, experiences: newExperiences })
+ }}
+ />
+
+
+
+ {
+ const newExperiences = [...section.experiences]
+ newExperiences[index] = { ...experience, endDate: e.target.value }
+ onChange({ ...section, experiences: newExperiences })
+ }}
+ disabled={experience.current}
+ />
+
+
+
+ {
+ const newExperiences = [...section.experiences]
+ newExperiences[index] = {
+ ...experience,
+ current: e.target.checked,
+ endDate: e.target.checked ? undefined : experience.endDate
+ }
+ onChange({ ...section, experiences: newExperiences })
+ }}
+ className="rounded border-gray-300"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ )
+}
+
+const EducationEditor: React.FC<{
+ section: EducationSection;
+ onChange: (section: EducationSection) => void;
+}> = ({ section, onChange }) => {
+ return (
+
+
+
+
Education
+
+
+
+ {/* Title */}
+
+
+ onChange({ ...section, title: e.target.value })}
+ placeholder="Enter section title"
+ />
+
+
+ {/* Education Items */}
+
+
+
+ {section.education.map((edu, index) => (
+
+
+
+
+
+ {
+ const newEducation = [...section.education]
+ newEducation[index] = { ...edu, field: e.target.value }
+ onChange({ ...section, education: newEducation })
+ }}
+ placeholder="Major or concentration"
+ />
+
+
+
+
+
+ {
+ const newEducation = [...section.education]
+ newEducation[index] = { ...edu, startDate: e.target.value }
+ onChange({ ...section, education: newEducation })
+ }}
+ />
+
+
+
+ {
+ const newEducation = [...section.education]
+ newEducation[index] = { ...edu, endDate: e.target.value }
+ onChange({ ...section, education: newEducation })
+ }}
+ disabled={edu.current}
+ />
+
+
+
+ {
+ const newEducation = [...section.education]
+ newEducation[index] = {
+ ...edu,
+ current: e.target.checked,
+ endDate: e.target.checked ? undefined : edu.endDate
+ }
+ onChange({ ...section, education: newEducation })
+ }}
+ className="rounded border-gray-300"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ )
+}
+
+const AffiliationEditor: React.FC<{
+ section: AffiliationSection;
+ onChange: (section: AffiliationSection) => void;
+}> = ({ section, onChange }) => {
+ return (
+
+
+
+
Affiliation
+
+
+
+ {/* Title */}
+
+
+ onChange({ ...section, title: e.target.value })}
+ placeholder="Enter section title"
+ />
+
+
+ {/* Affiliations */}
+
+
+
+ {section.affiliations.map((affiliation, index) => (
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ )
+}
+
+export default UserProfileBuilder
\ No newline at end of file
diff --git a/apps/web/components/Landings/LandingCustom.tsx b/apps/web/components/Landings/LandingCustom.tsx
index d83bb9b3..0ca59607 100644
--- a/apps/web/components/Landings/LandingCustom.tsx
+++ b/apps/web/components/Landings/LandingCustom.tsx
@@ -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) => (
-

+ {person.username ? (
+
+ ) : (
+

+ )}
{person.name}
{person.description}
diff --git a/apps/web/components/Objects/UserAvatar.tsx b/apps/web/components/Objects/UserAvatar.tsx
index 5d79d92d..0eef3d3c 100644
--- a/apps/web/components/Objects/UserAvatar.tsx
+++ b/apps/web/components/Objects/UserAvatar.tsx
@@ -1,9 +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
@@ -16,11 +17,28 @@ type UserAvatarProps = {
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
(null)
+
+ useEffect(() => {
+ const fetchUserByUsername = async () => {
+ if (props.username && session?.data?.tokens?.access_token) {
+ try {
+ const data = await getUserByUsername(props.username, session.data.tokens.access_token)
+ setUserData(data)
+ } catch (error) {
+ console.error('Error fetching user by username:', error)
+ }
+ }
+ }
+
+ fetchUserByUsername()
+ }, [props.username, session?.data?.tokens?.access_token])
const isExternalUrl = (url: string): boolean => {
return url.startsWith('http://') || url.startsWith('https://')
@@ -57,6 +75,17 @@ function UserAvatar(props: UserAvatarProps) {
return props.avatar_url
}
+ // 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
if (session?.data?.user?.avatar_image) {
const avatarUrl = session.data.user.avatar_image
@@ -92,9 +121,9 @@ function UserAvatar(props: UserAvatarProps) {
/>
)
- if (props.showProfilePopup && props.userId) {
+ if (props.showProfilePopup && (props.userId || (userData?.id))) {
return (
-
+
{avatarImage}
)
diff --git a/apps/web/components/Objects/UserProfilePopup.tsx b/apps/web/components/Objects/UserProfilePopup.tsx
index f0a48e01..ac94069f 100644
--- a/apps/web/components/Objects/UserProfilePopup.tsx
+++ b/apps/web/components/Objects/UserProfilePopup.tsx
@@ -120,7 +120,7 @@ const UserProfilePopup = ({ children, userId }: UserProfilePopupProps) => {
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-600 hover:text-gray-900 flex-shrink-0"
- onClick={() => router.push(`/profile/${userId}`)}
+ onClick={() => userData.username && router.push(`/user/${userData.username}`)}
>
diff --git a/apps/web/services/settings/profile.ts b/apps/web/services/settings/profile.ts
index 68bd5d57..147b7cc0 100644
--- a/apps/web/services/settings/profile.ts
+++ b/apps/web/services/settings/profile.ts
@@ -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
}
diff --git a/apps/web/services/users/users.ts b/apps/web/services/users/users.ts
index 8a6988e8..52cf3b77 100644
--- a/apps/web/services/users/users.ts
+++ b/apps/web/services/users/users.ts
@@ -16,6 +16,15 @@ export async function getUser(user_id: string, access_token: string) {
return res
}
+export async function getUserByUsername(username: string, access_token: string) {
+ const result = await fetch(
+ `${getAPIUrl()}users/username/${username}`,
+ RequestBodyWithAuthHeader('GET', null, null, access_token)
+ )
+ const res = await errorHandling(result)
+ return res
+}
+
export async function updateUserAvatar(
user_uuid: any,
avatar_file: any,