Merge pull request #466 from learnhouse/feat/multi-contributors

Multi-contributors
This commit is contained in:
Badr B. 2025-03-22 22:24:14 +01:00 committed by GitHub
commit 7f66369e95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1621 additions and 350 deletions

View file

@ -0,0 +1,48 @@
"""Multi-contributors
Revision ID: 4a88b680263c
Revises: 87a621284ae4
Create Date: 2025-03-20 11:05:24.951129
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa # noqa: F401
import sqlmodel # noqa: F401
from alembic_postgresql_enum import TableReference
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '4a88b680263c'
down_revision: Union[str, None] = '87a621284ae4'
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('course', sa.Column('open_to_contributors', sa.Boolean(), nullable=False, default=False, server_default='false'))
op.add_column('resourceauthor', sa.Column('authorship_status', postgresql.ENUM('ACTIVE', 'PENDING', 'INACTIVE', name='resourceauthorshipstatusenum', create_type=False), nullable=False, default='INACTIVE', server_default='INACTIVE'))
op.sync_enum_values(
enum_schema='public',
enum_name='resourceauthorshipenum',
new_values=['CREATOR', 'CONTRIBUTOR', 'MAINTAINER', 'REPORTER'],
affected_columns=[TableReference(table_schema='public', table_name='resourceauthor', column_name='authorship')],
enum_values_to_rename=[],
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.sync_enum_values(
enum_schema='public',
enum_name='resourceauthorshipenum',
new_values=['CREATOR', 'MAINTAINER', 'REPORTER'],
affected_columns=[TableReference(table_schema='public', table_name='resourceauthor', column_name='authorship')],
enum_values_to_rename=[],
)
op.drop_column('resourceauthor', 'authorship_status')
op.drop_column('course', 'open_to_contributors')
# ### end Alembic commands ###

View file

@ -4,6 +4,15 @@ from sqlmodel import Field, SQLModel
from src.db.users import UserRead
from src.db.trails import TrailRead
from src.db.courses.chapters import ChapterRead
from src.db.resource_authors import ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum
class AuthorWithRole(SQLModel):
user: UserRead
authorship: ResourceAuthorshipEnum
authorship_status: ResourceAuthorshipStatusEnum
creation_date: str
update_date: str
class CourseBase(SQLModel):
@ -14,6 +23,7 @@ class CourseBase(SQLModel):
tags: Optional[str]
thumbnail_image: Optional[str]
public: bool
open_to_contributors: bool
class Course(CourseBase, table=True):
@ -38,12 +48,13 @@ class CourseUpdate(CourseBase):
learnings: Optional[str]
tags: Optional[str]
public: Optional[bool]
open_to_contributors: Optional[bool]
class CourseRead(CourseBase):
id: int
org_id: int = Field(default=None, foreign_key="organization.id")
authors: Optional[List[UserRead]]
authors: List[AuthorWithRole]
course_uuid: str
creation_date: str
update_date: str
@ -57,7 +68,7 @@ class FullCourseRead(CourseBase):
update_date: Optional[str]
# Chapters, Activities
chapters: List[ChapterRead]
authors: List[UserRead]
authors: List[AuthorWithRole]
pass
@ -67,7 +78,7 @@ class FullCourseReadWithTrail(CourseBase):
creation_date: Optional[str]
update_date: Optional[str]
org_id: int = Field(default=None, foreign_key="organization.id")
authors: List[UserRead]
authors: List[AuthorWithRole]
# Chapters, Activities
chapters: List[ChapterRead]
# Trail

View file

@ -6,9 +6,15 @@ from sqlmodel import Field, SQLModel
class ResourceAuthorshipEnum(str, Enum):
CREATOR = "CREATOR"
CONTRIBUTOR = "CONTRIBUTOR"
MAINTAINER = "MAINTAINER"
REPORTER = "REPORTER"
class ResourceAuthorshipStatusEnum(str, Enum):
ACTIVE = "ACTIVE"
PENDING = "PENDING"
INACTIVE = "INACTIVE"
class ResourceAuthor(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
@ -16,6 +22,7 @@ class ResourceAuthor(SQLModel, table=True):
user_id: int = Field(
sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE"))
)
authorship: ResourceAuthorshipEnum = ResourceAuthorshipEnum.CREATOR
authorship: ResourceAuthorshipEnum
authorship_status: ResourceAuthorshipStatusEnum
creation_date: str = ""
update_date: str = ""

View file

@ -32,6 +32,12 @@ from src.services.courses.updates import (
get_updates_by_course_uuid,
update_update,
)
from src.services.courses.contributors import (
apply_course_contributor,
update_course_contributor,
get_course_contributors,
)
from src.db.resource_authors import ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum
router = APIRouter()
@ -63,6 +69,7 @@ async def api_create_course(
about=about,
learnings=learnings,
tags=tags,
open_to_contributors=False,
)
return await create_course(
request, org_id, course, current_user, db_session, thumbnail
@ -195,6 +202,19 @@ async def api_delete_course(
return await delete_course(request, course_uuid, current_user, db_session)
@router.post("/{course_uuid}/apply-contributor")
async def api_apply_course_contributor(
request: Request,
course_uuid: str,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
):
"""
Apply to be a contributor for a course
"""
return await apply_course_contributor(request, course_uuid, current_user, db_session)
@router.get("/{course_uuid}/updates")
async def api_get_course_updates(
request: Request,
@ -259,3 +279,41 @@ async def api_delete_course_update(
"""
return await delete_update(request, courseupdate_uuid, current_user, db_session)
@router.get("/{course_uuid}/contributors")
async def api_get_course_contributors(
request: Request,
course_uuid: str,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
):
"""
Get all contributors for a course
"""
return await get_course_contributors(request, course_uuid, current_user, db_session)
@router.put("/{course_uuid}/contributors/{contributor_user_id}")
async def api_update_course_contributor(
request: Request,
course_uuid: str,
contributor_user_id: int,
authorship: ResourceAuthorshipEnum,
authorship_status: ResourceAuthorshipStatusEnum,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
):
"""
Update a course contributor's role and status
Only administrators can perform this action
"""
return await update_course_contributor(
request,
course_uuid,
contributor_user_id,
authorship,
authorship_status,
current_user,
db_session
)

View file

@ -4,7 +4,7 @@ from sqlalchemy import null
from sqlmodel import Session, select
from src.db.collections import Collection
from src.db.courses.courses import Course
from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum
from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum
from src.db.roles import Role
from src.db.user_organizations import UserOrganization
from src.security.rbac.utils import check_element_type
@ -68,9 +68,10 @@ async def authorization_verify_if_user_is_author(
if resource_author:
if resource_author.user_id == int(user_id):
if (resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or (
resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER
):
if ((resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or
(resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER) or
(resource_author.authorship == ResourceAuthorshipEnum.CONTRIBUTOR)) and \
resource_author.authorship_status == ResourceAuthorshipStatusEnum.ACTIVE:
return True
else:
return False

View file

@ -40,7 +40,16 @@ async def create_activity(
)
# RBAC check
await rbac_check(request, chapter.chapter_uuid, current_user, "create", db_session)
statement = select(Course).where(Course.id == chapter.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
await rbac_check(request, course.course_uuid, current_user, "create", db_session)
# Create Activity
activity = Activity(**activity_object.model_dump())
@ -169,9 +178,16 @@ async def update_activity(
)
# RBAC check
await rbac_check(
request, activity.activity_uuid, current_user, "update", db_session
)
statement = select(Course).where(Course.id == activity.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
await rbac_check(request, course.course_uuid, current_user, "update", db_session)
# Update only the fields that were passed in
for var, value in vars(activity_object).items():
@ -203,9 +219,16 @@ async def delete_activity(
)
# RBAC check
await rbac_check(
request, activity.activity_uuid, current_user, "delete", db_session
)
statement = select(Course).where(Course.id == activity.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
await rbac_check(request, course.course_uuid, current_user, "delete", db_session)
# Delete activity from chapter
statement = select(ChapterActivity).where(
@ -249,7 +272,25 @@ async def get_activities(
)
# RBAC check
await rbac_check(request, "activity_x", current_user, "read", db_session)
statement = select(Chapter).where(Chapter.id == coursechapter_id)
chapter = db_session.exec(statement).first()
if not chapter:
raise HTTPException(
status_code=404,
detail="Chapter not found",
)
statement = select(Course).where(Course.id == chapter.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
activities = [ActivityRead.model_validate(activity) for activity in activities]

View file

@ -0,0 +1,176 @@
from datetime import datetime
from fastapi import HTTPException, Request, status
from sqlmodel import Session, select, and_
from src.db.users import PublicUser, AnonymousUser, User, UserRead
from src.db.courses.courses import Course
from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum
from src.security.rbac.rbac import authorization_verify_if_user_is_anon, authorization_verify_based_on_roles_and_authorship
from typing import List
async def apply_course_contributor(
request: Request,
course_uuid: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
# Verify user is not anonymous
await authorization_verify_if_user_is_anon(current_user.id)
# Check if course exists
statement = select(Course).where(Course.course_uuid == course_uuid)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# Check if user already has any authorship role for this course
existing_authorship = db_session.exec(
select(ResourceAuthor).where(
and_(
ResourceAuthor.resource_uuid == course_uuid,
ResourceAuthor.user_id == current_user.id
)
)
).first()
if existing_authorship:
raise HTTPException(
status_code=400,
detail="You already have an authorship role for this course",
)
# Create pending contributor application
resource_author = ResourceAuthor(
resource_uuid=course_uuid,
user_id=current_user.id,
authorship=ResourceAuthorshipEnum.CONTRIBUTOR,
authorship_status=ResourceAuthorshipStatusEnum.PENDING,
creation_date=str(datetime.now()),
update_date=str(datetime.now()),
)
db_session.add(resource_author)
db_session.commit()
db_session.refresh(resource_author)
return {
"detail": "Contributor application submitted successfully",
"status": "pending"
}
async def update_course_contributor(
request: Request,
course_uuid: str,
contributor_user_id: int,
authorship: ResourceAuthorshipEnum,
authorship_status: ResourceAuthorshipStatusEnum,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
"""
Update a course contributor's role and status
Only administrators can perform this action
"""
# Verify user is not anonymous
await authorization_verify_if_user_is_anon(current_user.id)
# RBAC check - verify if user has admin rights
authorized = await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, "update", course_uuid, db_session
)
if not authorized:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not authorized to update course contributors",
)
# Check if course exists
statement = select(Course).where(Course.course_uuid == course_uuid)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# Check if the contributor exists for this course
existing_authorship = db_session.exec(
select(ResourceAuthor).where(
and_(
ResourceAuthor.resource_uuid == course_uuid,
ResourceAuthor.user_id == contributor_user_id
)
)
).first()
if not existing_authorship:
raise HTTPException(
status_code=404,
detail="Contributor not found for this course",
)
# Don't allow changing the role of the creator
if existing_authorship.authorship == ResourceAuthorshipEnum.CREATOR:
raise HTTPException(
status_code=400,
detail="Cannot modify the role of the course creator",
)
# Update the contributor's role and status
existing_authorship.authorship = authorship
existing_authorship.authorship_status = authorship_status
existing_authorship.update_date = str(datetime.now())
db_session.add(existing_authorship)
db_session.commit()
db_session.refresh(existing_authorship)
return {
"detail": "Contributor updated successfully",
"status": "success"
}
async def get_course_contributors(
request: Request,
course_uuid: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> List[dict]:
"""
Get all contributors for a course with their user information
"""
# Check if course exists
statement = select(Course).where(Course.course_uuid == course_uuid)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# Get all contributors for this course with user information
statement = (
select(ResourceAuthor, User)
.join(User) # SQLModel will automatically join on foreign key
.where(ResourceAuthor.resource_uuid == course_uuid)
)
results = db_session.exec(statement).all()
return [
{
"user_id": contributor.user_id,
"authorship": contributor.authorship,
"authorship_status": contributor.authorship_status,
"creation_date": contributor.creation_date,
"update_date": contributor.update_date,
"user": UserRead.model_validate(user).model_dump()
}
for contributor, user in results
]

View file

@ -10,7 +10,7 @@ from src.security.features_utils.usage import (
increase_feature_usage,
)
from src.services.trail.trail import get_user_trail_with_orgid
from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum
from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum
from src.db.users import PublicUser, AnonymousUser, User, UserRead
from src.db.courses.courses import (
Course,
@ -18,6 +18,7 @@ from src.db.courses.courses import (
CourseRead,
CourseUpdate,
FullCourseReadWithTrail,
AuthorWithRole,
)
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship,
@ -48,16 +49,28 @@ async def get_course(
# RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
# Get course authors
# Get course authors with their roles
authors_statement = (
select(User)
.join(ResourceAuthor)
select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id)
.where(ResourceAuthor.resource_uuid == course.course_uuid)
.order_by(
ResourceAuthor.id.asc()
)
)
authors = db_session.exec(authors_statement).all()
author_results = db_session.exec(authors_statement).all()
# convert from User to UserRead
authors = [UserRead.model_validate(author) for author in authors]
# Convert to AuthorWithRole objects
authors = [
AuthorWithRole(
user=UserRead.model_validate(user),
authorship=resource_author.authorship,
authorship_status=resource_author.authorship_status,
creation_date=resource_author.creation_date,
update_date=resource_author.update_date
)
for resource_author, user in author_results
]
course = CourseRead(**course.model_dump(), authors=authors)
@ -82,16 +95,28 @@ async def get_course_by_id(
# RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
# Get course authors
# Get course authors with their roles
authors_statement = (
select(User)
.join(ResourceAuthor)
select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id)
.where(ResourceAuthor.resource_uuid == course.course_uuid)
.order_by(
ResourceAuthor.id.asc()
)
)
authors = db_session.exec(authors_statement).all()
author_results = db_session.exec(authors_statement).all()
# convert from User to UserRead
authors = [UserRead.model_validate(author) for author in authors]
# Convert to AuthorWithRole objects
authors = [
AuthorWithRole(
user=UserRead.model_validate(user),
authorship=resource_author.authorship,
authorship_status=resource_author.authorship_status,
creation_date=resource_author.creation_date,
update_date=resource_author.update_date
)
for resource_author, user in author_results
]
course = CourseRead(**course.model_dump(), authors=authors)
@ -123,12 +148,15 @@ async def get_course_meta(
# Start async tasks concurrently
tasks = []
# Task 1: Get course authors
# Task 1: Get course authors with their roles
async def get_authors():
authors_statement = (
select(User)
.join(ResourceAuthor)
select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id) # type: ignore
.where(ResourceAuthor.resource_uuid == course.course_uuid)
.order_by(
ResourceAuthor.id.asc() # type: ignore
)
)
return db_session.exec(authors_statement).all()
@ -153,10 +181,19 @@ async def get_course_meta(
tasks.append(get_trail())
# Run all tasks concurrently
authors_raw, chapters, trail = await asyncio.gather(*tasks)
author_results, chapters, trail = await asyncio.gather(*tasks)
# Convert authors from User to UserRead
authors = [UserRead.model_validate(author) for author in authors_raw]
# Convert to AuthorWithRole objects
authors = [
AuthorWithRole(
user=UserRead.model_validate(user),
authorship=resource_author.authorship,
authorship_status=resource_author.authorship_status,
creation_date=resource_author.creation_date,
update_date=resource_author.update_date
)
for resource_author, user in author_results
]
# Create course read model
course_read = CourseRead(**course.model_dump(), authors=authors)
@ -167,6 +204,7 @@ async def get_course_meta(
trail=trail,
)
async def get_courses_orgslug(
request: Request,
current_user: PublicUser | AnonymousUser,
@ -225,6 +263,9 @@ async def get_courses_orgslug(
select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id) # type: ignore
.where(ResourceAuthor.resource_uuid.in_(course_uuids)) # type: ignore
.order_by(
ResourceAuthor.id.asc()
)
)
author_results = db_session.exec(authors_query).all()
@ -234,13 +275,23 @@ async def get_courses_orgslug(
for resource_author, user in author_results:
if resource_author.resource_uuid not in course_authors:
course_authors[resource_author.resource_uuid] = []
course_authors[resource_author.resource_uuid].append(UserRead.model_validate(user))
course_authors[resource_author.resource_uuid].append(
AuthorWithRole(
user=UserRead.model_validate(user),
authorship=resource_author.authorship,
authorship_status=resource_author.authorship_status,
creation_date=resource_author.creation_date,
update_date=resource_author.update_date
)
)
# Create CourseRead objects with authors
course_reads = []
for course in courses:
course_read = CourseRead.model_validate(course)
course_read.authors = course_authors.get(course.course_uuid, [])
course_read = CourseRead(
**course.model_dump(),
authors=course_authors.get(course.course_uuid, [])
)
course_reads.append(course_read)
return course_reads
@ -306,15 +357,31 @@ async def search_courses(
# Fetch authors for each course
course_reads = []
for course in courses:
authors_query = (
select(User)
.join(ResourceAuthor, ResourceAuthor.user_id == User.id) # type: ignore
# Get course authors with their roles
authors_statement = (
select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id)
.where(ResourceAuthor.resource_uuid == course.course_uuid)
.order_by(
ResourceAuthor.id.asc()
)
)
authors = db_session.exec(authors_query).all()
author_results = db_session.exec(authors_statement).all()
# Convert to AuthorWithRole objects
authors = [
AuthorWithRole(
user=UserRead.model_validate(user),
authorship=resource_author.authorship,
authorship_status=resource_author.authorship_status,
creation_date=resource_author.creation_date,
update_date=resource_author.update_date
)
for resource_author, user in author_results
]
course_read = CourseRead.model_validate(course)
course_read.authors = [UserRead.model_validate(author) for author in authors]
course_read.authors = authors
course_reads.append(course_read)
return course_reads
@ -368,6 +435,7 @@ async def create_course(
resource_uuid=course.course_uuid,
user_id=current_user.id,
authorship=ResourceAuthorshipEnum.CREATOR,
authorship_status=ResourceAuthorshipStatusEnum.ACTIVE,
creation_date=str(datetime.now()),
update_date=str(datetime.now()),
)
@ -377,20 +445,32 @@ async def create_course(
db_session.commit()
db_session.refresh(resource_author)
# Get course authors
# Get course authors with their roles
authors_statement = (
select(User)
.join(ResourceAuthor)
select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id)
.where(ResourceAuthor.resource_uuid == course.course_uuid)
.order_by(
ResourceAuthor.id.asc()
)
)
authors = db_session.exec(authors_statement).all()
author_results = db_session.exec(authors_statement).all()
# Convert to AuthorWithRole objects
authors = [
AuthorWithRole(
user=UserRead.model_validate(user),
authorship=resource_author.authorship,
authorship_status=resource_author.authorship_status,
creation_date=resource_author.creation_date,
update_date=resource_author.update_date
)
for resource_author, user in author_results
]
# Feature usage
increase_feature_usage("courses", course.org_id, db_session)
# convert from User to UserRead
authors = [UserRead.model_validate(author) for author in authors]
course = CourseRead(**course.model_dump(), authors=authors)
return CourseRead.model_validate(course)
@ -444,16 +524,28 @@ async def update_course_thumbnail(
db_session.commit()
db_session.refresh(course)
# Get course authors
# Get course authors with their roles
authors_statement = (
select(User)
.join(ResourceAuthor)
select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id)
.where(ResourceAuthor.resource_uuid == course.course_uuid)
.order_by(
ResourceAuthor.id.asc()
)
)
authors = db_session.exec(authors_statement).all()
author_results = db_session.exec(authors_statement).all()
# convert from User to UserRead
authors = [UserRead.model_validate(author) for author in authors]
# Convert to AuthorWithRole objects
authors = [
AuthorWithRole(
user=UserRead.model_validate(user),
authorship=resource_author.authorship,
authorship_status=resource_author.authorship_status,
creation_date=resource_author.creation_date,
update_date=resource_author.update_date
)
for resource_author, user in author_results
]
course = CourseRead(**course.model_dump(), authors=authors)
@ -491,16 +583,28 @@ async def update_course(
db_session.commit()
db_session.refresh(course)
# Get course authors
# Get course authors with their roles
authors_statement = (
select(User)
.join(ResourceAuthor)
select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id)
.where(ResourceAuthor.resource_uuid == course.course_uuid)
.order_by(
ResourceAuthor.id.asc()
)
)
authors = db_session.exec(authors_statement).all()
author_results = db_session.exec(authors_statement).all()
# convert from User to UserRead
authors = [UserRead.model_validate(author) for author in authors]
# Convert to AuthorWithRole objects
authors = [
AuthorWithRole(
user=UserRead.model_validate(user),
authorship=resource_author.authorship,
authorship_status=resource_author.authorship_status,
creation_date=resource_author.creation_date,
update_date=resource_author.update_date
)
for resource_author, user in author_results
]
course = CourseRead(**course.model_dump(), authors=authors)

View file

@ -3,8 +3,10 @@ from fastapi import HTTPException, Request
from sqlmodel import Session, select
from sqlalchemy import text
from src.db.courses.courses import Course, CourseRead
from src.db.courses.courses import Course, CourseRead, AuthorWithRole
from src.db.organizations import Organization, OrganizationRead
from src.db.users import User, UserRead
from src.db.resource_authors import ResourceAuthor
def _get_sort_expression(salt: str):
@ -96,7 +98,27 @@ async def get_course_for_explore(
detail="Course not found",
)
return CourseRead.model_validate(course)
# Get course authors with their roles
authors_statement = (
select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id)
.where(ResourceAuthor.resource_uuid == course.course_uuid)
)
author_results = db_session.exec(authors_statement).all()
# Convert to AuthorWithRole objects
authors = [
AuthorWithRole(
user=UserRead.model_validate(user),
authorship=resource_author.authorship,
authorship_status=resource_author.authorship_status,
creation_date=resource_author.creation_date,
update_date=resource_author.update_date
)
for resource_author, user in author_results
]
return CourseRead(**course.model_dump(), authors=authors)
async def search_orgs_for_explore(
request: Request,

View file

@ -1,7 +1,7 @@
from fastapi import HTTPException, Request
from sqlmodel import Session, select
from typing import Any
from src.db.courses.courses import Course, CourseRead
from src.db.courses.courses import Course, CourseRead, AuthorWithRole
from src.db.payments.payments_courses import PaymentsCourse
from src.db.payments.payments_users import PaymentsUser, PaymentStatusEnum, ProviderSpecificData
from src.db.payments.payments_products import PaymentsProduct
@ -231,19 +231,28 @@ async def get_owned_courses(
# Get authors for each course and convert to CourseRead
course_reads = []
for course in unique_courses:
# Get course authors
# Get course authors with their roles
authors_statement = (
select(User)
.join(ResourceAuthor)
select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id)
.where(ResourceAuthor.resource_uuid == course.course_uuid)
)
authors = db_session.exec(authors_statement).all()
author_results = db_session.exec(authors_statement).all()
# Convert authors to UserRead
author_reads = [UserRead.model_validate(author) for author in authors]
# Convert to AuthorWithRole objects
authors = [
AuthorWithRole(
user=UserRead.model_validate(user),
authorship=resource_author.authorship,
authorship_status=resource_author.authorship_status,
creation_date=resource_author.creation_date,
update_date=resource_author.update_date
)
for resource_author, user in author_results
]
# Create CourseRead object
course_read = CourseRead(**course.model_dump(), authors=author_reads)
course_read = CourseRead(**course.model_dump(), authors=authors)
course_reads.append(course_read)
return course_reads

View file

@ -22,7 +22,7 @@ export default function RootLayout({
<head />
<body>
{isDevEnv ? '' : <Script data-website-id="a1af6d7a-9286-4a1f-8385-ddad2a29fcbb" src="/umami/script.js" />}
<SessionProvider>
<SessionProvider key="session-provider">
<LHSessionProvider>
<StyledComponentsRegistry>
<motion.main

View file

@ -3,7 +3,7 @@ import Link from 'next/link'
import { getAPIUrl, getUriWithOrg } from '@services/config/config'
import Canva from '@components/Objects/Activities/DynamicCanva/DynamicCanva'
import VideoActivity from '@components/Objects/Activities/Video/Video'
import { BookOpenCheck, Check, CheckCircle, ChevronDown, ChevronLeft, ChevronRight, FileText, Folder, List, Menu, MoreVertical, UserRoundPen, Video, Layers, ListFilter, ListTree, X } from 'lucide-react'
import { BookOpenCheck, Check, CheckCircle, ChevronDown, ChevronLeft, ChevronRight, FileText, Folder, List, Menu, MoreVertical, UserRoundPen, Video, Layers, ListFilter, ListTree, X, Edit2 } from 'lucide-react'
import { markActivityAsComplete } from '@services/courses/activity'
import DocumentPdfActivity from '@components/Objects/Activities/DocumentPdf/DocumentPdf'
import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators'
@ -27,6 +27,7 @@ import { mutate } from 'swr'
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import { useMediaQuery } from 'usehooks-ts'
import PaidCourseActivityDisclaimer from '@components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer'
import { useContributorStatus } from '../../../../../../../../hooks/useContributorStatus'
interface ActivityClientProps {
activityid: string
@ -49,6 +50,7 @@ function ActivityClient(props: ActivityClientProps) {
const [bgColor, setBgColor] = React.useState('bg-white')
const [assignment, setAssignment] = React.useState(null) as any;
const [markStatusButtonActive, setMarkStatusButtonActive] = React.useState(false);
const { contributorStatus } = useContributorStatus(courseuuid);
function getChapterNameByActivityId(course: any, activity_id: any) {
@ -90,27 +92,29 @@ function ActivityClient(props: ActivityClientProps) {
<AIChatBotProvider>
<GeneralWrapperStyled>
<div className="space-y-4 pt-4">
<div className="flex space-x-6">
<div className="flex">
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}`}
>
<img
className="w-[100px] h-[57px] rounded-md drop-shadow-md"
src={`${getCourseThumbnailMediaDirectory(
org?.org_uuid,
course.course_uuid,
course.thumbnail_image
)}`}
alt=""
/>
</Link>
</div>
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">Course </p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
{course.name}
</h1>
<div className="flex justify-between items-center">
<div className="flex space-x-6">
<div className="flex">
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}`}
>
<img
className="w-[100px] h-[57px] rounded-md drop-shadow-md"
src={`${getCourseThumbnailMediaDirectory(
org?.org_uuid,
course.course_uuid,
course.thumbnail_image
)}`}
alt=""
/>
</Link>
</div>
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">Course </p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
{course.name}
</h1>
</div>
</div>
</div>
<ActivityIndicators
@ -136,13 +140,22 @@ function ActivityClient(props: ActivityClientProps) {
</h1>
</div>
</div>
<div className="flex space-x-1 items-center">
<div className="flex space-x-2 items-center">
{activity && activity.published == true && activity.content.paid_access != false && (
<AuthenticatedClientElement checkMethod="authentication">
{activity.activity_type != 'TYPE_ASSIGNMENT' &&
<>
<AIActivityAsk activity={activity} />
<MoreVertical size={17} className="text-gray-300 " />
{contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && (
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}/activity/${activityid}/edit`}
className="bg-emerald-600 rounded-full px-5 drop-shadow-md flex items-center space-x-2 p-2.5 text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out"
>
<Edit2 size={17} />
<span className="text-xs font-bold">Contribute to Activity</span>
</Link>
)}
<MoreVertical size={17} className="text-gray-300" />
<MarkStatus
activity={activity}
activityid={activityid}
@ -165,7 +178,6 @@ function ActivityClient(props: ActivityClientProps) {
</AssignmentSubmissionProvider>
</>
}
</AuthenticatedClientElement>
)}
</div>

View file

@ -78,7 +78,7 @@ const ActivityPage = async (params: any) => {
fetchCourseMetadata(courseuuid, access_token),
getActivityWithAuthHeader(
activityid,
{ revalidate: 1800, tags: ['activities'] },
{ revalidate: 0, tags: ['activities'] },
access_token || null
)
])

View file

@ -29,6 +29,8 @@ const CourseClient = (props: any) => {
const router = useRouter()
const isMobile = useMediaQuery('(max-width: 768px)')
console.log(course)
function getLearningTags() {
if (!course?.learnings) {
setLearnings([])
@ -163,7 +165,7 @@ const CourseClient = (props: any) => {
<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) => {
return (
<div key={chapter} className="">
<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">
<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">
@ -173,7 +175,7 @@ const CourseClient = (props: any) => {
<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">
@ -230,7 +232,7 @@ const CourseClient = (props: any) => {
<div className="flex ">
{activity.activity_type ===
'TYPE_DYNAMIC' && (
<>
<div>
<Link
className="flex grow pl-2 text-gray-500"
href={
@ -248,10 +250,10 @@ const CourseClient = (props: any) => {
<ArrowRight size={13} />
</div>
</Link>
</>
</div>
)}
{activity.activity_type === 'TYPE_VIDEO' && (
<>
<div>
<Link
className="flex grow pl-2 text-gray-500"
href={
@ -269,11 +271,11 @@ const CourseClient = (props: any) => {
<ArrowRight size={13} />
</div>
</Link>
</>
</div>
)}
{activity.activity_type ===
'TYPE_DOCUMENT' && (
<>
<div>
<Link
className="flex grow pl-2 text-gray-500"
href={
@ -291,11 +293,11 @@ const CourseClient = (props: any) => {
<ArrowRight size={13} />
</div>
</Link>
</>
</div>
)}
{activity.activity_type ===
'TYPE_ASSIGNMENT' && (
<>
<div>
<Link
className="flex grow pl-2 text-gray-500"
href={
@ -313,11 +315,11 @@ const CourseClient = (props: any) => {
<ArrowRight size={13} />
</div>
</Link>
</>
</div>
)}
</div>
</div>
</>
</div>
)
})}
</div>
@ -333,7 +335,7 @@ const CourseClient = (props: any) => {
</GeneralWrapperStyled>
{isMobile && (
<div className="fixed bottom-0 left-0 right-0 bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 p-4 z-50">
<div className="fixed bottom-0 left-0 right-0 p-4 z-50">
<CourseActionsMobile courseuuid={courseuuid} orgslug={orgslug} course={course} />
</div>
)}

View file

@ -72,7 +72,7 @@ const CoursePage = async (params: any) => {
// Fetch course metadata once
const course_meta = await getCourseMetadata(
params.params.courseuuid,
{ revalidate: 1800, tags: ['courses'] },
{ revalidate: 0, tags: ['courses'] },
access_token ? access_token : null
)

View file

@ -5,11 +5,11 @@ import { CourseProvider } from '../../../../../../../../components/Contexts/Cour
import Link from 'next/link'
import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop'
import { motion } from 'framer-motion'
import { GalleryVerticalEnd, Info, UserRoundCog } from 'lucide-react'
import { GalleryVerticalEnd, Globe, Info, UserPen, UserRoundCog, Users } from 'lucide-react'
import EditCourseStructure from '@components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure'
import EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral'
import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess'
import EditCourseContributors from '@components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors'
export type CourseOverviewParams = {
orgslug: string
courseuuid: string
@ -26,7 +26,7 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
return (
<div className="h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto_1fr]">
<CourseProvider courseuuid={getEntireCourseUUID(params.courseuuid)}>
<div className="pl-10 pr-10 text-sm tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]">
<div className="pl-10 pr-10 text-sm tracking-tight bg-[#fcfbfc] z-10 nice-shadow">
<CourseOverviewTop params={params} />
<div className="flex space-x-3 font-black text-sm">
<Link
@ -47,24 +47,7 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
</div>
</div>
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/access`
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'access'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<UserRoundCog size={16} />
<div>Access</div>
</div>
</div>
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
@ -83,6 +66,42 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
</div>
</div>
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/access`
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'access'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<Globe size={16} />
<div>Access</div>
</div>
</div>
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/contributors`
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'contributors'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<UserPen size={16} />
<div>Contributors</div>
</div>
</div>
</Link>
</div>
</div>
@ -96,6 +115,8 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
{params.subpage == 'content' ? (<EditCourseStructure orgslug={params.orgslug} />) : ('')}
{params.subpage == 'general' ? (<EditCourseGeneral orgslug={params.orgslug} />) : ('')}
{params.subpage == 'access' ? (<EditCourseAccess orgslug={params.orgslug} />) : ('')}
{params.subpage == 'contributors' ? (<EditCourseContributors orgslug={params.orgslug} />) : ('')}
</motion.div>
</CourseProvider>
</div>

View file

@ -0,0 +1,308 @@
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import { getAPIUrl } from '@services/config/config'
import { editContributor, getCourseContributors } from '@services/courses/courses'
import { swrFetcher } from '@services/utils/ts/requests'
import { Check, ChevronDown, UserPen, Users } from 'lucide-react'
import React, { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import useSWR, { mutate } from 'swr'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import UserAvatar from '@components/Objects/UserAvatar'
type EditCourseContributorsProps = {
orgslug: string
course_uuid?: string
}
type ContributorRole = 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER'
type ContributorStatus = 'ACTIVE' | 'INACTIVE' | 'PENDING'
interface Contributor {
id: string;
user_id: string;
authorship: ContributorRole;
authorship_status: ContributorStatus;
user: {
username: string;
first_name: string;
last_name: string;
email: string;
avatar_image: string;
}
}
function EditCourseContributors(props: EditCourseContributorsProps) {
const session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token;
const course = useCourse() as any;
const { isLoading, courseStructure } = course as any;
const dispatchCourse = useCourseDispatch() as any;
const { data: contributors } = useSWR<Contributor[]>(
courseStructure ? `${getAPIUrl()}courses/${courseStructure.course_uuid}/contributors` : null,
(url: string) => swrFetcher(url, access_token)
);
const [isOpenToContributors, setIsOpenToContributors] = useState<boolean | undefined>(undefined);
useEffect(() => {
if (!isLoading && courseStructure?.open_to_contributors !== undefined) {
setIsOpenToContributors(courseStructure.open_to_contributors);
}
}, [isLoading, courseStructure]);
useEffect(() => {
if (!isLoading && courseStructure?.open_to_contributors !== undefined && isOpenToContributors !== undefined) {
if (isOpenToContributors !== courseStructure.open_to_contributors) {
dispatchCourse({ type: 'setIsNotSaved' });
const updatedCourse = {
...courseStructure,
open_to_contributors: isOpenToContributors,
};
dispatchCourse({ type: 'setCourseStructure', payload: updatedCourse });
}
}
}, [isLoading, isOpenToContributors, courseStructure, dispatchCourse]);
const updateContributor = async (contributorId: string, data: { authorship?: ContributorRole; authorship_status?: ContributorStatus }) => {
try {
// Find the current contributor to get their current values
const currentContributor = contributors?.find(c => c.user_id === contributorId);
if (!currentContributor) return;
// Don't allow editing if the user is a CREATOR
if (currentContributor.authorship === 'CREATOR') {
toast.error('Cannot modify a creator\'s role or status');
return;
}
// Always send both values in the request
const updatedData = {
authorship: data.authorship || currentContributor.authorship,
authorship_status: data.authorship_status || currentContributor.authorship_status
};
const res = await editContributor(courseStructure.course_uuid, contributorId, updatedData.authorship, updatedData.authorship_status, access_token);
if (res.status === 200 && res.data?.status === 'success') {
toast.success(res.data.detail || 'Successfully updated contributor');
mutate(`${getAPIUrl()}courses/${courseStructure.course_uuid}/contributors`);
} else {
toast.error(`Error: ${res.data?.detail || 'Failed to update contributor'}`);
}
} catch (error) {
toast.error('An error occurred while updating the contributor.');
}
};
const RoleDropdown = ({ contributor }: { contributor: Contributor }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-[200px] justify-between"
disabled={contributor.authorship === 'CREATOR'}
>
{contributor.authorship}
<ChevronDown className="ml-2 h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{['CONTRIBUTOR', 'MAINTAINER', 'REPORTER'].map((role) => (
<DropdownMenuItem
key={role}
onClick={() => updateContributor(contributor.user_id, { authorship: role as ContributorRole })}
className="justify-between"
>
{role}
{contributor.authorship === role && <Check className="ml-2 h-4 w-4" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
const StatusDropdown = ({ contributor }: { contributor: Contributor }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className={`w-[200px] justify-between ${getStatusStyle(contributor.authorship_status)}`}
disabled={contributor.authorship === 'CREATOR'}
>
{contributor.authorship_status}
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{['ACTIVE', 'INACTIVE', 'PENDING'].map((status) => (
<DropdownMenuItem
key={status}
onClick={() => updateContributor(contributor.user_id, { authorship_status: status as ContributorStatus })}
className="justify-between"
>
{status}
{contributor.authorship_status === status && <Check className="ml-2 h-4 w-4" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
const getStatusStyle = (status: ContributorStatus) => {
switch (status) {
case 'ACTIVE':
return 'bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800';
case 'INACTIVE':
return 'bg-gray-50 text-gray-700 hover:bg-gray-100 hover:text-gray-800';
case 'PENDING':
return 'bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800';
default:
return 'bg-gray-50 text-gray-700 hover:bg-gray-100 hover:text-gray-800';
}
};
const sortContributors = (contributors: Contributor[] | undefined) => {
if (!contributors) return [];
// Find the creator and other contributors
const creator = contributors.find(c => c.authorship === 'CREATOR');
const otherContributors = contributors.filter(c => c.authorship !== 'CREATOR');
// Return array with creator at the top, followed by other contributors in their original order
return creator ? [creator, ...otherContributors] : otherContributors;
};
return (
<div>
{courseStructure && (
<div>
<div className="h-6"></div>
<div className="mx-4 sm:mx-10 bg-white rounded-xl shadow-xs px-4 py-4">
<div className="flex flex-col bg-gray-50 -space-y-1 px-3 sm:px-5 py-3 rounded-md mb-3">
<h1 className="font-bold text-lg sm:text-xl text-gray-800">Course Contributors</h1>
<h2 className="text-gray-500 text-xs sm:text-sm">
Choose if you want your course to be open for contributors and manage existing contributors
</h2>
</div>
<div className="flex flex-col sm:flex-row sm:space-x-2 space-y-2 sm:space-y-0 mx-auto mb-3">
<ConfirmationModal
confirmationButtonText="Open to Contributors"
confirmationMessage="Are you sure you want to open this course to contributors?"
dialogTitle="Open to Contributors?"
dialogTrigger={
<div className="w-full h-[200px] bg-slate-100 rounded-lg cursor-pointer hover:bg-slate-200 transition-all">
{isOpenToContributors && (
<div className="bg-green-200 text-green-600 font-bold w-fit my-3 mx-3 absolute text-sm px-3 py-1 rounded-lg">
Active
</div>
)}
<div className="flex flex-col space-y-1 justify-center items-center h-full p-2 sm:p-4">
<UserPen className="text-slate-400" size={32} />
<div className="text-xl sm:text-2xl text-slate-700 font-bold">
Open to Contributors
</div>
<div className="text-gray-400 text-sm sm:text-md tracking-tight w-full sm:w-[500px] leading-5 text-center">
The course is open for contributors. Users can apply to become contributors and help improve the course content.
</div>
</div>
</div>
}
functionToExecute={() => setIsOpenToContributors(true)}
status="info"
/>
<ConfirmationModal
confirmationButtonText="Close to Contributors"
confirmationMessage="Are you sure you want to close this course to contributors?"
dialogTitle="Close to Contributors?"
dialogTrigger={
<div className="w-full h-[200px] bg-slate-100 rounded-lg cursor-pointer hover:bg-slate-200 transition-all">
{!isOpenToContributors && (
<div className="bg-green-200 text-green-600 font-bold w-fit my-3 mx-3 absolute text-sm px-3 py-1 rounded-lg">
Active
</div>
)}
<div className="flex flex-col space-y-1 justify-center items-center h-full p-2 sm:p-4">
<Users className="text-slate-400" size={32} />
<div className="text-xl sm:text-2xl text-slate-700 font-bold">
Closed to Contributors
</div>
<div className="text-gray-400 text-sm sm:text-md tracking-tight w-full sm:w-[500px] leading-5 text-center">
The course is closed for contributors. Only existing contributors can modify the course content.
</div>
</div>
</div>
}
functionToExecute={() => setIsOpenToContributors(false)}
status="info"
/>
</div>
<div className="flex flex-col bg-gray-50 -space-y-1 px-3 sm:px-5 py-3 rounded-md mb-3">
<h1 className="font-bold text-lg sm:text-xl text-gray-800">Current Contributors</h1>
<h2 className="text-gray-500 text-xs sm:text-sm">
Manage the current contributors of this course
</h2>
</div>
<div className="max-h-[600px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]"></TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortContributors(contributors)?.map((contributor) => (
<TableRow key={contributor.id}>
<TableCell>
<UserAvatar
width={30}
border='border-2'
avatar_url={contributor.user.avatar_image}
rounded="rounded"
predefined_avatar={contributor.user.avatar_image === '' ? 'empty' : undefined}
/>
</TableCell>
<TableCell className="font-medium">
{contributor.user.first_name} {contributor.user.last_name}
</TableCell>
<TableCell className="text-gray-500">
{contributor.user.email}
</TableCell>
<TableCell>
<RoleDropdown contributor={contributor} />
</TableCell>
<TableCell>
<StatusDropdown contributor={contributor} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
)}
</div>
);
}
export default EditCourseContributors;

View file

@ -9,6 +9,7 @@ import {
FilePenLine,
FileSymlink,
Globe,
Loader2,
Lock,
MoreVertical,
Pencil,
@ -52,6 +53,7 @@ function ActivityElement(props: ActivitiyElementProps) {
const [selectedActivity, setSelectedActivity] = React.useState<
string | undefined
>(undefined)
const [isUpdatingName, setIsUpdatingName] = React.useState<boolean>(false)
const activityUUID = props.activity.activity_uuid
const isMobile = useMediaQuery('(max-width: 767px)')
@ -92,18 +94,29 @@ function ActivityElement(props: ActivitiyElementProps) {
modifiedActivity?.activityId === activityId &&
selectedActivity !== undefined
) {
setIsUpdatingName(true)
let modifiedActivityCopy = {
...props.activity,
name: modifiedActivity.activityName,
}
await updateActivity(modifiedActivityCopy, activityUUID, access_token)
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`)
await revalidateTags(['courses'], props.orgslug)
router.refresh()
try {
await updateActivity(modifiedActivityCopy, activityUUID, access_token)
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`)
await revalidateTags(['courses'], props.orgslug)
toast.success('Activity name updated successfully')
router.refresh()
} catch (error) {
toast.error('Failed to update activity name')
console.error('Error updating activity name:', error)
} finally {
setIsUpdatingName(false)
setSelectedActivity(undefined)
}
} else {
setSelectedActivity(undefined)
}
setSelectedActivity(undefined)
}
return (
@ -142,20 +155,26 @@ function ActivityElement(props: ActivitiyElementProps) {
activityName: e.target.value,
})
}
disabled={isUpdatingName}
/>
<button
onClick={() => updateActivityName(props.activity.id)}
className="bg-transparent text-neutral-700 hover:cursor-pointer hover:text-neutral-900"
className="bg-transparent text-neutral-700 hover:cursor-pointer hover:text-neutral-900 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isUpdatingName}
>
<Save size={12} />
{isUpdatingName ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Save size={12} />
)}
</button>
</div>
) : (
<p className="first-letter:uppercase text-center sm:text-left"> {props.activity.name} </p>
)}
<Pencil
onClick={() => setSelectedActivity(props.activity.id)}
className="text-neutral-400 hover:cursor-pointer size-3 min-w-3"
onClick={() => !isUpdatingName && setSelectedActivity(props.activity.id)}
className={`text-neutral-400 hover:cursor-pointer size-3 min-w-3 ${isUpdatingName ? 'opacity-50 cursor-not-allowed' : ''}`}
/>
</div>

View file

@ -3,7 +3,7 @@ import { useRouter } from 'next/navigation'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { getUriWithoutOrg, getUriWithOrg } from '@services/config/config'
import { getProductsByCourse } from '@services/payments/products'
import { LogIn, LogOut, ShoppingCart } from 'lucide-react'
import { LogIn, LogOut, ShoppingCart, AlertCircle } from 'lucide-react'
import Modal from '@components/Objects/StyledElements/Modal/Modal'
import CoursePaidOptions from './CoursePaidOptions'
import { checkPaidAccess } from '@services/payments/payments'
@ -13,11 +13,15 @@ import UserAvatar from '../../UserAvatar'
import { getUserAvatarMediaDirectory } from '@services/media/media'
interface Author {
user_uuid: string
avatar_image: string
first_name: string
last_name: string
username: string
user: {
user_uuid: string
avatar_image: string
first_name: string
last_name: string
username: string
}
authorship: 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER'
authorship_status: 'ACTIVE' | 'INACTIVE' | 'PENDING'
}
interface CourseRun {
@ -49,11 +53,81 @@ interface CourseActionsMobileProps {
}
}
// Component for displaying multiple authors
const MultipleAuthors = ({ authors }: { authors: Author[] }) => {
const displayedAvatars = authors.slice(0, 3)
const remainingCount = Math.max(0, authors.length - 3)
// Avatar size for mobile
const avatarSize = 36
const borderSize = "border-2"
return (
<div className="flex items-center gap-3">
<div className="flex -space-x-3 relative">
{displayedAvatars.map((author, index) => (
<div
key={author.user.user_uuid}
className="relative"
style={{ zIndex: displayedAvatars.length - index }}
>
<UserAvatar
border={borderSize}
rounded='rounded-full'
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}
/>
</div>
))}
{remainingCount > 0 && (
<div
className="relative"
style={{ zIndex: 0 }}
>
<div
className="flex items-center justify-center bg-neutral-100 text-neutral-600 font-medium rounded-full border-2 border-white shadow-sm"
style={{
width: `${avatarSize}px`,
height: `${avatarSize}px`,
fontSize: '12px'
}}
>
+{remainingCount}
</div>
</div>
)}
</div>
<div className="flex flex-col">
<span className="text-xs text-neutral-400 font-medium">
{authors.length > 1 ? 'Authors' : 'Author'}
</span>
{authors.length === 1 ? (
<span className="text-sm font-semibold text-neutral-800">
{authors[0].user.first_name && authors[0].user.last_name
? `${authors[0].user.first_name} ${authors[0].user.last_name}`
: `@${authors[0].user.username}`}
</span>
) : (
<span className="text-sm font-semibold text-neutral-800">
{authors[0].user.first_name && authors[0].user.last_name
? `${authors[0].user.first_name} ${authors[0].user.last_name}`
: `@${authors[0].user.username}`}
{authors.length > 1 && ` & ${authors.length - 1} more`}
</span>
)}
</div>
</div>
)
}
const CourseActionsMobile = ({ courseuuid, orgslug, course }: CourseActionsMobileProps) => {
const router = useRouter()
const session = useLHSession() as any
const [linkedProducts, setLinkedProducts] = useState<any[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isActionLoading, setIsActionLoading] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const [hasAccess, setHasAccess] = useState<boolean | null>(null)
@ -107,106 +181,141 @@ const CourseActionsMobile = ({ courseuuid, orgslug, course }: CourseActionsMobil
return
}
if (isStarted) {
await removeCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
await revalidateTags(['courses'], orgslug)
router.refresh()
} else {
await startCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
await revalidateTags(['courses'], orgslug)
// Get the first activity from the first chapter
const firstChapter = course.chapters?.[0]
const firstActivity = firstChapter?.activities?.[0]
if (firstActivity) {
// Redirect to the first activity
router.push(
getUriWithOrg(orgslug, '') +
`/course/${courseuuid}/activity/${firstActivity.activity_uuid.replace('activity_', '')}`
)
} else {
setIsActionLoading(true)
try {
if (isStarted) {
await removeCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
await revalidateTags(['courses'], orgslug)
router.refresh()
} else {
await startCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
await revalidateTags(['courses'], orgslug)
// Get the first activity from the first chapter
const firstChapter = course.chapters?.[0]
const firstActivity = firstChapter?.activities?.[0]
if (firstActivity) {
// Redirect to the first activity
router.push(
getUriWithOrg(orgslug, '') +
`/course/${courseuuid}/activity/${firstActivity.activity_uuid.replace('activity_', '')}`
)
} else {
router.refresh()
}
}
} catch (error) {
console.error('Failed to perform course action:', error)
} finally {
setIsActionLoading(false)
}
}
if (isLoading) {
return <div className="animate-pulse h-16 bg-gray-100 rounded-lg" />
return <div className="animate-pulse h-16 bg-gray-100 rounded-lg mt-4 mb-8" />
}
const author = course.authors[0]
const authorName = author.first_name && author.last_name
? `${author.first_name} ${author.last_name}`
: `@${author.username}`
// Filter active authors and sort by role priority
const sortedAuthors = [...course.authors]
.filter(author => author.authorship_status === 'ACTIVE')
.sort((a, b) => {
const rolePriority: Record<string, number> = {
'CREATOR': 0,
'MAINTAINER': 1,
'CONTRIBUTOR': 2,
'REPORTER': 3
};
return rolePriority[a.authorship] - rolePriority[b.authorship];
});
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<UserAvatar
border="border-4"
avatar_url={author.avatar_image ? getUserAvatarMediaDirectory(author.user_uuid, author.avatar_image) : ''}
predefined_avatar={author.avatar_image ? undefined : 'empty'}
width={40}
/>
<div className="flex flex-col">
<span className="text-xs text-neutral-400 font-medium">Author</span>
<span className="text-sm font-semibold text-neutral-800">{authorName}</span>
</div>
</div>
<div className="bg-white/90 backdrop-blur-sm shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden p-4 my-6 mx-2">
<div className="flex flex-col space-y-4">
<MultipleAuthors authors={sortedAuthors} />
<div className="shrink-0">
{linkedProducts.length > 0 ? (
hasAccess ? (
<button
onClick={handleCourseAction}
className={`py-2 px-4 rounded-lg font-semibold text-sm transition-colors flex items-center gap-2 ${
isStarted
? 'bg-red-500 text-white hover:bg-red-600'
: 'bg-neutral-900 text-white hover:bg-neutral-800'
}`}
>
{isStarted ? (
<>
<LogOut className="w-4 h-4" />
Leave Course
</>
) : (
<>
<LogIn className="w-4 h-4" />
Start Course
</>
)}
</button>
) : (
<>
<Modal
isDialogOpen={isModalOpen}
onOpenChange={setIsModalOpen}
dialogContent={<CoursePaidOptions course={course} />}
dialogTitle="Purchase Course"
dialogDescription="Select a payment option to access this course"
minWidth="sm"
/>
<div className="space-y-3">
{hasAccess ? (
<div className="p-3 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span className="text-green-800 text-sm font-semibold">You Own This Course</span>
</div>
</div>
) : (
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-amber-800" />
<span className="text-amber-800 text-sm font-semibold">Paid Course</span>
</div>
</div>
)}
{hasAccess ? (
<button
onClick={() => setIsModalOpen(true)}
className="py-2 px-4 rounded-lg bg-neutral-900 text-white font-semibold text-sm hover:bg-neutral-800 transition-colors flex items-center gap-2"
onClick={handleCourseAction}
disabled={isActionLoading}
className={`w-full py-2 px-4 rounded-lg font-semibold text-sm transition-colors flex items-center justify-center gap-2 ${
isStarted
? 'bg-red-500 text-white hover:bg-red-600 disabled:bg-red-400'
: 'bg-neutral-900 text-white hover:bg-neutral-800 disabled:bg-neutral-700'
}`}
>
<ShoppingCart className="w-4 h-4" />
Purchase
{isActionLoading ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : isStarted ? (
<>
<LogOut className="w-4 h-4" />
Leave Course
</>
) : (
<>
<LogIn className="w-4 h-4" />
Start Course
</>
)}
</button>
</>
)
) : (
<>
<Modal
isDialogOpen={isModalOpen}
onOpenChange={setIsModalOpen}
dialogContent={<CoursePaidOptions course={course} />}
dialogTitle="Purchase Course"
dialogDescription="Select a payment option to access this course"
minWidth="sm"
/>
<button
onClick={() => setIsModalOpen(true)}
disabled={isActionLoading}
className="w-full py-2 px-4 rounded-lg bg-neutral-900 text-white font-semibold text-sm hover:bg-neutral-800 transition-colors flex items-center justify-center gap-2 disabled:bg-neutral-700"
>
{isActionLoading ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<>
<ShoppingCart className="w-4 h-4" />
Purchase Course
</>
)}
</button>
</>
)}
</div>
) : (
<button
onClick={handleCourseAction}
className={`py-2 px-4 rounded-lg font-semibold text-sm transition-colors flex items-center gap-2 ${
disabled={isActionLoading}
className={`w-full py-2 px-4 rounded-lg font-semibold text-sm transition-colors flex items-center justify-center gap-2 ${
isStarted
? 'bg-red-500 text-white hover:bg-red-600'
: 'bg-neutral-900 text-white hover:bg-neutral-800'
? 'bg-red-500 text-white hover:bg-red-600 disabled:bg-red-400'
: 'bg-neutral-900 text-white hover:bg-neutral-800 disabled:bg-neutral-700'
}`}
>
{!session.data?.user ? (
{isActionLoading ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : !session.data?.user ? (
<>
<LogIn className="w-4 h-4" />
Sign In

View file

@ -8,17 +8,24 @@ import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useMediaQuery } from 'usehooks-ts'
import { getUriWithOrg, getUriWithoutOrg } from '@services/config/config'
import { getProductsByCourse } from '@services/payments/products'
import { LogIn, LogOut, ShoppingCart, AlertCircle } from 'lucide-react'
import { LogIn, LogOut, ShoppingCart, AlertCircle, UserPen, ClockIcon } from 'lucide-react'
import Modal from '@components/Objects/StyledElements/Modal/Modal'
import CoursePaidOptions from './CoursePaidOptions'
import { checkPaidAccess } from '@services/payments/payments'
import { applyForContributor } from '@services/courses/courses'
import toast from 'react-hot-toast'
import { useContributorStatus } from '../../../../hooks/useContributorStatus'
interface Author {
user_uuid: string
avatar_image: string
first_name: string
last_name: string
username: string
user: {
user_uuid: string
avatar_image: string
first_name: string
last_name: string
username: string
}
authorship: 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER'
authorship_status: 'ACTIVE' | 'INACTIVE' | 'PENDING'
}
interface CourseRun {
@ -40,6 +47,7 @@ interface Course {
activity_type: string
}>
}>
open_to_contributors?: boolean
}
interface CourseActionsProps {
@ -55,23 +63,23 @@ const AuthorInfo = ({ author, isMobile }: { author: Author, isMobile: boolean })
<div className="flex flex-row md:flex-col mx-auto space-y-0 md:space-y-3 space-x-4 md:space-x-0 px-2 py-2 items-center">
<UserAvatar
border="border-8"
avatar_url={author.avatar_image ? getUserAvatarMediaDirectory(author.user_uuid, author.avatar_image) : ''}
predefined_avatar={author.avatar_image ? undefined : 'empty'}
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}
/>
<div className="md:-space-y-2">
<div className="text-[12px] text-neutral-400 font-semibold">Author</div>
<div className="text-lg md:text-xl font-bold text-neutral-800">
{(author.first_name && author.last_name) ? (
{(author.user.first_name && author.user.last_name) ? (
<div className="flex space-x-2 items-center">
<p>{`${author.first_name} ${author.last_name}`}</p>
<p>{`${author.user.first_name} ${author.user.last_name}`}</p>
<span className="text-xs bg-neutral-100 p-1 px-3 rounded-full text-neutral-400 font-semibold">
@{author.username}
@{author.user.username}
</span>
</div>
) : (
<div className="flex space-x-2 items-center">
<p>@{author.username}</p>
<p>@{author.user.username}</p>
</div>
)}
</div>
@ -79,13 +87,113 @@ const AuthorInfo = ({ author, isMobile }: { author: Author, isMobile: boolean })
</div>
)
const MultipleAuthors = ({ authors, isMobile }: { authors: Author[], isMobile: boolean }) => {
const displayedAvatars = authors.slice(0, 3)
const displayedNames = authors.slice(0, 2)
const remainingCount = Math.max(0, authors.length - 3)
// Consistent sizes for both avatars and badge
const avatarSize = isMobile ? 72 : 86
const borderSize = "border-4"
return (
<div className="flex flex-col items-center space-y-4 px-2 py-2">
<div className="text-[12px] text-neutral-400 font-semibold self-start">Authors</div>
{/* Avatars row */}
<div className="flex justify-center -space-x-6 relative">
{displayedAvatars.map((author, index) => (
<div
key={author.user.user_uuid}
className="relative"
style={{ zIndex: displayedAvatars.length - index }}
>
<div className="ring-white">
<UserAvatar
border={borderSize}
rounded='rounded-full'
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}
/>
</div>
</div>
))}
{remainingCount > 0 && (
<div
className="relative"
style={{ zIndex: 0 }}
>
<div
className="flex items-center justify-center bg-neutral-100 text-neutral-600 font-medium rounded-full border-4 border-white shadow-sm"
style={{
width: `${avatarSize}px`,
height: `${avatarSize}px`,
fontSize: isMobile ? '14px' : '16px'
}}
>
+{remainingCount}
</div>
</div>
)}
</div>
{/* Names row - improved display logic */}
<div className="text-center mt-2">
<div className="text-sm font-medium text-neutral-800">
{authors.length === 1 ? (
<span>
{authors[0].user.first_name && authors[0].user.last_name
? `${authors[0].user.first_name} ${authors[0].user.last_name}`
: `@${authors[0].user.username}`}
</span>
) : (
<>
{displayedNames.map((author, index) => (
<span key={author.user.user_uuid}>
{author.user.first_name && author.user.last_name
? `${author.user.first_name} ${author.user.last_name}`
: `@${author.user.username}`}
{index === 0 && authors.length > 1 && index < displayedNames.length - 1 && " & "}
</span>
))}
{authors.length > 2 && (
<span className="text-neutral-500 ml-1">
& {authors.length - 2} more
</span>
)}
</>
)}
</div>
<div className="text-xs text-neutral-500 mt-0.5">
{authors.length === 1 ? (
<span>@{authors[0].user.username}</span>
) : (
<>
{displayedNames.map((author, index) => (
<span key={author.user.user_uuid}>
@{author.user.username}
{index === 0 && authors.length > 1 && index < displayedNames.length - 1 && " & "}
</span>
))}
</>
)}
</div>
</div>
</div>
)
}
const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => {
const router = useRouter()
const session = useLHSession() as any
const [linkedProducts, setLinkedProducts] = useState<any[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isActionLoading, setIsActionLoading] = useState(false)
const [isContributeLoading, setIsContributeLoading] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const [hasAccess, setHasAccess] = useState<boolean | null>(null)
const { contributorStatus, refetch } = useContributorStatus(courseuuid);
const isStarted = course.trail?.runs?.some(
(run) => run.status === 'STATUS_IN_PROGRESS' && run.course_id === course.id
@ -123,6 +231,7 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => {
} catch (error) {
console.error('Failed to check course access')
toast.error('Failed to check course access. Please try again later.')
setHasAccess(false)
}
}
@ -138,27 +247,72 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => {
return
}
if (isStarted) {
await removeCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
await revalidateTags(['courses'], orgslug)
router.refresh()
} else {
await startCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
await revalidateTags(['courses'], orgslug)
setIsActionLoading(true)
const loadingToast = toast.loading(
isStarted ? 'Leaving course...' : 'Starting course...'
)
// Get the first activity from the first chapter
const firstChapter = course.chapters?.[0]
const firstActivity = firstChapter?.activities?.[0]
if (firstActivity) {
// Redirect to the first activity
router.push(
getUriWithOrg(orgslug, '') +
`/course/${courseuuid}/activity/${firstActivity.activity_uuid.replace('activity_', '')}`
)
} else {
try {
if (isStarted) {
await removeCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
await revalidateTags(['courses'], orgslug)
toast.success('Successfully left the course', { id: loadingToast })
router.refresh()
} else {
await startCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
await revalidateTags(['courses'], orgslug)
toast.success('Successfully started the course', { id: loadingToast })
// Get the first activity from the first chapter
const firstChapter = course.chapters?.[0]
const firstActivity = firstChapter?.activities?.[0]
if (firstActivity) {
// Redirect to the first activity
router.push(
getUriWithOrg(orgslug, '') +
`/course/${courseuuid}/activity/${firstActivity.activity_uuid.replace('activity_', '')}`
)
} else {
router.refresh()
}
}
} catch (error) {
console.error('Failed to perform course action:', error)
toast.error(
isStarted
? 'Failed to leave the course. Please try again later.'
: 'Failed to start the course. Please try again later.',
{ id: loadingToast }
)
} finally {
setIsActionLoading(false)
}
}
const handleApplyToContribute = async () => {
if (!session.data?.user) {
router.push(getUriWithoutOrg(`/signup?orgslug=${orgslug}`))
return
}
setIsContributeLoading(true)
const loadingToast = toast.loading('Submitting contributor application...')
try {
const data = {
message: "I would like to contribute to this course."
}
await applyForContributor('course_' + courseuuid, data, session.data?.tokens?.access_token)
await revalidateTags(['courses'], orgslug)
await refetch()
toast.success('Your application to contribute has been submitted successfully', { id: loadingToast })
} catch (error) {
console.error('Failed to apply as contributor:', error)
toast.error('Failed to submit your application. Please try again later.', { id: loadingToast })
} finally {
setIsContributeLoading(false)
}
}
@ -166,6 +320,60 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => {
return <div className="animate-pulse h-20 bg-gray-100 rounded-lg nice-shadow" />
}
const renderContributorButton = () => {
// Don't render anything if the course is not open to contributors or if the user status is INACTIVE
if (contributorStatus === 'INACTIVE' || course.open_to_contributors !== true) {
return null;
}
if (!session.data?.user) {
return (
<button
onClick={() => router.push(getUriWithoutOrg(`/signup?orgslug=${orgslug}`))}
className="w-full bg-white text-neutral-700 border border-neutral-200 py-3 rounded-lg nice-shadow font-semibold hover:bg-neutral-50 transition-colors flex items-center justify-center gap-2 mt-3 cursor-pointer"
>
<UserPen className="w-5 h-5" />
Authenticate to contribute
</button>
);
}
if (contributorStatus === 'ACTIVE') {
return (
<div className="w-full bg-green-50 text-green-700 border border-green-200 py-3 rounded-lg nice-shadow font-semibold flex items-center justify-center gap-2 mt-3">
<UserPen className="w-5 h-5" />
You are a contributor
</div>
);
}
if (contributorStatus === 'PENDING') {
return (
<div className="w-full bg-amber-50 text-amber-700 border border-amber-200 py-3 rounded-lg nice-shadow font-semibold flex items-center justify-center gap-2 mt-3">
<ClockIcon className="w-5 h-5" />
Contributor application pending
</div>
);
}
return (
<button
onClick={handleApplyToContribute}
disabled={isContributeLoading}
className="w-full bg-white text-neutral-700 py-3 rounded-lg nice-shadow font-semibold hover:bg-neutral-50 transition-colors flex items-center justify-center gap-2 mt-3 cursor-pointer disabled:cursor-not-allowed"
>
{isContributeLoading ? (
<div className="w-5 h-5 border-2 border-neutral-700 border-t-transparent rounded-full animate-spin" />
) : (
<>
<UserPen className="w-5 h-5" />
Apply to contribute
</>
)}
</button>
);
};
if (linkedProducts.length > 0) {
return (
<div className="space-y-4">
@ -182,13 +390,16 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => {
</div>
<button
onClick={handleCourseAction}
className={`w-full py-3 rounded-lg nice-shadow font-semibold transition-colors flex items-center justify-center gap-2 ${
disabled={isActionLoading}
className={`w-full py-3 rounded-lg nice-shadow font-semibold transition-colors flex items-center justify-center gap-2 cursor-pointer ${
isStarted
? 'bg-red-500 text-white hover:bg-red-600'
: 'bg-neutral-900 text-white hover:bg-neutral-800'
? 'bg-red-500 text-white hover:bg-red-600 disabled:bg-red-400'
: 'bg-neutral-900 text-white hover:bg-neutral-800 disabled:bg-neutral-700'
}`}
>
{isStarted ? (
{isActionLoading ? (
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : isStarted ? (
<>
<LogOut className="w-5 h-5" />
Leave Course
@ -200,6 +411,7 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => {
</>
)}
</button>
{renderContributorButton()}
</>
) : (
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg nice-shadow">
@ -230,6 +442,7 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => {
<ShoppingCart className="w-5 h-5" />
Purchase Course
</button>
{renderContributorButton()}
</>
)}
</div>
@ -237,31 +450,37 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => {
}
return (
<button
onClick={handleCourseAction}
className={`w-full py-3 rounded-lg nice-shadow font-semibold transition-colors flex items-center justify-center gap-2 ${
isStarted
? 'bg-red-500 text-white hover:bg-red-600'
: 'bg-neutral-900 text-white hover:bg-neutral-800'
}`}
>
{!session.data?.user ? (
<>
<LogIn className="w-5 h-5" />
Authenticate to start course
</>
) : isStarted ? (
<>
<LogOut className="w-5 h-5" />
Leave Course
</>
) : (
<>
<LogIn className="w-5 h-5" />
Start Course
</>
)}
</button>
<div className="space-y-4">
<button
onClick={handleCourseAction}
disabled={isActionLoading}
className={`w-full py-3 rounded-lg nice-shadow font-semibold transition-colors flex items-center justify-center gap-2 cursor-pointer ${
isStarted
? 'bg-red-500 text-white hover:bg-red-600 disabled:bg-red-400'
: 'bg-neutral-900 text-white hover:bg-neutral-800 disabled:bg-neutral-700'
}`}
>
{isActionLoading ? (
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : !session.data?.user ? (
<>
<LogIn className="w-5 h-5" />
Authenticate to start course
</>
) : isStarted ? (
<>
<LogOut className="w-5 h-5" />
Leave Course
</>
) : (
<>
<LogIn className="w-5 h-5" />
Start Course
</>
)}
</button>
{renderContributorButton()}
</div>
)
}
@ -270,10 +489,22 @@ function CoursesActions({ courseuuid, orgslug, course }: CourseActionsProps) {
const session = useLHSession() as any
const isMobile = useMediaQuery('(max-width: 768px)')
// Filter active authors and sort by role priority
const sortedAuthors = [...course.authors]
.filter(author => author.authorship_status === 'ACTIVE')
.sort((a, b) => {
const rolePriority: Record<string, number> = {
'CREATOR': 0,
'MAINTAINER': 1,
'CONTRIBUTOR': 2,
'REPORTER': 3
};
return rolePriority[a.authorship] - rolePriority[b.authorship];
});
return (
<div className=" space-y-3 antialiased flex flex-col p-3 py-5 bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
<AuthorInfo author={course.authors[0]} isMobile={isMobile} />
<div className="space-y-3 antialiased flex flex-col p-3 py-5 bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
<MultipleAuthors authors={sortedAuthors} isMobile={isMobile} />
<div className='px-3 py-2'>
<Actions courseuuid={courseuuid} orgslug={orgslug} course={course} />
</div>

View file

@ -22,11 +22,22 @@ function EditorWrapper(props: EditorWrapperProps): JSX.Element {
let activity = props.activity
activity.content = content
toast.promise(updateActivity(activity, activity.activity_uuid, access_token), {
loading: 'Saving...',
success: <b>Activity saved!</b>,
error: <b>Could not save.</b>,
})
toast.promise(
updateActivity(activity, activity.activity_uuid, access_token).then(res => {
if (!res.success) {
throw res;
}
return res;
}),
{
loading: 'Saving...',
success: () => <b>Activity saved!</b>,
error: (err) => {
const errorMessage = err?.data?.detail || err?.data?.message || `Error ${err?.status}: Could not save`;
return <b>{errorMessage}</b>;
},
}
)
}

View file

@ -12,6 +12,7 @@ type UserAvatarProps = {
border?: 'border-2' | 'border-4' | 'border-8'
borderColor?: string
predefined_avatar?: 'ai' | 'empty'
backgroundColor?: 'bg-white' | 'bg-gray-100'
}
function UserAvatar(props: UserAvatarProps) {
@ -78,7 +79,8 @@ function UserAvatar(props: UserAvatarProps) {
${props.avatar_url && session?.data?.user?.avatar_image ? '' : 'bg-gray-700'}
${props.border ? `border ${props.border}` : ''}
${props.borderColor ?? 'border-white'}
shadow-xl
${props.backgroundColor ?? 'bg-gray-100'}
shadow-md shadow-gray-300/45
aspect-square
w-[${props.width ?? 50}px]
h-[${props.width ?? 50}px]

View file

@ -54,7 +54,7 @@ function ActivityIndicators(props: Props) {
<div className="grid grid-flow-col justify-stretch space-x-6">
{course.chapters.map((chapter: any) => {
return (
<>
<React.Fragment key={chapter.id || `chapter-${chapter.name}`}>
<div className="grid grid-flow-col justify-stretch space-x-2">
{chapter.activities.map((activity: any) => {
return (
@ -84,7 +84,7 @@ function ActivityIndicators(props: Props) {
)
})}
</div>
</>
</React.Fragment>
)
})}
</div>

View file

@ -0,0 +1,51 @@
import { useState, useEffect, useCallback } from 'react';
import { getCourseContributors } from '@services/courses/courses';
import { useLHSession } from '@components/Contexts/LHSessionContext';
import toast from 'react-hot-toast';
export type ContributorStatus = 'NONE' | 'PENDING' | 'ACTIVE' | 'INACTIVE';
export function useContributorStatus(courseUuid: string) {
const session = useLHSession() as any;
const [contributorStatus, setContributorStatus] = useState<ContributorStatus>('NONE');
const [isLoading, setIsLoading] = useState(true);
const checkContributorStatus = useCallback(async () => {
if (!session.data?.user) {
setIsLoading(false);
return;
}
try {
const response = await getCourseContributors(
'course_' + courseUuid,
session.data?.tokens?.access_token
);
if (response && response.data) {
const currentUser = response.data.find(
(contributor: any) => contributor.user_id === session.data.user.id
);
if (currentUser) {
setContributorStatus(currentUser.authorship_status as ContributorStatus);
} else {
setContributorStatus('NONE');
}
}
} catch (error) {
console.error('Failed to check contributor status:', error);
toast.error('Failed to check contributor status');
} finally {
setIsLoading(false);
}
}, [courseUuid, session.data?.tokens?.access_token, session.data?.user]);
useEffect(() => {
if (session.data?.user) {
checkContributorStatus();
}
}, [checkContributorStatus, session.data?.user]);
return { contributorStatus, isLoading, refetch: checkContributorStatus };
}

View file

@ -60,7 +60,7 @@
"katex": "^0.16.21",
"lowlight": "^3.3.0",
"lucide-react": "^0.453.0",
"next": "15.2.2",
"next": "15.2.3",
"next-auth": "^4.24.11",
"nextjs-toploader": "^1.6.12",
"prosemirror-state": "^1.4.3",

100
apps/web/pnpm-lock.yaml generated
View file

@ -68,7 +68,7 @@ importers:
version: 1.1.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@sentry/nextjs':
specifier: ^9.5.0
version: 9.5.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.2.2(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.94.0(esbuild@0.17.19))
version: 9.5.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.2.3(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.94.0(esbuild@0.17.19))
'@sentry/utils':
specifier: ^8.55.0
version: 8.55.0
@ -160,14 +160,14 @@ importers:
specifier: ^0.453.0
version: 0.453.0(react@19.0.0)
next:
specifier: 15.2.2
version: 15.2.2(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
specifier: 15.2.3
version: 15.2.3(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next-auth:
specifier: ^4.24.11
version: 4.24.11(next@15.2.2(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
version: 4.24.11(next@15.2.3(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
nextjs-toploader:
specifier: ^1.6.12
version: 1.6.12(next@15.2.2(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
version: 1.6.12(next@15.2.3(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
prosemirror-state:
specifier: ^1.4.3
version: 1.4.3
@ -689,56 +689,56 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@next/env@15.2.2':
resolution: {integrity: sha512-yWgopCfA9XDR8ZH3taB5nRKtKJ1Q5fYsTOuYkzIIoS8TJ0UAUKAGF73JnGszbjk2ufAQDj6mDdgsJAFx5CLtYQ==}
'@next/env@15.2.3':
resolution: {integrity: sha512-a26KnbW9DFEUsSxAxKBORR/uD9THoYoKbkpFywMN/AFvboTt94b8+g/07T8J6ACsdLag8/PDU60ov4rPxRAixw==}
'@next/eslint-plugin-next@15.2.1':
resolution: {integrity: sha512-6ppeToFd02z38SllzWxayLxjjNfzvc7Wm07gQOKSLjyASvKcXjNStZrLXMHuaWkhjqxe+cnhb2uzfWXm1VEj/Q==}
'@next/swc-darwin-arm64@15.2.2':
resolution: {integrity: sha512-HNBRnz+bkZ+KfyOExpUxTMR0Ow8nkkcE6IlsdEa9W/rI7gefud19+Sn1xYKwB9pdCdxIP1lPru/ZfjfA+iT8pw==}
'@next/swc-darwin-arm64@15.2.3':
resolution: {integrity: sha512-uaBhA8aLbXLqwjnsHSkxs353WrRgQgiFjduDpc7YXEU0B54IKx3vU+cxQlYwPCyC8uYEEX7THhtQQsfHnvv8dw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@15.2.2':
resolution: {integrity: sha512-mJOUwp7al63tDpLpEFpKwwg5jwvtL1lhRW2fI1Aog0nYCPAhxbJsaZKdoVyPZCy8MYf/iQVNDuk/+i29iLCzIA==}
'@next/swc-darwin-x64@15.2.3':
resolution: {integrity: sha512-pVwKvJ4Zk7h+4hwhqOUuMx7Ib02u3gDX3HXPKIShBi9JlYllI0nU6TWLbPT94dt7FSi6mSBhfc2JrHViwqbOdw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@15.2.2':
resolution: {integrity: sha512-5ZZ0Zwy3SgMr7MfWtRE7cQWVssfOvxYfD9O7XHM7KM4nrf5EOeqwq67ZXDgo86LVmffgsu5tPO57EeFKRnrfSQ==}
'@next/swc-linux-arm64-gnu@15.2.3':
resolution: {integrity: sha512-50ibWdn2RuFFkOEUmo9NCcQbbV9ViQOrUfG48zHBCONciHjaUKtHcYFiCwBVuzD08fzvzkWuuZkd4AqbvKO7UQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@15.2.2':
resolution: {integrity: sha512-cgKWBuFMLlJ4TWcFHl1KOaVVUAF8vy4qEvX5KsNd0Yj5mhu989QFCq1WjuaEbv/tO1ZpsQI6h/0YR8bLwEi+nA==}
'@next/swc-linux-arm64-musl@15.2.3':
resolution: {integrity: sha512-2gAPA7P652D3HzR4cLyAuVYwYqjG0mt/3pHSWTCyKZq/N/dJcUAEoNQMyUmwTZWCJRKofB+JPuDVP2aD8w2J6Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@15.2.2':
resolution: {integrity: sha512-c3kWSOSsVL8rcNBBfOq1+/j2PKs2nsMwJUV4icUxRgGBwUOfppeh7YhN5s79enBQFU+8xRgVatFkhHU1QW7yUA==}
'@next/swc-linux-x64-gnu@15.2.3':
resolution: {integrity: sha512-ODSKvrdMgAJOVU4qElflYy1KSZRM3M45JVbeZu42TINCMG3anp7YCBn80RkISV6bhzKwcUqLBAmOiWkaGtBA9w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@15.2.2':
resolution: {integrity: sha512-PXTW9PLTxdNlVYgPJ0equojcq1kNu5NtwcNjRjHAB+/sdoKZ+X8FBu70fdJFadkxFIGekQTyRvPMFF+SOJaQjw==}
'@next/swc-linux-x64-musl@15.2.3':
resolution: {integrity: sha512-ZR9kLwCWrlYxwEoytqPi1jhPd1TlsSJWAc+H/CJHmHkf2nD92MQpSRIURR1iNgA/kuFSdxB8xIPt4p/T78kwsg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@15.2.2':
resolution: {integrity: sha512-nG644Es5llSGEcTaXhnGWR/aThM/hIaz0jx4MDg4gWC8GfTCp8eDBWZ77CVuv2ha/uL9Ce+nPTfYkSLG67/sHg==}
'@next/swc-win32-arm64-msvc@15.2.3':
resolution: {integrity: sha512-+G2FrDcfm2YDbhDiObDU/qPriWeiz/9cRR0yMWJeTLGGX6/x8oryO3tt7HhodA1vZ8r2ddJPCjtLcpaVl7TE2Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@15.2.2':
resolution: {integrity: sha512-52nWy65S/R6/kejz3jpvHAjZDPKIbEQu4x9jDBzmB9jJfuOy5rspjKu4u77+fI4M/WzLXrrQd57hlFGzz1ubcQ==}
'@next/swc-win32-x64-msvc@15.2.3':
resolution: {integrity: sha512-gHYS9tc+G2W0ZC8rBL+H6RdtXIyk40uLiaos0yj5US85FNhbFEndMA2nW3z47nzOWiSvXTZ5kBClc3rD0zJg0w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -3678,8 +3678,8 @@ packages:
nodemailer:
optional: true
next@15.2.2:
resolution: {integrity: sha512-dgp8Kcx5XZRjMw2KNwBtUzhngRaURPioxoNIVl5BOyJbhi9CUgEtKDO7fx5wh8Z8vOVX1nYZ9meawJoRrlASYA==}
next@15.2.3:
resolution: {integrity: sha512-x6eDkZxk2rPpu46E1ZVUWIBhYCLszmUY6fvHBFcbzJ9dD+qRX6vcHusaqqDlnY+VngKzKbAiG2iRCkPbmi8f7w==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
@ -5127,34 +5127,34 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@next/env@15.2.2': {}
'@next/env@15.2.3': {}
'@next/eslint-plugin-next@15.2.1':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@15.2.2':
'@next/swc-darwin-arm64@15.2.3':
optional: true
'@next/swc-darwin-x64@15.2.2':
'@next/swc-darwin-x64@15.2.3':
optional: true
'@next/swc-linux-arm64-gnu@15.2.2':
'@next/swc-linux-arm64-gnu@15.2.3':
optional: true
'@next/swc-linux-arm64-musl@15.2.2':
'@next/swc-linux-arm64-musl@15.2.3':
optional: true
'@next/swc-linux-x64-gnu@15.2.2':
'@next/swc-linux-x64-gnu@15.2.3':
optional: true
'@next/swc-linux-x64-musl@15.2.2':
'@next/swc-linux-x64-musl@15.2.3':
optional: true
'@next/swc-win32-arm64-msvc@15.2.2':
'@next/swc-win32-arm64-msvc@15.2.3':
optional: true
'@next/swc-win32-x64-msvc@15.2.2':
'@next/swc-win32-x64-msvc@15.2.3':
optional: true
'@nodelib/fs.scandir@2.1.5':
@ -6322,7 +6322,7 @@ snapshots:
'@sentry/core@9.5.0': {}
'@sentry/nextjs@9.5.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.2.2(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.94.0(esbuild@0.17.19))':
'@sentry/nextjs@9.5.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.2.3(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.94.0(esbuild@0.17.19))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.30.0
@ -6335,7 +6335,7 @@ snapshots:
'@sentry/vercel-edge': 9.5.0
'@sentry/webpack-plugin': 3.2.1(webpack@5.94.0(esbuild@0.17.19))
chalk: 3.0.0
next: 15.2.2(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.2.3(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
resolve: 1.22.8
rollup: 4.34.9
stacktrace-parser: 0.1.11
@ -8398,13 +8398,13 @@ snapshots:
neo-async@2.6.2: {}
next-auth@4.24.11(next@15.2.2(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
next-auth@4.24.11(next@15.2.3(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@babel/runtime': 7.26.9
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
next: 15.2.2(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.2.3(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.26.4
@ -8413,9 +8413,9 @@ snapshots:
react-dom: 19.0.0(react@19.0.0)
uuid: 8.3.2
next@15.2.2(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
next@15.2.3(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@next/env': 15.2.2
'@next/env': 15.2.3
'@swc/counter': 0.1.3
'@swc/helpers': 0.5.15
busboy: 1.6.0
@ -8425,23 +8425,23 @@ snapshots:
react-dom: 19.0.0(react@19.0.0)
styled-jsx: 5.1.6(@babel/core@7.26.9)(react@19.0.0)
optionalDependencies:
'@next/swc-darwin-arm64': 15.2.2
'@next/swc-darwin-x64': 15.2.2
'@next/swc-linux-arm64-gnu': 15.2.2
'@next/swc-linux-arm64-musl': 15.2.2
'@next/swc-linux-x64-gnu': 15.2.2
'@next/swc-linux-x64-musl': 15.2.2
'@next/swc-win32-arm64-msvc': 15.2.2
'@next/swc-win32-x64-msvc': 15.2.2
'@next/swc-darwin-arm64': 15.2.3
'@next/swc-darwin-x64': 15.2.3
'@next/swc-linux-arm64-gnu': 15.2.3
'@next/swc-linux-arm64-musl': 15.2.3
'@next/swc-linux-x64-gnu': 15.2.3
'@next/swc-linux-x64-musl': 15.2.3
'@next/swc-win32-arm64-msvc': 15.2.3
'@next/swc-win32-x64-msvc': 15.2.3
'@opentelemetry/api': 1.9.0
sharp: 0.33.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
nextjs-toploader@1.6.12(next@15.2.2(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
nextjs-toploader@1.6.12(next@15.2.3(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
next: 15.2.2(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.2.3(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
nprogress: 0.2.0
prop-types: 15.8.1
react: 19.0.0

View file

@ -2,6 +2,7 @@ import { getAPIUrl } from '@services/config/config'
import {
RequestBodyFormWithAuthHeader,
RequestBodyWithAuthHeader,
getResponseMetadata,
} from '@services/utils/ts/requests'
export async function createActivity(
@ -130,6 +131,6 @@ export async function updateActivity(
`${getAPIUrl()}activities/${activity_uuid}`,
RequestBodyWithAuthHeader('PUT', data, null, access_token)
)
const res = await result.json()
const res = await getResponseMetadata(result)
return res
}

View file

@ -126,3 +126,30 @@ export async function deleteCourseFromBackend(course_uuid: any, access_token:any
const res = await errorHandling(result)
return res
}
export async function getCourseContributors(course_uuid: string, access_token:string | null | undefined) {
const result: any = await fetch(
`${getAPIUrl()}courses/${course_uuid}/contributors`,
RequestBodyWithAuthHeader('GET', null, null,access_token || undefined)
)
const res = await getResponseMetadata(result)
return res
}
export async function editContributor(course_uuid: string, contributor_id: string, authorship: any, authorship_status: any, access_token:string | null | undefined) {
const result: any = await fetch(
`${getAPIUrl()}courses/${course_uuid}/contributors/${contributor_id}?authorship=${authorship}&authorship_status=${authorship_status}`,
RequestBodyWithAuthHeader('PUT', null, null,access_token || undefined)
)
const res = await getResponseMetadata(result)
return res
}
export async function applyForContributor(course_uuid: string, data: any, access_token:string | null | undefined) {
const result: any = await fetch(
`${getAPIUrl()}courses/${course_uuid}/apply-contributor`,
RequestBodyWithAuthHeader('POST', data, null,access_token || undefined)
)
const res = await getResponseMetadata(result)
return res
}