mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
Merge pull request #466 from learnhouse/feat/multi-contributors
Multi-contributors
This commit is contained in:
commit
7f66369e95
28 changed files with 1621 additions and 350 deletions
|
|
@ -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 ###
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
176
apps/api/src/services/courses/contributors.py
Normal file
176
apps/api/src/services/courses/contributors.py
Normal 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
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
51
apps/web/hooks/useContributorStatus.ts
Normal file
51
apps/web/hooks/useContributorStatus.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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
100
apps/web/pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue