mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
Merge pull request #468 from learnhouse/feat/advanced-profiles
Advanced Profiles
This commit is contained in:
commit
32a59f0ffc
41 changed files with 4198 additions and 3896 deletions
|
|
@ -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 ###
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 ##
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 /> : ''}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 /> : ''}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export interface LandingUsers {
|
|||
name: string;
|
||||
description: string;
|
||||
image_url: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface LandingPeople {
|
||||
|
|
|
|||
|
|
@ -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...')
|
||||
|
||||
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>
|
||||
)}
|
||||
<Formik<FormValues>
|
||||
enableReinitialize
|
||||
initialValues={{
|
||||
username: userData.username,
|
||||
first_name: userData.first_name,
|
||||
last_name: userData.last_name,
|
||||
email: userData.email,
|
||||
bio: userData.bio || '',
|
||||
details: userData.details || {},
|
||||
}}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={(values, { setSubmitting }) => {
|
||||
const isEmailChanged = values.email !== userData.email
|
||||
const loadingToast = toast.loading('Updating profile...')
|
||||
|
||||
setTimeout(() => {
|
||||
setSubmitting(false)
|
||||
updateProfile(values, userData.id, access_token)
|
||||
.then(() => {
|
||||
toast.dismiss(loadingToast)
|
||||
if (isEmailChanged) {
|
||||
handleEmailChange(values.email)
|
||||
} else {
|
||||
toast.success('Profile Updated Successfully')
|
||||
}
|
||||
// Refresh user data after successful update
|
||||
getUser(userData.id, access_token).then(setUserData);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to update profile', { id: loadingToast })
|
||||
})
|
||||
}, 400)
|
||||
}}
|
||||
>
|
||||
{(formikProps) => (
|
||||
<UserEditForm
|
||||
{...formikProps}
|
||||
profilePicture={{
|
||||
error,
|
||||
success,
|
||||
isLoading,
|
||||
localAvatar,
|
||||
handleFileChange
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default UserEditGeneral
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react'
|
||||
import UserProfileBuilder from './UserProfileBuilder'
|
||||
|
||||
function UserProfile() {
|
||||
return (
|
||||
<div>
|
||||
<UserProfileBuilder />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserProfile
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
159
apps/web/components/Objects/UserProfilePopup.tsx
Normal file
159
apps/web/components/Objects/UserProfilePopup.tsx
Normal 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
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
44
apps/web/components/ui/hover-card.tsx
Normal file
44
apps/web/components/ui/hover-card.tsx
Normal 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 }
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
3954
apps/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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',
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue